mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("*");
|
||||
}
|
||||
}
|
||||
|
||||
126
apps/edr-freight-api/src/config/fayda.config.ts
Normal file
126
apps/edr-freight-api/src/config/fayda.config.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
21
apps/edr-freight-api/src/logger.middleware.ts
Normal file
21
apps/edr-freight-api/src/logger.middleware.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module";
|
||||
|
||||
exports: [
|
||||
OtpRepository,
|
||||
OtpService,
|
||||
],
|
||||
})
|
||||
export class OtpModule {}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
105
apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts
Normal file
105
apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
597
apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts
Normal file
597
apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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%">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }));
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -1013,6 +1013,7 @@ export interface InventoryFilter {
|
||||
containerId?: string;
|
||||
goodsId?: string;
|
||||
status?: InventoryStatus;
|
||||
direction?: 'IMPORT' | 'EXPORT';
|
||||
search?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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 & 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
Reference in New Issue
Block a user