mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into feat/improve-buid-pipeline
This commit is contained in:
@@ -2,3 +2,106 @@
|
|||||||
PORT=4000
|
PORT=4000
|
||||||
DATABASE_URL=postgresql://user:password@host:5432/edr_passenger
|
DATABASE_URL=postgresql://user:password@host:5432/edr_passenger
|
||||||
JWT_SECRET=change-me-in-production
|
JWT_SECRET=change-me-in-production
|
||||||
|
|
||||||
|
# Database (Prisma)
|
||||||
|
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger?schema=edr_passenger
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
PORTAL_URL=http://localhost:3001
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=edr-platform-secret-change-in-production
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# SendGrid
|
||||||
|
SENDGRID_API_KEY=
|
||||||
|
SENDGRID_FROM_EMAIL=noreply@edr-platform.com
|
||||||
|
|
||||||
|
# SMS Configuration
|
||||||
|
SMS_PROVIDER=twilio
|
||||||
|
SMS_API_KEY=
|
||||||
|
|
||||||
|
# Twilio (if SMS_PROVIDER=twilio)
|
||||||
|
TWILIO_ACCOUNT_SID=
|
||||||
|
TWILIO_AUTH_TOKEN=
|
||||||
|
TWILIO_FROM_NUMBER=
|
||||||
|
|
||||||
|
# Africa's Talking (if SMS_PROVIDER=africastalking)
|
||||||
|
AFRICASTALKING_USERNAME=
|
||||||
|
AFRICASTALKING_FROM=
|
||||||
|
|
||||||
|
# Telebirr
|
||||||
|
TELEBIRR_BASE_URL=
|
||||||
|
TELEBIRR_WEB_BASE_URL=
|
||||||
|
TELEBIRR_FABRIC_APP_ID=
|
||||||
|
TELEBIRR_APP_SECRET=
|
||||||
|
TELEBIRR_MERCHANT_APP_ID=
|
||||||
|
TELEBIRR_MERCHANT_CODE=
|
||||||
|
TELEBIRR_NOTIFY_URL=
|
||||||
|
TELEBIRR_RETURN_URL=
|
||||||
|
TELEBIRR_TIMEOUT_EXPRESS=15m
|
||||||
|
TELEBIRR_PRIVATE_KEY=
|
||||||
|
TELEBIRR_PUBLIC_KEY=
|
||||||
|
TELEBIRR_INSECURE_TLS=false
|
||||||
|
|
||||||
|
# CBE Birr
|
||||||
|
CBE_BASE_URL=
|
||||||
|
CBE_MERCHANT_ID=
|
||||||
|
CBE_SECRET_KEY=
|
||||||
|
CBE_NOTIFY_URL=
|
||||||
|
CBE_RETURN_URL=
|
||||||
|
|
||||||
|
# eBirr
|
||||||
|
EBIRR_BASE_URL=
|
||||||
|
EBIRR_MERCHANT_CODE=
|
||||||
|
EBIRR_SECRET_KEY=
|
||||||
|
EBIRR_NOTIFY_URL=
|
||||||
|
EBIRR_RETURN_URL=
|
||||||
|
|
||||||
|
# Card Gateway (Stripe-like)
|
||||||
|
CARD_BASE_URL=
|
||||||
|
CARD_API_KEY=
|
||||||
|
CARD_WEBHOOK_SECRET=
|
||||||
|
CARD_WEBHOOK_URL=
|
||||||
|
CARD_RETURN_URL=
|
||||||
|
|
||||||
|
# Waafi (Djibouti Mobile Money)
|
||||||
|
WAAFI_BASE_URL=https://api.waafipay.net
|
||||||
|
WAAFI_MERCHANT_UID=
|
||||||
|
WAAFI_API_USER_ID=
|
||||||
|
WAAFI_API_KEY=
|
||||||
|
WAAFI_NOTIFY_URL=
|
||||||
|
WAAFI_RETURN_URL=
|
||||||
|
|
||||||
|
# Payment Configuration
|
||||||
|
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI
|
||||||
|
|
||||||
|
# Session Configuration
|
||||||
|
SESSION_INACTIVITY_MINUTES=30
|
||||||
|
|
||||||
|
# i18n Configuration
|
||||||
|
DEFAULT_LOCALE=en
|
||||||
|
SUPPORTED_LOCALES=en,am,fr,om
|
||||||
|
|
||||||
|
# Corporate IAM Configuration (for back-office authentication)
|
||||||
|
IAM_ENABLED=false
|
||||||
|
IAM_API_URL=https://iam.tria-plc.com/api
|
||||||
|
IAM_API_KEY=
|
||||||
|
|
||||||
|
# --- VeriFayda 2.0 (eSignet) OIDC integration ---
|
||||||
|
FAYDA_ENABLED=true
|
||||||
|
FAYDA_CLIENT_ID=
|
||||||
|
FAYDA_AUTHORIZATION_ENDPOINT=
|
||||||
|
FAYDA_TOKEN_ENDPOINT=
|
||||||
|
FAYDA_USERINFO_ENDPOINT=
|
||||||
|
# Base64 of the RSA private JWK (JSON). Secret — never commit a real value.
|
||||||
|
FAYDA_PRIVATE_KEY_BASE64=
|
||||||
|
FAYDA_REDIRECT_URI=
|
||||||
|
# Optional (defaults shown)
|
||||||
|
FAYDA_SCOPE=openid profile email
|
||||||
|
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
|
||||||
|
FAYDA_CLAIMS_LOCALES=en am
|
||||||
|
FAYDA_SESSION_TTL_MINUTES=10
|
||||||
|
|
||||||
|
GITHUB_PACKAGE_TOKEN=
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
|
"jose": "^5.10.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[faydaSub]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN "faydaSub" TEXT,
|
||||||
|
ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "faydaVerifiedName" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "passenger"."User" ADD COLUMN "faydaSub" TEXT,
|
||||||
|
ADD COLUMN "faydaVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "passenger"."FaydaVerificationSession" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"state" TEXT NOT NULL,
|
||||||
|
"codeVerifier" TEXT NOT NULL,
|
||||||
|
"purpose" TEXT NOT NULL DEFAULT 'PURCHASE',
|
||||||
|
"saveToAccount" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||||
|
"errorCode" TEXT,
|
||||||
|
"errorDescription" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"userId" TEXT,
|
||||||
|
"bookingId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "passenger"."FaydaVerificationSession"("state");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FaydaVerificationSession_userId_idx" ON "passenger"."FaydaVerificationSession"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "passenger"."FaydaVerificationSession"("bookingId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FaydaVerificationSession_state_idx" ON "passenger"."FaydaVerificationSession"("state");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "passenger"."FaydaVerificationSession"("expiresAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_faydaSub_key" ON "passenger"."User"("faydaSub");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "passenger"."FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "FaydaVerificationSession" ADD COLUMN "authCode" TEXT,
|
||||||
|
ADD COLUMN "platform" TEXT NOT NULL DEFAULT 'WEB';
|
||||||
@@ -224,6 +224,11 @@ model User {
|
|||||||
lastLoginAt DateTime?
|
lastLoginAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
faydaVerified Boolean @default(false)
|
||||||
|
faydaVerifiedAt DateTime?
|
||||||
|
faydaSub String? @unique
|
||||||
|
|
||||||
passenger Passenger?
|
passenger Passenger?
|
||||||
agent Agent?
|
agent Agent?
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
@@ -232,6 +237,8 @@ model User {
|
|||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
fraudAlerts FraudAlert[]
|
fraudAlerts FraudAlert[]
|
||||||
|
|
||||||
|
faydaVerificationSessions FaydaVerificationSession[]
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +522,9 @@ model BookingSeat {
|
|||||||
passportCountry String?
|
passportCountry String?
|
||||||
verifaydaVerified Boolean @default(false)
|
verifaydaVerified Boolean @default(false)
|
||||||
verifaydaData Json?
|
verifaydaData Json?
|
||||||
|
faydaVerifiedAt DateTime?
|
||||||
|
faydaSub String?
|
||||||
|
faydaVerifiedName String?
|
||||||
seatLabelSnapshot String?
|
seatLabelSnapshot String?
|
||||||
fareMinor Int?
|
fareMinor Int?
|
||||||
displayCurrency Currency?
|
displayCurrency Currency?
|
||||||
@@ -1256,3 +1266,32 @@ model SavedPassengerProfile {
|
|||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model FaydaVerificationSession {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
state String @unique
|
||||||
|
codeVerifier String
|
||||||
|
purpose String @default("PURCHASE")
|
||||||
|
platform String @default("WEB") // WEB | MOBILE — recorded for audit
|
||||||
|
saveToAccount Boolean @default(false)
|
||||||
|
status String @default("PENDING")
|
||||||
|
errorCode String?
|
||||||
|
errorDescription String?
|
||||||
|
authCode String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
expiresAt DateTime
|
||||||
|
completedAt DateTime?
|
||||||
|
|
||||||
|
userId String?
|
||||||
|
bookingId String?
|
||||||
|
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([bookingId])
|
||||||
|
@@index([state])
|
||||||
|
@@index([expiresAt])
|
||||||
|
|
||||||
|
@@schema("passenger")
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import cbeConfig from './config/cbe.config';
|
|||||||
import ebirrConfig from './config/ebirr.config';
|
import ebirrConfig from './config/ebirr.config';
|
||||||
import cardConfig from './config/card.config';
|
import cardConfig from './config/card.config';
|
||||||
import waafiConfig from './config/waafi.config';
|
import waafiConfig from './config/waafi.config';
|
||||||
|
import faydaConfig from './config/fayda.config';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { StationsModule } from './modules/stations/stations.module';
|
import { StationsModule } from './modules/stations/stations.module';
|
||||||
import { FleetModule } from './modules/fleet/fleet.module';
|
import { FleetModule } from './modules/fleet/fleet.module';
|
||||||
@@ -35,12 +36,23 @@ import { AgentsModule } from './modules/agents/agents.module';
|
|||||||
import { ReportsModule } from './modules/reports/reports.module';
|
import { ReportsModule } from './modules/reports/reports.module';
|
||||||
import { FraudModule } from './modules/fraud/fraud.module';
|
import { FraudModule } from './modules/fraud/fraud.module';
|
||||||
import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
|
import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
|
||||||
|
import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
|
||||||
|
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfig, dbConfig, telebirrConfig, cbeConfig, ebirrConfig, cardConfig, waafiConfig],
|
load: [
|
||||||
|
appConfig,
|
||||||
|
dbConfig,
|
||||||
|
telebirrConfig,
|
||||||
|
cbeConfig,
|
||||||
|
ebirrConfig,
|
||||||
|
cardConfig,
|
||||||
|
waafiConfig,
|
||||||
|
faydaConfig,
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
@@ -69,6 +81,8 @@ import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
|
|||||||
ReportsModule,
|
ReportsModule,
|
||||||
FraudModule,
|
FraudModule,
|
||||||
SeatClassesModule,
|
SeatClassesModule,
|
||||||
|
FareEngineModule,
|
||||||
|
VerifaydaModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
|
|||||||
118
apps/edr-passenger-api/src/config/fayda.config.ts
Normal file
118
apps/edr-passenger-api/src/config/fayda.config.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
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;
|
||||||
|
redirectUri: 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';
|
||||||
|
const scope = process.env.FAYDA_SCOPE ?? 'openid profile email';
|
||||||
|
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 ?? '';
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
|
||||||
|
scope,
|
||||||
|
acrValues,
|
||||||
|
claimsLocales,
|
||||||
|
sessionTtlMinutes: sessionTtl,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -214,6 +214,7 @@ Payment providers send notifications to:
|
|||||||
.addTag("Auth", "Registration and login")
|
.addTag("Auth", "Registration and login")
|
||||||
.addTag("Booking", "Booking lifecycle")
|
.addTag("Booking", "Booking lifecycle")
|
||||||
.addTag("Dashboard", "Home dashboard aggregate")
|
.addTag("Dashboard", "Home dashboard aggregate")
|
||||||
|
.addTag("Fare Engine", "Distance-based fare calculator — km × rate × exchange rate, nationality-aware currency")
|
||||||
.addTag("Fleet", "Train services and coaches")
|
.addTag("Fleet", "Train services and coaches")
|
||||||
.addTag("Fraud Detection", "Fraud detection and monitoring")
|
.addTag("Fraud Detection", "Fraud detection and monitoring")
|
||||||
.addTag("Live Tracking", "Real-time trip status and crowd signals")
|
.addTag("Live Tracking", "Real-time trip status and crowd signals")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
@@ -47,9 +47,8 @@ export class CurrencyService {
|
|||||||
|
|
||||||
async syncExchangeRates(): Promise<void> {
|
async syncExchangeRates(): Promise<void> {
|
||||||
this.logger.log('Syncing exchange rates from external provider');
|
this.logger.log('Syncing exchange rates from external provider');
|
||||||
|
|
||||||
// In production, fetch from external API
|
const today = this.todayUtc();
|
||||||
// For now, using static rates
|
|
||||||
const rates = [
|
const rates = [
|
||||||
{ from: 'ETB', to: 'ETB', rate: 1.0 },
|
{ from: 'ETB', to: 'ETB', rate: 1.0 },
|
||||||
{ from: 'ETB', to: 'DJF', rate: 3.25 },
|
{ from: 'ETB', to: 'DJF', rate: 3.25 },
|
||||||
@@ -59,25 +58,47 @@ export class CurrencyService {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (const { from, to, rate } of rates) {
|
for (const { from, to, rate } of rates) {
|
||||||
await this.prisma.currencyExchangeRate.upsert({
|
await this.upsertRate(from as Currency, to as Currency, rate, today, 'EXTERNAL_API');
|
||||||
where: {
|
|
||||||
fromCurrency_toCurrency_effectiveDate: {
|
|
||||||
fromCurrency: from as Currency,
|
|
||||||
toCurrency: to as Currency,
|
|
||||||
effectiveDate: new Date(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: { rate },
|
|
||||||
create: {
|
|
||||||
fromCurrency: from as Currency,
|
|
||||||
toCurrency: to as Currency,
|
|
||||||
rate,
|
|
||||||
effectiveDate: new Date(),
|
|
||||||
source: 'EXTERNAL_API',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log('Exchange rates synced successfully');
|
this.logger.log('Exchange rates synced successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listRates() {
|
||||||
|
return this.prisma.currencyExchangeRate.findMany({
|
||||||
|
orderBy: [{ fromCurrency: 'asc' }, { toCurrency: 'asc' }, { effectiveDate: 'desc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertRate(
|
||||||
|
fromCurrency: Currency,
|
||||||
|
toCurrency: Currency,
|
||||||
|
rate: number,
|
||||||
|
effectiveDate?: Date,
|
||||||
|
source = 'MANUAL',
|
||||||
|
) {
|
||||||
|
const date = effectiveDate ?? this.todayUtc();
|
||||||
|
return this.prisma.currencyExchangeRate.upsert({
|
||||||
|
where: { fromCurrency_toCurrency_effectiveDate: { fromCurrency, toCurrency, effectiveDate: date } },
|
||||||
|
update: { rate, source },
|
||||||
|
create: { fromCurrency, toCurrency, rate, effectiveDate: date, source },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRateById(id: string, rate: number, source = 'MANUAL') {
|
||||||
|
const existing = await this.prisma.currencyExchangeRate.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException('Exchange rate not found');
|
||||||
|
return this.prisma.currencyExchangeRate.update({ where: { id }, data: { rate, source } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRate(id: string) {
|
||||||
|
return this.prisma.currencyExchangeRate.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns midnight UTC for today — used as the date-only key for upserts. */
|
||||||
|
private todayUtc(): Date {
|
||||||
|
const d = new Date();
|
||||||
|
d.setUTCHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Patch, Put, Post } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiParam, ApiProperty, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { CurrencyService } from '../currency/currency.service';
|
||||||
|
import { UpsertExchangeRateDto } from './currency.dto';
|
||||||
|
import { IsNumber, IsPositive, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
class UpdateExchangeRateDto {
|
||||||
|
@ApiProperty({ example: 3.5 }) @Type(() => Number) @IsNumber() @IsPositive() rate: number;
|
||||||
|
@ApiProperty({ example: 'MANUAL', required: false }) @IsOptional() @IsString() source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Fare Engine')
|
||||||
|
@Controller('fare-engine/exchange-rates')
|
||||||
|
export class CurrencyController {
|
||||||
|
constructor(private currency: CurrencyService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List all exchange rates (latest per pair first)' })
|
||||||
|
list() {
|
||||||
|
return this.currency.listRates();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put()
|
||||||
|
@ApiOperation({ summary: 'Upsert an exchange rate for today' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Rate created or updated for today\'s effective date' })
|
||||||
|
upsert(@Body() dto: UpsertExchangeRateDto) {
|
||||||
|
return this.currency.upsertRate(dto.fromCurrency, dto.toCurrency, dto.rate, undefined, dto.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Update an exchange rate by ID' })
|
||||||
|
@ApiParam({ name: 'id', description: 'CurrencyExchangeRate UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Rate updated' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Rate not found' })
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateExchangeRateDto) {
|
||||||
|
return this.currency.updateRateById(id, dto.rate, dto.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Delete an exchange rate record by ID' })
|
||||||
|
@ApiParam({ name: 'id', description: 'CurrencyExchangeRate UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Rate deleted' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Rate not found' })
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.currency.deleteRate(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sync')
|
||||||
|
@ApiOperation({ summary: 'Trigger exchange rate sync from external provider' })
|
||||||
|
sync() {
|
||||||
|
return this.currency.syncExchangeRates();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsEnum, IsNumber, IsPositive, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
|
export class UpsertExchangeRateDto {
|
||||||
|
@ApiProperty({ enum: Currency, example: 'ETB' }) @IsEnum(Currency) fromCurrency: Currency;
|
||||||
|
@ApiProperty({ enum: Currency, example: 'DJF' }) @IsEnum(Currency) toCurrency: Currency;
|
||||||
|
@ApiProperty({ example: 3.25 }) @Type(() => Number) @IsNumber() @IsPositive() rate: number;
|
||||||
|
@ApiPropertyOptional({ example: 'MANUAL', description: 'Source label e.g. MANUAL, EXTERNAL_API' })
|
||||||
|
@IsOptional() @IsString() source?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { FareEngineService } from './fare-engine.service';
|
||||||
|
import { FareCalculateDto, FareBreakdownDto } from './fare-engine.dto';
|
||||||
|
|
||||||
|
@ApiTags('Fare Engine')
|
||||||
|
@Controller('fare-engine')
|
||||||
|
export class FareEngineController {
|
||||||
|
constructor(private service: FareEngineService) {}
|
||||||
|
|
||||||
|
@Post('calculate')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Calculate fare for a journey leg',
|
||||||
|
description: `Computes fare using the formula:
|
||||||
|
|
||||||
|
**Fare = totalKm × ratePerKm × exchangeRate**
|
||||||
|
|
||||||
|
- \`totalKm\` — sum of \`distanceKm\` on RouteStop records between origin and destination
|
||||||
|
- \`ratePerKm\` — \`SeatClass.basePrice\` (stored in ETB minor units per km)
|
||||||
|
- \`exchangeRate\` — derived from passenger nationality:
|
||||||
|
- **Ethiopian** → ETB (rate = 1.0)
|
||||||
|
- **Djiboutian** → DJF (rate ≈ 3.25)
|
||||||
|
- **Other / unspecified** → USD (rate ≈ 0.018)
|
||||||
|
|
||||||
|
Age-based pricing: first child (age < 5) travels free, subsequent children pay full fare.
|
||||||
|
5% tax applied after promo discount.
|
||||||
|
Returns a full breakdown including a human-readable calculation trace.`,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 201, type: FareBreakdownDto, description: 'Full fare breakdown with calculation trace' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Invalid route/station combination or missing distanceKm on route stops' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Route or seat class not found' })
|
||||||
|
calculate(@Body() dto: FareCalculateDto) {
|
||||||
|
return this.service.calculate(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('compare')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Compare fares across all seat classes for a route leg',
|
||||||
|
description: 'Returns fare breakdown for every active seat class on the requested leg. Useful for rendering a class-selection table on the booking screen.',
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: 'routeId', description: 'Route UUID' })
|
||||||
|
@ApiQuery({ name: 'originStationId', description: 'Origin station UUID' })
|
||||||
|
@ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' })
|
||||||
|
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality (Ethiopian | Djiboutian | other). Determines billing currency.' })
|
||||||
|
@ApiQuery({ name: 'adultCount', required: false, type: Number, description: 'Number of adults (default 1)' })
|
||||||
|
@ApiQuery({ name: 'childCount', required: false, type: Number, description: 'Number of children (default 0)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Array of fare breakdowns, one per active seat class, ordered by price ascending' })
|
||||||
|
compareClasses(
|
||||||
|
@Query('routeId') routeId: string,
|
||||||
|
@Query('originStationId') originStationId: string,
|
||||||
|
@Query('destinationStationId') destinationStationId: string,
|
||||||
|
@Query('nationality') nationality?: string,
|
||||||
|
@Query('adultCount') adultCount?: string,
|
||||||
|
@Query('childCount') childCount?: string,
|
||||||
|
) {
|
||||||
|
return this.service.compareClasses(
|
||||||
|
routeId,
|
||||||
|
originStationId,
|
||||||
|
destinationStationId,
|
||||||
|
nationality,
|
||||||
|
adultCount ? parseInt(adultCount) : 1,
|
||||||
|
childCount ? parseInt(childCount) : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { IsString, IsOptional, IsEnum, IsInt, Min } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
|
// Nationality → home currency mapping
|
||||||
|
export const NATIONALITY_CURRENCY_MAP: Record<string, Currency> = {
|
||||||
|
Ethiopian: Currency.ETB,
|
||||||
|
Djiboutian: Currency.DJF,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveCurrencyFromNationality(nationality?: string): Currency {
|
||||||
|
if (!nationality) return Currency.ETB;
|
||||||
|
return NATIONALITY_CURRENCY_MAP[nationality] ?? Currency.USD;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FareCalculateDto {
|
||||||
|
@ApiProperty({ example: 'route-uuid', description: 'Route UUID — used to look up stop distances' })
|
||||||
|
@IsString() routeId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID (must be a stop on the route)' })
|
||||||
|
@IsString() originStationId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID (must come after origin)' })
|
||||||
|
@IsString() destinationStationId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID — its basePrice is the per-km rate in ETB minor units' })
|
||||||
|
@IsString() seatClassId: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'Ethiopian',
|
||||||
|
description: 'Passenger nationality. Determines the billing currency: Ethiopian → ETB, Djiboutian → DJF, other → USD. Defaults to ETB.',
|
||||||
|
})
|
||||||
|
@IsOptional() @IsString() nationality?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 2,
|
||||||
|
description: 'Number of adult passengers (age ≥ 5). Defaults to 1.',
|
||||||
|
})
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) adultCount?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 1,
|
||||||
|
description: 'Number of child passengers (age < 5). First child travels free.',
|
||||||
|
})
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(0) childCount?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'WEEKEND15', description: 'Promo code for discount' })
|
||||||
|
@IsOptional() @IsString() promoCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FareBreakdownDto {
|
||||||
|
@ApiProperty({ example: 'ADD-DJI' }) routeCode: string;
|
||||||
|
@ApiProperty({ example: 'Addis Ababa' }) originName: string;
|
||||||
|
@ApiProperty({ example: 'Djibouti' }) destinationName: string;
|
||||||
|
@ApiProperty({ example: 'Economy Regular' }) seatClassName: string;
|
||||||
|
@ApiProperty({ example: 756 }) totalDistanceKm: number;
|
||||||
|
@ApiProperty({ example: 120 }) ratePerKmMinor: number;
|
||||||
|
@ApiProperty({ example: 90720 }) baseFarePerPassengerMinor: number;
|
||||||
|
@ApiProperty({ example: 2 }) adultCount: number;
|
||||||
|
@ApiProperty({ example: 1 }) childCount: number;
|
||||||
|
@ApiProperty({ example: 1 }) freeChildrenCount: number;
|
||||||
|
@ApiProperty({ example: 0 }) paidChildrenCount: number;
|
||||||
|
@ApiProperty({ example: 181440 }) subtotalMinor: number;
|
||||||
|
@ApiProperty({ example: 0 }) discountMinor: number;
|
||||||
|
@ApiProperty({ example: 9072 }) taxMinor: number;
|
||||||
|
@ApiProperty({ example: 190512 }) totalMinor: number;
|
||||||
|
@ApiProperty({ example: 'ETB', enum: Currency }) billingCurrency: Currency;
|
||||||
|
@ApiProperty({ example: 190512 }) totalInBillingCurrency: number;
|
||||||
|
@ApiProperty({ example: 3.25 }) exchangeRate: number;
|
||||||
|
@ApiProperty({ description: 'Step-by-step calculation trace for transparency' }) calculation: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { FareEngineController } from './fare-engine.controller';
|
||||||
|
import { FareEngineService } from './fare-engine.service';
|
||||||
|
import { CurrencyController } from './currency.controller';
|
||||||
|
import { CurrencyModule } from '../currency/currency.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [CurrencyModule],
|
||||||
|
controllers: [FareEngineController, CurrencyController],
|
||||||
|
providers: [FareEngineService],
|
||||||
|
exports: [FareEngineService],
|
||||||
|
})
|
||||||
|
export class FareEngineModule {}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
|
import { CurrencyService } from '../currency/currency.service';
|
||||||
|
import { FareCalculateDto, resolveCurrencyFromNationality } from './fare-engine.dto';
|
||||||
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
|
const TAX_RATE = 0.05;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FareEngineService {
|
||||||
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private currencyService: CurrencyService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async calculate(dto: FareCalculateDto) {
|
||||||
|
const route = await this.prisma.route.findUnique({
|
||||||
|
where: { id: dto.routeId },
|
||||||
|
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||||
|
});
|
||||||
|
if (!route) throw new NotFoundException('Route not found');
|
||||||
|
|
||||||
|
const originStop = route.stops.find(s => s.stationId === dto.originStationId);
|
||||||
|
const destStop = route.stops.find(s => s.stationId === dto.destinationStationId);
|
||||||
|
|
||||||
|
if (!originStop) throw new BadRequestException('Origin station not found on this route');
|
||||||
|
if (!destStop) throw new BadRequestException('Destination station not found on this route');
|
||||||
|
if (originStop.sequence >= destStop.sequence)
|
||||||
|
throw new BadRequestException('Origin must come before destination in the route sequence');
|
||||||
|
|
||||||
|
const legStops = route.stops.filter(
|
||||||
|
s => s.sequence > originStop.sequence && s.sequence <= destStop.sequence,
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined);
|
||||||
|
if (missingDistance.length > 0)
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
|
||||||
|
|
||||||
|
const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } });
|
||||||
|
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||||
|
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
|
||||||
|
|
||||||
|
const ratePerKmMinor = seatClass.basePrice;
|
||||||
|
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
||||||
|
|
||||||
|
const adultCount = dto.adultCount ?? 1;
|
||||||
|
const childCount = dto.childCount ?? 0;
|
||||||
|
const freeChildrenCount = Math.min(childCount, 1);
|
||||||
|
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||||
|
|
||||||
|
const subtotalMinor =
|
||||||
|
baseFarePerPassengerMinor * adultCount +
|
||||||
|
baseFarePerPassengerMinor * paidChildrenCount;
|
||||||
|
|
||||||
|
let discountMinor = 0;
|
||||||
|
let promoLabel = 'none';
|
||||||
|
if (dto.promoCode) {
|
||||||
|
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
||||||
|
if (promo?.active && promo.validUntil > new Date()) {
|
||||||
|
discountMinor = promo.percentOff
|
||||||
|
? Math.round(subtotalMinor * promo.percentOff / 100)
|
||||||
|
: (promo.amountOffMinor ?? 0);
|
||||||
|
promoLabel = `${dto.promoCode} (-${promo.percentOff ?? 0}%)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterDiscountMinor = subtotalMinor - discountMinor;
|
||||||
|
const taxMinor = Math.round(afterDiscountMinor * TAX_RATE);
|
||||||
|
const totalEtbMinor = afterDiscountMinor + taxMinor;
|
||||||
|
|
||||||
|
const billingCurrency = resolveCurrencyFromNationality(dto.nationality);
|
||||||
|
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
|
||||||
|
const totalInBillingCurrency = Math.round(totalEtbMinor * exchangeRate);
|
||||||
|
|
||||||
|
const [originStation, destStation] = await Promise.all([
|
||||||
|
this.prisma.station.findUnique({ where: { id: dto.originStationId } }),
|
||||||
|
this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const calculation = [
|
||||||
|
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
|
||||||
|
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`,
|
||||||
|
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} = ${baseFarePerPassengerMinor} ETB minor`,
|
||||||
|
`Passengers: ${adultCount} adult(s) × ${baseFarePerPassengerMinor} = ${baseFarePerPassengerMinor * adultCount} ETB minor`,
|
||||||
|
`Children: ${childCount} child(ren) — ${freeChildrenCount} free, ${paidChildrenCount} paid`,
|
||||||
|
`Subtotal: ${subtotalMinor} ETB minor`,
|
||||||
|
`Promo: ${promoLabel} → -${discountMinor} ETB minor`,
|
||||||
|
`Tax (5%): +${taxMinor} ETB minor`,
|
||||||
|
`Total (ETB): ${totalEtbMinor} ETB minor`,
|
||||||
|
`Nationality: ${dto.nationality ?? 'unspecified'} → ${billingCurrency}`,
|
||||||
|
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
|
||||||
|
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
routeCode: route.code,
|
||||||
|
originName: originStation?.name ?? dto.originStationId,
|
||||||
|
destinationName: destStation?.name ?? dto.destinationStationId,
|
||||||
|
seatClassName: seatClass.name,
|
||||||
|
totalDistanceKm,
|
||||||
|
ratePerKmMinor,
|
||||||
|
baseFarePerPassengerMinor,
|
||||||
|
adultCount,
|
||||||
|
childCount,
|
||||||
|
freeChildrenCount,
|
||||||
|
paidChildrenCount,
|
||||||
|
subtotalMinor,
|
||||||
|
discountMinor,
|
||||||
|
taxMinor,
|
||||||
|
totalMinor: totalEtbMinor,
|
||||||
|
billingCurrency,
|
||||||
|
totalInBillingCurrency,
|
||||||
|
exchangeRate,
|
||||||
|
calculation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async compareClasses(
|
||||||
|
routeId: string,
|
||||||
|
originStationId: string,
|
||||||
|
destinationStationId: string,
|
||||||
|
nationality?: string,
|
||||||
|
adultCount = 1,
|
||||||
|
childCount = 0,
|
||||||
|
) {
|
||||||
|
const seatClasses = await this.prisma.seatClass.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { basePrice: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
seatClasses.map(sc =>
|
||||||
|
this.calculate({ routeId, originStationId, destinationStationId, seatClassId: sc.id, nationality, adultCount, childCount })
|
||||||
|
.catch(() => null),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve schedule → route/origin/destination, then calculate fare for one seat class. */
|
||||||
|
async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) {
|
||||||
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
|
where: { id: scheduleId },
|
||||||
|
select: { routeId: true, originStationId: true, destinationStationId: true },
|
||||||
|
});
|
||||||
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route');
|
||||||
|
|
||||||
|
return this.calculate({
|
||||||
|
routeId: schedule.routeId,
|
||||||
|
originStationId: schedule.originStationId,
|
||||||
|
destinationStationId: schedule.destinationStationId,
|
||||||
|
seatClassId,
|
||||||
|
nationality,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate fares for all active seat classes on a schedule. */
|
||||||
|
async calculateAllForSchedule(scheduleId: string, nationality?: string) {
|
||||||
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
|
where: { id: scheduleId },
|
||||||
|
select: { routeId: true, originStationId: true, destinationStationId: true },
|
||||||
|
});
|
||||||
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
|
||||||
|
// ── Route-based calculation (fare engine) ────────────────────────────────
|
||||||
|
if (schedule.routeId) {
|
||||||
|
const seatClasses = await this.prisma.seatClass.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { basePrice: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
seatClasses.map(sc =>
|
||||||
|
this.calculate({
|
||||||
|
routeId: schedule.routeId!,
|
||||||
|
originStationId: schedule.originStationId,
|
||||||
|
destinationStationId: schedule.destinationStationId,
|
||||||
|
seatClassId: sc.id,
|
||||||
|
nationality,
|
||||||
|
}).catch(() => null),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fallback: FareRule records scoped to this schedule ───────────────────
|
||||||
|
const now = new Date();
|
||||||
|
const fareRules = await this.prisma.fareRule.findMany({
|
||||||
|
where: {
|
||||||
|
tripId: scheduleId,
|
||||||
|
validFrom: { lte: now },
|
||||||
|
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||||
|
},
|
||||||
|
include: { seatClass: true },
|
||||||
|
orderBy: { seatClass: { basePrice: 'asc' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fareRules.length > 0) {
|
||||||
|
const billingCurrency = resolveCurrencyFromNationality(nationality);
|
||||||
|
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
|
||||||
|
return fareRules.map(rule => ({
|
||||||
|
seatClassId: rule.seatClassId,
|
||||||
|
seatClassName: rule.seatClass.name,
|
||||||
|
baseFareMinor: rule.baseFareMinor,
|
||||||
|
totalMinor: rule.baseFareMinor,
|
||||||
|
billingCurrency,
|
||||||
|
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
|
||||||
|
exchangeRate,
|
||||||
|
source: 'FARE_RULE',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Schedule has no associated route and no fare rules. Assign a route or create fare rules for this schedule.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,12 +89,45 @@ Origin and destination are derived from the first and last route stop — no nee
|
|||||||
// ── Fares ──────────────────────────────────────────────────────────────────
|
// ── Fares ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Get(':scheduleId/fares')
|
@Get(':scheduleId/fares')
|
||||||
@ApiOperation({ summary: 'Get applicable fare for a schedule and seat class' })
|
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
|
||||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||||
@ApiQuery({ name: 'class', required: false, description: 'Seat class name: "Economy Regular" | "Economy Bed" | "VIP Bed". Defaults to Economy Regular.' })
|
@ApiQuery({ name: 'seatClassId', required: true, description: 'SeatClass UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Fare rule or default fare' })
|
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency (Ethiopian→ETB, Djiboutian→DJF, other→USD)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Live fare breakdown from fare engine' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Schedule or seat class not found' })
|
||||||
|
getFare(
|
||||||
|
@Param('scheduleId') scheduleId: string,
|
||||||
|
@Query('seatClassId') seatClassId: string,
|
||||||
|
@Query('nationality') nationality?: string,
|
||||||
|
) {
|
||||||
|
return this.service.getFareFromEngine(scheduleId, seatClassId, nationality);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':scheduleId/fares/all')
|
||||||
|
@ApiOperation({ summary: 'Get fares for all active seat classes on a schedule' })
|
||||||
|
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||||
|
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Array of fare breakdowns for every active seat class, ordered by price ascending' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' })
|
||||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||||
getFare(@Param('scheduleId') scheduleId: string, @Query('class') cls: string) {
|
getAllFares(
|
||||||
return this.service.getFare(scheduleId, cls ?? 'Economy Regular');
|
@Param('scheduleId') scheduleId: string,
|
||||||
|
@Query('nationality') nationality?: string,
|
||||||
|
) {
|
||||||
|
return this.service.getAllFaresFromEngine(scheduleId, nationality);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/fares/sync')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Sync fares from fare engine',
|
||||||
|
description: 'Recalculates fares for all active seat classes using the fare engine (km × ratePerKm + tax) and upserts them as FareRule records scoped to this schedule. Previous active rules are expired.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Fares synced — returns count of synced rules and any errors' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Schedule has no associated route or missing distanceKm on stops' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||||
|
syncFares(@Param('id') id: string) {
|
||||||
|
return this.service.syncFaresFromEngine(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { SchedulesController } from './schedules.controller';
|
|||||||
import { SchedulesService } from './schedules.service';
|
import { SchedulesService } from './schedules.service';
|
||||||
import { RoutesController } from './routes.controller';
|
import { RoutesController } from './routes.controller';
|
||||||
import { RoutesService } from './routes.service';
|
import { RoutesService } from './routes.service';
|
||||||
|
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [FareEngineModule],
|
||||||
controllers: [RoutesController, SchedulesController],
|
controllers: [RoutesController, SchedulesController],
|
||||||
providers: [RoutesService, SchedulesService],
|
providers: [RoutesService, SchedulesService],
|
||||||
exports: [RoutesService, SchedulesService],
|
exports: [RoutesService, SchedulesService],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { RoutesService } from './routes.service';
|
import { RoutesService } from './routes.service';
|
||||||
|
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||||
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
|
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -8,6 +9,7 @@ export class SchedulesService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private routesService: RoutesService,
|
private routesService: RoutesService,
|
||||||
|
private fareEngine: FareEngineService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── Schedule CRUD ──────────────────────────────────────────────────────────
|
// ── Schedule CRUD ──────────────────────────────────────────────────────────
|
||||||
@@ -100,7 +102,63 @@ export class SchedulesService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
return schedule;
|
|
||||||
|
// Compute effective seat statuses from SeatHold + JourneySegment
|
||||||
|
// (seat.status DB column is no longer written during booking)
|
||||||
|
const allSeatIds = schedule.coachAssignments.flatMap(a => a.coach.seats.map(s => s.id));
|
||||||
|
const effectiveStatuses = await this.resolveEffectiveStatuses(id, allSeatIds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...schedule,
|
||||||
|
coachAssignments: schedule.coachAssignments.map(a => ({
|
||||||
|
...a,
|
||||||
|
coach: {
|
||||||
|
...a.coach,
|
||||||
|
seats: a.coach.seats.map(s => ({
|
||||||
|
...s,
|
||||||
|
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes effective seat status for a schedule by checking active SeatHolds
|
||||||
|
* and confirmed JourneySegments. The DB seat.status column is not written
|
||||||
|
* during segment-based booking, so this overlay is required.
|
||||||
|
* Priority: BLOCKED (physical) > BOOKED (confirmed) > HELD (active hold) > AVAILABLE
|
||||||
|
*/
|
||||||
|
private async resolveEffectiveStatuses(
|
||||||
|
scheduleId: string,
|
||||||
|
seatIds: string[],
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const statusMap = new Map<string, string>();
|
||||||
|
if (seatIds.length === 0) return statusMap;
|
||||||
|
|
||||||
|
const [activeHolds, bookedSegments] = await Promise.all([
|
||||||
|
this.prisma.seatHold.findMany({
|
||||||
|
where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } },
|
||||||
|
select: { seatIds: true },
|
||||||
|
}),
|
||||||
|
this.prisma.journeySegment.findMany({
|
||||||
|
where: {
|
||||||
|
scheduleId,
|
||||||
|
seatId: { in: seatIds },
|
||||||
|
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||||
|
},
|
||||||
|
select: { seatId: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const hold of activeHolds)
|
||||||
|
for (const seatId of hold.seatIds)
|
||||||
|
if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD');
|
||||||
|
|
||||||
|
for (const seg of bookedSegments)
|
||||||
|
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
|
||||||
|
|
||||||
|
return statusMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
|
updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
|
||||||
@@ -149,27 +207,51 @@ export class SchedulesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFare(scheduleId: string, seatClassName: string) {
|
getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) {
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality);
|
||||||
where: { id: scheduleId },
|
}
|
||||||
include: { originStation: true, destinationStation: true },
|
|
||||||
});
|
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
|
||||||
|
|
||||||
const route = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
getAllFaresFromEngine(scheduleId: string, nationality?: string) {
|
||||||
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: seatClassName } });
|
return this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate fares for all active seat classes on a schedule using the fare engine
|
||||||
|
* and upsert them as FareRule records scoped to this schedule.
|
||||||
|
*/
|
||||||
|
async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> {
|
||||||
|
const results = await this.fareEngine.calculateAllForSchedule(scheduleId);
|
||||||
|
const errors: string[] = [];
|
||||||
|
let synced = 0;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const rule = await this.prisma.fareRule.findFirst({
|
for (const fare of results as any[]) {
|
||||||
where: {
|
try {
|
||||||
seatClassId: seatClass?.id,
|
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: fare.seatClassName } });
|
||||||
validFrom: { lte: now },
|
if (!seatClass) { errors.push(`Seat class not found: ${fare.seatClassName}`); continue; }
|
||||||
OR: [{ tripId: scheduleId }, { route }, { tripId: null, route: null }],
|
|
||||||
AND: [{ OR: [{ validUntil: null }, { validUntil: { gte: now } }] }],
|
|
||||||
},
|
|
||||||
orderBy: { validFrom: 'desc' },
|
|
||||||
});
|
|
||||||
|
|
||||||
return rule ?? { baseFareMinor: 45000, currency: 'ETB', seatClassName };
|
// Expire any existing active rule for this schedule + seat class
|
||||||
|
await this.prisma.fareRule.updateMany({
|
||||||
|
where: { tripId: scheduleId, seatClassId: seatClass.id, validUntil: null },
|
||||||
|
data: { validUntil: now },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.fareRule.create({
|
||||||
|
data: {
|
||||||
|
tripId: scheduleId,
|
||||||
|
seatClassId: seatClass.id,
|
||||||
|
baseFareMinor: fare.totalMinor,
|
||||||
|
currency: 'ETB',
|
||||||
|
validFrom: now,
|
||||||
|
validUntil: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
synced++;
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`${fare.seatClassName}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { synced, errors };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { Module } from '@nestjs/common';
|
|||||||
import { SearchController } from './search.controller';
|
import { SearchController } from './search.controller';
|
||||||
import { SearchService } from './search.service';
|
import { SearchService } from './search.service';
|
||||||
import { CurrencyModule } from '../currency/currency.module';
|
import { CurrencyModule } from '../currency/currency.module';
|
||||||
|
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||||
|
import { SegmentsModule } from '../segments/segments.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CurrencyModule],
|
imports: [CurrencyModule, FareEngineModule, SegmentsModule],
|
||||||
controllers: [SearchController],
|
controllers: [SearchController],
|
||||||
providers: [SearchService],
|
providers: [SearchService],
|
||||||
exports: [SearchService]
|
exports: [SearchService],
|
||||||
})
|
})
|
||||||
export class SearchModule {}
|
export class SearchModule {}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
||||||
import { CurrencyService } from '../currency/currency.service';
|
import { CurrencyService } from '../currency/currency.service';
|
||||||
|
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||||
|
import { SegmentsService } from '../segments/segments.service';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
const POINTS_TO_MINOR = 10;
|
const POINTS_TO_MINOR = 10;
|
||||||
@@ -11,6 +13,8 @@ export class SearchService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private currencyService: CurrencyService,
|
private currencyService: CurrencyService,
|
||||||
|
private fareEngine: FareEngineService,
|
||||||
|
private segmentsService: SegmentsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async searchTrips(dto: SearchTripsDto) {
|
async searchTrips(dto: SearchTripsDto) {
|
||||||
@@ -56,7 +60,8 @@ export class SearchService {
|
|||||||
|
|
||||||
for (const seat of assignment.coach.seats) {
|
for (const seat of assignment.coach.seats) {
|
||||||
if (seat.status === 'BLOCKED') continue;
|
if (seat.status === 'BLOCKED') continue;
|
||||||
const free = await this.isSeatFreeForSegment(
|
// Use segment-aware check — a seat booked A→B is still free for B→D
|
||||||
|
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||||
schedule.id, seat.id,
|
schedule.id, seat.id,
|
||||||
originStop.sequence, destStop.sequence,
|
originStop.sequence, destStop.sequence,
|
||||||
);
|
);
|
||||||
@@ -68,6 +73,11 @@ export class SearchService {
|
|||||||
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||||
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
||||||
|
|
||||||
|
// Fetch fares for all seat classes from fare engine in one call
|
||||||
|
const faresByClass = await this.fareEngine
|
||||||
|
.calculateAllForSchedule(schedule.id, dto.nationality)
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
scheduleId: schedule.id,
|
scheduleId: schedule.id,
|
||||||
trainNumber: schedule.train.number,
|
trainNumber: schedule.train.number,
|
||||||
@@ -92,7 +102,6 @@ export class SearchService {
|
|||||||
(new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000,
|
(new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000,
|
||||||
),
|
),
|
||||||
status: schedule.status,
|
status: schedule.status,
|
||||||
// Only return stops within the requested leg (origin → destination inclusive)
|
|
||||||
stops: schedule.stopTimes
|
stops: schedule.stopTimes
|
||||||
.filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
|
.filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
|
||||||
.map(st => ({
|
.map(st => ({
|
||||||
@@ -104,6 +113,7 @@ export class SearchService {
|
|||||||
})),
|
})),
|
||||||
availabilityByClass,
|
availabilityByClass,
|
||||||
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
||||||
|
faresByClass,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,70 +240,6 @@ export class SearchService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the seat has no active hold or confirmed booking
|
|
||||||
* whose segment range overlaps [fromSeq, toSeq).
|
|
||||||
* Overlap condition: existingFrom < toSeq AND fromSeq < existingTo
|
|
||||||
*/
|
|
||||||
private async isSeatFreeForSegment(
|
|
||||||
scheduleId: string,
|
|
||||||
seatId: string,
|
|
||||||
fromSeq: number,
|
|
||||||
toSeq: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
// Check active holds that include this seat on this schedule
|
|
||||||
const holds = await this.prisma.seatHold.findMany({
|
|
||||||
where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const hold of holds) {
|
|
||||||
// Resolve hold segment range from its stored origin/destination via JourneySegment
|
|
||||||
// For holds we use the stop sequences stored on the hold's origin/destination
|
|
||||||
// Since SeatHold doesn't store sequences directly, we check JourneySegments
|
|
||||||
// that reference this seat on this schedule with PENDING_PAYMENT status
|
|
||||||
const holdSegs = await this.prisma.journeySegment.findMany({
|
|
||||||
where: { scheduleId, seatId },
|
|
||||||
include: {
|
|
||||||
journey: true,
|
|
||||||
schedule: { include: { stopTimes: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const js of holdSegs) {
|
|
||||||
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
|
|
||||||
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
|
|
||||||
if (depSeq !== undefined && arrSeq !== undefined) {
|
|
||||||
if (depSeq < toSeq && fromSeq < arrSeq) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no journey segments yet (hold just created), treat the whole hold as blocking
|
|
||||||
if (holdSegs.length === 0) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check confirmed/pending bookings via JourneySegment
|
|
||||||
const bookedSegments = await this.prisma.journeySegment.findMany({
|
|
||||||
where: {
|
|
||||||
scheduleId,
|
|
||||||
seatId,
|
|
||||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
schedule: { include: { stopTimes: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const js of bookedSegments) {
|
|
||||||
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
|
|
||||||
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
|
|
||||||
if (depSeq !== undefined && arrSeq !== undefined) {
|
|
||||||
if (depSeq < toSeq && fromSeq < arrSeq) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private defaultFare(seatClassName: string): number {
|
private defaultFare(seatClassName: string): number {
|
||||||
const fares: Record<string, number> = {
|
const fares: Record<string, number> = {
|
||||||
'Economy Regular': 45000,
|
'Economy Regular': 45000,
|
||||||
|
|||||||
@@ -26,6 +26,34 @@ Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED`
|
|||||||
getSeatMap(@Param('scheduleId') scheduleId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(scheduleId, coachId); }
|
getSeatMap(@Param('scheduleId') scheduleId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(scheduleId, coachId); }
|
||||||
|
|
||||||
// ── Hold / Release ────────────────────────────────────────────────────────
|
// ── Hold / Release ────────────────────────────────────────────────────────
|
||||||
|
@Get('holds')
|
||||||
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'List active seat holds with full leg context',
|
||||||
|
description: `Returns all non-expired holds enriched with:
|
||||||
|
- **schedule**: train number, departure/arrival, full route origin→destination
|
||||||
|
- **leg**: the specific origin→destination this hold covers (station name, code, stop sequence)
|
||||||
|
- **seats**: seat label, coach, seat class, row, col
|
||||||
|
- **ttlSeconds**: seconds remaining before the hold expires
|
||||||
|
|
||||||
|
This makes it clear which segment of the route each seat is held for, enabling segment-based reuse of the same seat on non-overlapping legs.`,
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter by TrainSchedule UUID' })
|
||||||
|
@ApiQuery({ name: 'passengerId', required: false, description: 'Filter by Passenger UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Active holds with schedule, leg, and seat details' })
|
||||||
|
getHolds(
|
||||||
|
@Query('scheduleId') scheduleId?: string,
|
||||||
|
@Query('passengerId') passengerId?: string,
|
||||||
|
) { return this.service.getHolds(scheduleId, passengerId); }
|
||||||
|
|
||||||
|
@Get('holds/:holdId')
|
||||||
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Get a single hold with full leg context' })
|
||||||
|
@ApiParam({ name: 'holdId', description: 'SeatHold UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Hold with schedule, leg, and seat details' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Hold not found' })
|
||||||
|
getHold(@Param('holdId') holdId: string) { return this.service.getHold(holdId); }
|
||||||
|
|
||||||
@Post('hold')
|
@Post('hold')
|
||||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -1,9 +1,35 @@
|
|||||||
import { IsString, IsArray, IsOptional } from 'class-validator';
|
import { IsString, IsArray, ValidateNested } from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class PassengerSeatDto {
|
||||||
|
@ApiProperty({ example: 'passenger-uuid', description: 'Passenger UUID' })
|
||||||
|
@IsString() passengerId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'seat-uuid', description: 'Seat UUID assigned to this passenger' })
|
||||||
|
@IsString() seatId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class HoldSeatsDto {
|
export class HoldSeatsDto {
|
||||||
@ApiProperty({ example: 'schedule-uuid' }) @IsString() scheduleId: string;
|
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' })
|
||||||
@ApiProperty({ example: 'passenger-uuid' }) @IsString() passengerId: string;
|
@IsString() scheduleId: string;
|
||||||
@ApiProperty({ type: [String], example: ['seat-uuid-1', 'seat-uuid-2'] }) @IsArray() seatIds: string[];
|
|
||||||
@ApiPropertyOptional({ example: 'fare-quote-uuid' }) @IsOptional() @IsString() fareQuoteId?: string;
|
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID — scopes the hold to a leg so the seat can be reused on non-overlapping legs' })
|
||||||
|
@IsString() originStationId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg' })
|
||||||
|
@IsString() destinationStationId: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: [PassengerSeatDto],
|
||||||
|
description: 'One entry per passenger. Each passenger is assigned exactly one seat. Duplicate passengerId or seatId within the same request is rejected.',
|
||||||
|
example: [
|
||||||
|
{ passengerId: 'passenger-uuid-1', seatId: 'seat-uuid-1' },
|
||||||
|
{ passengerId: 'passenger-uuid-2', seatId: 'seat-uuid-2' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => PassengerSeatDto)
|
||||||
|
passengers: PassengerSeatDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { SeatsController } from './seats.controller';
|
import { SeatsController } from './seats.controller';
|
||||||
import { SeatsService } from './seats.service';
|
import { SeatsService } from './seats.service';
|
||||||
|
import { SegmentsModule } from '../segments/segments.module';
|
||||||
|
|
||||||
@Module({ controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService] })
|
@Module({
|
||||||
|
imports: [SegmentsModule],
|
||||||
|
controllers: [SeatsController],
|
||||||
|
providers: [SeatsService],
|
||||||
|
exports: [SeatsService],
|
||||||
|
})
|
||||||
export class SeatsModule {}
|
export class SeatsModule {}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
|
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { HoldSeatsDto } from './seats.dto';
|
import { HoldSeatsDto } from './seats.dto';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { SegmentsService } from '../segments/segments.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SeatsService {
|
export class SeatsService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private segmentsService: SegmentsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
// ── Seat Map ──────────────────────────────────────────────────────────────
|
// ── Seat Map ──────────────────────────────────────────────────────────────
|
||||||
async getSeatMap(scheduleId: string, coachId?: string) {
|
async getSeatMap(scheduleId: string, coachId?: string) {
|
||||||
@@ -14,6 +18,10 @@ export class SeatsService {
|
|||||||
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
|
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
|
||||||
orderBy: { positionNumber: 'asc' },
|
orderBy: { positionNumber: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id));
|
||||||
|
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
coaches: assignments.map((a) => ({
|
coaches: assignments.map((a) => ({
|
||||||
id: a.coach.id,
|
id: a.coach.id,
|
||||||
@@ -21,34 +29,334 @@ export class SeatsService {
|
|||||||
name: `Coach ${a.coach.label}`,
|
name: `Coach ${a.coach.label}`,
|
||||||
seatClass: a.coach.seatClass.name,
|
seatClass: a.coach.seatClass.name,
|
||||||
positionNumber: a.positionNumber,
|
positionNumber: a.positionNumber,
|
||||||
seats: a.coach.seats.map((s) => ({ id: s.id, number: s.label, status: s.status, kind: s.kind })),
|
seats: a.coach.seats.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
number: s.label,
|
||||||
|
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||||
|
kind: s.kind,
|
||||||
|
row: s.row,
|
||||||
|
col: s.col,
|
||||||
|
isWindow: s.isWindow,
|
||||||
|
isAisle: s.isAisle,
|
||||||
|
bedPosition: s.bedPosition,
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the effective seat status for a set of seats on a specific schedule
|
||||||
|
* by checking active SeatHolds and confirmed JourneySegments.
|
||||||
|
*
|
||||||
|
* Priority: BLOCKED (physical) > BOOKED (confirmed journey) > HELD (active hold) > AVAILABLE
|
||||||
|
*
|
||||||
|
* This is needed because seat.status is no longer written during booking —
|
||||||
|
* availability is segment-scoped, so the DB column stays AVAILABLE even when held.
|
||||||
|
*/
|
||||||
|
async resolveEffectiveStatuses(
|
||||||
|
scheduleId: string,
|
||||||
|
seatIds: string[],
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const statusMap = new Map<string, string>();
|
||||||
|
|
||||||
|
if (seatIds.length === 0) return statusMap;
|
||||||
|
|
||||||
|
// 1. Active holds — any seat in an unexpired SeatHold for this schedule is HELD
|
||||||
|
const activeHolds = await this.prisma.seatHold.findMany({
|
||||||
|
where: {
|
||||||
|
scheduleId,
|
||||||
|
expiresAt: { gt: new Date() },
|
||||||
|
seatIds: { hasSome: seatIds },
|
||||||
|
},
|
||||||
|
select: { seatIds: true },
|
||||||
|
});
|
||||||
|
for (const hold of activeHolds) {
|
||||||
|
for (const seatId of hold.seatIds) {
|
||||||
|
if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Active bookings via JourneySegment — CONFIRMED or PENDING_PAYMENT → BOOKED
|
||||||
|
// (overwrites HELD if the same seat has a confirmed booking)
|
||||||
|
const bookedSegments = await this.prisma.journeySegment.findMany({
|
||||||
|
where: {
|
||||||
|
scheduleId,
|
||||||
|
seatId: { in: seatIds },
|
||||||
|
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||||
|
},
|
||||||
|
select: { seatId: true },
|
||||||
|
});
|
||||||
|
for (const seg of bookedSegments) {
|
||||||
|
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusMap;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Hold / Release ────────────────────────────────────────────────────────
|
// ── Hold / Release ────────────────────────────────────────────────────────
|
||||||
async holdSeats(dto: HoldSeatsDto) {
|
async holdSeats(dto: HoldSeatsDto) {
|
||||||
|
// ── Validate request integrity ───────────────────────────────────────────
|
||||||
|
const passengerIds = dto.passengers.map(p => p.passengerId);
|
||||||
|
const seatIds = dto.passengers.map(p => p.seatId);
|
||||||
|
|
||||||
|
if (new Set(passengerIds).size !== passengerIds.length)
|
||||||
|
throw new BadRequestException('Duplicate passengerId in passengers list — each passenger must appear once');
|
||||||
|
if (new Set(seatIds).size !== seatIds.length)
|
||||||
|
throw new BadRequestException('Duplicate seatId in passengers list — each seat can only be assigned to one passenger');
|
||||||
|
|
||||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
|
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
|
||||||
|
|
||||||
const hold = await this.prisma.$transaction(async (tx) => {
|
const hold = await this.prisma.$transaction(async (tx) => {
|
||||||
const seats = await tx.seat.findMany({ where: { id: { in: dto.seatIds } }, select: { id: true, status: true, heldUntil: true } });
|
// ── 1. Validate seats exist and none are BLOCKED ─────────────────────
|
||||||
const unavailable = seats.filter((s) => s.status === 'BOOKED' || s.status === 'BLOCKED' || (s.status === 'HELD' && s.heldUntil && s.heldUntil > new Date()));
|
const seats = await tx.seat.findMany({
|
||||||
if (unavailable.length > 0) throw new ConflictException('One or more seats unavailable');
|
where: { id: { in: seatIds } },
|
||||||
await tx.seat.updateMany({ where: { id: { in: dto.seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } });
|
select: { id: true, status: true, label: true },
|
||||||
return tx.seatHold.create({ data: { scheduleId: dto.scheduleId, passengerId: dto.passengerId, seatIds: dto.seatIds, fareQuoteId: dto.fareQuoteId, expiresAt } });
|
});
|
||||||
|
|
||||||
|
if (seats.length !== seatIds.length) {
|
||||||
|
const found = new Set(seats.map(s => s.id));
|
||||||
|
const missing = seatIds.filter(id => !found.has(id));
|
||||||
|
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocked = seats.filter(s => s.status === 'BLOCKED');
|
||||||
|
if (blocked.length > 0)
|
||||||
|
throw new ConflictException(`Seat(s) ${blocked.map(s => s.label).join(', ')} are blocked`);
|
||||||
|
|
||||||
|
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.label]));
|
||||||
|
|
||||||
|
// ── 2. Resolve requested leg sequences ──────────────────────────────
|
||||||
|
const stopTimes = await tx.tripStopTime.findMany({
|
||||||
|
where: { scheduleId: dto.scheduleId },
|
||||||
|
select: { stationId: true, sequence: true },
|
||||||
|
});
|
||||||
|
const seqOf = (stationId: string) =>
|
||||||
|
stopTimes.find(s => s.stationId === stationId)?.sequence;
|
||||||
|
|
||||||
|
const reqFrom = seqOf(dto.originStationId);
|
||||||
|
const reqTo = seqOf(dto.destinationStationId);
|
||||||
|
|
||||||
|
if (reqFrom === undefined || reqTo === undefined)
|
||||||
|
throw new BadRequestException('Origin or destination station not found on this schedule');
|
||||||
|
if (reqFrom >= reqTo)
|
||||||
|
throw new BadRequestException('Origin must come before destination');
|
||||||
|
|
||||||
|
// ── 3. Load active holds for this schedule ───────────────────────────
|
||||||
|
const activeHolds = await tx.seatHold.findMany({
|
||||||
|
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
|
||||||
|
select: { seatIds: true, createdBy: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse each hold's leg range and passenger list
|
||||||
|
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = [];
|
||||||
|
for (const h of activeHolds) {
|
||||||
|
try {
|
||||||
|
if (h.createdBy?.trimStart().startsWith('{')) {
|
||||||
|
const meta = JSON.parse(h.createdBy);
|
||||||
|
const holdFrom = seqOf(meta.originStationId);
|
||||||
|
const holdTo = seqOf(meta.destinationStationId);
|
||||||
|
if (holdFrom !== undefined && holdTo !== undefined) {
|
||||||
|
parsedHolds.push({
|
||||||
|
seatIds: h.seatIds,
|
||||||
|
from: holdFrom,
|
||||||
|
to: holdTo,
|
||||||
|
passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore malformed */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Per-passenger validation with overlap check ───────────────────
|
||||||
|
for (const { passengerId, seatId } of dto.passengers) {
|
||||||
|
for (const hold of parsedHolds) {
|
||||||
|
const legsOverlap = hold.from < reqTo && reqFrom < hold.to;
|
||||||
|
if (!legsOverlap) continue; // non-overlapping leg — no conflict
|
||||||
|
|
||||||
|
// Rule A: seat is held on an overlapping leg
|
||||||
|
if (hold.seatIds.includes(seatId)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Seat ${seatLabelById[seatId]} is already held for this leg. Please choose a different seat.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule B: passenger already holds a seat on an overlapping leg
|
||||||
|
if (hold.passengerIds.includes(passengerId)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Passenger already holds a seat on this journey leg. You can only hold one seat per journey.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store passenger→seat mapping AND leg in createdBy as JSON
|
||||||
|
const holdMeta = {
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.destinationStationId,
|
||||||
|
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
|
||||||
|
};
|
||||||
|
|
||||||
|
return tx.seatHold.create({
|
||||||
|
data: {
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
passengerId: dto.passengers[0].passengerId,
|
||||||
|
seatIds,
|
||||||
|
createdBy: JSON.stringify(holdMeta),
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return { id: hold.id, scheduleId: dto.scheduleId, seatIds: dto.seatIds, expiresAt };
|
|
||||||
|
return this.enrichHold(hold);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHolds(scheduleId?: string, passengerId?: string) {
|
||||||
|
const holds = await this.prisma.seatHold.findMany({
|
||||||
|
where: {
|
||||||
|
expiresAt: { gt: new Date() },
|
||||||
|
...(scheduleId ? { scheduleId } : {}),
|
||||||
|
...(passengerId ? { passengerId } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return Promise.all(holds.map(h => this.enrichHold(h)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHold(holdId: string) {
|
||||||
|
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
|
||||||
|
if (!hold) throw new NotFoundException('Hold not found');
|
||||||
|
return this.enrichHold(hold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the opaque fareQuoteId leg encoding into human-readable station
|
||||||
|
* names and enriches the hold with schedule, seat, and leg details.
|
||||||
|
*/
|
||||||
|
private async enrichHold(hold: any) {
|
||||||
|
// Decode leg and passenger→seat mapping from createdBy JSON
|
||||||
|
let originStationId: string | null = null;
|
||||||
|
let destinationStationId: string | null = null;
|
||||||
|
let passengerSeatMap: { passengerId: string; seatId: string }[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (hold.createdBy) {
|
||||||
|
const raw = hold.createdBy;
|
||||||
|
// Guard: only parse if it looks like a JSON object, not a plain number/string
|
||||||
|
if (typeof raw === 'string' && raw.trimStart().startsWith('{')) {
|
||||||
|
const meta = JSON.parse(raw);
|
||||||
|
originStationId = meta.originStationId ?? null;
|
||||||
|
destinationStationId = meta.destinationStationId ?? null;
|
||||||
|
passengerSeatMap = Array.isArray(meta.passengers) ? meta.passengers : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore malformed createdBy */ }
|
||||||
|
|
||||||
|
const seatIds = hold.seatIds as string[];
|
||||||
|
|
||||||
|
const [schedule, originStation, destinationStation, seats] = await Promise.all([
|
||||||
|
this.prisma.trainSchedule.findUnique({
|
||||||
|
where: { id: hold.scheduleId },
|
||||||
|
include: { train: true, originStation: true, destinationStation: true },
|
||||||
|
}),
|
||||||
|
originStationId ? this.prisma.station.findUnique({ where: { id: originStationId } }) : null,
|
||||||
|
destinationStationId ? this.prisma.station.findUnique({ where: { id: destinationStationId } }) : null,
|
||||||
|
this.prisma.seat.findMany({
|
||||||
|
where: { id: { in: seatIds } },
|
||||||
|
include: { coach: { include: { seatClass: true } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let originSequence: number | null = null;
|
||||||
|
let destinationSequence: number | null = null;
|
||||||
|
if (originStationId && destinationStationId) {
|
||||||
|
const stopTimes = await this.prisma.tripStopTime.findMany({
|
||||||
|
where: { scheduleId: hold.scheduleId, stationId: { in: [originStationId, destinationStationId] } },
|
||||||
|
select: { stationId: true, sequence: true },
|
||||||
|
});
|
||||||
|
originSequence = stopTimes.find(s => s.stationId === originStationId)?.sequence ?? null;
|
||||||
|
destinationSequence = stopTimes.find(s => s.stationId === destinationStationId)?.sequence ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build seat map keyed by seatId for quick lookup
|
||||||
|
const seatById = Object.fromEntries(seats.map(s => [s.id, s]));
|
||||||
|
|
||||||
|
// Merge passenger→seat mapping with seat details
|
||||||
|
const passengers = passengerSeatMap.length > 0
|
||||||
|
? passengerSeatMap.map(({ passengerId, seatId }) => {
|
||||||
|
const s = seatById[seatId];
|
||||||
|
return {
|
||||||
|
passengerId,
|
||||||
|
seat: s ? {
|
||||||
|
id: s.id,
|
||||||
|
label: s.label,
|
||||||
|
seatNumber: s.seatNumber,
|
||||||
|
coach: s.coach.label,
|
||||||
|
seatClass: s.coach.seatClass.name,
|
||||||
|
row: s.row,
|
||||||
|
col: s.col,
|
||||||
|
} : { id: seatId },
|
||||||
|
};
|
||||||
|
})
|
||||||
|
// Fallback for holds created before this change
|
||||||
|
: seatIds.map(seatId => {
|
||||||
|
const s = seatById[seatId];
|
||||||
|
return {
|
||||||
|
passengerId: hold.passengerId,
|
||||||
|
seat: s ? {
|
||||||
|
id: s.id,
|
||||||
|
label: s.label,
|
||||||
|
seatNumber: s.seatNumber,
|
||||||
|
coach: s.coach.label,
|
||||||
|
seatClass: s.coach.seatClass.name,
|
||||||
|
row: s.row,
|
||||||
|
col: s.col,
|
||||||
|
} : { id: seatId },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
holdId: hold.id,
|
||||||
|
expiresAt: hold.expiresAt,
|
||||||
|
createdAt: hold.createdAt,
|
||||||
|
ttlSeconds: Math.max(0, Math.floor((hold.expiresAt.getTime() - Date.now()) / 1000)),
|
||||||
|
schedule: schedule ? {
|
||||||
|
id: schedule.id,
|
||||||
|
trainNumber: schedule.train.number,
|
||||||
|
trainName: schedule.train.name,
|
||||||
|
departureAt: schedule.departureAt,
|
||||||
|
arrivalAt: schedule.arrivalAt,
|
||||||
|
fullRouteOrigin: schedule.originStation.name,
|
||||||
|
fullRouteDestination: schedule.destinationStation.name,
|
||||||
|
} : null,
|
||||||
|
leg: {
|
||||||
|
originStationId,
|
||||||
|
originStationName: originStation?.name ?? null,
|
||||||
|
originStationCode: originStation?.code ?? null,
|
||||||
|
originSequence,
|
||||||
|
destinationStationId,
|
||||||
|
destinationStationName: destinationStation?.name ?? null,
|
||||||
|
destinationStationCode: destinationStation?.code ?? null,
|
||||||
|
destinationSequence,
|
||||||
|
},
|
||||||
|
passengers,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async releaseHold(holdId: string) {
|
async releaseHold(holdId: string) {
|
||||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
|
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
|
||||||
if (!hold) throw new NotFoundException('Hold not found');
|
if (!hold) throw new NotFoundException('Hold not found');
|
||||||
await this.prisma.seat.updateMany({ where: { id: { in: hold.seatIds }, status: 'HELD' }, data: { status: 'AVAILABLE', heldUntil: null } });
|
|
||||||
await this.prisma.seatHold.delete({ where: { id: holdId } });
|
await this.prisma.seatHold.delete({ where: { id: holdId } });
|
||||||
return { released: true };
|
return { released: true, holdId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'BOOKED', heldUntil: null } }); }
|
async confirmSeats(seatIds: string[]) {
|
||||||
async releaseSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'AVAILABLE', heldUntil: null } }); }
|
// No-op for status — availability is segment-scoped via JourneySegment
|
||||||
|
// seat.status = BLOCKED is the only hard gate; BOOKED is not used as a booking flag
|
||||||
|
}
|
||||||
|
async releaseSeats(seatIds: string[]) {
|
||||||
|
// Only reset seats that are physically BLOCKED back to AVAILABLE if needed
|
||||||
|
// For segment-based bookings, releasing is handled by JourneySegment deletion
|
||||||
|
}
|
||||||
|
|
||||||
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise<string[]> {
|
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise<string[]> {
|
||||||
const seats = await this.prisma.seat.findMany({
|
const seats = await this.prisma.seat.findMany({
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export interface SeatHoldRequest {
|
|||||||
passengerId: string;
|
passengerId: string;
|
||||||
originStationId: string;
|
originStationId: string;
|
||||||
destinationStationId: string;
|
destinationStationId: string;
|
||||||
fareQuoteId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingConfirmRequest {
|
export interface BookingConfirmRequest {
|
||||||
@@ -27,28 +26,42 @@ export class EnhancedSeatsService {
|
|||||||
|
|
||||||
async holdSeats(request: SeatHoldRequest) {
|
async holdSeats(request: SeatHoldRequest) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const segments = await this.segmentsService.getJourneySegments(request.scheduleId, request.originStationId, request.destinationStationId);
|
const segments = await this.segmentsService.getJourneySegments(
|
||||||
|
request.scheduleId, request.originStationId, request.destinationStationId,
|
||||||
|
);
|
||||||
|
const reqFrom = Math.min(...segments.map(s => s.fromSequence));
|
||||||
|
const reqTo = Math.max(...segments.map(s => s.toSequence));
|
||||||
|
|
||||||
for (const seatId of request.seatIds) {
|
for (const seatId of request.seatIds) {
|
||||||
const seat = await tx.seat.findUnique({ where: { id: seatId } });
|
const seat = await tx.seat.findUnique({ where: { id: seatId } });
|
||||||
if (!seat) throw new BadRequestException(`Seat ${seatId} not found`);
|
if (!seat) throw new BadRequestException(`Seat ${seatId} not found`);
|
||||||
|
// Only BLOCKED seats are hard-rejected — BOOKED/HELD are fine if the
|
||||||
|
// segment does not overlap (another passenger may occupy a different leg)
|
||||||
if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.label} is blocked`);
|
if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.label} is blocked`);
|
||||||
const overlaps = await this.segmentsService.getOverlappingReservations(request.scheduleId, seatId, segments);
|
|
||||||
if (overlaps.length > 0) throw new ConflictException(`Seat ${seat.label} is not available for the requested segments`);
|
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||||
|
request.scheduleId, seatId, reqFrom, reqTo,
|
||||||
|
);
|
||||||
|
if (!free) throw new ConflictException(`Seat ${seat.label} is not available for the requested leg`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
||||||
// Encode origin/destination into fareQuoteId so confirmBooking can resolve the leg range
|
|
||||||
// Format: "leg:{originStationId}:{destinationStationId}" (or preserve actual fareQuoteId)
|
|
||||||
const legKey = request.fareQuoteId ?? `leg:${request.originStationId}:${request.destinationStationId}`;
|
|
||||||
const seatHold = await tx.seatHold.create({
|
const seatHold = await tx.seatHold.create({
|
||||||
data: { scheduleId: request.scheduleId, seatIds: request.seatIds, passengerId: request.passengerId, fareQuoteId: legKey, expiresAt },
|
data: {
|
||||||
|
scheduleId: request.scheduleId,
|
||||||
|
seatIds: request.seatIds,
|
||||||
|
passengerId: request.passengerId,
|
||||||
|
// Store leg in createdBy JSON — no fareQuoteId needed
|
||||||
|
createdBy: JSON.stringify({
|
||||||
|
originStationId: request.originStationId,
|
||||||
|
destinationStationId: request.destinationStationId,
|
||||||
|
}),
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.seat.updateMany({ where: { id: { in: request.seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } });
|
// Do NOT set seat.status = HELD globally — status is segment-scoped
|
||||||
|
|
||||||
this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments });
|
this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments });
|
||||||
|
|
||||||
return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds };
|
return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -68,19 +81,16 @@ export class EnhancedSeatsService {
|
|||||||
});
|
});
|
||||||
if (!schedule) throw new BadRequestException('Schedule not found');
|
if (!schedule) throw new BadRequestException('Schedule not found');
|
||||||
|
|
||||||
// Resolve the passenger's leg range from the hold's fareQuoteId (encoded as "leg:originId:destId")
|
// Resolve the passenger's leg from createdBy JSON
|
||||||
const legKey = hold.fareQuoteId ?? '';
|
|
||||||
let originStationId: string | undefined;
|
let originStationId: string | undefined;
|
||||||
let destinationStationId: string | undefined;
|
let destinationStationId: string | undefined;
|
||||||
if (legKey.startsWith('leg:')) {
|
try {
|
||||||
const parts = legKey.split(':');
|
if (hold.createdBy) {
|
||||||
originStationId = parts[1];
|
const meta = JSON.parse(hold.createdBy);
|
||||||
destinationStationId = parts[2];
|
originStationId = meta.originStationId;
|
||||||
} else {
|
destinationStationId = meta.destinationStationId;
|
||||||
// Fall back to booking's own origin/destination if available
|
}
|
||||||
originStationId = (booking as any).originStationId;
|
} catch { /* ignore */ }
|
||||||
destinationStationId = (booking as any).destinationStationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const originStop = originStationId ? schedule.stopTimes.find(s => s.stationId === originStationId) : undefined;
|
const originStop = originStationId ? schedule.stopTimes.find(s => s.stationId === originStationId) : undefined;
|
||||||
const destStop = destinationStationId ? schedule.stopTimes.find(s => s.stationId === destinationStationId) : undefined;
|
const destStop = destinationStationId ? schedule.stopTimes.find(s => s.stationId === destinationStationId) : undefined;
|
||||||
@@ -122,11 +132,10 @@ export class EnhancedSeatsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.seat.updateMany({ where: { id: { in: hold.seatIds } }, data: { status: 'BOOKED', heldUntil: null } });
|
// Do NOT set seat.status = BOOKED globally — availability is segment-scoped
|
||||||
await tx.seatHold.delete({ where: { id: request.holdId } });
|
await tx.seatHold.delete({ where: { id: request.holdId } });
|
||||||
|
|
||||||
this.eventEmitter.emit('booking.confirmed', { bookingId: request.bookingId, scheduleId: hold.scheduleId, seatIds: hold.seatIds, segments });
|
this.eventEmitter.emit('booking.confirmed', { bookingId: request.bookingId, scheduleId: hold.scheduleId, seatIds: hold.seatIds, segments });
|
||||||
|
|
||||||
return { bookingId: request.bookingId, confirmedSeats: hold.seatIds, segments };
|
return { bookingId: request.bookingId, confirmedSeats: hold.seatIds, segments };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -171,6 +180,8 @@ export class EnhancedSeatsService {
|
|||||||
|
|
||||||
async getSeatAvailability(scheduleId: string, originStationId: string, destinationStationId: string) {
|
async getSeatAvailability(scheduleId: string, originStationId: string, destinationStationId: string) {
|
||||||
const segments = await this.segmentsService.getJourneySegments(scheduleId, originStationId, destinationStationId);
|
const segments = await this.segmentsService.getJourneySegments(scheduleId, originStationId, destinationStationId);
|
||||||
|
const reqFrom = Math.min(...segments.map(s => s.fromSequence));
|
||||||
|
const reqTo = Math.max(...segments.map(s => s.toSequence));
|
||||||
|
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
where: { id: scheduleId },
|
where: { id: scheduleId },
|
||||||
@@ -181,13 +192,20 @@ export class EnhancedSeatsService {
|
|||||||
const availableSeats = [];
|
const availableSeats = [];
|
||||||
for (const assignment of schedule.coachAssignments) {
|
for (const assignment of schedule.coachAssignments) {
|
||||||
for (const seat of assignment.coach.seats) {
|
for (const seat of assignment.coach.seats) {
|
||||||
const overlaps = await this.segmentsService.getOverlappingReservations(scheduleId, seat.id, segments);
|
// Hard-blocked seats are never available
|
||||||
if (overlaps.length === 0 && seat.status === 'AVAILABLE') {
|
if (seat.status === 'BLOCKED') continue;
|
||||||
|
// Availability is determined purely by segment overlap — not global seat.status
|
||||||
|
const free = await this.segmentsService.isSeatFreeForLeg(scheduleId, seat.id, reqFrom, reqTo);
|
||||||
|
if (free) {
|
||||||
availableSeats.push({
|
availableSeats.push({
|
||||||
id: seat.id, label: seat.label,
|
id: seat.id, label: seat.label,
|
||||||
coach: assignment.coach.label,
|
coach: assignment.coach.label,
|
||||||
seatClass: assignment.coach.seatClass.name,
|
seatClass: assignment.coach.seatClass.name,
|
||||||
row: seat.row, col: seat.col,
|
row: seat.row, col: seat.col,
|
||||||
|
kind: seat.kind,
|
||||||
|
isWindow: seat.isWindow,
|
||||||
|
isAisle: seat.isAisle,
|
||||||
|
bedPosition: seat.bedPosition,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,132 +1,51 @@
|
|||||||
import { Controller, Post, Get, Body, Query, Param } from '@nestjs/common';
|
import { Controller, Post, Get, Body, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
import { EnhancedSeatsService } from './enhanced-seats.service';
|
import { EnhancedSeatsService } from './enhanced-seats.service';
|
||||||
import { HoldSeatsDto, ConfirmBookingDto, SeatAvailabilityDto, ReleaseSeatsDto } from './segments.dto';
|
import { ConfirmBookingDto, SeatAvailabilityDto, ReleaseSeatsDto } from './segments.dto';
|
||||||
|
|
||||||
@ApiTags('Segment-based Seats')
|
@ApiTags('Segment-based Seats')
|
||||||
@Controller('segments/seats')
|
@Controller('segments/seats')
|
||||||
export class SegmentSeatsController {
|
export class SegmentSeatsController {
|
||||||
constructor(private enhancedSeatsService: EnhancedSeatsService) {}
|
constructor(private enhancedSeatsService: EnhancedSeatsService) {}
|
||||||
|
|
||||||
@Post('hold')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Hold seats for specific journey segments',
|
|
||||||
description: 'Reserve seats for a partial journey (e.g., Addis Ababa → Dire Dawa) with 10-minute expiry'
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 201,
|
|
||||||
description: 'Seats held successfully',
|
|
||||||
schema: {
|
|
||||||
example: {
|
|
||||||
holdId: 'hold_123',
|
|
||||||
expiresAt: '2024-01-15T10:10:00Z',
|
|
||||||
segments: [
|
|
||||||
{ fromName: 'Addis Ababa', toName: 'Adama', fromSequence: 0, toSequence: 1 },
|
|
||||||
{ fromName: 'Adama', toName: 'Awash', fromSequence: 1, toSequence: 2 },
|
|
||||||
{ fromName: 'Awash', toName: 'Dire Dawa', fromSequence: 2, toSequence: 3 }
|
|
||||||
],
|
|
||||||
seats: ['seat_1', 'seat_2']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 409, description: 'Seats not available for requested segments' })
|
|
||||||
async holdSeats(@Body() dto: HoldSeatsDto) {
|
|
||||||
return this.enhancedSeatsService.holdSeats({
|
|
||||||
scheduleId: dto.scheduleId,
|
|
||||||
seatIds: dto.seatIds,
|
|
||||||
passengerId: dto.passengerId,
|
|
||||||
originStationId: dto.originStationId,
|
|
||||||
destinationStationId: dto.destinationStationId,
|
|
||||||
fareQuoteId: dto.fareQuoteId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('confirm')
|
@Post('confirm')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Confirm booking and convert hold to reservation',
|
summary: 'Confirm booking — convert hold to reservation',
|
||||||
description: 'Convert seat hold to confirmed booking after payment success'
|
description: 'Call after payment succeeds. Converts the SeatHold (created via POST /seats/hold) into JourneySegment records scoped to the passenger\'s leg.',
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({ status: 200, description: 'Booking confirmed, JourneySegments created for the held leg' })
|
||||||
status: 200,
|
@ApiResponse({ status: 400, description: 'Hold expired or booking not found' })
|
||||||
description: 'Booking confirmed successfully',
|
confirmBooking(@Body() dto: ConfirmBookingDto) {
|
||||||
schema: {
|
|
||||||
example: {
|
|
||||||
bookingId: 'booking_123',
|
|
||||||
confirmedSeats: ['seat_1', 'seat_2'],
|
|
||||||
segments: [
|
|
||||||
{ fromName: 'Addis Ababa', toName: 'Adama' },
|
|
||||||
{ fromName: 'Adama', toName: 'Awash' },
|
|
||||||
{ fromName: 'Awash', toName: 'Dire Dawa' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 400, description: 'Hold expired or not found' })
|
|
||||||
async confirmBooking(@Body() dto: ConfirmBookingDto) {
|
|
||||||
return this.enhancedSeatsService.confirmBooking(dto);
|
return this.enhancedSeatsService.confirmBooking(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('release')
|
@Post('release')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Release seats when train reaches station',
|
summary: 'Release seats when train reaches a station',
|
||||||
description: 'Automatically release seats for passengers who have reached their destination'
|
description: 'Called by the live tracking system when the train departs a station. Frees seats for passengers whose journey ended at that station.',
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({ status: 200, description: 'Seats released for passengers who reached their destination' })
|
||||||
status: 200,
|
releaseSeats(@Body() dto: ReleaseSeatsDto) {
|
||||||
description: 'Seats released successfully',
|
|
||||||
schema: {
|
|
||||||
example: {
|
|
||||||
releasedSeats: ['seat_1', 'seat_2'],
|
|
||||||
stationId: 'st_DRE'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
async releaseSeats(@Body() dto: ReleaseSeatsDto) {
|
|
||||||
return this.enhancedSeatsService.releaseSeats(dto.scheduleId, dto.currentStationId);
|
return this.enhancedSeatsService.releaseSeats(dto.scheduleId, dto.currentStationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('availability')
|
@Get('availability')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Check seat availability for journey segments',
|
summary: 'Get available seats for a specific leg',
|
||||||
description: 'Get available seats for a specific origin-destination pair'
|
description: 'Returns seats that have no overlapping reservation for the requested origin→destination leg. A seat booked A→B is shown as available for B→D.',
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({ status: 200, description: 'Available seats with coach, seat class, row, col, window/aisle/bed flags' })
|
||||||
status: 200,
|
getSeatAvailability(@Query() dto: SeatAvailabilityDto) {
|
||||||
description: 'Seat availability retrieved',
|
|
||||||
schema: {
|
|
||||||
example: {
|
|
||||||
segments: [
|
|
||||||
{ fromName: 'Addis Ababa', toName: 'Adama', fromSequence: 0, toSequence: 1 },
|
|
||||||
{ fromName: 'Adama', toName: 'Awash', fromSequence: 1, toSequence: 2 }
|
|
||||||
],
|
|
||||||
availableSeats: [
|
|
||||||
{ id: 'seat_1', label: '1A', coach: 'A', seatClass: 'Economy Regular', row: 1, col: 'A' },
|
|
||||||
{ id: 'seat_2', label: '1B', coach: 'A', seatClass: 'Economy Regular', row: 1, col: 'B' }
|
|
||||||
],
|
|
||||||
totalAvailable: 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
async getSeatAvailability(@Query() dto: SeatAvailabilityDto) {
|
|
||||||
return this.enhancedSeatsService.getSeatAvailability(dto.scheduleId, dto.originStationId, dto.destinationStationId);
|
return this.enhancedSeatsService.getSeatAvailability(dto.scheduleId, dto.originStationId, dto.destinationStationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('expire-holds')
|
@Post('expire-holds')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Expire old seat holds (background job)',
|
summary: 'Expire stale seat holds (background job)',
|
||||||
description: 'Release seats from expired holds and make them available'
|
description: 'Removes holds past their expiry time. Called by the scheduler every minute.',
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({ status: 200, description: 'Expired holds removed' })
|
||||||
status: 200,
|
expireHolds() {
|
||||||
description: 'Expired holds processed',
|
|
||||||
schema: {
|
|
||||||
example: {
|
|
||||||
expiredHolds: 5,
|
|
||||||
releasedSeats: ['seat_1', 'seat_2', 'seat_3']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
async expireHolds() {
|
|
||||||
return this.enhancedSeatsService.expireHolds();
|
return this.enhancedSeatsService.expireHolds();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ export class SegmentsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const originStop = stopTimes.find(st => st.stationId === originStationId);
|
const originStop = stopTimes.find(st => st.stationId === originStationId);
|
||||||
const destStop = stopTimes.find(st => st.stationId === destinationStationId);
|
const destStop = stopTimes.find(st => st.stationId === destinationStationId);
|
||||||
|
|
||||||
if (!originStop || !destStop) {
|
if (!originStop || !destStop) {
|
||||||
throw new BadRequestException('Origin or destination station not found on this schedule');
|
throw new BadRequestException('Origin or destination station not found on this schedule');
|
||||||
@@ -38,22 +38,126 @@ export class SegmentsService {
|
|||||||
const segments: Segment[] = [];
|
const segments: Segment[] = [];
|
||||||
for (let i = originStop.sequence; i < destStop.sequence; i++) {
|
for (let i = originStop.sequence; i < destStop.sequence; i++) {
|
||||||
const fromStop = stopTimes.find(st => st.sequence === i);
|
const fromStop = stopTimes.find(st => st.sequence === i);
|
||||||
const toStop = stopTimes.find(st => st.sequence === i + 1);
|
const toStop = stopTimes.find(st => st.sequence === i + 1);
|
||||||
if (fromStop && toStop) {
|
if (fromStop && toStop) {
|
||||||
segments.push({
|
segments.push({
|
||||||
fromStationId: fromStop.stationId,
|
fromStationId: fromStop.stationId,
|
||||||
toStationId: toStop.stationId,
|
toStationId: toStop.stationId,
|
||||||
fromSequence: fromStop.sequence,
|
fromSequence: fromStop.sequence,
|
||||||
toSequence: toStop.sequence,
|
toSequence: toStop.sequence,
|
||||||
fromName: fromStop.station.name,
|
fromName: fromStop.station.name,
|
||||||
toName: toStop.station.name,
|
toName: toStop.station.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if two segment ranges overlap: [a.from, a.to) ∩ [b.from, b.to) ≠ ∅ */
|
/**
|
||||||
|
* Checks whether a seat is free for the requested leg [reqFrom, reqTo).
|
||||||
|
*
|
||||||
|
* Overlap rule (strict): existingFrom < reqTo AND reqFrom < existingTo
|
||||||
|
*
|
||||||
|
* This means two journeys that TOUCH at a boundary do NOT conflict:
|
||||||
|
* P1: A(1) → B(2) reqFrom=1, reqTo=2
|
||||||
|
* P2: B(2) → D(4) reqFrom=2, reqTo=4
|
||||||
|
* Check P1 vs P2: 1 < 4 AND 2 < 2 → true AND false → NO conflict ✓
|
||||||
|
*
|
||||||
|
* P3: A(1) → D(4) reqFrom=1, reqTo=4
|
||||||
|
* Check P3 vs P2: 1 < 4 AND 2 < 4 → true AND true → CONFLICT ✓
|
||||||
|
*
|
||||||
|
* Sources checked:
|
||||||
|
* 1. Active SeatHolds — leg decoded from createdBy JSON ({ originStationId, destinationStationId })
|
||||||
|
* 2. Active JourneySegments — per-leg rows for CONFIRMED / PENDING_PAYMENT journeys
|
||||||
|
*/
|
||||||
|
async isSeatFreeForLeg(
|
||||||
|
scheduleId: string,
|
||||||
|
seatId: string,
|
||||||
|
reqFrom: number,
|
||||||
|
reqTo: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
// ── Load stop-time sequences once ────────────────────────────────────────
|
||||||
|
const stopTimes = await this.prisma.tripStopTime.findMany({
|
||||||
|
where: { scheduleId },
|
||||||
|
select: { stationId: true, sequence: true },
|
||||||
|
});
|
||||||
|
const seqOf = (stationId: string) =>
|
||||||
|
stopTimes.find(s => s.stationId === stationId)?.sequence;
|
||||||
|
|
||||||
|
// ── 1. Active holds ───────────────────────────────────────────────────────
|
||||||
|
const activeHolds = await this.prisma.seatHold.findMany({
|
||||||
|
where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const hold of activeHolds) {
|
||||||
|
// Decode leg from createdBy JSON: { originStationId, destinationStationId, passengers }
|
||||||
|
let holdFrom: number | undefined;
|
||||||
|
let holdTo: number | undefined;
|
||||||
|
try {
|
||||||
|
if (hold.createdBy) {
|
||||||
|
const meta = JSON.parse(hold.createdBy);
|
||||||
|
holdFrom = seqOf(meta.originStationId);
|
||||||
|
holdTo = seqOf(meta.destinationStationId);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
if (holdFrom !== undefined && holdTo !== undefined) {
|
||||||
|
if (holdFrom < reqTo && reqFrom < holdTo) return false;
|
||||||
|
} else {
|
||||||
|
// Cannot resolve leg — conservative block
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Active JourneySegments ─────────────────────────────────────────────
|
||||||
|
// Each row is one leg (e.g. A→B, B→C). We group by journeyId to get the
|
||||||
|
// full range [min(depSeq), max(arrSeq)] per journey for this seat.
|
||||||
|
const bookedLegs = await this.prisma.journeySegment.findMany({
|
||||||
|
where: {
|
||||||
|
scheduleId,
|
||||||
|
seatId,
|
||||||
|
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group legs by journeyId → find the full range each journey occupies
|
||||||
|
const journeyRanges = new Map<string, { from: number; to: number }>();
|
||||||
|
for (const leg of bookedLegs) {
|
||||||
|
const depSeq = seqOf(leg.departureStationId);
|
||||||
|
const arrSeq = seqOf(leg.arrivalStationId);
|
||||||
|
if (depSeq === undefined || arrSeq === undefined) continue;
|
||||||
|
|
||||||
|
const existing = journeyRanges.get(leg.journeyId);
|
||||||
|
if (!existing) {
|
||||||
|
journeyRanges.set(leg.journeyId, { from: depSeq, to: arrSeq });
|
||||||
|
} else {
|
||||||
|
journeyRanges.set(leg.journeyId, {
|
||||||
|
from: Math.min(existing.from, depSeq),
|
||||||
|
to: Math.max(existing.to, arrSeq),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { from, to } of journeyRanges.values()) {
|
||||||
|
// Strict overlap: existingFrom < reqTo AND reqFrom < existingTo
|
||||||
|
if (from < reqTo && reqFrom < to) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */
|
||||||
|
async getOverlappingReservations(
|
||||||
|
scheduleId: string,
|
||||||
|
seatId: string,
|
||||||
|
requestedSegments: Segment[],
|
||||||
|
): Promise<{ type: string; id: string }[]> {
|
||||||
|
const reqFrom = Math.min(...requestedSegments.map(s => s.fromSequence));
|
||||||
|
const reqTo = Math.max(...requestedSegments.map(s => s.toSequence));
|
||||||
|
const free = await this.isSeatFreeForLeg(scheduleId, seatId, reqFrom, reqTo);
|
||||||
|
return free ? [] : [{ type: 'conflict', id: seatId }];
|
||||||
|
}
|
||||||
|
|
||||||
segmentsOverlap(segments1: Segment[], segments2: Segment[]): boolean {
|
segmentsOverlap(segments1: Segment[], segments2: Segment[]): boolean {
|
||||||
for (const s1 of segments1) {
|
for (const s1 of segments1) {
|
||||||
for (const s2 of segments2) {
|
for (const s2 of segments2) {
|
||||||
@@ -62,69 +166,4 @@ export class SegmentsService {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns conflicts for a seat on a schedule for the requested segment range.
|
|
||||||
* Checks:
|
|
||||||
* 1. Active SeatHolds — resolved to sequence range via JourneySegment if available,
|
|
||||||
* otherwise treated as full-schedule block.
|
|
||||||
* 2. Active BookingSeats — resolved via JourneySegment sequence ranges.
|
|
||||||
*/
|
|
||||||
async getOverlappingReservations(
|
|
||||||
scheduleId: string,
|
|
||||||
seatId: string,
|
|
||||||
requestedSegments: Segment[],
|
|
||||||
) {
|
|
||||||
const overlaps: { type: string; id: string }[] = [];
|
|
||||||
const reqFrom = Math.min(...requestedSegments.map(s => s.fromSequence));
|
|
||||||
const reqTo = Math.max(...requestedSegments.map(s => s.toSequence));
|
|
||||||
|
|
||||||
// ── 1. Active holds ──────────────────────────────────────────────────────
|
|
||||||
const activeHolds = await this.prisma.seatHold.findMany({
|
|
||||||
where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const hold of activeHolds) {
|
|
||||||
// Resolve hold range from JourneySegments created at hold time
|
|
||||||
const holdSegs = await this.prisma.journeySegment.findMany({
|
|
||||||
where: { scheduleId, seatId },
|
|
||||||
include: { schedule: { include: { stopTimes: true } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (holdSegs.length === 0) {
|
|
||||||
// No journey segments yet — conservative: treat as full-schedule conflict
|
|
||||||
overlaps.push({ type: 'hold', id: hold.id });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const js of holdSegs) {
|
|
||||||
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
|
|
||||||
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
|
|
||||||
if (depSeq !== undefined && arrSeq !== undefined && depSeq < reqTo && reqFrom < arrSeq) {
|
|
||||||
overlaps.push({ type: 'hold', id: hold.id });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 2. Active bookings via JourneySegment ────────────────────────────────
|
|
||||||
const bookedSegments = await this.prisma.journeySegment.findMany({
|
|
||||||
where: {
|
|
||||||
scheduleId,
|
|
||||||
seatId,
|
|
||||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
|
||||||
},
|
|
||||||
include: { schedule: { include: { stopTimes: true } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const js of bookedSegments) {
|
|
||||||
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
|
|
||||||
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
|
|
||||||
if (depSeq !== undefined && arrSeq !== undefined && depSeq < reqTo && reqFrom < arrSeq) {
|
|
||||||
overlaps.push({ type: 'booking', id: js.journeyId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return overlaps;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like {@link JwtGuard}, but never rejects the request.
|
||||||
|
*
|
||||||
|
* When a valid `Authorization: Bearer <jwt>` is present, `request.user` is
|
||||||
|
* populated from the JWT strategy (`{ userId, ... }`). When the token is
|
||||||
|
* missing or invalid, the request still proceeds with `request.user`
|
||||||
|
* undefined — the handler decides what to do.
|
||||||
|
*
|
||||||
|
* Used on `POST /fayda/verification/start`, which must work for both
|
||||||
|
* logged-in users (who can opt to save the verification to their account)
|
||||||
|
* and guests (anchored to a booking only).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class OptionalJwtGuard extends AuthGuard('jwt') {
|
||||||
|
handleRequest<TUser = unknown>(_err: unknown, user: TUser): TUser {
|
||||||
|
return (user ?? null) as TUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,111 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
|
import { OptionalJwtGuard } from './optional-jwt.guard';
|
||||||
|
import {
|
||||||
|
CompleteVerificationResultDto,
|
||||||
|
StartVerificationDto,
|
||||||
|
VerifaydaCallbackDto,
|
||||||
|
VerificationStatusDto,
|
||||||
|
} from './verifayda.dto';
|
||||||
|
import { VerifaydaService } from './verifayda.service';
|
||||||
|
|
||||||
|
/** Shape the JWT strategy puts on `request.user` (see common/jwt.strategy.ts). */
|
||||||
|
interface AuthedUser {
|
||||||
|
userId: string;
|
||||||
|
email?: string;
|
||||||
|
role?: string;
|
||||||
|
passengerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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?: AuthedUser;
|
||||||
|
}
|
||||||
|
interface RequestWithUser {
|
||||||
|
user: AuthedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Fayda Verification')
|
||||||
|
@Controller('fayda/verification')
|
||||||
|
export class VerifaydaController {
|
||||||
|
constructor(private readonly service: VerifaydaService) {}
|
||||||
|
|
||||||
|
@Post('start')
|
||||||
|
@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; when \`saveToAccount\` is true their account is marked verified on success.
|
||||||
|
- For a **PURCHASE** flow, pass \`bookingId\` to stamp the booking's seats as Fayda-verified.
|
||||||
|
- 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 ?? 'PURCHASE',
|
||||||
|
platform: dto.platform ?? 'WEB',
|
||||||
|
userId: req.user?.userId,
|
||||||
|
bookingId: dto.bookingId,
|
||||||
|
saveToAccount: dto.saveToAccount,
|
||||||
|
});
|
||||||
|
return { authorizationUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('complete')
|
||||||
|
@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.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class StartVerificationDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ['LOGIN', 'PURCHASE'],
|
||||||
|
default: 'PURCHASE',
|
||||||
|
description: 'Reason for verification.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['LOGIN', 'PURCHASE'])
|
||||||
|
purpose?: 'LOGIN' | 'PURCHASE';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Booking the verification should attach to (PURCHASE flow). If omitted, the session is anchored only to the user.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
bookingId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'When true and the user is logged in, copy faydaVerified=true / faydaSub onto their User record after verification.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
saveToAccount?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ['WEB', 'MOBILE'],
|
||||||
|
default: 'WEB',
|
||||||
|
description:
|
||||||
|
'Client platform. Decides where /callback redirects on completion: a web https URL (WEB) or a custom-scheme deep link the Flutter app intercepts (MOBILE).',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['WEB', 'MOBILE'])
|
||||||
|
platform?: 'WEB' | 'MOBILE';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CompleteVerificationResultDto {
|
||||||
|
@ApiProperty({ enum: ['LOGIN', 'PURCHASE'] })
|
||||||
|
purpose: 'LOGIN' | 'PURCHASE';
|
||||||
|
|
||||||
|
@ApiProperty() verified: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'JWT (LOGIN flow only).' })
|
||||||
|
token?: 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 (PURCHASE flow).',
|
||||||
|
})
|
||||||
|
fullName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { VerifaydaController } from './verifayda.controller';
|
||||||
import { VerifaydaService } from './verifayda.service';
|
import { VerifaydaService } from './verifayda.service';
|
||||||
import { PrismaModule } from '../../common/prisma.module';
|
import { PrismaModule } from '../../common/prisma.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
// AuthModule re-exports JwtModule, giving us JwtService (same secret/expiry
|
||||||
|
// config as /auth/login) to mint tokens for the LOGIN flow.
|
||||||
|
imports: [PrismaModule, AuthModule],
|
||||||
|
controllers: [VerifaydaController],
|
||||||
providers: [VerifaydaService],
|
providers: [VerifaydaService],
|
||||||
exports: [VerifaydaService],
|
exports: [VerifaydaService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,569 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { exportJWK, generateKeyPair, type JWK } from 'jose';
|
||||||
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
|
import { FaydaConfig } from '../../config/fayda.config';
|
||||||
|
import { VerifaydaService } from './verifayda.service';
|
||||||
|
|
||||||
|
function buildPrismaMock() {
|
||||||
|
return {
|
||||||
|
faydaVerificationSession: {
|
||||||
|
create: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
updateMany: jest.fn(),
|
||||||
|
},
|
||||||
|
bookingSeat: {
|
||||||
|
updateMany: jest.fn(),
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
passenger: { create: jest.fn() },
|
||||||
|
loyaltyAccount: { create: jest.fn() },
|
||||||
|
walletAccount: { create: jest.fn() },
|
||||||
|
userPreferences: { create: jest.fn() },
|
||||||
|
verifaydaVerification: { create: jest.fn() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildJwtMock(): jest.Mocked<JwtService> {
|
||||||
|
return {
|
||||||
|
sign: jest.fn(() => 'signed.jwt.token'),
|
||||||
|
} as unknown as jest.Mocked<JwtService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
clientId: 'edr-test-client',
|
||||||
|
authorizationEndpoint: 'https://esignet.test/authorize',
|
||||||
|
tokenEndpoint: 'https://esignet.test/token',
|
||||||
|
userInfoEndpoint: 'https://esignet.test/userinfo',
|
||||||
|
redirectUri: 'http://localhost:4000/fayda/verification/complete',
|
||||||
|
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
||||||
|
scope: 'openid profile email',
|
||||||
|
acrValues: 'mosip:idp:acr:generated-code',
|
||||||
|
claimsLocales: 'en am',
|
||||||
|
sessionTtlMinutes: 10,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked<ConfigService> {
|
||||||
|
return {
|
||||||
|
get: jest.fn((key: string, defaultValue?: unknown) => {
|
||||||
|
if (key === 'fayda') return faydaConfig;
|
||||||
|
if (key === 'VERIFAYDA_ENABLED') return false;
|
||||||
|
return defaultValue;
|
||||||
|
}),
|
||||||
|
} as unknown as jest.Mocked<ConfigService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('VerifaydaService (OIDC, client-callback)', () => {
|
||||||
|
let prisma: ReturnType<typeof buildPrismaMock>;
|
||||||
|
let jwt: jest.Mocked<JwtService>;
|
||||||
|
let service: VerifaydaService;
|
||||||
|
let realPrivateJwk: JWK;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const kp = await generateKeyPair('RS256', { extractable: true });
|
||||||
|
realPrivateJwk = await exportJWK(kp.privateKey);
|
||||||
|
realPrivateJwk.kty = 'RSA';
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = buildPrismaMock();
|
||||||
|
jwt = buildJwtMock();
|
||||||
|
const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] });
|
||||||
|
service = new VerifaydaService(
|
||||||
|
buildConfigService(cfg),
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
jwt,
|
||||||
|
);
|
||||||
|
(global as any).fetch = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('startVerification', () => {
|
||||||
|
it('persists a session and returns a fully-formed authorize URL', async () => {
|
||||||
|
prisma.faydaVerificationSession.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const url = await service.startVerification({
|
||||||
|
purpose: 'PURCHASE',
|
||||||
|
userId: 'user-1',
|
||||||
|
saveToAccount: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
|
||||||
|
expect(created.purpose).toBe('PURCHASE');
|
||||||
|
expect(created.platform).toBe('WEB');
|
||||||
|
expect(typeof created.state).toBe('string');
|
||||||
|
expect(typeof created.codeVerifier).toBe('string');
|
||||||
|
|
||||||
|
const parsed = new URL(url);
|
||||||
|
expect(parsed.origin + parsed.pathname).toBe('https://esignet.test/authorize');
|
||||||
|
expect(parsed.searchParams.get('client_id')).toBe('edr-test-client');
|
||||||
|
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
|
||||||
|
expect(parsed.searchParams.get('redirect_uri')).toBe(
|
||||||
|
'http://localhost:4000/fayda/verification/complete',
|
||||||
|
);
|
||||||
|
expect(parsed.searchParams.get('state')).toBe(created.state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the same single redirect_uri regardless of platform (platform is only recorded)', async () => {
|
||||||
|
prisma.faydaVerificationSession.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const url = await service.startVerification({
|
||||||
|
purpose: 'LOGIN',
|
||||||
|
platform: 'MOBILE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
|
||||||
|
expect(created.platform).toBe('MOBILE');
|
||||||
|
expect(new URL(url).searchParams.get('redirect_uri')).toBe(
|
||||||
|
'http://localhost:4000/fayda/verification/complete',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ServiceUnavailable when fayda integration is disabled', async () => {
|
||||||
|
const disabledService = new VerifaydaService(
|
||||||
|
buildConfigService(buildConfig({ enabled: false })),
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
jwt,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
disabledService.startVerification({ purpose: 'PURCHASE' }),
|
||||||
|
).rejects.toMatchObject({ status: 503 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('completeVerification — validation', () => {
|
||||||
|
function pendingSession(overrides: Partial<any> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'session-1',
|
||||||
|
state: 'state-abc',
|
||||||
|
codeVerifier: 'verifier-xyz',
|
||||||
|
purpose: 'PURCHASE',
|
||||||
|
platform: 'WEB',
|
||||||
|
saveToAccount: false,
|
||||||
|
status: 'PENDING',
|
||||||
|
errorCode: null,
|
||||||
|
errorDescription: null,
|
||||||
|
userId: null,
|
||||||
|
bookingId: null,
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('throws and marks failed when callback carries an error', async () => {
|
||||||
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
await expect(
|
||||||
|
service.completeVerification({
|
||||||
|
error: 'access_denied',
|
||||||
|
error_description: 'user cancelled',
|
||||||
|
state: 'state-abc',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws FAYDA_MISSING_PARAMETERS when code/state absent', async () => {
|
||||||
|
await expect(service.completeVerification({})).rejects.toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws FAYDA_INVALID_STATE for unknown state', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.completeVerification({ code: 'c', state: 'bogus' }),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws FAYDA_INVALID_STATE for a non-pending session', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||||
|
pendingSession({ status: 'COMPLETED' }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.completeVerification({ code: 'c', state: 'state-abc' }),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws FAYDA_SESSION_EXPIRED and marks failed for an expired session', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||||
|
pendingSession({ expiresAt: new Date(Date.now() - 1000) }),
|
||||||
|
);
|
||||||
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
await expect(
|
||||||
|
service.completeVerification({ code: 'c', state: 'state-abc' }),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('completeVerification — PURCHASE', () => {
|
||||||
|
function pendingSession(overrides: Partial<any> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'session-1',
|
||||||
|
state: 'state-abc',
|
||||||
|
codeVerifier: 'verifier-xyz',
|
||||||
|
purpose: 'PURCHASE',
|
||||||
|
platform: 'WEB',
|
||||||
|
saveToAccount: false,
|
||||||
|
status: 'PENDING',
|
||||||
|
userId: null,
|
||||||
|
bookingId: null,
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFetchSequence(...responses: Array<Partial<Response>>) {
|
||||||
|
const queue = responses.map((r) => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () => '',
|
||||||
|
json: async () => ({}),
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
...r,
|
||||||
|
}));
|
||||||
|
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('stamps the booking seats and returns { verified, fullName }', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||||
|
pendingSession({ bookingId: 'booking-1' }),
|
||||||
|
);
|
||||||
|
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||||
|
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
mockFetchSequence(
|
||||||
|
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||||
|
{
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
text: async () =>
|
||||||
|
JSON.stringify({ sub: 'fayda-sub-1', name: 'Test User' }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.completeVerification({
|
||||||
|
code: 'authcode',
|
||||||
|
state: 'state-abc',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
purpose: 'PURCHASE',
|
||||||
|
verified: true,
|
||||||
|
fullName: 'Test User',
|
||||||
|
});
|
||||||
|
expect(result.token).toBeUndefined();
|
||||||
|
expect(prisma.bookingSeat.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { bookingId: 'booking-1' },
|
||||||
|
data: expect.objectContaining({ faydaSub: 'fayda-sub-1' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves to the User account when saveToAccount=true and no conflict', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||||
|
pendingSession({ userId: 'user-1', saveToAccount: true }),
|
||||||
|
);
|
||||||
|
prisma.user.findFirst.mockResolvedValue(null);
|
||||||
|
prisma.user.update.mockResolvedValue({});
|
||||||
|
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
mockFetchSequence(
|
||||||
|
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||||
|
{
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
text: async () =>
|
||||||
|
JSON.stringify({ sub: 'fayda-sub-2', name: 'Test User' }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.completeVerification({
|
||||||
|
code: 'authcode',
|
||||||
|
state: 'state-abc',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.verified).toBe(true);
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'user-1' },
|
||||||
|
data: expect.objectContaining({ faydaVerified: true, faydaSub: 'fayda-sub-2' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws identity_conflict (409) when faydaSub belongs to another user', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||||
|
pendingSession({ userId: 'user-1', saveToAccount: true }),
|
||||||
|
);
|
||||||
|
prisma.user.findFirst.mockResolvedValue({ id: 'other-user' });
|
||||||
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
mockFetchSequence(
|
||||||
|
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||||
|
{
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
text: async () =>
|
||||||
|
JSON.stringify({ sub: 'fayda-sub-3', name: 'Test User' }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
|
||||||
|
).rejects.toMatchObject({ status: 409 });
|
||||||
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 502 when the token endpoint returns 4xx', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
|
||||||
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
mockFetchSequence({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
text: async () => '{"error":"invalid_assertion"}',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
|
||||||
|
).rejects.toMatchObject({ status: 502 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 502 when userinfo is an unsupported format', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
|
||||||
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
mockFetchSequence(
|
||||||
|
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||||
|
{
|
||||||
|
headers: new Headers({ 'content-type': 'text/plain' }),
|
||||||
|
text: async () => 'not-a-jwt-not-a-json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
|
||||||
|
).rejects.toMatchObject({ status: 502 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to localized name (name#en) when name is missing', async () => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
||||||
|
pendingSession({ bookingId: 'booking-2' }),
|
||||||
|
);
|
||||||
|
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||||
|
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
mockFetchSequence(
|
||||||
|
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
||||||
|
{
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
text: async () =>
|
||||||
|
JSON.stringify({
|
||||||
|
sub: 'fayda-sub-4',
|
||||||
|
'name#en': 'English Name',
|
||||||
|
'name#am': 'Amharic Name',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.completeVerification({
|
||||||
|
code: 'c',
|
||||||
|
state: 'state-abc',
|
||||||
|
});
|
||||||
|
expect(result.fullName).toBe('English Name');
|
||||||
|
expect(prisma.bookingSeat.updateMany.mock.calls[0][0].data.faydaVerifiedName).toBe(
|
||||||
|
'English Name',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('completeVerification — LOGIN', () => {
|
||||||
|
function loginSession(overrides: Partial<any> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'login-session',
|
||||||
|
state: 'state-login',
|
||||||
|
codeVerifier: 'verifier-xyz',
|
||||||
|
purpose: 'LOGIN',
|
||||||
|
platform: 'WEB',
|
||||||
|
saveToAccount: false,
|
||||||
|
status: 'PENDING',
|
||||||
|
userId: null,
|
||||||
|
bookingId: null,
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockLoginFetch(userInfo: Record<string, unknown>) {
|
||||||
|
const queue = [
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ access_token: 'tok', token_type: 'Bearer' }),
|
||||||
|
text: async () => '',
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({}),
|
||||||
|
text: async () => JSON.stringify(userInfo),
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** user.findUnique answers the faydaSub lookup and the issueLoginToken id lookup. */
|
||||||
|
function mockUserFindUnique(bySub: any, fullUser: any) {
|
||||||
|
prisma.user.findUnique.mockImplementation(async (args: any) => {
|
||||||
|
if (args?.where?.faydaSub !== undefined) return bySub;
|
||||||
|
if (args?.where?.id !== undefined) return fullUser;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a new user when no match and returns { token, user }', async () => {
|
||||||
|
const fullUser = {
|
||||||
|
id: 'new-user',
|
||||||
|
email: 'new@example.com',
|
||||||
|
role: 'PASSENGER',
|
||||||
|
passenger: { id: 'p-new' },
|
||||||
|
agent: null,
|
||||||
|
};
|
||||||
|
mockUserFindUnique(null, fullUser);
|
||||||
|
prisma.user.findFirst.mockResolvedValue(null);
|
||||||
|
prisma.user.create.mockResolvedValue({ id: 'new-user' });
|
||||||
|
prisma.passenger.create.mockResolvedValue({ id: 'p-new' });
|
||||||
|
prisma.loyaltyAccount.create.mockResolvedValue({});
|
||||||
|
prisma.walletAccount.create.mockResolvedValue({});
|
||||||
|
prisma.userPreferences.create.mockResolvedValue({});
|
||||||
|
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' });
|
||||||
|
|
||||||
|
const result = await service.completeVerification({
|
||||||
|
code: 'c',
|
||||||
|
state: 'state-login',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
purpose: 'LOGIN',
|
||||||
|
verified: true,
|
||||||
|
token: 'signed.jwt.token',
|
||||||
|
user: { id: 'new-user', passengerId: 'p-new' },
|
||||||
|
});
|
||||||
|
expect(prisma.user.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
faydaSub: 'login-sub-1',
|
||||||
|
faydaVerified: true,
|
||||||
|
email: 'new@example.com',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(prisma.passenger.create).toHaveBeenCalled();
|
||||||
|
expect(jwt.sign).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ sub: 'new-user', passengerId: 'p-new' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs in an existing user already linked by faydaSub', async () => {
|
||||||
|
const fullUser = {
|
||||||
|
id: 'known-user',
|
||||||
|
email: 'k@example.com',
|
||||||
|
role: 'PASSENGER',
|
||||||
|
passenger: { id: 'p-k' },
|
||||||
|
agent: null,
|
||||||
|
};
|
||||||
|
mockUserFindUnique({ id: 'known-user' }, fullUser);
|
||||||
|
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
mockLoginFetch({ sub: 'login-sub-2', name: 'Known' });
|
||||||
|
|
||||||
|
const result = await service.completeVerification({
|
||||||
|
code: 'c',
|
||||||
|
state: 'state-login',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.user?.id).toBe('known-user');
|
||||||
|
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links Fayda to an existing account matched by email', async () => {
|
||||||
|
const fullUser = {
|
||||||
|
id: 'acc-1',
|
||||||
|
email: 'match@example.com',
|
||||||
|
role: 'PASSENGER',
|
||||||
|
passenger: { id: 'p-1' },
|
||||||
|
agent: null,
|
||||||
|
};
|
||||||
|
mockUserFindUnique(null, fullUser);
|
||||||
|
prisma.user.findFirst.mockResolvedValue({ id: 'acc-1', faydaSub: null });
|
||||||
|
prisma.user.update.mockResolvedValue({});
|
||||||
|
prisma.faydaVerificationSession.update.mockResolvedValue({});
|
||||||
|
|
||||||
|
mockLoginFetch({ sub: 'login-sub-3', email: 'match@example.com' });
|
||||||
|
|
||||||
|
const result = await service.completeVerification({
|
||||||
|
code: 'c',
|
||||||
|
state: 'state-login',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.user?.id).toBe('acc-1');
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: 'acc-1' },
|
||||||
|
data: expect.objectContaining({ faydaSub: 'login-sub-3' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws identity_conflict (409) when matched account has a different faydaSub', async () => {
|
||||||
|
mockUserFindUnique(null, null);
|
||||||
|
prisma.user.findFirst.mockResolvedValue({ id: 'acc-2', faydaSub: 'someone-else' });
|
||||||
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
mockLoginFetch({ sub: 'login-sub-4', email: 'match@example.com' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.completeVerification({ code: 'c', state: 'state-login' }),
|
||||||
|
).rejects.toMatchObject({ status: 409 });
|
||||||
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVerificationStatus', () => {
|
||||||
|
it('returns verified=true when User row has the flag', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
faydaVerified: true,
|
||||||
|
faydaVerifiedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
fullName: 'Test User',
|
||||||
|
});
|
||||||
|
const result = await service.getVerificationStatus('user-1');
|
||||||
|
expect(result).toEqual({
|
||||||
|
verified: true,
|
||||||
|
verifiedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
fullName: 'Test User',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns verified=false when User row is missing or unverified', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(null);
|
||||||
|
const result = await service.getVerificationStatus('user-x');
|
||||||
|
expect(result).toEqual({ verified: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,35 @@
|
|||||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import axios, { AxiosInstance } from 'axios';
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
|
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
|
||||||
|
import {
|
||||||
|
generateCodeChallenge,
|
||||||
|
generateCodeVerifier,
|
||||||
|
generateState,
|
||||||
|
} from './utils/pkce.util';
|
||||||
|
import { generateClientAssertion } from './utils/client-assertion.util';
|
||||||
|
import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto';
|
||||||
|
import {
|
||||||
|
FaydaIdentityConflictException,
|
||||||
|
FaydaTokenExchangeException,
|
||||||
|
FaydaUserInfoException,
|
||||||
|
} from './verifayda.errors';
|
||||||
|
import {
|
||||||
|
FaydaTokenResponse,
|
||||||
|
FaydaUserInfo,
|
||||||
|
NormalizedFaydaUserInfo,
|
||||||
|
VerifaydaPurpose,
|
||||||
|
} from './verifayda.types';
|
||||||
|
|
||||||
export interface VerifaydaPassengerData {
|
export interface VerifaydaPassengerData {
|
||||||
fullName: string;
|
fullName: string;
|
||||||
@@ -17,38 +45,554 @@ export interface VerifaydaVerificationResult {
|
|||||||
failureReason?: string;
|
failureReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StartVerificationInput {
|
||||||
|
purpose: VerifaydaPurpose;
|
||||||
|
platform?: FaydaPlatform;
|
||||||
|
userId?: string;
|
||||||
|
bookingId?: string;
|
||||||
|
saveToAccount?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FaydaUserSummary {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
passengerId?: string;
|
||||||
|
agentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of completing a verification. `verified` is always true on success.
|
||||||
|
* LOGIN additionally returns a JWT + user; PURCHASE returns the verified name.
|
||||||
|
*/
|
||||||
|
export interface CompleteVerificationResult {
|
||||||
|
purpose: VerifaydaPurpose;
|
||||||
|
verified: boolean;
|
||||||
|
token?: string;
|
||||||
|
user?: FaydaUserSummary;
|
||||||
|
fullName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VerifaydaService {
|
export class VerifaydaService {
|
||||||
private readonly logger = new Logger(VerifaydaService.name);
|
private readonly logger = new Logger(VerifaydaService.name);
|
||||||
|
|
||||||
|
|
||||||
|
private readonly faydaConfig: FaydaConfig;
|
||||||
|
|
||||||
private readonly httpClient: AxiosInstance;
|
private readonly httpClient: AxiosInstance;
|
||||||
private readonly enabled: boolean;
|
private readonly stubEnabled: boolean;
|
||||||
private readonly apiUrl: string;
|
private readonly stubApiUrl: string;
|
||||||
private readonly apiKey: string;
|
private readonly stubApiKey: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jwt: JwtService,
|
||||||
) {
|
) {
|
||||||
this.enabled = this.config.get<boolean>('VERIFAYDA_ENABLED', false);
|
const fayda = this.config.get<FaydaConfig>('fayda');
|
||||||
this.apiUrl = this.config.get<string>('VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2');
|
if (!fayda) {
|
||||||
this.apiKey = this.config.get<string>('VERIFAYDA_API_KEY', '');
|
throw new Error('Fayda config namespace not registered');
|
||||||
|
}
|
||||||
|
this.faydaConfig = fayda;
|
||||||
|
|
||||||
|
this.stubEnabled = this.config.get<boolean>('VERIFAYDA_ENABLED', false);
|
||||||
|
this.stubApiUrl = this.config.get<string>(
|
||||||
|
'VERIFAYDA_API_URL',
|
||||||
|
'https://api.verifayda.gov.et/v2',
|
||||||
|
);
|
||||||
|
this.stubApiKey = this.config.get<string>('VERIFAYDA_API_KEY', '');
|
||||||
this.httpClient = axios.create({
|
this.httpClient = axios.create({
|
||||||
baseURL: this.apiUrl,
|
baseURL: this.stubApiUrl,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json', 'X-API-Key': this.stubApiKey },
|
||||||
'Content-Type': 'application/json',
|
});
|
||||||
'X-API-Key': this.apiKey,
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// 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.prisma.faydaVerificationSession.create({
|
||||||
|
data: {
|
||||||
|
state,
|
||||||
|
codeVerifier,
|
||||||
|
purpose: input.purpose,
|
||||||
|
platform: input.platform ?? 'WEB',
|
||||||
|
saveToAccount: input.saveToAccount ?? false,
|
||||||
|
userId: input.userId ?? null,
|
||||||
|
bookingId: input.bookingId ?? null,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'} bookingId=${input.bookingId ?? 'none'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.buildAuthorizationUrl({ state, codeChallenge });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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.prisma.faydaVerificationSession.findUnique({
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
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 === 'PURCHASE') {
|
||||||
|
await this.handlePurchaseSuccess(session, normalized);
|
||||||
|
result = {
|
||||||
|
purpose: 'PURCHASE',
|
||||||
|
verified: true,
|
||||||
|
fullName: normalized.fullName,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const { userId } = await this.handleLoginSuccess(normalized);
|
||||||
|
const login = await this.issueLoginToken(userId);
|
||||||
|
result = { purpose: 'LOGIN', verified: true, ...login };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.faydaVerificationSession.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: { 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads a user (+ relations) and mints the same JWT shape as `/auth/login`. */
|
||||||
|
private async issueLoginToken(
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ token: string; user: FaydaUserSummary }> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: { passenger: true, agent: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
// Should not happen — we just resolved/created this user.
|
||||||
|
throw new UnauthorizedException({
|
||||||
|
code: 'FAYDA_LOGIN_FAILED',
|
||||||
|
message: 'Could not load the verified user',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: FaydaUserSummary = {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
passengerId: user.passenger?.id,
|
||||||
|
agentId: user.agent?.id,
|
||||||
|
};
|
||||||
|
const token = this.jwt.sign({
|
||||||
|
sub: summary.id,
|
||||||
|
email: summary.email,
|
||||||
|
role: summary.role,
|
||||||
|
passengerId: summary.passengerId,
|
||||||
|
agentId: summary.agentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Fayda login issued token for user ${user.id}`);
|
||||||
|
return { token, user: summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVerificationStatus(userId: string): Promise<VerificationStatusDto> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { faydaVerified: true, faydaVerifiedAt: true, fullName: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
verified: user?.faydaVerified ?? false,
|
||||||
|
verifiedAt: user?.faydaVerifiedAt ?? undefined,
|
||||||
|
fullName: user?.fullName ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// OIDC internals
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
private buildAuthorizationUrl(args: {
|
||||||
|
state: string;
|
||||||
|
codeChallenge: string;
|
||||||
|
}): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: this.faydaConfig.clientId,
|
||||||
|
response_type: 'code',
|
||||||
|
redirect_uri: this.faydaConfig.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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const claims = {
|
||||||
|
userinfo: {
|
||||||
|
name: { essential: true },
|
||||||
|
phone_number: { essential: true },
|
||||||
|
email: { essential: false },
|
||||||
|
birthdate: { essential: true },
|
||||||
|
gender: { essential: false },
|
||||||
|
picture: { essential: false },
|
||||||
|
},
|
||||||
|
id_token: {},
|
||||||
|
};
|
||||||
|
params.set('claims', JSON.stringify(claims));
|
||||||
|
|
||||||
|
return `${this.faydaConfig.authorizationEndpoint}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exchangeCodeForTokens(
|
||||||
|
code: string,
|
||||||
|
codeVerifier: 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: this.faydaConfig.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 {
|
||||||
|
return {
|
||||||
|
sub: raw.sub,
|
||||||
|
fullName: raw.name ?? raw['name#en'] ?? raw['name#am'],
|
||||||
|
phoneNumber:
|
||||||
|
raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone,
|
||||||
|
email: raw.email,
|
||||||
|
gender: raw.gender,
|
||||||
|
birthdate: raw.birthdate,
|
||||||
|
picture: raw.picture,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handlePurchaseSuccess(
|
||||||
|
session: {
|
||||||
|
id: string;
|
||||||
|
userId: string | null;
|
||||||
|
bookingId: string | null;
|
||||||
|
saveToAccount: boolean;
|
||||||
|
},
|
||||||
|
normalized: NormalizedFaydaUserInfo,
|
||||||
|
): Promise<void> {
|
||||||
|
if (session.bookingId) {
|
||||||
|
await this.prisma.bookingSeat.updateMany({
|
||||||
|
where: { bookingId: session.bookingId },
|
||||||
|
data: {
|
||||||
|
faydaVerifiedAt: new Date(),
|
||||||
|
faydaSub: normalized.sub,
|
||||||
|
faydaVerifiedName: normalized.fullName ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.userId && session.saveToAccount) {
|
||||||
|
const conflict = await this.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
faydaSub: normalized.sub,
|
||||||
|
NOT: { id: session.userId },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (conflict) {
|
||||||
|
throw new FaydaIdentityConflictException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: session.userId },
|
||||||
|
data: {
|
||||||
|
faydaVerified: true,
|
||||||
|
faydaVerifiedAt: new Date(),
|
||||||
|
faydaSub: normalized.sub,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the User for a LOGIN flow and returns its id (the caller mints the
|
||||||
|
* JWT via {@link issueLoginToken}). Resolution order:
|
||||||
|
* 1. Existing user already linked to this Fayda `sub`.
|
||||||
|
* 2. Existing account whose email/phone matches — linked to this `sub`.
|
||||||
|
* 3. Otherwise a fresh Fayda-backed account is created.
|
||||||
|
*/
|
||||||
|
private async handleLoginSuccess(
|
||||||
|
normalized: NormalizedFaydaUserInfo,
|
||||||
|
): Promise<{ userId: string }> {
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
const bySub = await this.prisma.user.findUnique({
|
||||||
|
where: { faydaSub: normalized.sub },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bySub) {
|
||||||
|
userId = bySub.id;
|
||||||
|
} else {
|
||||||
|
const matchers: Array<{ email?: string; phone?: string }> = [];
|
||||||
|
if (normalized.email) matchers.push({ email: normalized.email });
|
||||||
|
if (normalized.phoneNumber) matchers.push({ phone: normalized.phoneNumber });
|
||||||
|
|
||||||
|
const existing = matchers.length
|
||||||
|
? await this.prisma.user.findFirst({
|
||||||
|
where: { OR: matchers },
|
||||||
|
select: { id: true, faydaSub: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.faydaSub && existing.faydaSub !== normalized.sub) {
|
||||||
|
// The matched account is already tied to a different Fayda identity.
|
||||||
|
throw new FaydaIdentityConflictException();
|
||||||
|
}
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
faydaSub: normalized.sub,
|
||||||
|
faydaVerified: true,
|
||||||
|
faydaVerifiedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userId = existing.id;
|
||||||
|
this.logger.log(`Fayda login linked existing user ${existing.id}`);
|
||||||
|
} else {
|
||||||
|
userId = await this.createFaydaUser(normalized);
|
||||||
|
this.logger.log(`Fayda login created new user ${userId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Fayda-backed User plus the same satellite rows registration makes
|
||||||
|
* (Passenger, LoyaltyAccount, WalletAccount, UserPreferences).
|
||||||
|
*
|
||||||
|
* The user has no password — `passwordHash` is set to a bcrypt of random bytes
|
||||||
|
* so password login is impossible; they authenticate only via Fayda. When
|
||||||
|
* Fayda doesn't supply an email/phone, a deterministic placeholder derived from
|
||||||
|
* the (unique) `sub` keeps the NOT NULL + unique columns satisfied.
|
||||||
|
*/
|
||||||
|
private async createFaydaUser(
|
||||||
|
normalized: NormalizedFaydaUserInfo,
|
||||||
|
): Promise<string> {
|
||||||
|
const passwordHash = await bcrypt.hash(
|
||||||
|
randomBytes(32).toString('hex'),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const email = normalized.email ?? `fayda_${normalized.sub}@users.fayda.local`;
|
||||||
|
const phone = normalized.phoneNumber ?? `fayda:${normalized.sub}`;
|
||||||
|
const fullName = normalized.fullName ?? 'Fayda User';
|
||||||
|
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
fullName,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
passwordHash,
|
||||||
|
faydaVerified: true,
|
||||||
|
faydaVerifiedAt: new Date(),
|
||||||
|
faydaSub: normalized.sub,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const passenger = await this.prisma.passenger.create({
|
||||||
|
data: { userId: user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await this.prisma.loyaltyAccount.create({
|
||||||
|
data: { passengerId: passenger.id },
|
||||||
|
});
|
||||||
|
await this.prisma.walletAccount.create({
|
||||||
|
data: { passengerId: passenger.id },
|
||||||
|
});
|
||||||
|
await this.prisma.userPreferences.create({ data: { userId: user.id } });
|
||||||
|
|
||||||
|
return user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markSessionFailed(
|
||||||
|
state: string,
|
||||||
|
errorCode: string,
|
||||||
|
errorDescription?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.prisma.faydaVerificationSession.updateMany({
|
||||||
|
where: { state, status: 'PENDING' },
|
||||||
|
data: {
|
||||||
|
status: 'FAILED',
|
||||||
|
errorCode,
|
||||||
|
errorDescription: errorDescription ?? null,
|
||||||
|
completedAt: new Date(),
|
||||||
|
codeVerifier: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private classifyFailureReason(err: unknown): string {
|
||||||
|
if (err instanceof FaydaIdentityConflictException) return 'identity_conflict';
|
||||||
|
if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';
|
||||||
|
if (err instanceof FaydaUserInfoException) return 'userinfo_failed';
|
||||||
|
return 'verification_failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// DEPRECATED: legacy stub flow
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/** @deprecated Use the OIDC flow instead. Retained until cleanup. */
|
||||||
async verifyNationalId(
|
async verifyNationalId(
|
||||||
nationalId: string,
|
nationalId: string,
|
||||||
bookingId?: string,
|
bookingId?: string,
|
||||||
): Promise<VerifaydaVerificationResult> {
|
): Promise<VerifaydaVerificationResult> {
|
||||||
if (!this.enabled) {
|
if (!this.stubEnabled) {
|
||||||
this.logger.warn('Verifayda is disabled - skipping verification');
|
this.logger.warn('Verifayda stub is disabled - skipping verification');
|
||||||
return {
|
return {
|
||||||
verified: false,
|
verified: false,
|
||||||
failureReason: 'Verifayda integration is disabled',
|
failureReason: 'Verifayda integration is disabled',
|
||||||
@@ -62,10 +606,8 @@ export class VerifaydaService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logger.log(`Verifying national ID via Verifayda 2.0`);
|
this.logger.log('Verifying national ID via legacy Verifayda stub');
|
||||||
|
|
||||||
const response = await this.httpClient.post('/verify', requestPayload);
|
const response = await this.httpClient.post('/verify', requestPayload);
|
||||||
|
|
||||||
const { data } = response;
|
const { data } = response;
|
||||||
|
|
||||||
if (data.status === 'verified' && data.citizen) {
|
if (data.status === 'verified' && data.citizen) {
|
||||||
@@ -88,36 +630,24 @@ export class VerifaydaService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log('Verifayda verification successful');
|
return { verified: true, passengerData };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
const failureReason = data.message || 'Verification failed';
|
||||||
verified: true,
|
await this.prisma.verifaydaVerification.create({
|
||||||
passengerData,
|
data: {
|
||||||
};
|
bookingId,
|
||||||
} else {
|
nationalId,
|
||||||
const failureReason = data.message || 'Verification failed';
|
requestPayload,
|
||||||
|
responsePayload: data,
|
||||||
await this.prisma.verifaydaVerification.create({
|
|
||||||
data: {
|
|
||||||
bookingId,
|
|
||||||
nationalId,
|
|
||||||
requestPayload,
|
|
||||||
responsePayload: data,
|
|
||||||
verified: false,
|
|
||||||
failureReason,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.warn(`Verifayda verification failed: ${failureReason}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
verified: false,
|
verified: false,
|
||||||
failureReason,
|
failureReason,
|
||||||
};
|
},
|
||||||
}
|
});
|
||||||
|
return { verified: false, failureReason };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errorMessage = error.response?.data?.message || error.message || 'Unknown error';
|
const errorMessage =
|
||||||
|
error.response?.data?.message || error.message || 'Unknown error';
|
||||||
await this.prisma.verifaydaVerification.create({
|
await this.prisma.verifaydaVerification.create({
|
||||||
data: {
|
data: {
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -127,16 +657,15 @@ export class VerifaydaService {
|
|||||||
failureReason: errorMessage,
|
failureReason: errorMessage,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.logger.error(`Verifayda stub error: ${errorMessage}`);
|
||||||
this.logger.error(`Verifayda API error: ${errorMessage}`);
|
|
||||||
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`National ID verification failed: ${errorMessage}`,
|
`National ID verification failed: ${errorMessage}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use `faydaConfig.enabled` for the OIDC flow. */
|
||||||
isEnabled(): boolean {
|
isEnabled(): boolean {
|
||||||
return this.enabled;
|
return this.stubEnabled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export type VerifaydaPurpose = 'LOGIN' | 'PURCHASE';
|
||||||
|
|
||||||
|
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;
|
||||||
|
fullName?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
email?: string;
|
||||||
|
gender?: string;
|
||||||
|
birthdate?: string;
|
||||||
|
picture?: string;
|
||||||
|
}
|
||||||
@@ -1,22 +1,25 @@
|
|||||||
// ── Enums ──────────────────────────────────────────────────────────────────────
|
// ── Enums ────────────────────────────────────────────────────────────────
|
||||||
export type TripStatus = 'SCHEDULED' | 'BOARDING' | 'EN_ROUTE' | 'ARRIVED' | 'CANCELLED' | 'DELAYED';
|
export type TripStatus = 'SCHEDULED' | 'BOARDING' | 'EN_ROUTE' | 'ARRIVED' | 'CANCELLED' | 'DELAYED';
|
||||||
export type SeatStatus = 'AVAILABLE' | 'HELD' | 'BOOKED' | 'BLOCKED';
|
export type SeatStatus = 'AVAILABLE' | 'HELD' | 'BOOKED' | 'BLOCKED';
|
||||||
export type ServiceClass = 'ECONOMY' | 'BUSINESS' | 'FIRST';
|
export type ServiceClass = 'ECONOMY' | 'BUSINESS' | 'FIRST';
|
||||||
export type BookingStatus = 'DRAFT' | 'PENDING_PAYMENT' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW';
|
export type BookingStatus = 'DRAFT' | 'PENDING_PAYMENT' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW';
|
||||||
export type PaymentMethod = 'TELEBIRR' | 'CBE_BIRR' | 'EBIRR' | 'CARD' | 'WALLET';
|
export type PaymentMethod = 'TELEBIRR' | 'CBE_BIRR' | 'EBIRR' | 'CARD' | 'WALLET';
|
||||||
|
|
||||||
// ── Station ────────────────────────────────────────────────────────────────────
|
// ── Station ────────────────────────────────────────────────────────────────
|
||||||
export interface IStation {
|
export interface IStation {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
city: string;
|
city: string;
|
||||||
|
country: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
|
createdAt: string | Date;
|
||||||
|
updatedAt: string | Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Trip / Schedule ────────────────────────────────────────────────────────────
|
// ── Trip / Schedule ────────────────────────────────────────────────────────
|
||||||
export interface ITripStation {
|
export interface ITripStation {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -62,7 +65,7 @@ export interface IStopTime {
|
|||||||
station: IStation;
|
station: IStation;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Seat ───────────────────────────────────────────────────────────────────────
|
// ── Seat ────────────────────────────────────────────────────────────────────
|
||||||
export interface ISeat {
|
export interface ISeat {
|
||||||
id: string;
|
id: string;
|
||||||
number: string; // label from API
|
number: string; // label from API
|
||||||
@@ -81,7 +84,7 @@ export interface ISeatMap {
|
|||||||
coaches: ICoach[];
|
coaches: ICoach[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Segment-based seats ────────────────────────────────────────────────────────
|
// ── Segment-based seats ────────────────────────────────────────────────────
|
||||||
export interface ISegment {
|
export interface ISegment {
|
||||||
fromStationId: string;
|
fromStationId: string;
|
||||||
toStationId: string;
|
toStationId: string;
|
||||||
@@ -119,7 +122,7 @@ export interface ISegmentConfirmResult {
|
|||||||
segments: Array<{ fromStationId: string; toStationId: string; fromSequence: number; toSequence: number }>;
|
segments: Array<{ fromStationId: string; toStationId: string; fromSequence: number; toSequence: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Booking ────────────────────────────────────────────────────────────────────
|
// ── Booking ────────────────────────────────────────────────────────────────
|
||||||
export interface IBooking {
|
export interface IBooking {
|
||||||
id: string;
|
id: string;
|
||||||
bookingRef: string;
|
bookingRef: string;
|
||||||
@@ -140,7 +143,7 @@ export interface IBooking {
|
|||||||
payment?: { method: PaymentMethod; status: string };
|
payment?: { method: PaymentMethod; status: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ticket ─────────────────────────────────────────────────────────────────────
|
// ── Ticket ─────────────────────────────────────────────────────────────────
|
||||||
export interface ITicket {
|
export interface ITicket {
|
||||||
id: string;
|
id: string;
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
@@ -158,7 +161,7 @@ export interface ITicket {
|
|||||||
qrPayload: string;
|
qrPayload: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fare quote ─────────────────────────────────────────────────────────────────
|
// ── Fare quote ─────────────────────────────────────────────────────────────
|
||||||
export interface IFareQuote {
|
export interface IFareQuote {
|
||||||
tripId: string;
|
tripId: string;
|
||||||
serviceClass: ServiceClass;
|
serviceClass: ServiceClass;
|
||||||
@@ -171,7 +174,7 @@ export interface IFareQuote {
|
|||||||
currency: string;
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Passenger ─────────────────────────────────────────────────────────────────
|
// ── Passenger ──────────────────────────────────────────────────────────────
|
||||||
export interface IPassengerProfile {
|
export interface IPassengerProfile {
|
||||||
id: string;
|
id: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
@@ -181,7 +184,7 @@ export interface IPassengerProfile {
|
|||||||
bookings: IBooking[];
|
bookings: IBooking[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Loyalty ────────────────────────────────────────────────────────────────────
|
// ── Loyalty ────────────────────────────────────────────────────────────────
|
||||||
export interface ILoyaltyAccount {
|
export interface ILoyaltyAccount {
|
||||||
id: string;
|
id: string;
|
||||||
passengerId: string;
|
passengerId: string;
|
||||||
@@ -192,7 +195,7 @@ export interface ILoyaltyAccount {
|
|||||||
tierProgressPercent: number;
|
tierProgressPercent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Wallet ─────────────────────────────────────────────────────────────────────
|
// ── Wallet ─────────────────────────────────────────────────────────────────
|
||||||
export interface IWallet {
|
export interface IWallet {
|
||||||
id: string;
|
id: string;
|
||||||
passengerId: string;
|
passengerId: string;
|
||||||
@@ -200,7 +203,7 @@ export interface IWallet {
|
|||||||
currency: string;
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Notification ───────────────────────────────────────────────────────────────
|
// ── Notification ───────────────────────────────────────────────────────────
|
||||||
export interface INotification {
|
export interface INotification {
|
||||||
id: string;
|
id: string;
|
||||||
passengerId: string;
|
passengerId: string;
|
||||||
@@ -212,7 +215,7 @@ export interface INotification {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Promotion ──────────────────────────────────────────────────────────────────
|
// ── Promotion ──────────────────────────────────────────────────────────────
|
||||||
export interface IPromotion {
|
export interface IPromotion {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -225,7 +228,7 @@ export interface IPromotion {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Live tracking ──────────────────────────────────────────────────────────────
|
// ── Live tracking ──────────────────────────────────────────────────────────
|
||||||
export interface ILiveStatus {
|
export interface ILiveStatus {
|
||||||
tripId: string;
|
tripId: string;
|
||||||
trainName: string;
|
trainName: string;
|
||||||
@@ -241,7 +244,7 @@ export interface ILiveStatus {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dashboard ──────────────────────────────────────────────────────────────────
|
// ── Dashboard ──────────────────────────────────────────────────────────────
|
||||||
export interface IDashboard {
|
export interface IDashboard {
|
||||||
user: { firstName: string; greetingKey: 'MORNING' | 'AFTERNOON' | 'EVENING' };
|
user: { firstName: string; greetingKey: 'MORNING' | 'AFTERNOON' | 'EVENING' };
|
||||||
upcomingTicket: {
|
upcomingTicket: {
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -323,6 +323,9 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.14.0
|
specifier: ^0.14.0
|
||||||
version: 0.14.4
|
version: 0.14.4
|
||||||
|
jose:
|
||||||
|
specifier: ^5.10.0
|
||||||
|
version: 5.10.0
|
||||||
passport:
|
passport:
|
||||||
specifier: ^0.7.0
|
specifier: ^0.7.0
|
||||||
version: 0.7.0
|
version: 0.7.0
|
||||||
|
|||||||
Reference in New Issue
Block a user