mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +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
|
REDIS_PORT=6379
|
||||||
|
|
||||||
# --- Notification broker (RabbitMQ) ---------------------------------------------
|
# --- Notification broker (RabbitMQ) ---------------------------------------------
|
||||||
# SMS OTP / notifications are queued to RabbitMQ (consumed by the shared SMS service).
|
# SMS/email OTP + notifications are queued to RabbitMQ (consumed by the shared
|
||||||
# Set RABBITMQ_ENABLED=false to skip the broker entirely (dev without a local broker).
|
# SMS/email services). Set RABBITMQ_ENABLED=false to skip the broker entirely
|
||||||
|
# (dev without a local broker).
|
||||||
RABBITMQ_ENABLED=false
|
RABBITMQ_ENABLED=false
|
||||||
RABBITMQ_URL=amqp://localhost:5672
|
RABBITMQ_URL=amqp://localhost:5672
|
||||||
SMS_QUEUE=sms_queue
|
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": "^17.4.2",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"handlebars": "^4.7.9",
|
"handlebars": "^4.7.9",
|
||||||
|
"jose": "^5.10.0",
|
||||||
"libphonenumber-js": "^1.13.6",
|
"libphonenumber-js": "^1.13.6",
|
||||||
"minio": "7.1.3",
|
"minio": "7.1.3",
|
||||||
"pg": "^8.13.0",
|
"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 { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||||
import { ScheduleModule } from "@nestjs/schedule";
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
@@ -12,6 +16,7 @@ import appConfig from "./config/app.config";
|
|||||||
import databaseConfig from "./config/database.config";
|
import databaseConfig from "./config/database.config";
|
||||||
import telebirrConfig from "./config/telebirr.config";
|
import telebirrConfig from "./config/telebirr.config";
|
||||||
import rabbitmqConfig from "./config/rabbitmq.config";
|
import rabbitmqConfig from "./config/rabbitmq.config";
|
||||||
|
import faydaConfig from "./config/fayda.config";
|
||||||
|
|
||||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||||
import { ContractsModule } from "./modules/contracts/contracts.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";
|
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||||
//New Trains, Wagons, Container and Cargo management modules
|
//New Trains, Wagons, Container and Cargo management modules
|
||||||
import { TrainsModule } from "./modules/trains/trains.module";
|
import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||||
import { ContainersModule } from './modules/container-management/containers.module';
|
import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module';
|
||||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
import { WagonsModule } from "./modules/wagons/wagons.module";
|
||||||
import { RoutesModule } from './modules/routes/routes.module';
|
import { ContainersModule } from "./modules/container-management/containers.module";
|
||||||
import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
import { CargoesModule } from "./modules/cargoes/cargoes.module";
|
||||||
import { OverviewModule } from './modules/overview/overview.module';
|
import { RoutesModule } from "./modules/routes/routes.module";
|
||||||
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
|
||||||
import { DriversModule } from './modules/drivers/drivers.module';
|
import { OverviewModule } from "./modules/overview/overview.module";
|
||||||
import { FuelModule } from './modules/fuel/fuel.module';
|
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
||||||
import { MaintenanceModule } from './modules/maintenance/maintenance.module';
|
import { DriversModule } from "./modules/drivers/drivers.module";
|
||||||
import { FirstMileModule } from './modules/first-mile/first-mile.module';
|
import { FuelModule } from "./modules/fuel/fuel.module";
|
||||||
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
|
||||||
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
|
||||||
import { ImportOperationsModule } from './modules/import-operations/import-operations.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
|
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig],
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
@@ -141,6 +149,8 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
|||||||
LastMileModule,
|
LastMileModule,
|
||||||
InterchangeDocumentsModule,
|
InterchangeDocumentsModule,
|
||||||
ImportOperationsModule,
|
ImportOperationsModule,
|
||||||
|
VerifaydaModule,
|
||||||
|
FleetHistoryModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
@@ -211,4 +221,8 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
// bookings bill to. Idempotent — keyed by fixed IDs.
|
// bookings bill to. Idempotent — keyed by fixed IDs.
|
||||||
await this.govCompaniesSeeder.run();
|
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
|
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
|
// enableImplicitConversion is OFF: class-transformer's implicit boolean
|
||||||
// coercion turns any non-empty multipart/form-data string (including the
|
// coercion turns any non-empty multipart/form-data string (including the
|
||||||
// literal "false") into `true`, silently corrupting flags like isHazardous
|
// 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 { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { SignaturesService } from '../signatures/signatures.service';
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
|
import { OtpService } from '../otp/otp.service';
|
||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
@@ -63,6 +64,7 @@ export class ContractTransitionService {
|
|||||||
private readonly renderer: ContractRendererService,
|
private readonly renderer: ContractRendererService,
|
||||||
private readonly pdfService: ContractPdfService,
|
private readonly pdfService: ContractPdfService,
|
||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
|
private readonly otpService: OtpService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||||
@@ -520,6 +522,12 @@ export class ContractTransitionService {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
throw new BadRequestException('Customer has already signed this contract');
|
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.applySignature(contract, dto, options);
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'SIGNED_CUSTOMER',
|
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 { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||||
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
||||||
import { SignaturesModule } from '../signatures/signatures.module';
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
|
import { OtpModule } from '../otp/otp.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
FilesModule,
|
FilesModule,
|
||||||
MinioModule,
|
MinioModule,
|
||||||
SignaturesModule,
|
SignaturesModule,
|
||||||
|
OtpModule,
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||||
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
// 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 { 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 {
|
export class SignContractDto {
|
||||||
@ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] })
|
@ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] })
|
||||||
@@ -26,4 +26,19 @@ export class SignContractDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
consentText?: string;
|
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 { DriversService } from './drivers.service';
|
||||||
import { CreateDriverDto } from './dto/create-driver.dto';
|
import { CreateDriverDto } from './dto/create-driver.dto';
|
||||||
import { UpdateDriverDto } from './dto/update-driver.dto';
|
import { UpdateDriverDto } from './dto/update-driver.dto';
|
||||||
|
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
||||||
|
|
||||||
@ApiTags('drivers')
|
@ApiTags('drivers')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('drivers')
|
@Controller('drivers')
|
||||||
@FleetView()
|
@FleetView()
|
||||||
export class DriversController {
|
export class DriversController {
|
||||||
constructor(private readonly driversService: DriversService) {}
|
constructor(
|
||||||
|
private readonly driversService: DriversService,
|
||||||
|
private readonly fleetHistory: FleetHistoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@FleetManage()
|
@FleetManage()
|
||||||
@@ -55,6 +59,12 @@ export class DriversController {
|
|||||||
return this.driversService.findById(id);
|
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')
|
@Patch(':id')
|
||||||
@FleetManage()
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Update a driver' })
|
@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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { CreateDriverDto } from './dto/create-driver.dto';
|
import { CreateDriverDto } from './dto/create-driver.dto';
|
||||||
import { UpdateDriverDto } from './dto/update-driver.dto';
|
import { UpdateDriverDto } from './dto/update-driver.dto';
|
||||||
import { Driver, DriverStatus } from './entities/driver.entity';
|
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()
|
@Injectable()
|
||||||
export class DriversService {
|
export class DriversService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Driver)
|
@InjectRepository(Driver)
|
||||||
private readonly driverRepo: Repository<Driver>,
|
private readonly driverRepo: Repository<Driver>,
|
||||||
|
private readonly history: FleetHistoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(dto: CreateDriverDto): Promise<Driver> {
|
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({
|
const existing = await this.driverRepo.findOne({
|
||||||
where: [
|
where: [
|
||||||
{ licenseNumber: dto.licenseNumber },
|
{ 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);
|
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: {
|
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);
|
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);
|
return this.driverRepo.save(driver);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray } from 'class-validator';
|
import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray, IsBoolean } from 'class-validator';
|
||||||
import { DriverStatus } from '../entities/driver.entity';
|
import { DriverStatus, DriverGender } from '../entities/driver.entity';
|
||||||
|
|
||||||
export class CreateDriverDto {
|
export class CreateDriverDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -20,6 +20,10 @@ export class CreateDriverDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
dateOfBirth!: string;
|
dateOfBirth!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(DriverGender)
|
||||||
|
gender?: DriverGender;
|
||||||
|
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
licenseExpiryDate!: string;
|
licenseExpiryDate!: string;
|
||||||
|
|
||||||
@@ -42,4 +46,12 @@ export class CreateDriverDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
faydaVerified?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
faydaSub?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export enum DriverStatus {
|
|||||||
ON_LEAVE = 'ON_LEAVE',
|
ON_LEAVE = 'ON_LEAVE',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum DriverGender {
|
||||||
|
MALE = 'MALE',
|
||||||
|
FEMALE = 'FEMALE',
|
||||||
|
OTHER = 'OTHER',
|
||||||
|
}
|
||||||
|
|
||||||
@Entity({ name: 'drivers', schema: 'freight' })
|
@Entity({ name: 'drivers', schema: 'freight' })
|
||||||
export class Driver extends BaseEntity {
|
export class Driver extends BaseEntity {
|
||||||
@Column({ name: 'license_number', unique: true, nullable: true })
|
@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 })
|
@Column({ name: 'date_of_birth', type: 'date', nullable: true })
|
||||||
dateOfBirth?: Date;
|
dateOfBirth?: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
gender?: DriverGender | null;
|
||||||
|
|
||||||
@Column({ name: 'license_expiry_date', type: 'date', nullable: true })
|
@Column({ name: 'license_expiry_date', type: 'date', nullable: true })
|
||||||
licenseExpiryDate?: Date;
|
licenseExpiryDate?: Date;
|
||||||
|
|
||||||
@@ -51,4 +60,12 @@ export class Driver extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ type: 'numeric', precision: 3, scale: 2, nullable: true })
|
@Column({ type: 'numeric', precision: 3, scale: 2, nullable: true })
|
||||||
rating?: number | null;
|
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',
|
type: 'DELIVERY_FEE',
|
||||||
companyId: fm.booking!.companyId,
|
companyId: fm.booking!.companyId,
|
||||||
companyProfileId: fm.booking!.companyProfileId || '',
|
companyProfileId: fm.booking!.companyProfileId || '',
|
||||||
currency: 'ETB',
|
currency: fm.booking!.paymentCurrency || 'ETB',
|
||||||
lines: [
|
lines: [
|
||||||
{
|
{
|
||||||
chargeType: 'DELIVERY',
|
chargeType: 'DELIVERY',
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export class FirstMileController {
|
|||||||
const record = await this.firstMileService.update(id, dto);
|
const record = await this.firstMileService.update(id, dto);
|
||||||
// Auto-generate invoice if distance or payment was updated
|
// Auto-generate invoice if distance or payment was updated
|
||||||
const booking = await this.bookingsService.findById(record.bookingId);
|
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) {
|
if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) {
|
||||||
await this.billingService.generateInvoice({
|
await this.billingService.generateInvoice({
|
||||||
source: Freight.InvoiceSource.FirstMile,
|
source: Freight.InvoiceSource.FirstMile,
|
||||||
@@ -99,7 +100,7 @@ export class FirstMileController {
|
|||||||
type: "FIRST_MILE",
|
type: "FIRST_MILE",
|
||||||
companyId: booking.companyId,
|
companyId: booking.companyId,
|
||||||
companyProfileId: booking.companyProfileId,
|
companyProfileId: booking.companyProfileId,
|
||||||
currency: "ETB",
|
currency,
|
||||||
|
|
||||||
lines: [
|
lines: [
|
||||||
{
|
{
|
||||||
@@ -108,7 +109,7 @@ export class FirstMileController {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
unitRate: record.remainingPayment,
|
unitRate: record.remainingPayment,
|
||||||
amount: record.remainingPayment,
|
amount: record.remainingPayment,
|
||||||
currency: "ETB",
|
currency,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { FindOptionsWhere, In } from 'typeorm';
|
import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
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 { FirstMileRepository } from "./first-mile.repository";
|
||||||
import { OnEvent } from "@nestjs/event-emitter";
|
import { OnEvent } from "@nestjs/event-emitter";
|
||||||
import { InvoiceEventPayload } from "../billing/billing.service";
|
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 = {
|
type FirstMileListFilter = {
|
||||||
status?: FirstMileStatus;
|
status?: FirstMileStatus;
|
||||||
@@ -43,8 +45,54 @@ export class FirstMileService {
|
|||||||
private readonly vehiclesService: VehiclesService,
|
private readonly vehiclesService: VehiclesService,
|
||||||
private readonly driversService: DriversService,
|
private readonly driversService: DriversService,
|
||||||
private readonly smsClient: SmsClientService,
|
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
|
* 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
|
* paid before any first-mile work proceeds. Throws if the reference is
|
||||||
@@ -210,6 +258,20 @@ export class FirstMileService {
|
|||||||
|
|
||||||
if (dto.vehicleId) {
|
if (dto.vehicleId) {
|
||||||
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
|
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;
|
return record;
|
||||||
@@ -248,6 +310,18 @@ export class FirstMileService {
|
|||||||
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
|
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
|
||||||
const existing = await this.findById(id);
|
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 dtoAny = dto as any;
|
||||||
const updated = await this.firstMileRepository.update(id, {
|
const updated = await this.firstMileRepository.update(id, {
|
||||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||||
@@ -278,6 +352,39 @@ export class FirstMileService {
|
|||||||
if (existing.vehicleId) {
|
if (existing.vehicleId) {
|
||||||
await this.vehiclesService.releaseIfUnused([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
|
// Notify assigned driver on every explicit vehicle assignment or reassignment
|
||||||
@@ -285,6 +392,25 @@ export class FirstMileService {
|
|||||||
void this.notifyDriverAssignment(dto.vehicleId, existing);
|
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
|
// Trip finished — release the vehicles it was holding
|
||||||
if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
|
if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
|
||||||
await this.releaseVehicles(updated);
|
await this.releaseVehicles(updated);
|
||||||
@@ -295,12 +421,40 @@ export class FirstMileService {
|
|||||||
|
|
||||||
async updateStatus(id: string, status: FirstMileStatus): Promise<FirstMile> {
|
async updateStatus(id: string, status: FirstMileStatus): Promise<FirstMile> {
|
||||||
const existing = await this.findById(id);
|
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 });
|
const updated = await this.firstMileRepository.update(id, { status });
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw new NotFoundException(`First-mile record ${id} not found`);
|
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') {
|
if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
|
||||||
await this.releaseVehicles(updated);
|
await this.releaseVehicles(updated);
|
||||||
}
|
}
|
||||||
@@ -430,6 +584,34 @@ export class FirstMileService {
|
|||||||
previousVehicleIds.filter((id) => !vehicleIds.has(id)),
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
allocated: allocations.length,
|
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 { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||||
import { LastMileContainerAllocation } from './last-mile-container-allocation.entity';
|
import { LastMileContainerAllocation } from './last-mile-container-allocation.entity';
|
||||||
|
import { LastMileVehicleAssignment } from './last-mile-vehicle-assignment.entity';
|
||||||
|
|
||||||
export const LAST_MILE_STATUSES = [
|
export const LAST_MILE_STATUSES = [
|
||||||
'PAYMENT_PENDING',
|
'PAYMENT_PENDING',
|
||||||
@@ -57,4 +58,7 @@ export class LastMile extends BaseEntity {
|
|||||||
|
|
||||||
@OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile)
|
@OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile)
|
||||||
containerAllocations?: LastMileContainerAllocation[];
|
containerAllocations?: LastMileContainerAllocation[];
|
||||||
|
|
||||||
|
@OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile)
|
||||||
|
vehicleAssignments?: LastMileVehicleAssignment[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export class LastMileInvoiceService {
|
|||||||
type: 'DELIVERY_FEE',
|
type: 'DELIVERY_FEE',
|
||||||
companyId: lm.booking!.companyId,
|
companyId: lm.booking!.companyId,
|
||||||
companyProfileId: lm.booking!.companyProfileId || '',
|
companyProfileId: lm.booking!.companyProfileId || '',
|
||||||
currency: 'ETB',
|
currency: lm.booking!.paymentCurrency || 'ETB',
|
||||||
lines: [
|
lines: [
|
||||||
{
|
{
|
||||||
chargeType: 'DELIVERY',
|
chargeType: 'DELIVERY',
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export class LastMileController {
|
|||||||
const record = await this.lastMileService.update(id, dto);
|
const record = await this.lastMileService.update(id, dto);
|
||||||
// Auto-generate invoice if distance or payment was updated
|
// Auto-generate invoice if distance or payment was updated
|
||||||
const booking = await this.bookingsService.findById(record.bookingId);
|
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) {
|
if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) {
|
||||||
await this.billingService.generateInvoice({
|
await this.billingService.generateInvoice({
|
||||||
source: Freight.InvoiceSource.LastMile,
|
source: Freight.InvoiceSource.LastMile,
|
||||||
@@ -93,8 +94,8 @@ export class LastMileController {
|
|||||||
type: "LAST_MILE",
|
type: "LAST_MILE",
|
||||||
companyId: booking.companyId,
|
companyId: booking.companyId,
|
||||||
companyProfileId: booking.companyProfileId,
|
companyProfileId: booking.companyProfileId,
|
||||||
currency: "ETB",
|
currency,
|
||||||
|
|
||||||
lines: [
|
lines: [
|
||||||
{
|
{
|
||||||
chargeType: "LAST_MILE",
|
chargeType: "LAST_MILE",
|
||||||
@@ -102,7 +103,7 @@ export class LastMileController {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
unitRate: record.remainingPayment,
|
unitRate: record.remainingPayment,
|
||||||
amount: record.remainingPayment,
|
amount: record.remainingPayment,
|
||||||
currency: "ETB",
|
currency,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource, FindOptionsWhere } from 'typeorm';
|
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||||
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { DriversService } from '../drivers/drivers.service';
|
import { DriversService } from '../drivers/drivers.service';
|
||||||
import { SmsClientService } from '../notifications/sms-client.service';
|
import { SmsClientService } from '../notifications/sms-client.service';
|
||||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||||
|
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
||||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||||
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
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 { LastMileRepository } from './last-mile.repository';
|
||||||
import { InvoiceEventPayload } from '../billing/billing.service';
|
import { InvoiceEventPayload } from '../billing/billing.service';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
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 = {
|
type LastMileListFilter = {
|
||||||
status?: LastMileStatus;
|
status?: LastMileStatus;
|
||||||
@@ -41,8 +44,57 @@ export class LastMileService {
|
|||||||
private readonly driversService: DriversService,
|
private readonly driversService: DriversService,
|
||||||
private readonly smsClient: SmsClientService,
|
private readonly smsClient: SmsClientService,
|
||||||
private readonly dataSource: DataSource,
|
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> {
|
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
||||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||||
|
|
||||||
@@ -132,7 +184,7 @@ export class LastMileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
||||||
return this.lastMileRepository.create({
|
const record = await this.lastMileRepository.create({
|
||||||
bookingId: dto.bookingId,
|
bookingId: dto.bookingId,
|
||||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||||
advancedPayment: dto.advancedPayment ?? 0,
|
advancedPayment: dto.advancedPayment ?? 0,
|
||||||
@@ -142,6 +194,26 @@ export class LastMileService {
|
|||||||
vehicleId: dto.vehicleId ?? null,
|
vehicleId: dto.vehicleId ?? null,
|
||||||
paid: (dto as any).paid ?? false,
|
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")
|
@OnEvent("lastmile.invoice.paid")
|
||||||
@@ -159,6 +231,18 @@ export class LastMileService {
|
|||||||
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
|
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
|
||||||
const existing = await this.findById(id);
|
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 dtoAny = dto as any;
|
||||||
const updated = await this.lastMileRepository.update(id, {
|
const updated = await this.lastMileRepository.update(id, {
|
||||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||||
@@ -180,9 +264,95 @@ export class LastMileService {
|
|||||||
void this.notifyDriverAssignment(dto.vehicleId, existing);
|
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;
|
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> {
|
private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const vehicle = await this.vehiclesService.findById(vehicleId);
|
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||||
@@ -236,6 +406,21 @@ export class LastMileService {
|
|||||||
throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
|
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) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
for (const allocation of allocations) {
|
for (const allocation of allocations) {
|
||||||
await manager.delete(LastMileContainerAllocation, {
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
allocated: allocations.length,
|
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 { NotificationsService } from "./notifications.service";
|
||||||
import { SmsClientService } from "./sms-client.service";
|
import { SmsClientService } from "./sms-client.service";
|
||||||
|
import { EmailClientService } from "./email-client.service";
|
||||||
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
|
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
|
||||||
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
|
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
|
||||||
|
|
||||||
@@ -20,10 +21,25 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"
|
|||||||
queueOptions: { durable: true },
|
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: [],
|
controllers: [],
|
||||||
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService],
|
providers: [
|
||||||
exports: [NotificationsService, SmsClientService],
|
EmailNotificationStrategy,
|
||||||
|
SmsNotificationStrategy,
|
||||||
|
NotificationsService,
|
||||||
|
SmsClientService,
|
||||||
|
EmailClientService,
|
||||||
|
],
|
||||||
|
exports: [NotificationsService, SmsClientService, EmailClientService],
|
||||||
})
|
})
|
||||||
export class NotificationsModule {}
|
export class NotificationsModule {}
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
// otp.controller.ts
|
// otp.controller.ts
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Post,
|
Post,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
|
||||||
|
|
||||||
import { OtpService } from "./otp.service";
|
import { OtpService, OtpTarget } from "./otp.service";
|
||||||
import { Public } from "@edr/api-common";
|
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")
|
@Controller("otp")
|
||||||
@Public()
|
@Public()
|
||||||
export class OtpController {
|
export class OtpController {
|
||||||
@@ -24,9 +33,12 @@ export class OtpController {
|
|||||||
@Post("send")
|
@Post("send")
|
||||||
async sendOtp(
|
async sendOtp(
|
||||||
@Body("phone")
|
@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")
|
@Post("verify")
|
||||||
async verifyOtp(
|
async verifyOtp(
|
||||||
@Body("phone")
|
@Body("phone")
|
||||||
phone: string,
|
phone: string | undefined,
|
||||||
|
|
||||||
|
@Body("email")
|
||||||
|
email: string | undefined,
|
||||||
|
|
||||||
@Body("otp")
|
@Body("otp")
|
||||||
otp: string
|
otp: string
|
||||||
) {
|
) {
|
||||||
return this.otpService.verifyOtp(
|
return this.otpService.verifyOtp(
|
||||||
phone,
|
toTarget(phone, email),
|
||||||
otp
|
otp
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,19 @@ import { BaseEntity } from "@edr/api-common";
|
|||||||
name: "otp_verifications",
|
name: "otp_verifications",
|
||||||
})
|
})
|
||||||
export class OtpVerification extends BaseEntity{
|
export class OtpVerification extends BaseEntity{
|
||||||
|
// Exactly one of phone/email is set per row — the channel the code was sent
|
||||||
|
// through.
|
||||||
@Column({
|
@Column({
|
||||||
unique: true,
|
unique: true,
|
||||||
|
nullable: true,
|
||||||
})
|
})
|
||||||
phone!: string;
|
phone?: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
unique: true,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
email?: string;
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
otp!: string;
|
otp!: string;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module";
|
|||||||
|
|
||||||
exports: [
|
exports: [
|
||||||
OtpRepository,
|
OtpRepository,
|
||||||
|
OtpService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class OtpModule {}
|
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
|
// Create OTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async createOtp(
|
async createOtp(
|
||||||
phone: string,
|
target: { phone?: string; email?: string },
|
||||||
otp: string
|
otp: string
|
||||||
) {
|
) {
|
||||||
const entity =
|
const entity =
|
||||||
this.repository.create({
|
this.repository.create({
|
||||||
phone,
|
phone: target.phone,
|
||||||
|
email: target.email,
|
||||||
otp,
|
otp,
|
||||||
verified: false,
|
verified: false,
|
||||||
});
|
});
|
||||||
@@ -70,10 +97,10 @@ export class OtpRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Verify Phone
|
// Mark Verified
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async verifyPhone(
|
async markVerified(
|
||||||
otpVerification: OtpVerification
|
otpVerification: OtpVerification
|
||||||
) {
|
) {
|
||||||
otpVerification.verified =
|
otpVerification.verified =
|
||||||
@@ -83,4 +110,18 @@ export class OtpRepository {
|
|||||||
otpVerification
|
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
|
// otp.service.ts
|
||||||
|
|
||||||
import {
|
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
|
|
||||||
import { OtpRepository } from "./otp.repository";
|
import { OtpRepository } from "./otp.repository";
|
||||||
|
|
||||||
import { SmsClientService } from "../notifications/sms-client.service";
|
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()
|
@Injectable()
|
||||||
export class OtpService {
|
export class OtpService {
|
||||||
|
logger = new Logger(OtpService.name);
|
||||||
constructor(
|
constructor(
|
||||||
private readonly otpRepository: OtpRepository,
|
private readonly otpRepository: OtpRepository,
|
||||||
private readonly smsClient: SmsClientService
|
private readonly smsClient: SmsClientService,
|
||||||
) {}
|
private readonly emailClient: EmailClientService,
|
||||||
|
) { }
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Generate OTP
|
// Generate OTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
generateOtp(): string {
|
generateOtp(): string {
|
||||||
return Math.floor(
|
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
100000 + Math.random() * 900000
|
|
||||||
).toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Send OTP
|
// Send OTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async sendOtp(phone: string) {
|
async sendOtp(target: OtpTarget) {
|
||||||
try {
|
try {
|
||||||
// The verification code is generated server-side — never supplied by the
|
// 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
|
// 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();
|
const otp = this.generateOtp();
|
||||||
|
|
||||||
// find existing phone
|
// find existing row for this channel
|
||||||
const existingPhone =
|
const existing = await this.otpRepository.findByTarget(target);
|
||||||
await this.otpRepository.findByPhone(
|
|
||||||
phone
|
|
||||||
);
|
|
||||||
|
|
||||||
// update existing otp
|
// update existing otp
|
||||||
if (existingPhone) {
|
if (existing) {
|
||||||
await this.otpRepository.updateOtp(
|
await this.otpRepository.updateOtp(existing, otp);
|
||||||
existingPhone,
|
|
||||||
otp
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// create new otp
|
// create new otp
|
||||||
await this.otpRepository.createOtp(
|
await this.otpRepository.createOtp(target, otp);
|
||||||
phone,
|
|
||||||
otp
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// send sms (queued to RabbitMQ via the shared SMS service)
|
if (target.email) {
|
||||||
await this.smsClient.sendSms({
|
// send email (queued to RabbitMQ via the shared Email service)
|
||||||
to: phone,
|
await this.emailClient.sendEmail({
|
||||||
message: `Your verification code is ${otp}`,
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
||||||
message:
|
message: "OTP sent successfully",
|
||||||
"OTP sent successfully",
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException("Failed to send OTP");
|
||||||
"Failed to send OTP"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,40 +82,70 @@ export class OtpService {
|
|||||||
// Verify OTP
|
// Verify OTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async verifyOtp(
|
async verifyOtp(target: OtpTarget, otp: string) {
|
||||||
phone: string,
|
// find the channel's row
|
||||||
otp: string
|
const otpData = await this.otpRepository.findByTarget(target);
|
||||||
) {
|
|
||||||
// find phone
|
|
||||||
const otpData =
|
|
||||||
await this.otpRepository.findByPhone(
|
|
||||||
phone
|
|
||||||
);
|
|
||||||
|
|
||||||
// phone not found
|
// not found
|
||||||
if (!otpData) {
|
if (!otpData) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Phone number not found"
|
target.email ? "Email address not found" : "Phone number not found",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// invalid otp
|
// invalid otp
|
||||||
if (otpData.otp !== otp) {
|
if (otpData.otp !== otp) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException("Invalid OTP");
|
||||||
"Invalid OTP"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// verify phone
|
// mark verified
|
||||||
await this.otpRepository.verifyPhone(
|
await this.otpRepository.markVerified(otpData);
|
||||||
otpData
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
||||||
message:
|
message: target.email
|
||||||
"Phone verified successfully",
|
? "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 {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Post,
|
Logger,
|
||||||
|
Post,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
import { Public } from "@edr/api-common";
|
import { Public } from "@edr/api-common";
|
||||||
@@ -22,14 +23,17 @@ import { PaymentService } from "./payment.service";
|
|||||||
@Public()
|
@Public()
|
||||||
@Controller("internal/payments")
|
@Controller("internal/payments")
|
||||||
export class InternalPaymentController {
|
export class InternalPaymentController {
|
||||||
constructor(private readonly paymentService: PaymentService) { }
|
private readonly logger = new Logger(InternalPaymentController.name);
|
||||||
|
constructor(private readonly paymentService: PaymentService) { }
|
||||||
|
|
||||||
@Post("mark-paid")
|
@Post("mark-paid")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)",
|
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);
|
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 { BaseEntity } from '@edr/api-common';
|
||||||
|
import { LoadingStatus } from '@edr/types';
|
||||||
import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm';
|
import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm';
|
||||||
|
|
||||||
import { Booking } from '../../bookings/entities/booking.entity';
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { TrainSchedule } from './train-schedule.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' })
|
@Entity({ schema: 'freight', name: 'train_schedule_bookings' })
|
||||||
@Index(['trainScheduleId', 'bookingId'], { unique: true })
|
@Index(['trainScheduleId', 'bookingId'], { unique: true })
|
||||||
@Index(['bookingId'], { unique: true })
|
@Index(['bookingId'], { unique: true })
|
||||||
@@ -23,4 +29,7 @@ export class TrainScheduleBooking extends BaseEntity {
|
|||||||
@ManyToOne(() => Booking)
|
@ManyToOne(() => Booking)
|
||||||
@JoinColumn({ name: 'booking_id' })
|
@JoinColumn({ name: 'booking_id' })
|
||||||
booking?: Booking;
|
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 },
|
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 { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
|
||||||
import { PinWagonsDto } from "./dto/pin-wagons.dto";
|
import { PinWagonsDto } from "./dto/pin-wagons.dto";
|
||||||
import { UpdateContainerItemDto } from "./dto/update-container-item.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 { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
|
||||||
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
|
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
|
||||||
import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
|
import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
|
||||||
@@ -336,6 +337,28 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.getCompositionRemovals(id);
|
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")
|
@Post("schedules/:id/pin-wagons")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: "Pin physical wagons to train set slots" })
|
@ApiOperation({ summary: "Pin physical wagons to train set slots" })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
AllocationLoadType,
|
AllocationLoadType,
|
||||||
|
LoadingStatus,
|
||||||
SchedulingStatus,
|
SchedulingStatus,
|
||||||
TrainCheckpointKind,
|
TrainCheckpointKind,
|
||||||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
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 { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
||||||
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
||||||
import { UpdateContainerItemDto } from './dto/update-container-item.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 { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
||||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||||
import { PreviewTrainScheduleDto } from './dto/preview-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) {
|
async pinWagons(scheduleId: string, dto: PinWagonsDto) {
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
|
|||||||
@@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards';
|
|||||||
import { VehiclesService } from './vehicles.service';
|
import { VehiclesService } from './vehicles.service';
|
||||||
import { CreateVehicleDto } from './dto/create-vehicle.dto';
|
import { CreateVehicleDto } from './dto/create-vehicle.dto';
|
||||||
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
|
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
|
||||||
|
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
||||||
|
|
||||||
@ApiTags('vehicles')
|
@ApiTags('vehicles')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('vehicles')
|
@Controller('vehicles')
|
||||||
@FleetView()
|
@FleetView()
|
||||||
export class VehiclesController {
|
export class VehiclesController {
|
||||||
constructor(private readonly vehiclesService: VehiclesService) {}
|
constructor(
|
||||||
|
private readonly vehiclesService: VehiclesService,
|
||||||
|
private readonly fleetHistory: FleetHistoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@FleetManage()
|
@FleetManage()
|
||||||
@@ -57,6 +61,12 @@ export class VehiclesController {
|
|||||||
return this.vehiclesService.findById(id);
|
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')
|
@Patch(':id')
|
||||||
@FleetManage()
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Update a vehicle' })
|
@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 { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity';
|
||||||
import { LastMileContainerAllocation } from '../last-mile/entities/last-mile-container-allocation.entity';
|
import { LastMileContainerAllocation } from '../last-mile/entities/last-mile-container-allocation.entity';
|
||||||
import { BookingContainerAllocation } from '../bookings/entities/booking-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()
|
@Injectable()
|
||||||
export class VehiclesService {
|
export class VehiclesService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Vehicle)
|
@InjectRepository(Vehicle)
|
||||||
private readonly vehicleRepo: Repository<Vehicle>,
|
private readonly vehicleRepo: Repository<Vehicle>,
|
||||||
|
private readonly history: FleetHistoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(dto: CreateVehicleDto): Promise<Vehicle> {
|
async create(dto: CreateVehicleDto): Promise<Vehicle> {
|
||||||
@@ -34,7 +37,28 @@ export class VehiclesService {
|
|||||||
registrationNumber,
|
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: {
|
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);
|
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> {
|
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 });
|
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 { 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 {
|
export class CreateAllocationRuleDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: 100 })
|
@ApiPropertyOptional({ default: 100 })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
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';
|
import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity';
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ export class CreateWarehouseDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(160)
|
@MaxLength(160)
|
||||||
|
@Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
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';
|
import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ export class FeeRuleTierDto {
|
|||||||
export class CreateFeeRuleDto {
|
export class CreateFeeRuleDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: FEE_RULE_TYPES })
|
@ApiProperty({ enum: FEE_RULE_TYPES })
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export class FilterWarehouseInventoryDto {
|
|||||||
@IsEnum(WAREHOUSE_INVENTORY_STATUSES)
|
@IsEnum(WAREHOUSE_INVENTORY_STATUSES)
|
||||||
status?: WarehouseInventoryStatus;
|
status?: WarehouseInventoryStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['IMPORT', 'EXPORT'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['IMPORT', 'EXPORT'])
|
||||||
|
direction?: 'IMPORT' | 'EXPORT';
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -430,6 +430,7 @@ export class WarehouseInventoryService {
|
|||||||
...(filter.status ? { status: filter.status } : {}),
|
...(filter.status ? { status: filter.status } : {}),
|
||||||
...(createdAt ? { createdAt } : {}),
|
...(createdAt ? { createdAt } : {}),
|
||||||
...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}),
|
...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}),
|
||||||
|
...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const search = filter.search?.trim();
|
const search = filter.search?.trim();
|
||||||
@@ -2032,6 +2033,22 @@ export class WarehouseInventoryService {
|
|||||||
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
|
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
|
||||||
if (isTruckLeaving) {
|
if (isTruckLeaving) {
|
||||||
await this.invoices.assertClearanceAllowed(id);
|
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
|
const releaseDate = isTruckLeaving
|
||||||
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
|
? 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 WarehouseListPage from "./pages/warehouses/WarehouseListPage";
|
||||||
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
|
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
|
||||||
import { HealthCheck } from "./features/health/HealthCheck";
|
import { HealthCheck } from "./features/health/HealthCheck";
|
||||||
|
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
||||||
|
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
{
|
{
|
||||||
@@ -330,7 +331,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Terminal Inventory",
|
label: "Terminal Inventory",
|
||||||
href: "/dashboard/warehouse-inventory",
|
href: "/dashboard/warehouse-inventory?direction=IMPORT",
|
||||||
icon: <Package />,
|
icon: <Package />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -377,7 +378,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Terminal Inventory",
|
label: "Terminal Inventory",
|
||||||
href: "/dashboard/warehouse-inventory",
|
href: "/dashboard/warehouse-inventory?direction=EXPORT",
|
||||||
icon: <Package />,
|
icon: <Package />,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -569,6 +570,7 @@ const App = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/auth" element={<LoginPage />} />
|
<Route path="/auth" element={<LoginPage />} />
|
||||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||||
|
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
@@ -578,6 +580,7 @@ const App = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||||
<Route path="/health" element={<HealthCheck />} />
|
<Route path="/health" element={<HealthCheck />} />
|
||||||
|
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Loader2, Calendar } from "lucide-react";
|
import { Loader2, ShieldCheck } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -12,7 +14,6 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
TextInput,
|
TextInput,
|
||||||
ActionIcon,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +21,10 @@ import {
|
|||||||
type FleetFormFieldDef,
|
type FleetFormFieldDef,
|
||||||
} from "@/pages/fleet/config/resources";
|
} from "@/pages/fleet/config/resources";
|
||||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||||
|
import {
|
||||||
|
verifaydaService,
|
||||||
|
type FaydaCallbackMessage,
|
||||||
|
} from "@/services/verifayda.service";
|
||||||
|
|
||||||
export interface FleetFormDialogProps {
|
export interface FleetFormDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -31,8 +36,32 @@ export interface FleetFormDialogProps {
|
|||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
selectOptionsLoading?: boolean;
|
selectOptionsLoading?: boolean;
|
||||||
onSubmit: (values: Record<string, unknown>) => void;
|
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 = (
|
const buildInitialValues = (
|
||||||
fields: FleetFormFieldDef[],
|
fields: FleetFormFieldDef[],
|
||||||
emptyValues: Record<string, unknown>,
|
emptyValues: Record<string, unknown>,
|
||||||
@@ -72,9 +101,12 @@ const FleetFormDialog = ({
|
|||||||
isSubmitting,
|
isSubmitting,
|
||||||
selectOptionsLoading,
|
selectOptionsLoading,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
verifyWithFayda,
|
||||||
}: FleetFormDialogProps) => {
|
}: FleetFormDialogProps) => {
|
||||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
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
|
// 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
|
// when `fields`/`emptyValues` get new object refs (they're rebuilt whenever the
|
||||||
@@ -87,10 +119,88 @@ const FleetFormDialog = ({
|
|||||||
if (open) {
|
if (open) {
|
||||||
setValues(buildInitialValues(fields, emptyValues, initialRecord));
|
setValues(buildInitialValues(fields, emptyValues, initialRecord));
|
||||||
setErrors({});
|
setErrors({});
|
||||||
|
setFaydaError(null);
|
||||||
|
setFaydaLoading(false);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, recordId]);
|
}, [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(
|
const shortFields = useMemo(
|
||||||
() => fields.filter((f) => f.type !== "textarea"),
|
() => fields.filter((f) => f.type !== "textarea"),
|
||||||
[fields],
|
[fields],
|
||||||
@@ -127,6 +237,12 @@ const FleetFormDialog = ({
|
|||||||
const date = new Date(stringValue + "T00:00:00Z");
|
const date = new Date(stringValue + "T00:00:00Z");
|
||||||
if (isNaN(date.getTime())) {
|
if (isNaN(date.getTime())) {
|
||||||
next[field.name] = `${field.label} is not a valid date`;
|
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()) {
|
} else if (date > new Date()) {
|
||||||
next[field.name] = `${field.label} cannot be in the future`;
|
next[field.name] = `${field.label} cannot be in the future`;
|
||||||
}
|
}
|
||||||
@@ -147,6 +263,12 @@ const FleetFormDialog = ({
|
|||||||
}, [fields]);
|
}, [fields]);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
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;
|
if (!validate()) return;
|
||||||
const payload = Object.fromEntries(
|
const payload = Object.fromEntries(
|
||||||
Object.entries(values)
|
Object.entries(values)
|
||||||
@@ -167,6 +289,9 @@ const FleetFormDialog = ({
|
|||||||
const renderField = (field: FleetFormFieldDef) => {
|
const renderField = (field: FleetFormFieldDef) => {
|
||||||
const value = values[field.name];
|
const value = values[field.name];
|
||||||
const error = errors[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") {
|
if (field.type === "select") {
|
||||||
return (
|
return (
|
||||||
@@ -186,7 +311,7 @@ const FleetFormDialog = ({
|
|||||||
}
|
}
|
||||||
error={error}
|
error={error}
|
||||||
searchable
|
searchable
|
||||||
disabled={selectOptionsLoading}
|
disabled={selectOptionsLoading || isDisabled}
|
||||||
rightSection={
|
rightSection={
|
||||||
selectOptionsLoading ? (
|
selectOptionsLoading ? (
|
||||||
<Loader2 size={14} className="animate-spin" />
|
<Loader2 size={14} className="animate-spin" />
|
||||||
@@ -216,7 +341,7 @@ const FleetFormDialog = ({
|
|||||||
error={error}
|
error={error}
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
disabled={selectOptionsLoading}
|
disabled={selectOptionsLoading || isDisabled}
|
||||||
rightSection={
|
rightSection={
|
||||||
selectOptionsLoading ? (
|
selectOptionsLoading ? (
|
||||||
<Loader2 size={14} className="animate-spin" />
|
<Loader2 size={14} className="animate-spin" />
|
||||||
@@ -240,7 +365,7 @@ const FleetFormDialog = ({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
error={error}
|
error={error}
|
||||||
disabled={field.disabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -260,7 +385,7 @@ const FleetFormDialog = ({
|
|||||||
}
|
}
|
||||||
error={error}
|
error={error}
|
||||||
minRows={3}
|
minRows={3}
|
||||||
disabled={field.disabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -280,13 +405,8 @@ const FleetFormDialog = ({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
error={error}
|
error={error}
|
||||||
disabled={field.disabled}
|
disabled={isDisabled}
|
||||||
description={field.description || "Select a date"}
|
description={field.description || "Select a date"}
|
||||||
rightSection={
|
|
||||||
<ActionIcon size="sm" variant="subtle" color="green">
|
|
||||||
<Calendar size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
styles={{
|
styles={{
|
||||||
@@ -318,7 +438,7 @@ const FleetFormDialog = ({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
error={error}
|
error={error}
|
||||||
disabled={field.disabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -333,6 +453,40 @@ const FleetFormDialog = ({
|
|||||||
centered
|
centered
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<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">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
{shortFields.map(renderField)}
|
{shortFields.map(renderField)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
@@ -345,6 +499,7 @@ const FleetFormDialog = ({
|
|||||||
color="edr-green"
|
color="edr-green"
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
|
disabled={verifyWithFayda && !faydaVerified}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</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 { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ export interface FleetRecordActionsProps {
|
|||||||
onEdit: (record: FleetRecord) => void;
|
onEdit: (record: FleetRecord) => void;
|
||||||
onRemove: (record: FleetRecord) => void;
|
onRemove: (record: FleetRecord) => void;
|
||||||
onAssignDriver?: (record: FleetRecord) => void;
|
onAssignDriver?: (record: FleetRecord) => void;
|
||||||
|
onHistory?: (record: FleetRecord) => void;
|
||||||
layout?: "row" | "compact";
|
layout?: "row" | "compact";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,12 +21,16 @@ const FleetRecordActions = ({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onRemove,
|
onRemove,
|
||||||
onAssignDriver,
|
onAssignDriver,
|
||||||
|
onHistory,
|
||||||
layout = "row",
|
layout = "row",
|
||||||
}: FleetRecordActionsProps) => {
|
}: FleetRecordActionsProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const removeLabel = config.removeActionLabel ?? "Delete";
|
const removeLabel = config.removeActionLabel ?? "Delete";
|
||||||
const showDetail = Boolean(config.detailPath && "id" in record);
|
const showDetail = Boolean(config.detailPath && "id" in record);
|
||||||
const isVehicle = config.slug === "vehicles";
|
const isVehicle = config.slug === "vehicles";
|
||||||
|
const showHistory =
|
||||||
|
Boolean(onHistory) &&
|
||||||
|
(config.slug === "drivers" || config.slug === "vehicles");
|
||||||
|
|
||||||
const handleDetail = () => {
|
const handleDetail = () => {
|
||||||
if (!config.detailPath || !("id" in record)) return;
|
if (!config.detailPath || !("id" in record)) return;
|
||||||
@@ -57,6 +62,14 @@ const FleetRecordActions = ({
|
|||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{showHistory ? (
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => onHistory?.(record)}
|
||||||
|
leftSection={<History size={14} strokeWidth={2} />}
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
{showDetail ? (
|
{showDetail ? (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleDetail}
|
onClick={handleDetail}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Badge, Text } from "@mantine/core";
|
|||||||
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
|
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
|
||||||
import { formatCell as formatRuleEngineCell } from "@/components/ruleEngine/ruleEngineFormat";
|
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>>();
|
const optionLabelMap = new Map<string, Map<string, string>>();
|
||||||
|
|
||||||
@@ -20,6 +20,16 @@ export const formatFleetCell = (
|
|||||||
format?: FleetColumnFormat,
|
format?: FleetColumnFormat,
|
||||||
accessorKey?: string,
|
accessorKey?: string,
|
||||||
): ReactNode => {
|
): 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") {
|
if (format === "statusBadge") {
|
||||||
const status = value == null || value === "" ? "—" : String(value);
|
const status = value == null || value === "" ? "—" : String(value);
|
||||||
const getStatusColor = (st: string): string => {
|
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 { api } from '@/services/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
|
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
|
||||||
import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options';
|
import { extractErrorMessage, lettersOnly, statusOptions, warehouseTypeOptions } from './options';
|
||||||
|
|
||||||
interface CreateWarehouseModalProps {
|
interface CreateWarehouseModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
@@ -120,7 +120,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
|||||||
placeholder="Modjo Open Warehouse"
|
placeholder="Modjo Open Warehouse"
|
||||||
required
|
required
|
||||||
value={form.name}
|
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
|
<TextInput
|
||||||
label="Code"
|
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') => {
|
export const extractErrorMessage = (error: unknown, fallback = 'Something went wrong') => {
|
||||||
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
||||||
const data = responseData && typeof responseData === 'object' ? (responseData as Record<string, unknown>) : undefined;
|
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,
|
["train-scheduling", "unassigned", id] as const,
|
||||||
compositionRemovals: (id: string) =>
|
compositionRemovals: (id: string) =>
|
||||||
["train-scheduling", "removals", id] as const,
|
["train-scheduling", "removals", id] as const,
|
||||||
|
importLoadingBookings: (id: string) =>
|
||||||
|
["train-scheduling", "import-loading-bookings", id] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
FLEET: {
|
FLEET: {
|
||||||
|
|||||||
@@ -324,6 +324,10 @@ export const URL_CONSTANTS = {
|
|||||||
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
||||||
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
||||||
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
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) =>
|
IMPORT_DJIBOUTI: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/import-djibouti`,
|
`/train-scheduling/schedules/${id}/import-djibouti`,
|
||||||
IMPORT_DJIBOUTI_DOCUMENTS: (id: string) =>
|
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 FleetCardGrid from "@/components/fleet/FleetCardGrid";
|
||||||
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
||||||
|
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
|
||||||
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||||
@@ -42,6 +43,7 @@ const FleetResourcePage = () => {
|
|||||||
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
||||||
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
|
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
|
||||||
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
|
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
|
||||||
|
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
||||||
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
||||||
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
||||||
|
|
||||||
@@ -273,6 +275,7 @@ const FleetResourcePage = () => {
|
|||||||
}}
|
}}
|
||||||
onRemove={setRemoveTarget}
|
onRemove={setRemoveTarget}
|
||||||
onAssignDriver={setAssigningDriver}
|
onAssignDriver={setAssigningDriver}
|
||||||
|
onHistory={setHistoryTarget}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -510,6 +513,7 @@ const FleetResourcePage = () => {
|
|||||||
isSubmitting={create.isPending || update.isPending}
|
isSubmitting={create.isPending || update.isPending}
|
||||||
selectOptionsLoading={selectOptionsLoading}
|
selectOptionsLoading={selectOptionsLoading}
|
||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
|
verifyWithFayda={Boolean(config.faydaVerification)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -580,6 +584,13 @@ const FleetResourcePage = () => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<FleetHistoryModal
|
||||||
|
opened={Boolean(historyTarget)}
|
||||||
|
onClose={() => setHistoryTarget(null)}
|
||||||
|
entity={slug === "vehicles" ? "vehicle" : "driver"}
|
||||||
|
record={historyTarget}
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ const DRIVER_STATUS_OPTIONS = [
|
|||||||
{ label: "On leave", value: "ON_LEAVE" },
|
{ 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 = {
|
export const driversConfig: FleetResourceConfig = {
|
||||||
slug: "drivers",
|
slug: "drivers",
|
||||||
label: "Drivers",
|
label: "Drivers",
|
||||||
@@ -29,24 +35,28 @@ export const driversConfig: FleetResourceConfig = {
|
|||||||
options: DRIVER_STATUS_OPTIONS,
|
options: DRIVER_STATUS_OPTIONS,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
faydaVerification: true,
|
||||||
searchKeys: ["firstName", "lastName", "email", "phoneNumber", "licenseNumber", "status"],
|
searchKeys: ["firstName", "lastName", "email", "phoneNumber", "licenseNumber", "status"],
|
||||||
columns: [
|
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: "firstName", header: "First Name", accessorKey: "firstName", format: "code", size: 120 },
|
||||||
{ id: "lastName", header: "Last Name", accessorKey: "lastName", 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: "email", header: "Email", accessorKey: "email", format: "code", size: 180 },
|
||||||
{ id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber", format: "code", size: 120 },
|
{ 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: "licenseExpiryDate", header: "License Expiry", accessorKey: "licenseExpiryDate", format: "code", size: 130 },
|
||||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
|
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
|
||||||
|
{ id: "faydaVerified", header: "Fayda", accessorKey: "faydaVerified", format: "verifiedBadge", size: 90 },
|
||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
{ name: "licenseNumber", label: "License Number", type: "text", required: true },
|
{ name: "licenseNumber", label: "Driver's License Number", type: "text", required: true },
|
||||||
{ name: "firstName", label: "First Name", type: "text", required: true },
|
{ name: "firstName", label: "First Name", type: "text", required: true, faydaLocked: true },
|
||||||
{ name: "lastName", label: "Last Name", type: "text", required: true },
|
{ name: "lastName", label: "Last Name", type: "text", required: true, faydaLocked: true },
|
||||||
{ name: "email", label: "Email", type: "email", required: true },
|
{ name: "email", label: "Email", type: "email", required: true, faydaLocked: true },
|
||||||
{ name: "phoneNumber", label: "Phone Number", type: "text", required: true },
|
{ name: "phoneNumber", label: "Phone Number", type: "text", required: true, faydaLocked: true },
|
||||||
{ name: "dateOfBirth", label: "Date of Birth", type: "date", required: true },
|
{ name: "dateOfBirth", label: "Date of Birth", type: "date", required: true, faydaLocked: true },
|
||||||
{ name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: 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: "status", label: "Status", type: "select", required: true, options: DRIVER_STATUS_OPTIONS },
|
||||||
{ name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS },
|
{ name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS },
|
||||||
{ name: "address", label: "Address", type: "textarea" },
|
{ name: "address", label: "Address", type: "textarea" },
|
||||||
@@ -60,6 +70,7 @@ export const driversConfig: FleetResourceConfig = {
|
|||||||
email: "",
|
email: "",
|
||||||
phoneNumber: "",
|
phoneNumber: "",
|
||||||
dateOfBirth: "",
|
dateOfBirth: "",
|
||||||
|
gender: "",
|
||||||
licenseExpiryDate: "",
|
licenseExpiryDate: "",
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
vehicleTypesAuthorized: [],
|
vehicleTypesAuthorized: [],
|
||||||
|
|||||||
@@ -33,13 +33,23 @@ export interface FleetResourceColumn {
|
|||||||
id: string;
|
id: string;
|
||||||
header: string;
|
header: string;
|
||||||
accessorKey: string;
|
accessorKey: string;
|
||||||
format?: ColumnFormat | "statusBadge";
|
format?: ColumnFormat | "statusBadge" | "verifiedBadge";
|
||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FleetFormFieldDef extends FormFieldDef {
|
export interface FleetFormFieldDef extends FormFieldDef {
|
||||||
dynamicOptions?: FleetDynamicOptions;
|
dynamicOptions?: FleetDynamicOptions;
|
||||||
noneOption?: boolean;
|
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 {
|
export interface FleetListFilterDef {
|
||||||
@@ -73,6 +83,8 @@ export interface FleetResourceConfig {
|
|||||||
cardCodeKey?: string;
|
cardCodeKey?: string;
|
||||||
cardSubtitleKey?: string;
|
cardSubtitleKey?: string;
|
||||||
searchKeys: 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> = {
|
export const FLEET_BASE_PATH_BY_SLUG: Record<FleetResourceSlug, string> = {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
Printer,
|
Printer,
|
||||||
|
Receipt,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Ruler,
|
Ruler,
|
||||||
Trash,
|
Trash,
|
||||||
@@ -879,10 +880,14 @@ const FirstMilePage = () => {
|
|||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<ArrowRight size={15} />}
|
leftSection={<ArrowRight size={15} />}
|
||||||
disabled={!nextStatus}
|
disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)}
|
||||||
onClick={() => handleAdvanceStatus(row.original)}
|
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.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@@ -919,6 +924,13 @@ const FirstMilePage = () => {
|
|||||||
>
|
>
|
||||||
Add distance
|
Add distance
|
||||||
</Menu.Item>
|
</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 && (
|
{canPrint && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Printer size={15} />}
|
leftSection={<Printer size={15} />}
|
||||||
@@ -954,7 +966,7 @@ const FirstMilePage = () => {
|
|||||||
}, [vehicleOptions]);
|
}, [vehicleOptions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md" p="md">
|
||||||
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="md" pb="sm" w="100%">
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Printer,
|
Printer,
|
||||||
|
Receipt,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Ruler,
|
Ruler,
|
||||||
Trash,
|
Trash,
|
||||||
@@ -49,6 +50,7 @@ import { driversService, type Driver } from "@/services/drivers.service";
|
|||||||
import { ratesService } from "@/services/rates.service";
|
import { ratesService } from "@/services/rates.service";
|
||||||
import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable";
|
import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable";
|
||||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||||
|
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
||||||
import { api } from "@/auth/http";
|
import { api } from "@/auth/http";
|
||||||
|
|
||||||
const formatPrice = (amount: number) =>
|
const formatPrice = (amount: number) =>
|
||||||
@@ -92,6 +94,58 @@ const vehicleLabel = (record: LastMileRecord) => {
|
|||||||
|
|
||||||
const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId);
|
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 bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId;
|
||||||
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
|
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
|
||||||
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
|
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
|
||||||
@@ -955,7 +1009,24 @@ const LastMilePage = () => {
|
|||||||
const delivered = row.original.status === "DELIVERED";
|
const delivered = row.original.status === "DELIVERED";
|
||||||
const releaseRow =
|
const releaseRow =
|
||||||
pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original));
|
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 (
|
return (
|
||||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||||
<Menu position="bottom-end" width={200} withinPortal>
|
<Menu position="bottom-end" width={200} withinPortal>
|
||||||
@@ -967,15 +1038,17 @@ const LastMilePage = () => {
|
|||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<ArrowRight size={15} />}
|
leftSection={<ArrowRight size={15} />}
|
||||||
disabled={!nextStatus}
|
disabled={!nextStatus || !canAdvance}
|
||||||
onClick={() => handleAdvanceStatus(row.original)}
|
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.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Truck size={15} />}
|
leftSection={<Truck size={15} />}
|
||||||
disabled={assigned || delivered}
|
disabled={!canAssignStep}
|
||||||
onClick={() => openAssign(row.original.id)}
|
onClick={() => openAssign(row.original.id)}
|
||||||
>
|
>
|
||||||
Assign
|
Assign
|
||||||
@@ -989,10 +1062,17 @@ const LastMilePage = () => {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Truck size={15} />}
|
leftSection={<Truck size={15} />}
|
||||||
disabled={!assigned}
|
disabled={!canArrive}
|
||||||
onClick={() => openTruckArrival(row.original)}
|
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.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@@ -1003,11 +1083,18 @@ const LastMilePage = () => {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Ruler size={15} />}
|
leftSection={<Ruler size={15} />}
|
||||||
disabled={delivered}
|
disabled={!canDistance}
|
||||||
onClick={() => openDistance(row.original.id)}
|
onClick={() => openDistance(row.original.id)}
|
||||||
>
|
>
|
||||||
Add distance
|
Add distance
|
||||||
</Menu.Item>
|
</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 && (
|
{canPrint && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Printer size={15} />}
|
leftSection={<Printer size={15} />}
|
||||||
@@ -1044,7 +1131,7 @@ const LastMilePage = () => {
|
|||||||
}, [vehicleOptions, pickupReadyByBooking]);
|
}, [vehicleOptions, pickupReadyByBooking]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md" p="md">
|
||||||
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="md" pb="sm" w="100%">
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
@@ -1320,6 +1407,18 @@ const LastMilePage = () => {
|
|||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{activeRecord && <BookingInfo record={activeRecord} />}
|
{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">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" onClick={() => { setDetailOpen(false); setActiveId(null); }}>Close</Button>
|
<Button variant="default" onClick={() => { setDetailOpen(false); setActiveId(null); }}>Close</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
} from "@/components/trainScheduling/containerPlacement.util";
|
} from "@/components/trainScheduling/containerPlacement.util";
|
||||||
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
||||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||||
|
import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||||
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
||||||
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
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(
|
const eligibleFilters = useMemo(
|
||||||
() =>
|
() =>
|
||||||
schedule
|
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 ? (
|
{gatepassApplies ? (
|
||||||
<Paper radius="xl" p="lg" withBorder>
|
<Paper radius="xl" p="lg" withBorder>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
|||||||
@@ -14,7 +14,17 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { PageHeader } from '@/components/page';
|
||||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||||
@@ -36,6 +46,7 @@ import {
|
|||||||
useInterchangeDocuments,
|
useInterchangeDocuments,
|
||||||
} from '@/hooks/useInterchangeDocuments';
|
} from '@/hooks/useInterchangeDocuments';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { trainSchedulingService } from '@/services/trainScheduling.service';
|
||||||
import type {
|
import type {
|
||||||
AutoUnloadExportDjiboutiResult,
|
AutoUnloadExportDjiboutiResult,
|
||||||
ExportTrain,
|
ExportTrain,
|
||||||
@@ -179,6 +190,14 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
|||||||
const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' });
|
const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' });
|
||||||
const autoUnload = useAutoUnloadExportAtDjibouti();
|
const autoUnload = useAutoUnloadExportAtDjibouti();
|
||||||
const generateInterchange = useGenerateInterchangeDocument();
|
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 [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
|
||||||
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
|
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
|
||||||
const [historyInventoryId, setHistoryInventoryId] = 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]),
|
.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) => {
|
const unloadTrain = async (train: ExportTrain) => {
|
||||||
setBusyScheduleId(train.scheduleId);
|
setBusyScheduleId(train.scheduleId);
|
||||||
try {
|
try {
|
||||||
@@ -346,6 +384,16 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
|||||||
>
|
>
|
||||||
Open
|
Open
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
color="green"
|
color="green"
|
||||||
@@ -356,7 +404,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
|||||||
<Truck size={14} />
|
<Truck size={14} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
loading={busyScheduleId === train.scheduleId}
|
loading={busyScheduleId === train.scheduleId && autoUnload.isPending}
|
||||||
onClick={() => unloadTrain(train)}
|
onClick={() => unloadTrain(train)}
|
||||||
>
|
>
|
||||||
Auto Unload Export Items
|
Auto Unload Export Items
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default function WarehouseInventoryPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
|
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
|
||||||
|
const direction = (searchParams.get('direction') as 'IMPORT' | 'EXPORT' | null) ?? undefined;
|
||||||
const [filter, setFilter] = useState<InventoryFilter>(
|
const [filter, setFilter] = useState<InventoryFilter>(
|
||||||
initialStatus ? { status: initialStatus } : {},
|
initialStatus ? { status: initialStatus } : {},
|
||||||
);
|
);
|
||||||
@@ -28,8 +29,8 @@ export default function WarehouseInventoryPage() {
|
|||||||
|
|
||||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||||
const queryFilter = useMemo<InventoryFilter>(
|
const queryFilter = useMemo<InventoryFilter>(
|
||||||
() => ({ ...filter, search: debouncedSearch || undefined }),
|
() => ({ ...filter, direction, search: debouncedSearch || undefined }),
|
||||||
[filter, debouncedSearch],
|
[filter, direction, debouncedSearch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const warehousesQuery = useWarehouses();
|
const warehousesQuery = useWarehouses();
|
||||||
@@ -53,7 +54,13 @@ export default function WarehouseInventoryPage() {
|
|||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<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."
|
subtitle="Track received items through the storage, reservation, loading and dispatch lifecycle."
|
||||||
action={
|
action={
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
} from '@/hooks/useWarehouses';
|
} from '@/hooks/useWarehouses';
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
||||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options';
|
||||||
|
|
||||||
const FREIGHT = [
|
const FREIGHT = [
|
||||||
{ value: 'CONTAINER', label: 'Container' },
|
{ value: 'CONTAINER', label: 'Container' },
|
||||||
@@ -253,7 +253,7 @@ function AllocationRules() {
|
|||||||
required
|
required
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.currentTarget.value;
|
const value = lettersOnly(e.currentTarget.value);
|
||||||
setForm((f) => ({ ...f, name: value }));
|
setForm((f) => ({ ...f, name: value }));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -569,7 +569,7 @@ function FeeRules() {
|
|||||||
required
|
required
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.currentTarget.value;
|
const value = lettersOnly(e.currentTarget.value);
|
||||||
setForm((f) => ({ ...f, name: value }));
|
setForm((f) => ({ ...f, name: value }));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ import type {
|
|||||||
CreateTrainSchedulePayload,
|
CreateTrainSchedulePayload,
|
||||||
EligibleContainerBookingsResponse,
|
EligibleContainerBookingsResponse,
|
||||||
FreightType,
|
FreightType,
|
||||||
|
ImportLoadingBookingsResponse,
|
||||||
|
LoadingStatus,
|
||||||
LocomotiveRecord,
|
LocomotiveRecord,
|
||||||
PinWagonsPayload,
|
PinWagonsPayload,
|
||||||
RecordCheckpointPayload,
|
RecordCheckpointPayload,
|
||||||
@@ -370,6 +372,25 @@ export const api = {
|
|||||||
QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId),
|
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 ──────────────────────────────────────────────────────────
|
// ── Mutations ──────────────────────────────────────────────────────────
|
||||||
runAllocation: endpoint<
|
runAllocation: endpoint<
|
||||||
{ scheduleId: string },
|
{ scheduleId: string },
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export interface Driver {
|
|||||||
address?: string | null;
|
address?: string | null;
|
||||||
emergencyContact?: string | null;
|
emergencyContact?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
|
faydaVerified?: boolean;
|
||||||
|
faydaSub?: string | null;
|
||||||
totalTrips: number;
|
totalTrips: number;
|
||||||
rating: number;
|
rating: number;
|
||||||
createdAt: string;
|
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,
|
ImportDjiboutiActionPayload,
|
||||||
ImportDjiboutiLoadList,
|
ImportDjiboutiLoadList,
|
||||||
ImportDjiboutiOperation,
|
ImportDjiboutiOperation,
|
||||||
|
ImportLoadingBookingsResponse,
|
||||||
|
LoadingStatus,
|
||||||
LocomotiveRecord,
|
LocomotiveRecord,
|
||||||
PinWagonsPayload,
|
PinWagonsPayload,
|
||||||
RecordCheckpointPayload,
|
RecordCheckpointPayload,
|
||||||
@@ -312,6 +314,26 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data);
|
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 (
|
getImportDjiboutiOperation: async (
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
): Promise<ImportDjiboutiOperation> => {
|
): 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;
|
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 {
|
export interface ImportDjiboutiOperation {
|
||||||
trainScheduleId: string;
|
trainScheduleId: string;
|
||||||
trainNumber: string | null;
|
trainNumber: string | null;
|
||||||
|
|||||||
@@ -1013,6 +1013,7 @@ export interface InventoryFilter {
|
|||||||
containerId?: string;
|
containerId?: string;
|
||||||
goodsId?: string;
|
goodsId?: string;
|
||||||
status?: InventoryStatus;
|
status?: InventoryStatus;
|
||||||
|
direction?: 'IMPORT' | 'EXPORT';
|
||||||
search?: string;
|
search?: string;
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
dateTo?: string;
|
dateTo?: string;
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Building2,
|
Building2,
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
Clock,
|
||||||
FileText,
|
FileText,
|
||||||
Globe2,
|
Globe2,
|
||||||
@@ -42,44 +40,29 @@ import type { UpdateProfilePayload } from "@/types/profile";
|
|||||||
import { extractApiError } from "@/utils/result";
|
import { extractApiError } from "@/utils/result";
|
||||||
|
|
||||||
/** Form steps rendered by CompanyProfileForm. */
|
/** Form steps rendered by CompanyProfileForm. */
|
||||||
type FormStep =
|
type FormStep = "company" | "personnel" | "contact" | "poa" | "documents";
|
||||||
| "company"
|
|
||||||
| "personnel"
|
|
||||||
| "contact"
|
|
||||||
| "verify"
|
|
||||||
| "poa"
|
|
||||||
| "documents"
|
|
||||||
| "additional";
|
|
||||||
const FORM_STEPS: FormStep[] = [
|
const FORM_STEPS: FormStep[] = [
|
||||||
"company",
|
"company",
|
||||||
"personnel",
|
"personnel",
|
||||||
"contact",
|
"contact",
|
||||||
"verify",
|
|
||||||
"poa",
|
"poa",
|
||||||
"documents",
|
"documents",
|
||||||
"additional",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** The full onboarding journey: the two pre-form phases + the form steps. */
|
/** The full onboarding journey: the two pre-form phases + the form steps. */
|
||||||
type WizardStep = "nationality" | "role" | FormStep;
|
type WizardStep = "nationality-role" | FormStep;
|
||||||
const WIZARD_STEPS: WizardStep[] = ["nationality", "role", ...FORM_STEPS];
|
const WIZARD_STEPS: WizardStep[] = ["nationality-role", ...FORM_STEPS];
|
||||||
|
|
||||||
/** Icon + title + description shown in the global dialog header per step. */
|
/** Icon + title + description shown in the global dialog header per step. */
|
||||||
const STEP_META: Record<
|
const STEP_META: Record<
|
||||||
WizardStep,
|
WizardStep,
|
||||||
{ icon: ReactNode; title: string; description: string }
|
{ icon: ReactNode; title: string; description: string }
|
||||||
> = {
|
> = {
|
||||||
nationality: {
|
"nationality-role": {
|
||||||
icon: <Globe2 size={20} />,
|
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.",
|
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: {
|
company: {
|
||||||
icon: <Building2 size={20} />,
|
icon: <Building2 size={20} />,
|
||||||
title: "Company Information",
|
title: "Company Information",
|
||||||
@@ -95,11 +78,6 @@ const STEP_META: Record<
|
|||||||
title: "Contact Person",
|
title: "Contact Person",
|
||||||
description: "Who should we reach out to about this account?",
|
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: {
|
poa: {
|
||||||
icon: <FileText size={20} />,
|
icon: <FileText size={20} />,
|
||||||
title: "Power of Attorney",
|
title: "Power of Attorney",
|
||||||
@@ -110,11 +88,6 @@ const STEP_META: Record<
|
|||||||
title: "Upload Documents",
|
title: "Upload Documents",
|
||||||
description: "Provide the required company 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 {
|
interface OnboardingWizardDialogProps {
|
||||||
@@ -172,12 +145,8 @@ export default function OnboardingWizardDialog({
|
|||||||
|
|
||||||
// Phases: nationality → role → form. If a draft already exists, resume
|
// Phases: nationality → role → form. If a draft already exists, resume
|
||||||
// straight into the form with nationality + roles pre-selected.
|
// straight into the form with nationality + roles pre-selected.
|
||||||
const [phase, setPhase] = useState<"nationality" | "role" | "form">(
|
const [phase, setPhase] = useState<"nationality-role" | "form">(
|
||||||
companyAlreadyStarted
|
companyAlreadyStarted ? "form" : "nationality-role",
|
||||||
? hasOperationalProfiles
|
|
||||||
? "form"
|
|
||||||
: "role"
|
|
||||||
: "nationality",
|
|
||||||
);
|
);
|
||||||
const [nationality, setNationality] = useState<CompanyNationality | null>(
|
const [nationality, setNationality] = useState<CompanyNationality | null>(
|
||||||
savedNationality,
|
savedNationality,
|
||||||
@@ -302,16 +271,12 @@ export default function OnboardingWizardDialog({
|
|||||||
setNationality(savedNationality);
|
setNationality(savedNationality);
|
||||||
// Resume into the form only when profiles exist; otherwise send the user to
|
// Resume into the form only when profiles exist; otherwise send the user to
|
||||||
// role selection so the missing operational profiles get created.
|
// role selection so the missing operational profiles get created.
|
||||||
setPhase(hasOperationalProfiles ? "form" : "role");
|
setPhase(hasOperationalProfiles ? "form" : "nationality-role");
|
||||||
const idx = FORM_STEPS.indexOf(resumeFormStep);
|
const idx = FORM_STEPS.indexOf(resumeFormStep);
|
||||||
if (idx > furthestIdxRef.current) furthestIdxRef.current = idx;
|
if (idx > furthestIdxRef.current) furthestIdxRef.current = idx;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [companyAlreadyStarted, resumeFormStep]);
|
}, [companyAlreadyStarted, resumeFormStep]);
|
||||||
|
|
||||||
const handleNationalityContinue = useCallback(() => {
|
|
||||||
if (nationality) setPhase("role");
|
|
||||||
}, [nationality]);
|
|
||||||
|
|
||||||
const handleRolesContinue = useCallback(() => {
|
const handleRolesContinue = useCallback(() => {
|
||||||
setStartError(null);
|
setStartError(null);
|
||||||
startMutation.mutate({
|
startMutation.mutate({
|
||||||
@@ -394,6 +359,7 @@ export default function OnboardingWizardDialog({
|
|||||||
// The active step across the whole journey, driving the header + progress pill.
|
// The active step across the whole journey, driving the header + progress pill.
|
||||||
const activeStep: WizardStep = phase === "form" ? formStep : phase;
|
const activeStep: WizardStep = phase === "form" ? formStep : phase;
|
||||||
const stepMeta = STEP_META[activeStep];
|
const stepMeta = STEP_META[activeStep];
|
||||||
|
console.log({ stepMeta, activeStep, STEP_META });
|
||||||
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
|
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
|
||||||
|
|
||||||
// Closing from the congratulations panel also clears the completed flag so a
|
// Closing from the congratulations panel also clears the completed flag so a
|
||||||
@@ -425,7 +391,7 @@ export default function OnboardingWizardDialog({
|
|||||||
);
|
);
|
||||||
const effectiveResumeStep: FormStep =
|
const effectiveResumeStep: FormStep =
|
||||||
requiredDocsMissing &&
|
requiredDocsMissing &&
|
||||||
FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents")
|
FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents")
|
||||||
? "documents"
|
? "documents"
|
||||||
: resumeFormStep;
|
: resumeFormStep;
|
||||||
|
|
||||||
@@ -497,26 +463,19 @@ export default function OnboardingWizardDialog({
|
|||||||
<OnboardingCompletePanel onClose={handleClose} />
|
<OnboardingCompletePanel onClose={handleClose} />
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
{phase === "nationality" ? (
|
{phase === "nationality-role" ? (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
<Text fw={600} size="lg" c="edr-text">
|
||||||
|
Where is your company registered?
|
||||||
|
</Text>
|
||||||
<NationalitySelect
|
<NationalitySelect
|
||||||
value={nationality}
|
value={nationality}
|
||||||
onChange={setNationality}
|
onChange={setNationality}
|
||||||
embedded
|
embedded
|
||||||
/>
|
/>
|
||||||
<Group justify="flex-end" pt="xs">
|
<Text fw={600} size="lg" c="edr-text">
|
||||||
<Button
|
What does your company do?(multiple)
|
||||||
color="edr-green"
|
</Text>
|
||||||
onClick={handleNationalityContinue}
|
|
||||||
disabled={!nationality}
|
|
||||||
rightSection={<ArrowRight size={16} />}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
) : phase === "role" ? (
|
|
||||||
<Stack gap="lg">
|
|
||||||
<OnboardingRoleSelect
|
<OnboardingRoleSelect
|
||||||
value={roles}
|
value={roles}
|
||||||
onChange={setRoles}
|
onChange={setRoles}
|
||||||
@@ -527,14 +486,7 @@ export default function OnboardingWizardDialog({
|
|||||||
{startError}
|
{startError}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Group justify="space-between" pt="xs">
|
<Group justify="flex-end" pt="xs">
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
leftSection={<ArrowLeft size={16} />}
|
|
||||||
onClick={() => setPhase("nationality")}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
onClick={handleRolesContinue}
|
onClick={handleRolesContinue}
|
||||||
@@ -616,7 +568,7 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Button color="edr-green" size="md" onClick={onClose} mt="xs">
|
<Button color="edr-green" size="md" onClick={onClose} mt="xs">
|
||||||
Go to my dashboard
|
Continue to Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
PinInput,
|
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -12,15 +11,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react";
|
||||||
AlertCircle,
|
|
||||||
ArrowLeft,
|
|
||||||
ArrowRight,
|
|
||||||
CheckCircle2,
|
|
||||||
RotateCw,
|
|
||||||
Smartphone,
|
|
||||||
UserCheck,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
@@ -35,7 +26,6 @@ import RoleLicenseStep, {
|
|||||||
type RoleLicenseProfile,
|
type RoleLicenseProfile,
|
||||||
} from "@/components/onboarding/RoleLicenseStep";
|
} from "@/components/onboarding/RoleLicenseStep";
|
||||||
import ETradeInfo from "@/components/onboarding/ETradeInfo";
|
import ETradeInfo from "@/components/onboarding/ETradeInfo";
|
||||||
import { extractApiError } from "@/utils/result";
|
|
||||||
import {
|
import {
|
||||||
type CompanyStep,
|
type CompanyStep,
|
||||||
type FormData,
|
type FormData,
|
||||||
@@ -44,8 +34,6 @@ import {
|
|||||||
} from "./companyProfileForm/schema";
|
} from "./companyProfileForm/schema";
|
||||||
import {
|
import {
|
||||||
buildPayload,
|
buildPayload,
|
||||||
maskPhone,
|
|
||||||
samePhone,
|
|
||||||
stepPayload,
|
stepPayload,
|
||||||
toFormValues,
|
toFormValues,
|
||||||
} from "./companyProfileForm/helpers";
|
} from "./companyProfileForm/helpers";
|
||||||
@@ -295,6 +283,7 @@ export default function CompanyProfileForm({
|
|||||||
const useOwnerAsManager = () => {
|
const useOwnerAsManager = () => {
|
||||||
if (!etradeOwner) return;
|
if (!etradeOwner) return;
|
||||||
setValue("generalManagerName", etradeOwner.name);
|
setValue("generalManagerName", etradeOwner.name);
|
||||||
|
setValue("generalManagerEmail", user.email);
|
||||||
setValue("generalManagerPhone", etradeOwner.phone ?? "", {
|
setValue("generalManagerPhone", etradeOwner.phone ?? "", {
|
||||||
shouldValidate: true,
|
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);
|
const hasDocuments = Boolean(uploadSetting?.fields?.length);
|
||||||
|
|
||||||
// The registration/license details come straight from the eTrade lookup and
|
// The registration/license details come straight from the eTrade lookup and
|
||||||
@@ -451,10 +361,8 @@ export default function CompanyProfileForm({
|
|||||||
"company",
|
"company",
|
||||||
"personnel",
|
"personnel",
|
||||||
"contact",
|
"contact",
|
||||||
"verify",
|
|
||||||
"poa",
|
"poa",
|
||||||
"documents",
|
"documents",
|
||||||
"additional",
|
|
||||||
];
|
];
|
||||||
const currentIdx = stepOrder.indexOf(step);
|
const currentIdx = stepOrder.indexOf(step);
|
||||||
|
|
||||||
@@ -485,30 +393,6 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
const nextStep = async () => {
|
const nextStep = async () => {
|
||||||
userNavigatedRef.current = true;
|
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
|
// The documents step auto-uploads whatever the user selected as they
|
||||||
// continue (partial uploads are allowed — required-doc completeness is
|
// continue (partial uploads are allowed — required-doc completeness is
|
||||||
// re-checked on resume). A failed upload holds them on the step.
|
// re-checked on resume). A failed upload holds them on the step.
|
||||||
@@ -525,8 +409,15 @@ export default function CompanyProfileForm({
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!licenseComplete) {
|
||||||
|
setSaveError(
|
||||||
|
"Please upload a business license for each of your operational profiles.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
setStep(stepOrder[currentIdx + 1]);
|
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Field steps validate + save before advancing.
|
// Field steps validate + save before advancing.
|
||||||
@@ -551,10 +442,7 @@ export default function CompanyProfileForm({
|
|||||||
<form onSubmit={(e) => e.preventDefault()}>
|
<form onSubmit={(e) => e.preventDefault()}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{step === "company" && (
|
{step === "company" && (
|
||||||
<>
|
<Stack gap="sm">
|
||||||
<Text fw={600} size="sm" c="edr-text">
|
|
||||||
Enter your TIN to auto-fill company information from eTrade
|
|
||||||
</Text>
|
|
||||||
<ETradeInfo
|
<ETradeInfo
|
||||||
tin={watch("tinNumber")}
|
tin={watch("tinNumber")}
|
||||||
register={register("tinNumber")}
|
register={register("tinNumber")}
|
||||||
@@ -693,7 +581,7 @@ export default function CompanyProfileForm({
|
|||||||
{...register("houseNo")}
|
{...register("houseNo")}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "personnel" && (
|
{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" && (
|
{step === "poa" && (
|
||||||
<>
|
<>
|
||||||
<Text size="sm" c="edr-muted">
|
<Text size="sm" c="edr-muted">
|
||||||
@@ -954,15 +741,13 @@ export default function CompanyProfileForm({
|
|||||||
onChange={setDocumentFiles}
|
onChange={setDocumentFiles}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === "additional" && (
|
<RoleLicenseStep
|
||||||
<RoleLicenseStep
|
profiles={roleProfiles ?? []}
|
||||||
profiles={roleProfiles ?? []}
|
value={licenseFiles ?? {}}
|
||||||
value={licenseFiles ?? {}}
|
onChange={onLicenseChange ?? (() => { })}
|
||||||
onChange={onLicenseChange ?? (() => { })}
|
/>
|
||||||
/>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{saveError && (
|
{saveError && (
|
||||||
@@ -970,11 +755,7 @@ export default function CompanyProfileForm({
|
|||||||
color="red"
|
color="red"
|
||||||
variant="light"
|
variant="light"
|
||||||
icon={<AlertCircle size={18} />}
|
icon={<AlertCircle size={18} />}
|
||||||
title={
|
title={"Couldn't save this step"}
|
||||||
step === "additional"
|
|
||||||
? "Business license required"
|
|
||||||
: "Couldn't save this step"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{saveError}
|
{saveError}
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -998,7 +779,7 @@ export default function CompanyProfileForm({
|
|||||||
onClick={prevStep}
|
onClick={prevStep}
|
||||||
leftSection={<ArrowLeft size={16} />}
|
leftSection={<ArrowLeft size={16} />}
|
||||||
>
|
>
|
||||||
{step === "additional" ? "Back to Documents" : "Back"}
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
@@ -1009,17 +790,14 @@ export default function CompanyProfileForm({
|
|||||||
disabled={
|
disabled={
|
||||||
isPending ||
|
isPending ||
|
||||||
saving ||
|
saving ||
|
||||||
(step === "documents" && !hasDocuments && loadingDocuments) ||
|
(step === "documents" && !hasDocuments && loadingDocuments)
|
||||||
(step === "verify" && !phoneVerified)
|
|
||||||
}
|
}
|
||||||
loading={isPending || saving}
|
loading={isPending || saving}
|
||||||
rightSection={
|
rightSection={
|
||||||
!isPending && !saving && step !== "additional" ? (
|
!isPending && !saving ? <ArrowRight size={16} /> : undefined
|
||||||
<ArrowRight size={16} />
|
|
||||||
) : undefined
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{step === "additional" ? "Submit for review" : "Continue"}
|
{step === "documents" ? "Submit for review" : "Continue"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,18 +1,38 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react";
|
import {
|
||||||
import { Controller, useForm } from "react-hook-form";
|
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 { useNavigate } from "react-router-dom";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import RPNInput from "react-phone-number-input";
|
|
||||||
import "react-phone-number-input/style.css";
|
|
||||||
|
|
||||||
import { userType } from "@/enums/userType";
|
import { userType } from "@/enums/userType";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import type { SignupPayload } from "@/types/auth";
|
import type { SignupPayload } from "@/types/auth";
|
||||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
import AuthShell from "@/components/auth/AuthShell";
|
||||||
import { isValidPhone } from "@/components/PhoneField";
|
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||||
import "@/components/phone-field.css";
|
import { api } from "@/services/api";
|
||||||
|
import { extractApiError } from "@/utils/result";
|
||||||
|
|
||||||
const EDR_LOGO = "/assets/edr-logo.png";
|
const EDR_LOGO = "/assets/edr-logo.png";
|
||||||
|
|
||||||
@@ -50,16 +70,46 @@ const userSchema = z
|
|||||||
|
|
||||||
type FormData = z.infer<typeof userSchema>;
|
type FormData = z.infer<typeof userSchema>;
|
||||||
|
|
||||||
const errorText = (msg?: string) =>
|
/** Mask all but the first 7 chars of an E.164 phone for display. */
|
||||||
msg ? <p className="mt-1 text-xs text-red-600">{msg}</p> : null;
|
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() {
|
export default function SignupPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { signup } = useAuth();
|
const { signup } = useAuth();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
// Two-stage signup: fill the form, then a mandatory SMS OTP challenge on the
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
// 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 {
|
const {
|
||||||
register,
|
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);
|
setError(null);
|
||||||
setLoading(true);
|
setSending(true);
|
||||||
try {
|
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 = {
|
const payload: SignupPayload = {
|
||||||
email: data.email,
|
email: pendingData.email,
|
||||||
username: data.email,
|
username: pendingData.email,
|
||||||
// Already a canonical E.164 string from the phone field (e.g. +251912345678).
|
// Already a canonical E.164 string from the phone field (e.g. +251912345678).
|
||||||
phoneNumber: data.phone,
|
phoneNumber: pendingData.phone,
|
||||||
userType: data.userType,
|
userType: pendingData.userType,
|
||||||
name: {
|
name: {
|
||||||
en: `${data.firstName.en} ${data.lastName.en}`,
|
en: `${pendingData.firstName.en} ${pendingData.lastName.en}`,
|
||||||
am: `${data.firstName.en} ${data.lastName.en}`,
|
am: `${pendingData.firstName.en} ${pendingData.lastName.en}`,
|
||||||
},
|
},
|
||||||
password: data.password,
|
password: pendingData.password,
|
||||||
confirmPassword: data.confirmPassword,
|
confirmPassword: pendingData.confirmPassword,
|
||||||
};
|
};
|
||||||
const result = await signup(payload);
|
const result = await signup(payload);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
navigate("/portal");
|
navigate("/portal");
|
||||||
} else {
|
} else {
|
||||||
setError(result.error.message);
|
setOtpError(result.error.message);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
setError("An unexpected error occurred");
|
setOtpError(extractApiError(err).message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setVerifying(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const passwordValue = watch("password") ?? "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell
|
<AuthShell
|
||||||
tagline="Smart Freight Operations"
|
tagline="Smart Freight Operations"
|
||||||
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
|
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">
|
<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" />
|
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
{stage === "form" ? (
|
||||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
<form onSubmit={handleSubmit(requestOtp)} className="flex w-full flex-col">
|
||||||
Create account
|
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||||
</h1>
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
<p className="text-sm leading-relaxed text-gray-500">
|
Create account
|
||||||
Register to access EDR Freight services.
|
</h1>
|
||||||
</p>
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
</div>
|
Register to access EDR Freight services.
|
||||||
|
</p>
|
||||||
<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)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium text-gray-800">
|
<Stack gap="md">
|
||||||
Last name <span className="text-red-500">*</span>
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
</label>
|
<TextInput
|
||||||
<input
|
label="First name"
|
||||||
placeholder="Doe"
|
placeholder="John"
|
||||||
disabled={loading}
|
required
|
||||||
className={fieldClass}
|
disabled={sending}
|
||||||
{...register("lastName.en")}
|
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">
|
<ControlledPhoneField
|
||||||
<label className="text-sm font-medium text-gray-800">
|
control={control}
|
||||||
Email <span className="text-red-500">*</span>
|
name="phone"
|
||||||
</label>
|
label="Phone"
|
||||||
<input
|
required
|
||||||
type="email"
|
disabled={sending}
|
||||||
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")}
|
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
<div className="space-y-1.5">
|
||||||
onClick={() => setShowPassword((current) => !current)}
|
<Text size="sm" fw={500} c="edr-text">
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
Send verification code via
|
||||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
</Text>
|
||||||
>
|
<SegmentedControl
|
||||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
fullWidth
|
||||||
</button>
|
disabled={sending}
|
||||||
</div>
|
value={channel}
|
||||||
{errorText(errors.password?.message)}
|
onChange={(v) => setChannel(v as OtpChannel)}
|
||||||
{passwordValue.length > 0 ? (
|
data={[
|
||||||
<div className="mt-2 space-y-1">
|
{
|
||||||
{passwordRequirements.map((req) => {
|
value: "phone",
|
||||||
const met = req.test(passwordValue);
|
label: (
|
||||||
return (
|
<span className="flex items-center justify-center gap-1.5">
|
||||||
<div key={req.label} className="flex items-center gap-2">
|
<Smartphone size={14} /> Phone
|
||||||
<span
|
</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"
|
},
|
||||||
}`}
|
{
|
||||||
>
|
value: "email",
|
||||||
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
|
label: (
|
||||||
</span>
|
<span className="flex items-center justify-center gap-1.5">
|
||||||
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
<Mail size={14} /> Email
|
||||||
{req.label}
|
</span>
|
||||||
</span>
|
),
|
||||||
</div>
|
},
|
||||||
);
|
]}
|
||||||
})}
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div>
|
||||||
<label className="text-sm font-medium text-gray-800">
|
<PasswordInput
|
||||||
Confirm password <span className="text-red-500">*</span>
|
label="Password"
|
||||||
</label>
|
placeholder="Create a strong password"
|
||||||
<div className="relative">
|
required
|
||||||
<input
|
disabled={sending}
|
||||||
type={showConfirm ? "text" : "password"}
|
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"
|
placeholder="Re-enter your password"
|
||||||
disabled={loading}
|
required
|
||||||
className={`${fieldClass} pr-11`}
|
disabled={sending}
|
||||||
|
error={errors.confirmPassword?.message}
|
||||||
{...register("confirmPassword")}
|
{...register("confirmPassword")}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
{error ? (
|
||||||
onClick={() => setShowConfirm((current) => !current)}
|
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
{error}
|
||||||
aria-label={showConfirm ? "Hide password" : "Show password"}
|
</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" />}
|
Continue
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{errorText(errors.confirmPassword?.message)}
|
<div className="space-y-1.5 text-center">
|
||||||
</div>
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
|
Verify your {otpChannel === "email" ? "email" : "phone"}
|
||||||
{error ? (
|
</h1>
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
{error}
|
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>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button
|
{otpError ? (
|
||||||
type="submit"
|
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||||
disabled={loading}
|
{otpError}
|
||||||
className={`${primaryButtonClass} flex items-center justify-center gap-2`}
|
</Alert>
|
||||||
>
|
) : null}
|
||||||
{loading ? "Creating account..." : "Create Account"}
|
|
||||||
{!loading ? <ArrowRight className="h-4 w-4" /> : null}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p className="text-center text-sm text-gray-500">
|
<Stack gap={6} align="center">
|
||||||
Already have an account?{" "}
|
<Text size="sm" fw={500} c="edr-text">
|
||||||
<button
|
Verification code
|
||||||
type="button"
|
</Text>
|
||||||
onClick={() => navigate("/login")}
|
<PinInput
|
||||||
className="font-semibold text-primary hover:underline"
|
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
|
Verify & create account
|
||||||
</button>
|
</Button>
|
||||||
</p>
|
|
||||||
</div>
|
<div className="flex items-center justify-between">
|
||||||
</form>
|
<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>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export type CompanyStep =
|
|||||||
| "company"
|
| "company"
|
||||||
| "personnel"
|
| "personnel"
|
||||||
| "contact"
|
| "contact"
|
||||||
| "verify"
|
|
||||||
| "poa"
|
| "poa"
|
||||||
| "documents"
|
| "documents"
|
||||||
| "additional";
|
| "additional";
|
||||||
@@ -103,7 +102,6 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
|||||||
"contactPersonEmail",
|
"contactPersonEmail",
|
||||||
"contactPersonPhone",
|
"contactPersonPhone",
|
||||||
],
|
],
|
||||||
verify: [],
|
|
||||||
poa: [],
|
poa: [],
|
||||||
documents: [],
|
documents: [],
|
||||||
additional: [],
|
additional: [],
|
||||||
|
|||||||
@@ -11,17 +11,27 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
|
PinInput,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} 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 toast from "react-hot-toast";
|
||||||
|
|
||||||
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
|
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
|
||||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
import { extractApiError } from "@/utils/result";
|
||||||
|
|
||||||
const CONSENT_TEXT =
|
const CONSENT_TEXT =
|
||||||
"I have read the entire contract and agree to its terms.";
|
"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 { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const { user } = useAuth();
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
const [signOpen, setSignOpen] = useState(false);
|
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 [successOpen, setSuccessOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
@@ -44,6 +58,15 @@ export default function ContractViewPage() {
|
|||||||
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
|
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
|
||||||
const [agreedToTerms, setAgreedToTerms] = 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({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ["contract-view", id],
|
queryKey: ["contract-view", id],
|
||||||
queryFn: () => contractsService.getContractView(id!),
|
queryFn: () => contractsService.getContractView(id!),
|
||||||
@@ -95,6 +118,18 @@ export default function ContractViewPage() {
|
|||||||
};
|
};
|
||||||
}, [checkScrollBottom]);
|
}, [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({
|
const signMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
contractsService.signContract(id!, {
|
contractsService.signContract(id!, {
|
||||||
@@ -104,16 +139,22 @@ export default function ContractViewPage() {
|
|||||||
: (signatureData as string),
|
: (signatureData as string),
|
||||||
signerDisplayName: signerName.trim(),
|
signerDisplayName: signerName.trim(),
|
||||||
consentText: CONSENT_TEXT,
|
consentText: CONSENT_TEXT,
|
||||||
|
otp: otpCode.trim(),
|
||||||
|
otpPhone: customerPhone,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSignOpen(false);
|
setOtpOpen(false);
|
||||||
|
setOtpCode("");
|
||||||
setSuccessOpen(true);
|
setSuccessOpen(true);
|
||||||
void refetch();
|
void refetch();
|
||||||
void qc.invalidateQueries({
|
void qc.invalidateQueries({
|
||||||
queryKey: api.contracts.get.queryKey({ id: id! }),
|
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 = () => {
|
const openSign = () => {
|
||||||
@@ -128,6 +169,17 @@ export default function ContractViewPage() {
|
|||||||
if (!signerName.trim()) return;
|
if (!signerName.trim()) return;
|
||||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||||
if (!image) return;
|
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();
|
signMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -315,20 +367,114 @@ export default function ContractViewPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
loading={signMutation.isPending}
|
loading={sendOtpMutation.isPending}
|
||||||
disabled={
|
disabled={
|
||||||
signMutation.isPending ||
|
sendOtpMutation.isPending ||
|
||||||
!signerName.trim() ||
|
!signerName.trim() ||
|
||||||
(!usingSaved && !signatureData)
|
(!usingSaved && !signatureData)
|
||||||
}
|
}
|
||||||
onClick={confirmSign}
|
onClick={confirmSign}
|
||||||
>
|
>
|
||||||
{usingSaved ? "Approve & sign" : "Confirm signature"}
|
Continue to verification
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</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
|
<ContractSignSuccessModal
|
||||||
opened={successOpen}
|
opened={successOpen}
|
||||||
reference={data.reference}
|
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