Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
Marshal
2026-07-03 18:48:18 +00:00
106 changed files with 4924 additions and 876 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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("*");
}
}

View File

@@ -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<FaydaJwk>;
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,
};
});

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.fayda_verification_sessions`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.drivers
DROP COLUMN IF EXISTS fayda_verified,
DROP COLUMN IF EXISTS fayda_sub
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_assignments`);
}
}

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE freight.drivers
ADD COLUMN IF NOT EXISTS gender varchar
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.drivers
DROP COLUMN IF EXISTS gender
`);
}
}

View File

@@ -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<void> {
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB"
ON freight.drivers (fayda_sub)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB"
`);
}
}

View File

@@ -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<void> {
// 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<void> {
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)
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.fleet_events`);
}
}

View File

@@ -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<void> {
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<void> {
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
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedule_bookings
DROP COLUMN IF EXISTS loading_status
`);
}
}

View File

@@ -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',

View File

@@ -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).

View File

@@ -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;
}

View File

@@ -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' })

View File

@@ -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<Driver>,
private readonly history: FleetHistoryService,
) {}
async create(dto: CreateDriverDto): Promise<Driver> {
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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -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,
},
],

View File

@@ -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<boolean> {
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<string | null> {
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<FirstMile> {
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<FirstMile> {
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,

View File

@@ -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<string, unknown> | null;
}

View File

@@ -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 {}

View File

@@ -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<string, unknown> | null;
}
@Injectable()
export class FleetHistoryService {
private readonly logger = new Logger(FleetHistoryService.name);
constructor(
@InjectRepository(FleetEvent)
private readonly eventRepo: Repository<FleetEvent>,
) {}
/**
* 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<void> {
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<FleetEvent[]> {
return this.eventRepo.find({
where: { vehicleId },
order: { createdAt: 'DESC' },
});
}
getDriverHistory(driverId: string): Promise<FleetEvent[]> {
return this.eventRepo.find({
where: { driverId },
order: { createdAt: 'DESC' },
});
}
}

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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',

View File

@@ -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,
},
],

View File

@@ -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<boolean> {
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<string | null> {
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<LastMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
@@ -132,7 +184,7 @@ export class LastMileService {
}
async create(dto: CreateLastMileDto): Promise<LastMile> {
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<LastMile> {
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<void> {
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<void> {
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,

View File

@@ -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;
}

View File

@@ -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 };
}
}

View File

@@ -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 {}

View File

@@ -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
);
}

View File

@@ -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;

View File

@@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module";
exports: [
OtpRepository,
OtpService,
],
})
export class OtpModule {}

View File

@@ -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
);
}
}

View File

@@ -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",
};
}
}
// ---------------------------------------------------------------------------
// 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 };
}
}

View File

@@ -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<MarkPaidResponseDto> {
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<MarkPaidResponseDto> {
this.logger.log(`Marking payment ${event} as PAID`);
return this.paymentService.handlePaymentEvent(event);
}
}

View File

@@ -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;
}

View File

@@ -47,4 +47,27 @@ export class TrainScheduleBookingsRepository extends BaseRepository<TrainSchedul
select: { id: true, bookingId: true, trainScheduleId: true },
});
}
findByScheduleId(
trainScheduleId: string,
manager?: EntityManager,
): Promise<TrainScheduleBooking[]> {
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<void> {
if (!bookingIds.length) return;
await this.repo(manager).update(
{ trainScheduleId, bookingId: In(bookingIds) },
{ loadingStatus },
);
}
}

View File

@@ -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;
}

View File

@@ -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" })

View File

@@ -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) {

View File

@@ -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' })

View File

@@ -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<Vehicle>,
private readonly history: FleetHistoryService,
) {}
async create(dto: CreateVehicleDto): Promise<Vehicle> {
@@ -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<void> {
// 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,
});
}
}
/**

View File

@@ -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;
}

View File

@@ -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());

View File

@@ -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<boolean> {
try {
await super.canActivate(context);
} catch {
context.switchToHttp().getRequest().user = undefined;
}
return true;
}
}

View File

@@ -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();
});
});

View File

@@ -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<string> {
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);
}

View File

@@ -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());
});
});
});

View File

@@ -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));
}

View File

@@ -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<CompleteVerificationResultDto> {
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<VerificationStatusDto> {
return this.service.getVerificationStatus(req.user.id);
}
}

View File

@@ -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;
}

View File

@@ -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 });
}
}

View File

@@ -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 {}

View File

@@ -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<FaydaVerificationSession>,
@InjectDataSource() private readonly dataSource: DataSource,
) {
const fayda = this.config.get<FaydaConfig>('fayda');
if (!fayda) {
throw new Error('Fayda config namespace not registered');
}
this.faydaConfig = fayda;
}
// ==========================================================================
// OIDC flow
// ==========================================================================
async startVerification(input: StartVerificationInput): Promise<string> {
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<CompleteVerificationResult> {
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<VerificationStatusDto> {
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<FaydaTokenResponse> {
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<FaydaUserInfo> {
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<void> {
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';
}
}

View File

@@ -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<string, unknown>;
[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
}

View File

@@ -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 })

View File

@@ -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()

View File

@@ -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 })

View File

@@ -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()

View File

@@ -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()

View File

@@ -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: <Package />,
},
{
@@ -377,7 +378,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory",
href: "/dashboard/warehouse-inventory?direction=EXPORT",
icon: <Package />,
},
],
@@ -569,6 +570,7 @@ const App = () => {
<Routes>
<Route path="/auth" element={<LoginPage />} />
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="/callback" element={<FaydaCallbackPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>
);
@@ -578,6 +580,7 @@ const App = () => {
<Routes>
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="/health" element={<HealthCheck />} />
<Route path="/callback" element={<FaydaCallbackPage />} />
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route
path="/dashboard"

View File

@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { Loader2, Calendar } from "lucide-react";
import { Loader2, ShieldCheck } from "lucide-react";
import {
Alert,
Badge,
Button,
Group,
Modal,
@@ -12,7 +14,6 @@ import {
Text,
Textarea,
TextInput,
ActionIcon,
} from "@mantine/core";
import {
@@ -20,6 +21,10 @@ import {
type FleetFormFieldDef,
} from "@/pages/fleet/config/resources";
import type { FleetRecord } from "@/services/fleet/fleet.service";
import {
verifaydaService,
type FaydaCallbackMessage,
} from "@/services/verifayda.service";
export interface FleetFormDialogProps {
open: boolean;
@@ -31,8 +36,32 @@ export interface FleetFormDialogProps {
isSubmitting: boolean;
selectOptionsLoading?: boolean;
onSubmit: (values: Record<string, unknown>) => 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<string, unknown>,
@@ -72,9 +101,12 @@ const FleetFormDialog = ({
isSubmitting,
selectOptionsLoading,
onSubmit,
verifyWithFayda,
}: FleetFormDialogProps) => {
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [faydaLoading, setFaydaLoading] = useState(false);
const [faydaError, setFaydaError] = useState<string | null>(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<FaydaCallbackMessage>) => {
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 ? (
<Loader2 size={14} className="animate-spin" />
@@ -216,7 +341,7 @@ const FleetFormDialog = ({
error={error}
searchable
clearable
disabled={selectOptionsLoading}
disabled={selectOptionsLoading || isDisabled}
rightSection={
selectOptionsLoading ? (
<Loader2 size={14} className="animate-spin" />
@@ -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={
<ActionIcon size="sm" variant="subtle" color="green">
<Calendar size={16} />
</ActionIcon>
}
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
>
<Stack gap="md">
{verifyWithFayda && (
<Group justify="space-between" wrap="nowrap">
{faydaVerified ? (
<Badge
color="green"
variant="light"
size="lg"
leftSection={<ShieldCheck size={14} />}
>
Identity verified with Fayda
</Badge>
) : (
<Text size="sm" c="dimmed">
Identity must be verified with Fayda before this driver can be
saved.
</Text>
)}
<Button
variant={faydaVerified ? "default" : "light"}
color="edr-green"
size="xs"
leftSection={<ShieldCheck size={14} />}
loading={faydaLoading}
onClick={handleFaydaVerify}
>
{faydaVerified ? "Re-verify" : "Verify with Fayda"}
</Button>
</Group>
)}
{verifyWithFayda && faydaError && (
<Alert color="red" variant="light">
{faydaError}
</Alert>
)}
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{shortFields.map(renderField)}
</SimpleGrid>
@@ -345,6 +499,7 @@ const FleetFormDialog = ({
color="edr-green"
loading={isSubmitting}
onClick={handleSubmit}
disabled={verifyWithFayda && !faydaVerified}
>
Save
</Button>

View File

@@ -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<string, unknown>;
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: <UserCheck size={14} />,
title: "Driver registered",
text: e.toValue ? `Status: ${e.toValue}` : "",
};
case "VEHICLE_REGISTERED":
return {
icon: <Truck size={14} />,
title: "Vehicle registered",
text: e.toValue ? `Availability: ${e.toValue}` : "",
};
case "DRIVER_ASSIGNED":
return {
icon: <UserPlus size={14} />,
title: entity === "vehicle" ? "Driver assigned" : "Assigned to vehicle",
text:
entity === "vehicle"
? driverName
? `Driver ${driverName}`
: ""
: vehiclePlate
? `Vehicle ${vehiclePlate}`
: "",
};
case "DRIVER_UNASSIGNED":
return {
icon: <UserMinus size={14} />,
title:
entity === "vehicle"
? "Driver unassigned"
: "Unassigned from vehicle",
text:
entity === "vehicle"
? driverName
? `Driver ${driverName}`
: ""
: vehiclePlate
? `Vehicle ${vehiclePlate}`
: "",
};
case "VEHICLE_STATUS_CHANGED":
return {
icon: <CircleDot size={14} />,
title: "Status changed",
text: arrow(e.fromValue, e.toValue),
};
case "VEHICLE_AVAILABILITY_CHANGED":
return {
icon: <Activity size={14} />,
title: `Marked ${e.toValue ?? ""}`.trim(),
text: e.fromValue ? arrow(e.fromValue, e.toValue) : "",
};
case "MILE_VEHICLE_ASSIGNED":
return {
icon: <Route size={14} />,
title: `${mileLabel(e)}: vehicle assigned`,
text: detail(e.label ? `Status: ${e.label}` : ""),
};
case "MILE_VEHICLE_RELEASED":
return {
icon: <Route size={14} />,
title: `${mileLabel(e)}: vehicle released`,
text: detail(),
};
case "MILE_STATUS_CHANGED":
return {
icon: <Route size={14} />,
title: `${mileLabel(e)} status`,
text: detail(arrow(e.fromValue, e.toValue)),
};
default:
return { icon: <CircleDot size={14} />, 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 (
<Modal
opened={opened}
onClose={onClose}
title={<Text fw={600}>{titleFor(entity, record)}</Text>}
radius="lg"
size="lg"
centered
>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : events.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No history recorded yet. Activity appears here as this{" "}
{entity} is assigned, reassigned, or its status changes.
</Text>
) : (
<Timeline active={events.length} bulletSize={24} lineWidth={2}>
{events.map((e) => {
const d = describe(e, entity);
return (
<Timeline.Item key={e.id} bullet={d.icon} title={d.title}>
{d.text && (
<Text size="sm" c="dimmed">
{d.text}
</Text>
)}
<Text size="xs" mt={4} c="dimmed">
{fmt(e.createdAt)}
</Text>
</Timeline.Item>
);
})}
</Timeline>
)}
</Modal>
);
};
export default FleetHistoryModal;

View File

@@ -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
</MenuItem>
{showHistory ? (
<MenuItem
onClick={() => onHistory?.(record)}
leftSection={<History size={14} strokeWidth={2} />}
>
History
</MenuItem>
) : null}
{showDetail ? (
<MenuItem
onClick={handleDetail}

View File

@@ -4,7 +4,7 @@ import { Badge, Text } from "@mantine/core";
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
import { formatCell as formatRuleEngineCell } from "@/components/ruleEngine/ruleEngineFormat";
export type FleetColumnFormat = ColumnFormat | "statusBadge";
export type FleetColumnFormat = ColumnFormat | "statusBadge" | "verifiedBadge";
const optionLabelMap = new Map<string, Map<string, string>>();
@@ -20,6 +20,16 @@ export const formatFleetCell = (
format?: FleetColumnFormat,
accessorKey?: string,
): ReactNode => {
if (format === "verifiedBadge") {
return value === true ? (
<Badge variant="light" color="green" size="sm" radius="md">
Verified
</Badge>
) : (
<Text size="sm" c="dimmed"></Text>
);
}
if (format === "statusBadge") {
const status = value == null || value === "" ? "—" : String(value);
const getStatusColor = (st: string): string => {

View File

@@ -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 (
<Group gap={4} wrap="nowrap">
{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 (
<Tooltip
key={i}
withArrow
label={`${s.label}${s.detail ? ` · ${s.detail}` : ""}`}
>
<span
style={{
width: 10,
height: 10,
borderRadius: "50%",
background: s.done ? color : "transparent",
border: `2px solid ${color}`,
boxShadow: s.active
? "0 0 0 2px var(--mantine-color-blue-1)"
: undefined,
display: "inline-block",
flex: "0 0 auto",
}}
/>
</Tooltip>
);
})}
</Group>
);
}
/**
* 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 (
<Timeline
active={activeIndex === -1 ? steps.length : doneCount}
bulletSize={22}
lineWidth={2}
color="green"
>
{steps.map((s, i) => (
<Timeline.Item
key={i}
bullet={s.done ? <Check size={12} /> : undefined}
title={
<Text size="sm" fw={s.active ? 600 : 500} c={s.active ? "blue" : undefined}>
{s.label}
</Text>
}
lineVariant={s.done ? "solid" : "dashed"}
>
<Stack gap={0}>
<Text size="xs" c="dimmed">
{s.done ? "Done" : s.active ? "Current step" : "Pending"}
</Text>
{s.detail && (
<Text size="xs" c="dimmed">
{s.detail}
</Text>
)}
</Stack>
</Timeline.Item>
))}
</Timeline>
);
}

View File

@@ -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 (
<Group
align="flex-start"
wrap="nowrap"
p="sm"
style={{
border: `1px solid ${
selected ? "var(--mantine-color-edr-green-3)" : "var(--mantine-color-gray-2)"
}`,
borderRadius: 12,
background: selected ? "var(--mantine-color-edr-green-0)" : "white",
transition: "border-color 120ms ease, background 120ms ease",
}}
>
<Checkbox checked={selected} onChange={onToggle} mt={4} color="edr-green" />
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="wrap">
<PackageCheck size={14} />
<Text fw={600} size="sm">
{booking.reference ?? booking.id}
</Text>
<Badge
variant="light"
size="xs"
color={booking.loadingStatus === "LOADED" ? "edr-green" : "gray"}
>
{booking.loadingStatus}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{booking.customer ?? "Unknown customer"}
</Text>
<Text size="xs" c="dimmed">
{booking.weightTons}T
</Text>
</Stack>
</Group>
);
}
export function ImportLoadingConfirmationPanel({
scheduleId,
items,
isLoading,
}: {
scheduleId: string;
items: ImportLoadingBooking[];
isLoading?: boolean;
}) {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
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 (
<Group justify="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Loading import bookings
</Text>
</Group>
);
}
if (!items.length) {
return (
<Paper p="lg" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed" ta="center">
No paid import bookings with wagons allocated on this schedule
</Text>
</Paper>
);
}
return (
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Text size="sm" fw={500}>
Import bookings ({items.length})
</Text>
<Group gap="sm">
<Button variant="light" size="compact-sm" onClick={() => setSelectedIds(allIds)}>
Select all
</Button>
<Button variant="subtle" size="compact-sm" onClick={() => setSelectedIds([])}>
Clear
</Button>
</Group>
</Group>
<Stack gap="sm">
{items.map((booking) => (
<ImportLoadingBookingRow
key={booking.id}
booking={booking}
selected={selectedIds.includes(booking.id)}
onToggle={() => toggle(booking.id)}
/>
))}
</Stack>
<Group gap="sm">
<Button
color="edr-green"
disabled={!selectedIds.length}
loading={updateStatus.isPending}
onClick={() =>
updateStatus.mutate({ id: scheduleId, bookingIds: selectedIds, loadingStatus: "LOADED" })
}
>
Mark loaded
</Button>
<Button
variant="outline"
disabled={!selectedIds.length}
loading={updateStatus.isPending}
onClick={() =>
updateStatus.mutate({ id: scheduleId, bookingIds: selectedIds, loadingStatus: "UNLOADED" })
}
>
Mark unloaded
</Button>
</Group>
</Stack>
);
}

View File

@@ -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 })); }}
/>
<TextInput
label="Code"

View File

@@ -48,6 +48,9 @@ export const formatDate = (value: string | null | undefined) => {
});
};
// 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<string, unknown>) : undefined;

View File

@@ -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: {

View File

@@ -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) =>

View File

@@ -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 (
<Center h="100vh">
<Stack align="center" gap="sm">
{standalone ? (
<>
<Text fw={600}>Verification window lost its parent page</Text>
<Text size="sm" c="dimmed">
Close this tab and restart the verification from the form.
</Text>
</>
) : (
<>
<Loader size="sm" />
<Text size="sm" c="dimmed">Completing Fayda verification</Text>
</>
)}
</Stack>
</Center>
);
};
export default FaydaCallbackPage;

View File

@@ -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<FleetRecord | null>(null);
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
const [selectedDriver, setSelectedDriver] = useState<string>("");
const { viewMode, setViewMode } = useFleetViewMode(slug);
@@ -273,6 +275,7 @@ const FleetResourcePage = () => {
}}
onRemove={setRemoveTarget}
onAssignDriver={setAssigningDriver}
onHistory={setHistoryTarget}
/>
</div>
),
@@ -510,6 +513,7 @@ const FleetResourcePage = () => {
isSubmitting={create.isPending || update.isPending}
selectOptionsLoading={selectOptionsLoading}
onSubmit={handleFormSubmit}
verifyWithFayda={Boolean(config.faydaVerification)}
/>
<Modal
@@ -580,6 +584,13 @@ const FleetResourcePage = () => {
</Group>
</Stack>
</Modal>
<FleetHistoryModal
opened={Boolean(historyTarget)}
onClose={() => setHistoryTarget(null)}
entity={slug === "vehicles" ? "vehicle" : "driver"}
record={historyTarget}
/>
</Container>
);
};

View File

@@ -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: [],

View File

@@ -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<FleetResourceSlug, string> = {

View File

@@ -6,6 +6,7 @@ import {
MoreHorizontal,
PackageCheck,
Printer,
Receipt,
RefreshCw,
Ruler,
Trash,
@@ -879,10 +880,14 @@ const FirstMilePage = () => {
<Menu.Dropdown>
<Menu.Item
leftSection={<ArrowRight size={15} />}
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}
</Menu.Item>
<Menu.Divider />
<Menu.Item
@@ -919,6 +924,13 @@ const FirstMilePage = () => {
>
Add distance
</Menu.Item>
<Menu.Item
leftSection={<Receipt size={15} />}
disabled={!(row.original.exactKm != null && row.original.exactKm > 0)}
onClick={() => openInvoice(row.original)}
>
Generate Invoice
</Menu.Item>
{canPrint && (
<Menu.Item
leftSection={<Printer size={15} />}
@@ -954,7 +966,7 @@ const FirstMilePage = () => {
}, [vehicleOptions]);
return (
<Stack gap="md">
<Stack gap="md" p="md">
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">

View File

@@ -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 (
<Group gap={4} justify="flex-end" wrap="nowrap">
<Menu position="bottom-end" width={200} withinPortal>
@@ -967,15 +1038,17 @@ const LastMilePage = () => {
<Menu.Dropdown>
<Menu.Item
leftSection={<ArrowRight size={15} />}
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}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<Truck size={15} />}
disabled={assigned || delivered}
disabled={!canAssignStep}
onClick={() => openAssign(row.original.id)}
>
Assign
@@ -989,10 +1062,17 @@ const LastMilePage = () => {
</Menu.Item>
<Menu.Item
leftSection={<Truck size={15} />}
disabled={!assigned}
disabled={!canArrive}
onClick={() => openTruckArrival(row.original)}
>
{truckArrivalLabel}
Truck Arrival
</Menu.Item>
<Menu.Item
leftSection={<Truck size={15} />}
disabled={!canLeave}
onClick={() => openTruckArrival(row.original)}
>
Truck Leaving
</Menu.Item>
<Menu.Divider />
<Menu.Item
@@ -1003,11 +1083,18 @@ const LastMilePage = () => {
</Menu.Item>
<Menu.Item
leftSection={<Ruler size={15} />}
disabled={delivered}
disabled={!canDistance}
onClick={() => openDistance(row.original.id)}
>
Add distance
</Menu.Item>
<Menu.Item
leftSection={<Receipt size={15} />}
disabled={!(row.original.exactKm != null && row.original.exactKm > 0)}
onClick={() => openInvoice(row.original)}
>
Generate Invoice
</Menu.Item>
{canPrint && (
<Menu.Item
leftSection={<Printer size={15} />}
@@ -1044,7 +1131,7 @@ const LastMilePage = () => {
}, [vehicleOptions, pickupReadyByBooking]);
return (
<Stack gap="md">
<Stack gap="md" p="md">
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
@@ -1320,6 +1407,18 @@ const LastMilePage = () => {
>
<Stack gap="md">
{activeRecord && <BookingInfo record={activeRecord} />}
{activeRecord && (
<Card withBorder padding="md" radius="md">
<Text fw={600} size="sm" mb="sm">Delivery steps</Text>
<LastMileStepper
steps={computeLastMileSteps(
activeRecord,
pickupReadyByBooking.get(activeRecord.bookingId) ??
pickupReadyByBooking.get(bookingRef(activeRecord)),
)}
/>
</Card>
)}
<Group justify="flex-end">
<Button variant="default" onClick={() => { setDetailOpen(false); setActiveId(null); }}>Close</Button>
</Group>

View File

@@ -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" ? (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">
<Text fw={600}>Import loading confirmation</Text>
<Text size="sm" c="dimmed">
Paid import bookings with a wagon allocated on this schedule. Marking loaded/unloaded
is tracking only it does not block dispatch.
</Text>
<ImportLoadingConfirmationPanel
scheduleId={scheduleId as string}
items={importLoadingQuery.data?.items ?? []}
isLoading={importLoadingQuery.isLoading}
/>
</Stack>
</Paper>
) : null}
{gatepassApplies ? (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">

View File

@@ -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<string | null>(null);
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
const [historyInventoryId, setHistoryInventoryId] = useState<string | null>(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
</Button>
<Button
size="compact-xs"
variant="light"
color="teal"
leftSection={<ShieldCheck size={14} />}
loading={busyScheduleId === train.scheduleId && secureGatePass.isPending}
onClick={() => secureGate(train)}
>
Secure Gate Pass
</Button>
<Button
size="compact-xs"
color="green"
@@ -356,7 +404,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
<Truck size={14} />
)
}
loading={busyScheduleId === train.scheduleId}
loading={busyScheduleId === train.scheduleId && autoUnload.isPending}
onClick={() => unloadTrain(train)}
>
Auto Unload Export Items

View File

@@ -21,6 +21,7 @@ export default function WarehouseInventoryPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
const direction = (searchParams.get('direction') as 'IMPORT' | 'EXPORT' | null) ?? undefined;
const [filter, setFilter] = useState<InventoryFilter>(
initialStatus ? { status: initialStatus } : {},
);
@@ -28,8 +29,8 @@ export default function WarehouseInventoryPage() {
const [debouncedSearch] = useDebouncedValue(search, 300);
const queryFilter = useMemo<InventoryFilter>(
() => ({ ...filter, search: debouncedSearch || undefined }),
[filter, debouncedSearch],
() => ({ ...filter, direction, search: debouncedSearch || undefined }),
[filter, direction, debouncedSearch],
);
const warehousesQuery = useWarehouses();
@@ -53,7 +54,13 @@ export default function WarehouseInventoryPage() {
return (
<PageContainer>
<PageHeader
title="Warehouse Inventory"
title={
direction === 'IMPORT'
? 'Import Terminal Inventory'
: direction === 'EXPORT'
? 'Export Terminal Inventory'
: 'Warehouse Inventory'
}
subtitle="Track received items through the storage, reservation, loading and dispatch lifecycle."
action={
<Group gap="xs">

View File

@@ -33,7 +33,7 @@ import {
} from '@/hooks/useWarehouses';
import { api } from '@/services/api';
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
import { extractErrorMessage } from '@/components/warehouses/options';
import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options';
const FREIGHT = [
{ value: 'CONTAINER', label: 'Container' },
@@ -253,7 +253,7 @@ function AllocationRules() {
required
value={form.name}
onChange={(e) => {
const value = e.currentTarget.value;
const value = lettersOnly(e.currentTarget.value);
setForm((f) => ({ ...f, name: value }));
}}
/>
@@ -569,7 +569,7 @@ function FeeRules() {
required
value={form.name}
onChange={(e) => {
const value = e.currentTarget.value;
const value = lettersOnly(e.currentTarget.value);
setForm((f) => ({ ...f, name: value }));
}}
/>

View File

@@ -49,6 +49,8 @@ import type {
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
FreightType,
ImportLoadingBookingsResponse,
LoadingStatus,
LocomotiveRecord,
PinWagonsPayload,
RecordCheckpointPayload,
@@ -370,6 +372,25 @@ export const api = {
QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId),
),
importLoadingBookings: endpoint<{ id: string }, ImportLoadingBookingsResponse>(
"train-scheduling",
"import-loading-bookings",
({ id }) => trainSchedulingService.getImportLoadingBookings(id),
({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.importLoadingBookings(id),
),
updateImportLoadingStatus: endpoint<
{ id: string; bookingIds: string[]; loadingStatus: LoadingStatus },
ImportLoadingBookingsResponse
>(
"train-scheduling",
"update-import-loading-status",
({ id, bookingIds, loadingStatus }) =>
trainSchedulingService.updateImportLoadingStatus(id, { bookingIds, loadingStatus }),
undefined,
({ id }) => [QUERY_KEYS.TRAIN_SCHEDULING.importLoadingBookings(id)],
),
// ── Mutations ──────────────────────────────────────────────────────────
runAllocation: endpoint<
{ scheduleId: string },

View File

@@ -26,6 +26,8 @@ export interface Driver {
address?: string | null;
emergencyContact?: string | null;
notes?: string | null;
faydaVerified?: boolean;
faydaSub?: string | null;
totalTrips: number;
rating: number;
createdAt: string;

View File

@@ -0,0 +1,38 @@
import { api as apiClient } from "../auth/http";
export type FleetEventType =
| "DRIVER_REGISTERED"
| "VEHICLE_REGISTERED"
| "DRIVER_ASSIGNED"
| "DRIVER_UNASSIGNED"
| "VEHICLE_STATUS_CHANGED"
| "VEHICLE_AVAILABILITY_CHANGED"
| "MILE_VEHICLE_ASSIGNED"
| "MILE_VEHICLE_RELEASED"
| "MILE_STATUS_CHANGED";
export interface FleetHistoryEvent {
id: string;
eventType: FleetEventType;
vehicleId?: string | null;
driverId?: string | null;
firstMileId?: string | null;
lastMileId?: string | null;
fromValue?: string | null;
toValue?: string | null;
label?: string | null;
metadata?: Record<string, unknown> | null;
createdAt: string;
}
/** Timeline of fleet events for a driver or a vehicle (newest first). */
export const fleetHistoryService = {
driver: (id: string) =>
apiClient
.get<FleetHistoryEvent[]>(`/drivers/${id}/history`)
.then((r) => r.data),
vehicle: (id: string) =>
apiClient
.get<FleetHistoryEvent[]>(`/vehicles/${id}/history`)
.then((r) => r.data),
};

View File

@@ -16,6 +16,8 @@ import type {
ImportDjiboutiActionPayload,
ImportDjiboutiLoadList,
ImportDjiboutiOperation,
ImportLoadingBookingsResponse,
LoadingStatus,
LocomotiveRecord,
PinWagonsPayload,
RecordCheckpointPayload,
@@ -312,6 +314,26 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getImportLoadingBookings: async (
scheduleId: string,
): Promise<ImportLoadingBookingsResponse> => {
const response = await client.get<ImportLoadingBookingsResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_LOADING_BOOKINGS(scheduleId),
);
return unwrap(response.data);
},
updateImportLoadingStatus: async (
scheduleId: string,
payload: { bookingIds: string[]; loadingStatus: LoadingStatus },
): Promise<ImportLoadingBookingsResponse> => {
const response = await client.patch<ImportLoadingBookingsResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_LOADING_STATUS(scheduleId),
payload,
);
return unwrap(response.data);
},
getImportDjiboutiOperation: async (
scheduleId: string,
): Promise<ImportDjiboutiOperation> => {

View File

@@ -0,0 +1,46 @@
import { api as apiClient } from '../auth/http';
export interface FaydaStartResponse {
authorizationUrl: string;
}
export interface FaydaCompleteResult {
purpose: 'LOGIN' | 'VERIFY';
verified: boolean;
fullName?: string;
email?: string;
phoneNumber?: string;
/** ISO yyyy-MM-dd */
birthdate?: string;
gender?: string;
iamUserId?: string;
userDataSaved?: boolean;
}
/** Message posted from the /callback popup back to the opener window. */
export interface FaydaCallbackMessage {
type: 'fayda-callback';
code?: string;
state?: string;
error?: string;
errorDescription?: string;
}
export const verifaydaService = {
/** Returns the eSignet authorize URL to open in a popup. */
start: () =>
apiClient
.post<FaydaStartResponse>('/fayda/verification/start', {
purpose: 'VERIFY',
platform: 'WEB',
})
.then((r) => r.data),
/** Exchange the callback code+state for the verified identity attributes. */
complete: (code: string, state: string) =>
apiClient
.get<FaydaCompleteResult>(
`/fayda/verification/complete?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
)
.then((r) => r.data),
};

View File

@@ -479,6 +479,21 @@ export interface ImportDjiboutiDocumentRecord {
notes?: string | null;
}
export type LoadingStatus = "LOADED" | "UNLOADED";
export interface ImportLoadingBooking {
id: string;
reference: string | null;
customer: string | null;
weightTons: number;
loadingStatus: LoadingStatus;
}
export interface ImportLoadingBookingsResponse {
count: number;
items: ImportLoadingBooking[];
}
export interface ImportDjiboutiOperation {
trainScheduleId: string;
trainNumber: string | null;

View File

@@ -1013,6 +1013,7 @@ export interface InventoryFilter {
containerId?: string;
goodsId?: string;
status?: InventoryStatus;
direction?: 'IMPORT' | 'EXPORT';
search?: string;
dateFrom?: string;
dateTo?: string;

View File

@@ -10,10 +10,8 @@ import {
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
ArrowRight,
Building2,
CheckCircle2,
Clock,
FileText,
Globe2,
@@ -42,44 +40,29 @@ import type { UpdateProfilePayload } from "@/types/profile";
import { extractApiError } from "@/utils/result";
/** Form steps rendered by CompanyProfileForm. */
type FormStep =
| "company"
| "personnel"
| "contact"
| "verify"
| "poa"
| "documents"
| "additional";
type FormStep = "company" | "personnel" | "contact" | "poa" | "documents";
const FORM_STEPS: FormStep[] = [
"company",
"personnel",
"contact",
"verify",
"poa",
"documents",
"additional",
];
/** The full onboarding journey: the two pre-form phases + the form steps. */
type WizardStep = "nationality" | "role" | FormStep;
const WIZARD_STEPS: WizardStep[] = ["nationality", "role", ...FORM_STEPS];
type WizardStep = "nationality-role" | FormStep;
const WIZARD_STEPS: WizardStep[] = ["nationality-role", ...FORM_STEPS];
/** Icon + title + description shown in the global dialog header per step. */
const STEP_META: Record<
WizardStep,
{ icon: ReactNode; title: string; description: string }
> = {
nationality: {
"nationality-role": {
icon: <Globe2 size={20} />,
title: "Where is your company registered?",
title: "Tell us about your company",
description: "This determines the documents we'll ask you to provide.",
},
role: {
icon: <Building2 size={20} />,
title: "What does your company do?",
description:
"Pick any combination of Importer, Exporter and Freight Forwarder — each is set up with its own business license.",
},
company: {
icon: <Building2 size={20} />,
title: "Company Information",
@@ -95,11 +78,6 @@ const STEP_META: Record<
title: "Contact Person",
description: "Who should we reach out to about this account?",
},
verify: {
icon: <ShieldCheck size={20} />,
title: "Verify Contact Person",
description: "Confirm the contact phone with a one-time SMS code.",
},
poa: {
icon: <FileText size={20} />,
title: "Power of Attorney",
@@ -110,11 +88,6 @@ const STEP_META: Record<
title: "Upload Documents",
description: "Provide the required company documents.",
},
additional: {
icon: <CheckCircle2 size={20} />,
title: "Business License",
description: "Upload a business license for each operational profile.",
},
};
interface OnboardingWizardDialogProps {
@@ -172,12 +145,8 @@ export default function OnboardingWizardDialog({
// Phases: nationality → role → form. If a draft already exists, resume
// straight into the form with nationality + roles pre-selected.
const [phase, setPhase] = useState<"nationality" | "role" | "form">(
companyAlreadyStarted
? hasOperationalProfiles
? "form"
: "role"
: "nationality",
const [phase, setPhase] = useState<"nationality-role" | "form">(
companyAlreadyStarted ? "form" : "nationality-role",
);
const [nationality, setNationality] = useState<CompanyNationality | null>(
savedNationality,
@@ -302,16 +271,12 @@ export default function OnboardingWizardDialog({
setNationality(savedNationality);
// Resume into the form only when profiles exist; otherwise send the user to
// role selection so the missing operational profiles get created.
setPhase(hasOperationalProfiles ? "form" : "role");
setPhase(hasOperationalProfiles ? "form" : "nationality-role");
const idx = FORM_STEPS.indexOf(resumeFormStep);
if (idx > furthestIdxRef.current) furthestIdxRef.current = idx;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [companyAlreadyStarted, resumeFormStep]);
const handleNationalityContinue = useCallback(() => {
if (nationality) setPhase("role");
}, [nationality]);
const handleRolesContinue = useCallback(() => {
setStartError(null);
startMutation.mutate({
@@ -394,6 +359,7 @@ export default function OnboardingWizardDialog({
// The active step across the whole journey, driving the header + progress pill.
const activeStep: WizardStep = phase === "form" ? formStep : phase;
const stepMeta = STEP_META[activeStep];
console.log({ stepMeta, activeStep, STEP_META });
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
// Closing from the congratulations panel also clears the completed flag so a
@@ -425,7 +391,7 @@ export default function OnboardingWizardDialog({
);
const effectiveResumeStep: FormStep =
requiredDocsMissing &&
FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents")
FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents")
? "documents"
: resumeFormStep;
@@ -497,26 +463,19 @@ export default function OnboardingWizardDialog({
<OnboardingCompletePanel onClose={handleClose} />
) : (
<Stack gap="xl">
{phase === "nationality" ? (
{phase === "nationality-role" ? (
<Stack gap="lg">
<Text fw={600} size="lg" c="edr-text">
Where is your company registered?
</Text>
<NationalitySelect
value={nationality}
onChange={setNationality}
embedded
/>
<Group justify="flex-end" pt="xs">
<Button
color="edr-green"
onClick={handleNationalityContinue}
disabled={!nationality}
rightSection={<ArrowRight size={16} />}
>
Continue
</Button>
</Group>
</Stack>
) : phase === "role" ? (
<Stack gap="lg">
<Text fw={600} size="lg" c="edr-text">
What does your company do?(multiple)
</Text>
<OnboardingRoleSelect
value={roles}
onChange={setRoles}
@@ -527,14 +486,7 @@ export default function OnboardingWizardDialog({
{startError}
</Text>
)}
<Group justify="space-between" pt="xs">
<Button
variant="default"
leftSection={<ArrowLeft size={16} />}
onClick={() => setPhase("nationality")}
>
Back
</Button>
<Group justify="flex-end" pt="xs">
<Button
color="edr-green"
onClick={handleRolesContinue}
@@ -616,7 +568,7 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
</Stack>
<Button color="edr-green" size="md" onClick={onClose} mt="xs">
Go to my dashboard
Continue to Dashboard
</Button>
</Stack>
);

View File

@@ -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<string | null>(
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<string | null>(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({
<form onSubmit={(e) => e.preventDefault()}>
<Stack gap="md">
{step === "company" && (
<>
<Text fw={600} size="sm" c="edr-text">
Enter your TIN to auto-fill company information from eTrade
</Text>
<Stack gap="sm">
<ETradeInfo
tin={watch("tinNumber")}
register={register("tinNumber")}
@@ -693,7 +581,7 @@ export default function CompanyProfileForm({
{...register("houseNo")}
/>
</SimpleGrid>
</>
</Stack>
)}
{step === "personnel" && (
@@ -783,107 +671,6 @@ export default function CompanyProfileForm({
</>
)}
{step === "verify" && (
<Stack gap="md">
<Text size="sm" c="edr-muted">
We'll text a one-time code to the contact person's phone to
confirm it's reachable. This is required before you continue.
</Text>
{!contactPhoneE164 ? (
<Alert
color="yellow"
variant="light"
icon={<AlertCircle size={18} />}
>
Add a valid contact phone number on the previous step first.
</Alert>
) : phoneVerified ? (
<Alert
color="edr-green"
variant="light"
icon={<CheckCircle2 size={18} />}
title="Phone verified"
>
{maskPhone(contactPhoneE164)} has been verified.
</Alert>
) : (
<Stack gap="sm">
<Group gap="xs" align="center">
<Smartphone
size={16}
className="text-[var(--mantine-color-edr-muted)]"
/>
<Text size="sm" c="edr-text">
{maskPhone(contactPhoneE164)}
</Text>
</Group>
{!otpSent ? (
<Button
color="edr-green"
variant="light"
onClick={sendContactOtp}
loading={sendingOtp}
leftSection={<Smartphone size={16} />}
style={{ alignSelf: "flex-start" }}
>
Send code via SMS
</Button>
) : (
<Stack gap="sm">
<PinInput
length={6}
type="number"
oneTimeCode
value={otpCode}
placeholder="0"
styles={{
input: {
textAlign: "center",
},
}}
onChange={setOtpCode}
/>
<Group gap="sm">
<Button
color="edr-green"
onClick={verifyContactOtp}
loading={verifyingOtp}
disabled={otpCode.length !== 6}
>
Verify
</Button>
<Button
variant="subtle"
color="edr-green"
onClick={sendContactOtp}
loading={sendingOtp}
disabled={resendIn > 0 || sendingOtp}
leftSection={<RotateCw size={14} />}
>
{resendIn > 0
? `Resend in ${resendIn}s`
: "Resend code"}
</Button>
</Group>
</Stack>
)}
{otpError && (
<Alert
color="red"
variant="light"
icon={<AlertCircle size={18} />}
>
{otpError}
</Alert>
)}
</Stack>
)}
</Stack>
)}
{step === "poa" && (
<>
<Text size="sm" c="edr-muted">
@@ -954,15 +741,13 @@ export default function CompanyProfileForm({
onChange={setDocumentFiles}
/>
)}
</>
)}
{step === "additional" && (
<RoleLicenseStep
profiles={roleProfiles ?? []}
value={licenseFiles ?? {}}
onChange={onLicenseChange ?? (() => { })}
/>
<RoleLicenseStep
profiles={roleProfiles ?? []}
value={licenseFiles ?? {}}
onChange={onLicenseChange ?? (() => { })}
/>
</>
)}
{saveError && (
@@ -970,11 +755,7 @@ export default function CompanyProfileForm({
color="red"
variant="light"
icon={<AlertCircle size={18} />}
title={
step === "additional"
? "Business license required"
: "Couldn't save this step"
}
title={"Couldn't save this step"}
>
{saveError}
</Alert>
@@ -998,7 +779,7 @@ export default function CompanyProfileForm({
onClick={prevStep}
leftSection={<ArrowLeft size={16} />}
>
{step === "additional" ? "Back to Documents" : "Back"}
Back
</Button>
) : (
<span />
@@ -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" ? (
<ArrowRight size={16} />
) : undefined
!isPending && !saving ? <ArrowRight size={16} /> : undefined
}
>
{step === "additional" ? "Submit for review" : "Continue"}
{step === "documents" ? "Submit for review" : "Continue"}
</Button>
</Group>
</Stack>

View File

@@ -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<typeof userSchema>;
const errorText = (msg?: string) =>
msg ? <p className="mt-1 text-xs text-red-600">{msg}</p> : 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<string | null>(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<FormData | null>(null);
// Which contact method the code was sent to — chosen on the form, locked in
// once the challenge is sent.
const [channel, setChannel] = useState<OtpChannel>("phone");
const [otpChannel, setOtpChannel] = useState<OtpChannel>("phone");
const [sending, setSending] = useState(false);
const [verifying, setVerifying] = useState(false);
const [otpCode, setOtpCode] = useState("");
const [otpError, setOtpError] = useState<string | null>(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 (
<AuthShell
tagline="Smart Freight Operations"
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
>
<form className="flex w-full flex-col" onSubmit={handleSubmit(onSubmit)}>
<div className="flex w-full flex-col">
<div className="mb-4 flex justify-center sm:mb-6">
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
</div>
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Create account
</h1>
<p className="text-sm leading-relaxed text-gray-500">
Register to access EDR Freight services.
</p>
</div>
<div className="flex w-full flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
First name <span className="text-red-500">*</span>
</label>
<input
placeholder="John"
disabled={loading}
className={fieldClass}
{...register("firstName.en")}
/>
{errorText(errors.firstName?.en?.message)}
{stage === "form" ? (
<form onSubmit={handleSubmit(requestOtp)} className="flex w-full flex-col">
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Create account
</h1>
<p className="text-sm leading-relaxed text-gray-500">
Register to access EDR Freight services.
</p>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Last name <span className="text-red-500">*</span>
</label>
<input
placeholder="Doe"
disabled={loading}
className={fieldClass}
{...register("lastName.en")}
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label="First name"
placeholder="John"
required
disabled={sending}
error={errors.firstName?.en?.message}
{...register("firstName.en")}
/>
<TextInput
label="Last name"
placeholder="Doe"
required
disabled={sending}
error={errors.lastName?.en?.message}
{...register("lastName.en")}
/>
</SimpleGrid>
<TextInput
label="Email"
type="email"
placeholder="john@example.com"
required
disabled={sending}
error={errors.email?.message}
{...register("email")}
/>
{errorText(errors.lastName?.en?.message)}
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Email <span className="text-red-500">*</span>
</label>
<input
type="email"
placeholder="john@example.com"
disabled={loading}
className={fieldClass}
{...register("email")}
/>
{errorText(errors.email?.message)}
</div>
<div className="space-y-1.5">
<label htmlFor="signup-phone" className="text-sm font-medium text-gray-800">
Phone <span className="text-red-500">*</span>
</label>
<Controller
control={control}
name="phone"
render={({ field }) => (
<div
className={`edr-phone-wrapper${
errors.phone ? " edr-phone-wrapper--error" : ""
}`}
>
<RPNInput
international
defaultCountry="ET"
countryCallingCodeEditable={false}
addInternationalOption
id="signup-phone"
placeholder="912 345 678"
disabled={loading}
value={field.value || undefined}
onChange={(v) => field.onChange(v ?? "")}
onBlur={field.onBlur}
/>
</div>
)}
/>
{errorText(errors.phone?.message)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Password <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
placeholder="Create a strong password"
disabled={loading}
className={`${fieldClass} pr-11`}
{...register("password")}
<ControlledPhoneField
control={control}
name="phone"
label="Phone"
required
disabled={sending}
/>
<button
type="button"
onClick={() => setShowPassword((current) => !current)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
{errorText(errors.password?.message)}
{passwordValue.length > 0 ? (
<div className="mt-2 space-y-1">
{passwordRequirements.map((req) => {
const met = req.test(passwordValue);
return (
<div key={req.label} className="flex items-center gap-2">
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500"
}`}
>
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
</span>
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
{req.label}
</span>
</div>
);
})}
<div className="space-y-1.5">
<Text size="sm" fw={500} c="edr-text">
Send verification code via
</Text>
<SegmentedControl
fullWidth
disabled={sending}
value={channel}
onChange={(v) => setChannel(v as OtpChannel)}
data={[
{
value: "phone",
label: (
<span className="flex items-center justify-center gap-1.5">
<Smartphone size={14} /> Phone
</span>
),
},
{
value: "email",
label: (
<span className="flex items-center justify-center gap-1.5">
<Mail size={14} /> Email
</span>
),
},
]}
/>
</div>
) : null}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Confirm password <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showConfirm ? "text" : "password"}
<div>
<PasswordInput
label="Password"
placeholder="Create a strong password"
required
disabled={sending}
error={errors.password?.message}
{...register("password")}
/>
{passwordValue.length > 0 ? (
<div className="mt-2 space-y-1">
{passwordRequirements.map((req) => {
const met = req.test(passwordValue);
return (
<div key={req.label} className="flex items-center gap-2">
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500"
}`}
>
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
</span>
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
{req.label}
</span>
</div>
);
})}
</div>
) : null}
</div>
<PasswordInput
label="Confirm password"
placeholder="Re-enter your password"
disabled={loading}
className={`${fieldClass} pr-11`}
required
disabled={sending}
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
/>
<button
type="button"
onClick={() => setShowConfirm((current) => !current)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
aria-label={showConfirm ? "Hide password" : "Show password"}
{error ? (
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
{error}
</Alert>
) : null}
<Button
type="submit"
color="edr-green"
fullWidth
loading={sending}
rightSection={!sending ? <ArrowRight size={16} /> : undefined}
>
{showConfirm ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
Continue
</Button>
<p className="text-center text-sm text-gray-500">
Already have an account?{" "}
<button
type="button"
onClick={() => navigate("/login")}
className="font-semibold text-primary hover:underline"
>
Sign In
</button>
</p>
</Stack>
</form>
) : (
<Stack gap="md">
<div className="mb-1 flex justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<ShieldCheck size={22} />
</span>
</div>
{errorText(errors.confirmPassword?.message)}
</div>
{error ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
{error}
<div className="space-y-1.5 text-center">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Verify your {otpChannel === "email" ? "email" : "phone"}
</h1>
<p className="text-sm leading-relaxed text-gray-500">
We sent a 6-digit code to{" "}
<span className="font-medium text-gray-700">
{otpChannel === "email"
? maskEmail(pendingData?.email ?? "")
: maskPhone(pendingData?.phone ?? "")}
</span>
. Enter it to finish creating your account.
</p>
</div>
) : null}
<button
type="submit"
disabled={loading}
className={`${primaryButtonClass} flex items-center justify-center gap-2`}
>
{loading ? "Creating account..." : "Create Account"}
{!loading ? <ArrowRight className="h-4 w-4" /> : null}
</button>
{otpError ? (
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
{otpError}
</Alert>
) : null}
<p className="text-center text-sm text-gray-500">
Already have an account?{" "}
<button
type="button"
onClick={() => navigate("/login")}
className="font-semibold text-primary hover:underline"
<Stack gap={6} align="center">
<Text size="sm" fw={500} c="edr-text">
Verification code
</Text>
<PinInput
length={6}
type="number"
oneTimeCode
value={otpCode}
placeholder="0"
disabled={verifying}
styles={{ input: { textAlign: "center" } }}
onChange={setOtpCode}
/>
</Stack>
<Button
color="edr-green"
fullWidth
loading={verifying}
disabled={verifying || otpCode.trim().length !== 6}
onClick={confirmOtp}
>
Sign In
</button>
</p>
</div>
</form>
Verify &amp; create account
</Button>
<div className="flex items-center justify-between">
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={14} />}
disabled={sending || verifying}
onClick={() => {
setStage("form");
setOtpError(null);
}}
>
Back
</Button>
<Button
variant="subtle"
color="edr-green"
leftSection={<RotateCw size={14} />}
disabled={resendIn > 0 || sending || verifying}
onClick={resendOtp}
>
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
</Button>
</div>
</Stack>
)}
</div>
</AuthShell>
);
}

View File

@@ -6,7 +6,6 @@ export type CompanyStep =
| "company"
| "personnel"
| "contact"
| "verify"
| "poa"
| "documents"
| "additional";
@@ -103,7 +102,6 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"contactPersonEmail",
"contactPersonPhone",
],
verify: [],
poa: [],
documents: [],
additional: [],

View File

@@ -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<HTMLIFrameElement>(null);
const [signOpen, setSignOpen] = useState(false);
const [otpOpen, setOtpOpen] = useState(false);
const [otpCode, setOtpCode] = useState("");
const [otpError, setOtpError] = useState<string | null>(null);
const [successOpen, setSuccessOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(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() {
</Button>
<Button
color="edr-green"
loading={signMutation.isPending}
loading={sendOtpMutation.isPending}
disabled={
signMutation.isPending ||
sendOtpMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData)
}
onClick={confirmSign}
>
{usingSaved ? "Approve & sign" : "Confirm signature"}
Continue to verification
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={otpOpen}
onClose={() => setOtpOpen(false)}
title="Verify it's you"
centered
radius="lg"
>
<Stack gap="md">
<Group gap="sm" wrap="nowrap">
<Box
w={40}
h={40}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 12,
background: "var(--mantine-color-edr-green-0)",
flexShrink: 0,
}}
>
<ShieldCheck
size={20}
color="var(--mantine-color-edr-green-6)"
/>
</Box>
<Text size="sm" c="dimmed">
For security, enter the 6-digit code we sent by SMS to{" "}
<Text span fw={600} c="edr-text">
{maskedPhone}
</Text>{" "}
to confirm and apply your signature.
</Text>
</Group>
{otpError && (
<Alert color="red" variant="light" radius="md">
{otpError}
</Alert>
)}
<Stack gap={6}>
<Text size="sm" fw={500} c="edr-text">
Verification code
</Text>
<PinInput
length={6}
type="number"
oneTimeCode
value={otpCode}
placeholder="0"
disabled={signMutation.isPending}
styles={{ input: { textAlign: "center" } }}
onChange={setOtpCode}
/>
</Stack>
<Group justify="space-between" gap="sm">
<Button
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<RotateCw size={14} />}
loading={sendOtpMutation.isPending}
disabled={sendOtpMutation.isPending || signMutation.isPending}
onClick={() => {
setOtpError(null);
sendOtpMutation.mutate();
}}
>
Resend code
</Button>
<Group gap="sm">
<Button
variant="default"
onClick={() => setOtpOpen(false)}
disabled={signMutation.isPending}
>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
loading={signMutation.isPending}
disabled={signMutation.isPending || otpCode.trim().length !== 6}
onClick={confirmOtp}
>
Verify &amp; sign
</Button>
</Group>
</Group>
</Stack>
</Modal>
<ContractSignSuccessModal
opened={successOpen}
reference={data.reference}

Some files were not shown because too many files have changed in this diff Show More