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
|
||||
DATABASE_URL=postgresql://user:password@host:5432/edr_passenger
|
||||
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",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"jose": "^5.10.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"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?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
faydaVerified Boolean @default(false)
|
||||
faydaVerifiedAt DateTime?
|
||||
faydaSub String? @unique
|
||||
|
||||
passenger Passenger?
|
||||
agent Agent?
|
||||
sessions Session[]
|
||||
@@ -232,6 +237,8 @@ model User {
|
||||
auditLogs AuditLog[]
|
||||
fraudAlerts FraudAlert[]
|
||||
|
||||
faydaVerificationSessions FaydaVerificationSession[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -515,6 +522,9 @@ model BookingSeat {
|
||||
passportCountry String?
|
||||
verifaydaVerified Boolean @default(false)
|
||||
verifaydaData Json?
|
||||
faydaVerifiedAt DateTime?
|
||||
faydaSub String?
|
||||
faydaVerifiedName String?
|
||||
seatLabelSnapshot String?
|
||||
fareMinor Int?
|
||||
displayCurrency Currency?
|
||||
@@ -1256,3 +1266,32 @@ model SavedPassengerProfile {
|
||||
|
||||
@@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 cardConfig from './config/card.config';
|
||||
import waafiConfig from './config/waafi.config';
|
||||
import faydaConfig from './config/fayda.config';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { StationsModule } from './modules/stations/stations.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 { FraudModule } from './modules/fraud/fraud.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({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfig, dbConfig, telebirrConfig, cbeConfig, ebirrConfig, cardConfig, waafiConfig],
|
||||
load: [
|
||||
appConfig,
|
||||
dbConfig,
|
||||
telebirrConfig,
|
||||
cbeConfig,
|
||||
ebirrConfig,
|
||||
cardConfig,
|
||||
waafiConfig,
|
||||
faydaConfig,
|
||||
],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
@@ -69,6 +81,8 @@ import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
|
||||
ReportsModule,
|
||||
FraudModule,
|
||||
SeatClassesModule,
|
||||
FareEngineModule,
|
||||
VerifaydaModule,
|
||||
],
|
||||
})
|
||||
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("Booking", "Booking lifecycle")
|
||||
.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("Fraud Detection", "Fraud detection and monitoring")
|
||||
.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 { Currency } from '@prisma/client';
|
||||
|
||||
@@ -47,9 +47,8 @@ export class CurrencyService {
|
||||
|
||||
async syncExchangeRates(): Promise<void> {
|
||||
this.logger.log('Syncing exchange rates from external provider');
|
||||
|
||||
// In production, fetch from external API
|
||||
// For now, using static rates
|
||||
|
||||
const today = this.todayUtc();
|
||||
const rates = [
|
||||
{ from: 'ETB', to: 'ETB', rate: 1.0 },
|
||||
{ from: 'ETB', to: 'DJF', rate: 3.25 },
|
||||
@@ -59,25 +58,47 @@ export class CurrencyService {
|
||||
];
|
||||
|
||||
for (const { from, to, rate } of rates) {
|
||||
await this.prisma.currencyExchangeRate.upsert({
|
||||
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',
|
||||
},
|
||||
});
|
||||
await this.upsertRate(from as Currency, to as Currency, rate, today, 'EXTERNAL_API');
|
||||
}
|
||||
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@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' })
|
||||
@ApiQuery({ name: 'class', required: false, description: 'Seat class name: "Economy Regular" | "Economy Bed" | "VIP Bed". Defaults to Economy Regular.' })
|
||||
@ApiResponse({ status: 200, description: 'Fare rule or default fare' })
|
||||
@ApiQuery({ name: 'seatClassId', required: true, description: 'SeatClass UUID' })
|
||||
@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' })
|
||||
getFare(@Param('scheduleId') scheduleId: string, @Query('class') cls: string) {
|
||||
return this.service.getFare(scheduleId, cls ?? 'Economy Regular');
|
||||
getAllFares(
|
||||
@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 { RoutesController } from './routes.controller';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
|
||||
@Module({
|
||||
imports: [FareEngineModule],
|
||||
controllers: [RoutesController, SchedulesController],
|
||||
providers: [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 { RoutesService } from './routes.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -8,6 +9,7 @@ export class SchedulesService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private routesService: RoutesService,
|
||||
private fareEngine: FareEngineService,
|
||||
) {}
|
||||
|
||||
// ── Schedule CRUD ──────────────────────────────────────────────────────────
|
||||
@@ -100,7 +102,63 @@ export class SchedulesService {
|
||||
},
|
||||
});
|
||||
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) {
|
||||
@@ -149,27 +207,51 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
async getFare(scheduleId: string, seatClassName: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
include: { originStation: true, destinationStation: true },
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) {
|
||||
return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality);
|
||||
}
|
||||
|
||||
const route = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
||||
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: seatClassName } });
|
||||
getAllFaresFromEngine(scheduleId: string, nationality?: string) {
|
||||
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 rule = await this.prisma.fareRule.findFirst({
|
||||
where: {
|
||||
seatClassId: seatClass?.id,
|
||||
validFrom: { lte: now },
|
||||
OR: [{ tripId: scheduleId }, { route }, { tripId: null, route: null }],
|
||||
AND: [{ OR: [{ validUntil: null }, { validUntil: { gte: now } }] }],
|
||||
},
|
||||
orderBy: { validFrom: 'desc' },
|
||||
});
|
||||
for (const fare of results as any[]) {
|
||||
try {
|
||||
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: fare.seatClassName } });
|
||||
if (!seatClass) { errors.push(`Seat class not found: ${fare.seatClassName}`); continue; }
|
||||
|
||||
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 { SearchService } from './search.service';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
import { SegmentsModule } from '../segments/segments.module';
|
||||
|
||||
@Module({
|
||||
imports: [CurrencyModule],
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
exports: [SearchService]
|
||||
@Module({
|
||||
imports: [CurrencyModule, FareEngineModule, SegmentsModule],
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
exports: [SearchService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
||||
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';
|
||||
|
||||
const POINTS_TO_MINOR = 10;
|
||||
@@ -11,6 +13,8 @@ export class SearchService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private currencyService: CurrencyService,
|
||||
private fareEngine: FareEngineService,
|
||||
private segmentsService: SegmentsService,
|
||||
) {}
|
||||
|
||||
async searchTrips(dto: SearchTripsDto) {
|
||||
@@ -56,7 +60,8 @@ export class SearchService {
|
||||
|
||||
for (const seat of assignment.coach.seats) {
|
||||
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,
|
||||
originStop.sequence, destStop.sequence,
|
||||
);
|
||||
@@ -68,6 +73,11 @@ export class SearchService {
|
||||
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||
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({
|
||||
scheduleId: schedule.id,
|
||||
trainNumber: schedule.train.number,
|
||||
@@ -92,7 +102,6 @@ export class SearchService {
|
||||
(new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000,
|
||||
),
|
||||
status: schedule.status,
|
||||
// Only return stops within the requested leg (origin → destination inclusive)
|
||||
stops: schedule.stopTimes
|
||||
.filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
|
||||
.map(st => ({
|
||||
@@ -104,6 +113,7 @@ export class SearchService {
|
||||
})),
|
||||
availabilityByClass,
|
||||
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 {
|
||||
const fares: Record<string, number> = {
|
||||
'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); }
|
||||
|
||||
// ── 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')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
import { IsString, IsArray, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsString, IsArray, ValidateNested } from 'class-validator';
|
||||
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 {
|
||||
@ApiProperty({ example: 'schedule-uuid' }) @IsString() scheduleId: string;
|
||||
@ApiProperty({ example: 'passenger-uuid' }) @IsString() passengerId: 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: 'schedule-uuid', description: 'TrainSchedule UUID' })
|
||||
@IsString() scheduleId: 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 { SeatsController } from './seats.controller';
|
||||
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 {}
|
||||
|
||||
@@ -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 { HoldSeatsDto } from './seats.dto';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { SegmentsService } from '../segments/segments.service';
|
||||
|
||||
@Injectable()
|
||||
export class SeatsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private segmentsService: SegmentsService,
|
||||
) {}
|
||||
|
||||
// ── Seat Map ──────────────────────────────────────────────────────────────
|
||||
async getSeatMap(scheduleId: string, coachId?: string) {
|
||||
@@ -14,6 +18,10 @@ export class SeatsService {
|
||||
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
});
|
||||
|
||||
const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id));
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds);
|
||||
|
||||
return {
|
||||
coaches: assignments.map((a) => ({
|
||||
id: a.coach.id,
|
||||
@@ -21,34 +29,334 @@ export class SeatsService {
|
||||
name: `Coach ${a.coach.label}`,
|
||||
seatClass: a.coach.seatClass.name,
|
||||
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 ────────────────────────────────────────────────────────
|
||||
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 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 } });
|
||||
const unavailable = seats.filter((s) => s.status === 'BOOKED' || s.status === 'BLOCKED' || (s.status === 'HELD' && s.heldUntil && s.heldUntil > new Date()));
|
||||
if (unavailable.length > 0) throw new ConflictException('One or more seats unavailable');
|
||||
await tx.seat.updateMany({ where: { id: { in: dto.seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } });
|
||||
return tx.seatHold.create({ data: { scheduleId: dto.scheduleId, passengerId: dto.passengerId, seatIds: dto.seatIds, fareQuoteId: dto.fareQuoteId, expiresAt } });
|
||||
// ── 1. Validate seats exist and none are BLOCKED ─────────────────────
|
||||
const seats = await tx.seat.findMany({
|
||||
where: { id: { in: seatIds } },
|
||||
select: { id: true, status: true, label: true },
|
||||
});
|
||||
|
||||
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) {
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
|
||||
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 } });
|
||||
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 releaseSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'AVAILABLE', heldUntil: null } }); }
|
||||
async confirmSeats(seatIds: string[]) {
|
||||
// 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[]> {
|
||||
const seats = await this.prisma.seat.findMany({
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface SeatHoldRequest {
|
||||
passengerId: string;
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
fareQuoteId?: string;
|
||||
}
|
||||
|
||||
export interface BookingConfirmRequest {
|
||||
@@ -27,28 +26,42 @@ export class EnhancedSeatsService {
|
||||
|
||||
async holdSeats(request: SeatHoldRequest) {
|
||||
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) {
|
||||
const seat = await tx.seat.findUnique({ where: { id: seatId } });
|
||||
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`);
|
||||
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);
|
||||
// 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({
|
||||
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 });
|
||||
|
||||
return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds };
|
||||
});
|
||||
}
|
||||
@@ -68,19 +81,16 @@ export class EnhancedSeatsService {
|
||||
});
|
||||
if (!schedule) throw new BadRequestException('Schedule not found');
|
||||
|
||||
// Resolve the passenger's leg range from the hold's fareQuoteId (encoded as "leg:originId:destId")
|
||||
const legKey = hold.fareQuoteId ?? '';
|
||||
// Resolve the passenger's leg from createdBy JSON
|
||||
let originStationId: string | undefined;
|
||||
let destinationStationId: string | undefined;
|
||||
if (legKey.startsWith('leg:')) {
|
||||
const parts = legKey.split(':');
|
||||
originStationId = parts[1];
|
||||
destinationStationId = parts[2];
|
||||
} else {
|
||||
// Fall back to booking's own origin/destination if available
|
||||
originStationId = (booking as any).originStationId;
|
||||
destinationStationId = (booking as any).destinationStationId;
|
||||
}
|
||||
try {
|
||||
if (hold.createdBy) {
|
||||
const meta = JSON.parse(hold.createdBy);
|
||||
originStationId = meta.originStationId;
|
||||
destinationStationId = meta.destinationStationId;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const originStop = originStationId ? schedule.stopTimes.find(s => s.stationId === originStationId) : 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 } });
|
||||
|
||||
this.eventEmitter.emit('booking.confirmed', { bookingId: request.bookingId, scheduleId: hold.scheduleId, seatIds: 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) {
|
||||
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({
|
||||
where: { id: scheduleId },
|
||||
@@ -181,13 +192,20 @@ export class EnhancedSeatsService {
|
||||
const availableSeats = [];
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
for (const seat of assignment.coach.seats) {
|
||||
const overlaps = await this.segmentsService.getOverlappingReservations(scheduleId, seat.id, segments);
|
||||
if (overlaps.length === 0 && seat.status === 'AVAILABLE') {
|
||||
// Hard-blocked seats are never 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({
|
||||
id: seat.id, label: seat.label,
|
||||
coach: assignment.coach.label,
|
||||
seatClass: assignment.coach.seatClass.name,
|
||||
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 { EnhancedSeatsService } from './enhanced-seats.service';
|
||||
import { HoldSeatsDto, ConfirmBookingDto, SeatAvailabilityDto, ReleaseSeatsDto } from './segments.dto';
|
||||
import { ConfirmBookingDto, SeatAvailabilityDto, ReleaseSeatsDto } from './segments.dto';
|
||||
|
||||
@ApiTags('Segment-based Seats')
|
||||
@Controller('segments/seats')
|
||||
export class SegmentSeatsController {
|
||||
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')
|
||||
@ApiOperation({
|
||||
summary: 'Confirm booking and convert hold to reservation',
|
||||
description: 'Convert seat hold to confirmed booking after payment success'
|
||||
@ApiOperation({
|
||||
summary: 'Confirm booking — convert hold to reservation',
|
||||
description: 'Call after payment succeeds. Converts the SeatHold (created via POST /seats/hold) into JourneySegment records scoped to the passenger\'s leg.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Booking confirmed successfully',
|
||||
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) {
|
||||
@ApiResponse({ status: 200, description: 'Booking confirmed, JourneySegments created for the held leg' })
|
||||
@ApiResponse({ status: 400, description: 'Hold expired or booking not found' })
|
||||
confirmBooking(@Body() dto: ConfirmBookingDto) {
|
||||
return this.enhancedSeatsService.confirmBooking(dto);
|
||||
}
|
||||
|
||||
@Post('release')
|
||||
@ApiOperation({
|
||||
summary: 'Release seats when train reaches station',
|
||||
description: 'Automatically release seats for passengers who have reached their destination'
|
||||
@ApiOperation({
|
||||
summary: 'Release seats when train reaches a station',
|
||||
description: 'Called by the live tracking system when the train departs a station. Frees seats for passengers whose journey ended at that station.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Seats released successfully',
|
||||
schema: {
|
||||
example: {
|
||||
releasedSeats: ['seat_1', 'seat_2'],
|
||||
stationId: 'st_DRE'
|
||||
}
|
||||
}
|
||||
})
|
||||
async releaseSeats(@Body() dto: ReleaseSeatsDto) {
|
||||
@ApiResponse({ status: 200, description: 'Seats released for passengers who reached their destination' })
|
||||
releaseSeats(@Body() dto: ReleaseSeatsDto) {
|
||||
return this.enhancedSeatsService.releaseSeats(dto.scheduleId, dto.currentStationId);
|
||||
}
|
||||
|
||||
@Get('availability')
|
||||
@ApiOperation({
|
||||
summary: 'Check seat availability for journey segments',
|
||||
description: 'Get available seats for a specific origin-destination pair'
|
||||
@ApiOperation({
|
||||
summary: 'Get available seats for a specific leg',
|
||||
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({
|
||||
status: 200,
|
||||
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) {
|
||||
@ApiResponse({ status: 200, description: 'Available seats with coach, seat class, row, col, window/aisle/bed flags' })
|
||||
getSeatAvailability(@Query() dto: SeatAvailabilityDto) {
|
||||
return this.enhancedSeatsService.getSeatAvailability(dto.scheduleId, dto.originStationId, dto.destinationStationId);
|
||||
}
|
||||
|
||||
@Post('expire-holds')
|
||||
@ApiOperation({
|
||||
summary: 'Expire old seat holds (background job)',
|
||||
description: 'Release seats from expired holds and make them available'
|
||||
@ApiOperation({
|
||||
summary: 'Expire stale seat holds (background job)',
|
||||
description: 'Removes holds past their expiry time. Called by the scheduler every minute.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Expired holds processed',
|
||||
schema: {
|
||||
example: {
|
||||
expiredHolds: 5,
|
||||
releasedSeats: ['seat_1', 'seat_2', 'seat_3']
|
||||
}
|
||||
}
|
||||
})
|
||||
async expireHolds() {
|
||||
@ApiResponse({ status: 200, description: 'Expired holds removed' })
|
||||
expireHolds() {
|
||||
return this.enhancedSeatsService.expireHolds();
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export class SegmentsService {
|
||||
});
|
||||
|
||||
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) {
|
||||
throw new BadRequestException('Origin or destination station not found on this schedule');
|
||||
@@ -38,22 +38,126 @@ export class SegmentsService {
|
||||
const segments: Segment[] = [];
|
||||
for (let i = originStop.sequence; i < destStop.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) {
|
||||
segments.push({
|
||||
fromStationId: fromStop.stationId,
|
||||
toStationId: toStop.stationId,
|
||||
fromSequence: fromStop.sequence,
|
||||
toSequence: toStop.sequence,
|
||||
fromName: fromStop.station.name,
|
||||
toName: toStop.station.name,
|
||||
toStationId: toStop.stationId,
|
||||
fromSequence: fromStop.sequence,
|
||||
toSequence: toStop.sequence,
|
||||
fromName: fromStop.station.name,
|
||||
toName: toStop.station.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
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 {
|
||||
for (const s1 of segments1) {
|
||||
for (const s2 of segments2) {
|
||||
@@ -62,69 +166,4 @@ export class SegmentsService {
|
||||
}
|
||||
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 { VerifaydaController } from './verifayda.controller';
|
||||
import { VerifaydaService } from './verifayda.service';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
import { AuthModule } from '../auth/auth.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],
|
||||
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 { PrismaService } from '../../common/prisma.service';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
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 {
|
||||
fullName: string;
|
||||
@@ -17,38 +45,554 @@ export interface VerifaydaVerificationResult {
|
||||
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()
|
||||
export class VerifaydaService {
|
||||
private readonly logger = new Logger(VerifaydaService.name);
|
||||
|
||||
|
||||
private readonly faydaConfig: FaydaConfig;
|
||||
|
||||
private readonly httpClient: AxiosInstance;
|
||||
private readonly enabled: boolean;
|
||||
private readonly apiUrl: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly stubEnabled: boolean;
|
||||
private readonly stubApiUrl: string;
|
||||
private readonly stubApiKey: string;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
) {
|
||||
this.enabled = this.config.get<boolean>('VERIFAYDA_ENABLED', false);
|
||||
this.apiUrl = this.config.get<string>('VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2');
|
||||
this.apiKey = this.config.get<string>('VERIFAYDA_API_KEY', '');
|
||||
const fayda = this.config.get<FaydaConfig>('fayda');
|
||||
if (!fayda) {
|
||||
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({
|
||||
baseURL: this.apiUrl,
|
||||
baseURL: this.stubApiUrl,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.apiKey,
|
||||
headers: { 'Content-Type': 'application/json', 'X-API-Key': this.stubApiKey },
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 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(
|
||||
nationalId: string,
|
||||
bookingId?: string,
|
||||
): Promise<VerifaydaVerificationResult> {
|
||||
if (!this.enabled) {
|
||||
this.logger.warn('Verifayda is disabled - skipping verification');
|
||||
if (!this.stubEnabled) {
|
||||
this.logger.warn('Verifayda stub is disabled - skipping verification');
|
||||
return {
|
||||
verified: false,
|
||||
failureReason: 'Verifayda integration is disabled',
|
||||
@@ -62,10 +606,8 @@ export class VerifaydaService {
|
||||
};
|
||||
|
||||
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 { data } = response;
|
||||
|
||||
if (data.status === 'verified' && data.citizen) {
|
||||
@@ -88,36 +630,24 @@ export class VerifaydaService {
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log('Verifayda verification successful');
|
||||
return { verified: true, passengerData };
|
||||
}
|
||||
|
||||
return {
|
||||
verified: true,
|
||||
passengerData,
|
||||
};
|
||||
} else {
|
||||
const failureReason = data.message || 'Verification failed';
|
||||
|
||||
await this.prisma.verifaydaVerification.create({
|
||||
data: {
|
||||
bookingId,
|
||||
nationalId,
|
||||
requestPayload,
|
||||
responsePayload: data,
|
||||
verified: false,
|
||||
failureReason,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.warn(`Verifayda verification failed: ${failureReason}`);
|
||||
|
||||
return {
|
||||
const failureReason = data.message || 'Verification failed';
|
||||
await this.prisma.verifaydaVerification.create({
|
||||
data: {
|
||||
bookingId,
|
||||
nationalId,
|
||||
requestPayload,
|
||||
responsePayload: data,
|
||||
verified: false,
|
||||
failureReason,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
return { verified: false, failureReason };
|
||||
} 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({
|
||||
data: {
|
||||
bookingId,
|
||||
@@ -127,16 +657,15 @@ export class VerifaydaService {
|
||||
failureReason: errorMessage,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.error(`Verifayda API error: ${errorMessage}`);
|
||||
|
||||
this.logger.error(`Verifayda stub error: ${errorMessage}`);
|
||||
throw new BadRequestException(
|
||||
`National ID verification failed: ${errorMessage}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use `faydaConfig.enabled` for the OIDC flow. */
|
||||
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 SeatStatus = 'AVAILABLE' | 'HELD' | 'BOOKED' | 'BLOCKED';
|
||||
export type ServiceClass = 'ECONOMY' | 'BUSINESS' | 'FIRST';
|
||||
export type BookingStatus = 'DRAFT' | 'PENDING_PAYMENT' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW';
|
||||
export type PaymentMethod = 'TELEBIRR' | 'CBE_BIRR' | 'EBIRR' | 'CARD' | 'WALLET';
|
||||
|
||||
// ── Station ────────────────────────────────────────────────────────────────────
|
||||
// ── Station ────────────────────────────────────────────────────────────────
|
||||
export interface IStation {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
city: string;
|
||||
country: string;
|
||||
timezone: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
createdAt: string | Date;
|
||||
updatedAt: string | Date;
|
||||
}
|
||||
|
||||
// ── Trip / Schedule ────────────────────────────────────────────────────────────
|
||||
// ── Trip / Schedule ────────────────────────────────────────────────────────
|
||||
export interface ITripStation {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -62,7 +65,7 @@ export interface IStopTime {
|
||||
station: IStation;
|
||||
}
|
||||
|
||||
// ── Seat ───────────────────────────────────────────────────────────────────────
|
||||
// ── Seat ────────────────────────────────────────────────────────────────────
|
||||
export interface ISeat {
|
||||
id: string;
|
||||
number: string; // label from API
|
||||
@@ -81,7 +84,7 @@ export interface ISeatMap {
|
||||
coaches: ICoach[];
|
||||
}
|
||||
|
||||
// ── Segment-based seats ────────────────────────────────────────────────────────
|
||||
// ── Segment-based seats ────────────────────────────────────────────────────
|
||||
export interface ISegment {
|
||||
fromStationId: string;
|
||||
toStationId: string;
|
||||
@@ -119,7 +122,7 @@ export interface ISegmentConfirmResult {
|
||||
segments: Array<{ fromStationId: string; toStationId: string; fromSequence: number; toSequence: number }>;
|
||||
}
|
||||
|
||||
// ── Booking ────────────────────────────────────────────────────────────────────
|
||||
// ── Booking ────────────────────────────────────────────────────────────────
|
||||
export interface IBooking {
|
||||
id: string;
|
||||
bookingRef: string;
|
||||
@@ -140,7 +143,7 @@ export interface IBooking {
|
||||
payment?: { method: PaymentMethod; status: string };
|
||||
}
|
||||
|
||||
// ── Ticket ─────────────────────────────────────────────────────────────────────
|
||||
// ── Ticket ─────────────────────────────────────────────────────────────────
|
||||
export interface ITicket {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
@@ -158,7 +161,7 @@ export interface ITicket {
|
||||
qrPayload: string;
|
||||
}
|
||||
|
||||
// ── Fare quote ─────────────────────────────────────────────────────────────────
|
||||
// ── Fare quote ─────────────────────────────────────────────────────────────
|
||||
export interface IFareQuote {
|
||||
tripId: string;
|
||||
serviceClass: ServiceClass;
|
||||
@@ -171,7 +174,7 @@ export interface IFareQuote {
|
||||
currency: string;
|
||||
}
|
||||
|
||||
// ── Passenger ─────────────────────────────────────────────────────────────────
|
||||
// ── Passenger ──────────────────────────────────────────────────────────────
|
||||
export interface IPassengerProfile {
|
||||
id: string;
|
||||
fullName: string;
|
||||
@@ -181,7 +184,7 @@ export interface IPassengerProfile {
|
||||
bookings: IBooking[];
|
||||
}
|
||||
|
||||
// ── Loyalty ────────────────────────────────────────────────────────────────────
|
||||
// ── Loyalty ────────────────────────────────────────────────────────────────
|
||||
export interface ILoyaltyAccount {
|
||||
id: string;
|
||||
passengerId: string;
|
||||
@@ -192,7 +195,7 @@ export interface ILoyaltyAccount {
|
||||
tierProgressPercent: number;
|
||||
}
|
||||
|
||||
// ── Wallet ─────────────────────────────────────────────────────────────────────
|
||||
// ── Wallet ─────────────────────────────────────────────────────────────────
|
||||
export interface IWallet {
|
||||
id: string;
|
||||
passengerId: string;
|
||||
@@ -200,7 +203,7 @@ export interface IWallet {
|
||||
currency: string;
|
||||
}
|
||||
|
||||
// ── Notification ───────────────────────────────────────────────────────────────
|
||||
// ── Notification ───────────────────────────────────────────────────────────
|
||||
export interface INotification {
|
||||
id: string;
|
||||
passengerId: string;
|
||||
@@ -212,7 +215,7 @@ export interface INotification {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ── Promotion ──────────────────────────────────────────────────────────────────
|
||||
// ── Promotion ──────────────────────────────────────────────────────────────
|
||||
export interface IPromotion {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -225,7 +228,7 @@ export interface IPromotion {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
// ── Live tracking ──────────────────────────────────────────────────────────────
|
||||
// ── Live tracking ──────────────────────────────────────────────────────────
|
||||
export interface ILiveStatus {
|
||||
tripId: string;
|
||||
trainName: string;
|
||||
@@ -241,7 +244,7 @@ export interface ILiveStatus {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Dashboard ──────────────────────────────────────────────────────────────────
|
||||
// ── Dashboard ──────────────────────────────────────────────────────────────
|
||||
export interface IDashboard {
|
||||
user: { firstName: string; greetingKey: 'MORNING' | 'AFTERNOON' | 'EVENING' };
|
||||
upcomingTicket: {
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -323,6 +323,9 @@ importers:
|
||||
class-validator:
|
||||
specifier: ^0.14.0
|
||||
version: 0.14.4
|
||||
jose:
|
||||
specifier: ^5.10.0
|
||||
version: 5.10.0
|
||||
passport:
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0
|
||||
|
||||
Reference in New Issue
Block a user