mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -130,6 +130,13 @@ FAYDA_SESSION_TTL_MINUTES=10
|
||||
|
||||
GITHUB_PACKAGE_TOKEN=
|
||||
|
||||
# --- Notification broker (RabbitMQ) -----------------------------------------------------------------
|
||||
# Set RABBITMQ_ENABLED=false to skip connection entirely (dev without a local broker).
|
||||
RABBITMQ_ENABLED=false
|
||||
RABBITMQ_URL=amqp://localhost:5672
|
||||
EMAIL_QUEUE=email_queue
|
||||
SMS_QUEUE=sms_queue
|
||||
|
||||
# --- Payment event consumer (RabbitMQ) -------------------------------------------------------
|
||||
# Consumes payment.succeeded / payment.failed events from the payment microservice. Separate
|
||||
# from any RABBITMQ_URL used by the IAM/notification modules so the two connections are
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
|
||||
|
||||
-- AlterTable: add return leg tracking columns to Booking
|
||||
ALTER TABLE "passenger"."Booking"
|
||||
ADD COLUMN "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
|
||||
ADD COLUMN "outboundBoardedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "returnBoardedAt" TIMESTAMP(3);
|
||||
|
||||
-- Set NEITHER_USED for existing confirmed round-trip bookings
|
||||
UPDATE "passenger"."Booking"
|
||||
SET "returnLegStatus" = 'NEITHER_USED'
|
||||
WHERE "bookingType" = 'ROUND_TRIP'
|
||||
AND "status" IN ('CONFIRMED', 'COMPLETED');
|
||||
|
||||
-- AlterTable: add leg column to GateValidationLog
|
||||
ALTER TABLE "passenger"."GateValidationLog"
|
||||
ADD COLUMN "leg" TEXT;
|
||||
@@ -115,6 +115,16 @@ enum BookingStatus {
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
enum ReturnLegStatus {
|
||||
NOT_APPLICABLE // one-way booking
|
||||
BOTH_USED // passenger used both legs
|
||||
OUTBOUND_ONLY // return leg not used (no-show on return)
|
||||
INBOUND_ONLY // outbound leg not used, return leg used
|
||||
NEITHER_USED // neither leg boarded yet
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
enum PaymentRegion {
|
||||
ETHIOPIA
|
||||
DJIBOUTI
|
||||
@@ -364,7 +374,8 @@ model TrainSchedule {
|
||||
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
|
||||
destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id])
|
||||
coachAssignments CoachAssignment[]
|
||||
bookings Booking[]
|
||||
bookings Booking[] @relation("OutboundSchedule")
|
||||
returnBookings Booking[] @relation("ReturnSchedule")
|
||||
stopTimes TripStopTime[]
|
||||
liveStatus TripLiveStatus?
|
||||
menuItems MenuItem[]
|
||||
@@ -511,6 +522,9 @@ model Booking {
|
||||
returnDestinationStationId String?
|
||||
returnHoldId String?
|
||||
returnSeatClassId String?
|
||||
returnLegStatus ReturnLegStatus @default(NOT_APPLICABLE)
|
||||
outboundBoardedAt DateTime?
|
||||
returnBoardedAt DateTime?
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
userAgent String?
|
||||
@@ -520,7 +534,8 @@ model Booking {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||
schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id])
|
||||
returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id])
|
||||
seats BookingSeat[]
|
||||
paymentIntent PaymentIntent?
|
||||
ticket Ticket?
|
||||
@@ -1159,6 +1174,7 @@ model GateValidationLog {
|
||||
ticketId String
|
||||
validatorId String
|
||||
gateId String?
|
||||
leg String? // 'OUTBOUND' | 'RETURN' — for round-trip tickets
|
||||
status String
|
||||
reason String?
|
||||
validatedAt DateTime @default(now())
|
||||
|
||||
@@ -75,7 +75,7 @@ import { CurrenciesModule } from './modules/currencies/currencies.module';
|
||||
PaymentsModule,
|
||||
TicketsModule,
|
||||
PassengersModule,
|
||||
NotificationsModule,
|
||||
NotificationsModule.register(),
|
||||
LoyaltyModule,
|
||||
WalletModule,
|
||||
PromosModule,
|
||||
|
||||
@@ -34,19 +34,23 @@ async function bootstrap() {
|
||||
## Overview
|
||||
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
|
||||
|
||||
## 🆕 Latest Updates
|
||||
- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display
|
||||
- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles
|
||||
- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing
|
||||
- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories
|
||||
- **Multi-Currency Display:** Bookings track display currency and converted amounts
|
||||
- **Ticket Lifecycle:** Tickets now include validatedAt and boardedAt timestamps for complete audit trail
|
||||
## Latest Updates
|
||||
- **Round-Trip Leg Tracking:** returnLegStatus on every booking tracks outbound/return leg usage (NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED). Gate validation accepts a leg field (OUTBOUND or RETURN).
|
||||
- **Auto No-Show Detection:** Cron marks OUTBOUND_ONLY 30 min after return departure when return leg was never scanned.
|
||||
- **Offline Batch Validation:** validateOfflineBatch now accepts leg per entry and handles both legs of a round-trip in one batch.
|
||||
- **Booking Filters:** GET /bookings now accepts ?returnLegStatus= to filter no-show/inbound-only cases in back-office.
|
||||
- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display.
|
||||
- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles.
|
||||
- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing.
|
||||
- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories.
|
||||
- **Multi-Currency Display:** Bookings track display currency and converted amounts.
|
||||
- **Ticket Lifecycle:** Tickets now include validatedAt, outboundBoardedAt, returnBoardedAt for complete audit trail.
|
||||
|
||||
## Key Features
|
||||
|
||||
### 🎫 Booking Lifecycle
|
||||
### Booking Lifecycle
|
||||
- Search trips with real-time availability
|
||||
- Age-based passenger categorization (Adult ≥5 years, Child <5 years)
|
||||
- Age-based passenger categorization (Adult 5+ years, Child under 5)
|
||||
- Nationality-based verification (Ethiopian Fayda, International Passport)
|
||||
- Passenger information collection with verification
|
||||
- Coach and seat selection with real-time availability
|
||||
@@ -55,130 +59,129 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
|
||||
- Modify bookings (seat changes, passenger updates)
|
||||
- Cancel bookings with automatic refunds
|
||||
- Multi-segment journey support
|
||||
- Cross-border journeys via Dire Dawa transit (Ethiopia → Djibouti)
|
||||
- Cross-border journeys via Dire Dawa transit (Ethiopia to Djibouti)
|
||||
- Round-trip booking with return journey scheduling
|
||||
- Coach type selection with seat class and pricing options
|
||||
- **NEW:** Booking type tracking (ONE_WAY vs ROUND_TRIP)
|
||||
- **NEW:** Display currency and converted pricing per booking
|
||||
- NEW: Booking type tracking (ONE_WAY vs ROUND_TRIP)
|
||||
- NEW: Display currency and converted pricing per booking
|
||||
- NEW: returnLegStatus field tracks which legs of a round-trip were used
|
||||
- NEW: GET /bookings?returnLegStatus=OUTBOUND_ONLY filters no-show returns in back-office
|
||||
|
||||
### 👤 Passenger Verification
|
||||
1. **Ethiopian Nationals:**
|
||||
- Automatic Fayda verification for adults (≥5 years)
|
||||
### Passenger Verification
|
||||
1. Ethiopian Nationals:
|
||||
- Automatic Fayda verification for adults (5+ years)
|
||||
- Real-time national ID verification via government database
|
||||
- Retrieves verified passenger data (name, DOB, gender)
|
||||
- National IDs not stored (policy compliant)
|
||||
|
||||
2. **International Passengers:**
|
||||
2. International Passengers:
|
||||
- Passport information collection
|
||||
- Manual verification for Djiboutian and other nationals
|
||||
- No government database verification required
|
||||
|
||||
### 💰 Age-Based Pricing
|
||||
- **ADULT** (≥5 years): Pay 100% of base fare
|
||||
- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100%
|
||||
### Age-Based Pricing
|
||||
- ADULT (5+ years): Pay 100% of base fare
|
||||
- CHILD (under 5): First child travels FREE, subsequent children pay 100%
|
||||
- Automatic age calculation from date of birth
|
||||
- Example: 2 adults + 3 children = 4× base fare (first child free)
|
||||
- **NEW:** Premium charges and insurance fees per seat class
|
||||
- **NEW:** Transparent fee breakdown in pricing calculations
|
||||
- Example: 2 adults + 3 children = 4x base fare (first child free)
|
||||
- NEW: Premium charges and insurance fees per seat class
|
||||
- NEW: Transparent fee breakdown in pricing calculations
|
||||
|
||||
### 💳 Payment Integration
|
||||
1. **Ethiopian Payment Methods:**
|
||||
- **Telebirr** - Ethiopia's leading mobile money
|
||||
- **CBE Birr** - Commercial Bank of Ethiopia
|
||||
### Payment Integration
|
||||
1. Ethiopian Payment Methods: Telebirr, CBE Birr
|
||||
2. Djiboutian Payment Methods: Waafi
|
||||
3. International Payment Methods: Card, Wallet
|
||||
|
||||
2. **Djiboutian Payment Methods:**
|
||||
- **Waafi** - Djibouti's mobile money service
|
||||
|
||||
3. **International Payment Methods:**
|
||||
- **Card** - International card payments (Visa, Mastercard)
|
||||
- **Wallet** - Internal wallet system
|
||||
|
||||
### 🪑 Seat Management
|
||||
### Seat Management
|
||||
- Real-time seat availability by coach and class
|
||||
- Seat holds with 15-minute expiry
|
||||
- Auto-assign seats with contiguous algorithm
|
||||
- Seat blocking for maintenance
|
||||
- Coach-level seat maps (ordered by sequence)
|
||||
- Class-based seating (Economy Regular, Economy Bed, VIP Bed)
|
||||
- **NEW:** Sequence-based coach ordering for consistent display
|
||||
- NEW: Sequence-based coach ordering for consistent display
|
||||
|
||||
### 🎟️ Ticketing
|
||||
### Ticketing
|
||||
- QR code and barcode generation
|
||||
- PDF ticket generation
|
||||
- Gate validation with audit logs
|
||||
- Offline validation support
|
||||
- Multi-passenger tickets
|
||||
- **NEW:** Ticket lifecycle tracking (validatedAt, boardedAt timestamps)
|
||||
- **NEW:** Complete audit trail for compliance and reporting
|
||||
- NEW: Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
|
||||
- NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
|
||||
- NEW: Complete audit trail per leg for compliance and reporting
|
||||
|
||||
### 🏆 Loyalty Program
|
||||
- 4 tiers: Bronze, Silver, Gold, Platinum
|
||||
- Points accumulation on trips
|
||||
- Reward redemption
|
||||
- Tier-based benefits
|
||||
### Round-Trip Leg Tracking (NEW)
|
||||
- returnLegStatus on Booking: NOT_APPLICABLE, NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED
|
||||
- Gate validation POST /tickets/:ref/validate accepts optional leg field (OUTBOUND or RETURN)
|
||||
- Auto no-show cron: sets OUTBOUND_ONLY 30 min after return departure when return leg unscanned
|
||||
- Back-office filter: GET /bookings?returnLegStatus=OUTBOUND_ONLY surfaces no-shows
|
||||
- Offline batch: validateOfflineBatch accepts leg per entry, handles both legs of same booking
|
||||
|
||||
### 💰 Wallet System
|
||||
- Top-up via payment methods
|
||||
- Pay with wallet balance
|
||||
- Transaction ledger
|
||||
- Refund to wallet
|
||||
|
||||
### 📍 Live Tracking
|
||||
- Real-time trip status
|
||||
- Location updates
|
||||
- Delay notifications
|
||||
- Station crowd signals
|
||||
|
||||
### 🔒 Fraud Detection
|
||||
- Velocity checks (multiple bookings)
|
||||
- High-value transaction monitoring
|
||||
- Failed payment pattern detection
|
||||
- Automatic user blocking
|
||||
|
||||
### 👤 Passenger Profiles
|
||||
- Comprehensive profile data: gender, date of birth, nationality
|
||||
- National ID for Ethiopian citizens (Fayda verified)
|
||||
- Passport information for international passengers
|
||||
- **NEW:** Complete demographic data for personalized services
|
||||
- **NEW:** Improved user targeting and communications
|
||||
|
||||
### 🌍 Internationalization
|
||||
- Multi-language support (English, Amharic, French, Oromo)
|
||||
- Locale-based responses
|
||||
- Currency formatting (ETB, DJF, USD)
|
||||
- **NEW:** Multi-currency display per booking (ETB, DJF, USD)
|
||||
|
||||
### 🚌 Transit Stop Management
|
||||
- Automatic detection of cross-border journeys (Ethiopia → Djibouti)
|
||||
- Dire Dawa as mandatory transit hub for international journeys
|
||||
- Dual-leg fare calculation (domestic + international)
|
||||
- Age-based pricing applied independently per leg
|
||||
- Seamless multi-segment booking workflow
|
||||
- Transit stop optimization and route planning
|
||||
|
||||
### 🔄 Round-Trip Booking
|
||||
### Round-Trip Booking
|
||||
- One-way and round-trip journey options
|
||||
- Flexible return date selection
|
||||
- Combined pricing for outbound + return legs
|
||||
- Separate seat management per leg
|
||||
- Independent modification/cancellation per leg
|
||||
- Return journey tracking and notifications
|
||||
- **NEW:** Booking type stored for analytics and reporting
|
||||
- NEW: Booking type stored for analytics and reporting
|
||||
|
||||
### 🚐 Coach Type & Class Selection
|
||||
- Browse available coach types per route (standard coaches, premium coaches)
|
||||
### Loyalty Program
|
||||
- 4 tiers: Bronze, Silver, Gold, Platinum
|
||||
- Points accumulation on trips
|
||||
- Reward redemption
|
||||
- Tier-based benefits
|
||||
|
||||
### Wallet System
|
||||
- Top-up via payment methods
|
||||
- Pay with wallet balance
|
||||
- Transaction ledger
|
||||
- Refund to wallet
|
||||
|
||||
### Live Tracking
|
||||
- Real-time trip status
|
||||
- Location updates
|
||||
- Delay notifications
|
||||
- Station crowd signals
|
||||
|
||||
### Fraud Detection
|
||||
- Velocity checks (multiple bookings)
|
||||
- High-value transaction monitoring
|
||||
- Failed payment pattern detection
|
||||
- Automatic user blocking
|
||||
|
||||
### Passenger Profiles
|
||||
- Comprehensive profile data: gender, date of birth, nationality
|
||||
- National ID for Ethiopian citizens (Fayda verified)
|
||||
- Passport information for international passengers
|
||||
- NEW: Complete demographic data for personalized services
|
||||
|
||||
### Internationalization
|
||||
- Multi-language support (English, Amharic, French, Oromo)
|
||||
- Locale-based responses
|
||||
- Currency formatting (ETB, DJF, USD)
|
||||
- NEW: Multi-currency display per booking (ETB, DJF, USD)
|
||||
|
||||
### Transit Stop Management
|
||||
- Automatic detection of cross-border journeys (Ethiopia to Djibouti)
|
||||
- Dire Dawa as mandatory transit hub for international journeys
|
||||
- Dual-leg fare calculation (domestic + international)
|
||||
- Age-based pricing applied independently per leg
|
||||
- Seamless multi-segment booking workflow
|
||||
|
||||
### Coach Type & Class Selection
|
||||
- Browse available coach types per route
|
||||
- View seat classes per coach (Economy Regular, Economy Bed, VIP Bed)
|
||||
- Compare base prices by coach type and class
|
||||
- Real-time availability per coach configuration
|
||||
- Deferred pricing at seat selection stage
|
||||
- Coach amenities and features display
|
||||
- **NEW:** Sequence-based coach ordering for consistent UI
|
||||
- **NEW:** Premium and insurance fee transparency per class
|
||||
- NEW: Sequence-based coach ordering for consistent UI
|
||||
- NEW: Premium and insurance fee transparency per class
|
||||
|
||||
### 📊 Data Organization
|
||||
- **Stations:** Ordered by sequence (1-15) for consistent route display
|
||||
- **Coaches:** Ordered by sequence (1+) per type for predictable configuration
|
||||
- **Booking History:** Sorted chronologically with filtering options
|
||||
### Data Organization
|
||||
- Stations ordered by sequence (1-15) for consistent route display
|
||||
- Coaches ordered by sequence (1+) per type for predictable configuration
|
||||
- Booking history sorted chronologically with filtering options
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -202,7 +205,7 @@ Used for agent, fraud, and reporting endpoints. Requires corporate IAM token.
|
||||
|
||||
### Step 3: Passenger Information & Verification
|
||||
**For Ethiopian Passengers:**
|
||||
\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (≥5 years)
|
||||
\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (5+ years)
|
||||
|
||||
**For International Passengers:**
|
||||
\`POST /passengers/register-international\` - Passport information collection
|
||||
@@ -265,7 +268,7 @@ Payment providers send notifications to:
|
||||
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
|
||||
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
|
||||
.addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management")
|
||||
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout")
|
||||
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports returnLegStatus filter for round-trip no-show management")
|
||||
.addTag("Config", "System settings, feature flags, and configuration management")
|
||||
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")
|
||||
.addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications")
|
||||
@@ -291,7 +294,7 @@ Payment providers send notifications to:
|
||||
.addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability")
|
||||
.addTag("Stations", "Station directory, location data, baggage facilities, and amenities")
|
||||
.addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution")
|
||||
.addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation, and audit trails")
|
||||
.addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN), and audit trails")
|
||||
.addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)")
|
||||
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger")
|
||||
//.addServer('http://localhost:4000', 'Development')
|
||||
|
||||
@@ -75,21 +75,24 @@ export class BookingsController {
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'List all bookings with filters (Admin/Agent)',
|
||||
description: 'Returns paginated list of bookings with search and status filters'
|
||||
description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.'
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
|
||||
@ApiQuery({ name: 'returnLegStatus', required: false, description: 'Filter round-trip leg usage: NEITHER_USED | OUTBOUND_ONLY | INBOUND_ONLY | BOTH_USED | NOT_APPLICABLE' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('returnLegStatus') returnLegStatus?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
search,
|
||||
status,
|
||||
status,
|
||||
returnLegStatus,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20
|
||||
});
|
||||
|
||||
@@ -7,9 +7,10 @@ import { GuestBookingService } from './guest-booking.service';
|
||||
import { SeatsModule } from '../seats/seats.module';
|
||||
import { VerifaydaModule } from '../verifayda/verifayda.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, GuestBookingService],
|
||||
exports: [BookingsService, GuestBookingService]
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
|
||||
function generateRef(): string {
|
||||
@@ -24,6 +25,7 @@ function calculateAge(dateOfBirth: Date): number {
|
||||
interface BookingFilters {
|
||||
search?: string;
|
||||
status?: string;
|
||||
returnLegStatus?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -36,6 +38,7 @@ export class BookingsService {
|
||||
private eventEmitter: EventEmitter2,
|
||||
private verifaydaService: VerifaydaService,
|
||||
private currencyService: CurrencyService,
|
||||
private fareEngine: FareEngineService,
|
||||
) {}
|
||||
|
||||
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
|
||||
@@ -82,6 +85,8 @@ export class BookingsService {
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
adultCount: booking.adultCount,
|
||||
childCount: booking.childCount,
|
||||
bookingType: booking.bookingType,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
@@ -159,6 +164,8 @@ export class BookingsService {
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
adultCount: booking.adultCount,
|
||||
childCount: booking.childCount,
|
||||
bookingType: booking.bookingType,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
@@ -180,7 +187,7 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
async findAll(filters: BookingFilters = {}) {
|
||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
||||
const { search, status, returnLegStatus, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
@@ -194,9 +201,8 @@ export class BookingsService {
|
||||
];
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
if (status) where.status = status;
|
||||
if (returnLegStatus) where.returnLegStatus = returnLegStatus;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.booking.findMany({
|
||||
@@ -225,6 +231,8 @@ export class BookingsService {
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
contactEmail: booking.contactEmail,
|
||||
contactPhone: booking.contactPhone,
|
||||
bookingType: booking.bookingType,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
createdAt: booking.createdAt,
|
||||
passenger: booking.passenger?.user,
|
||||
schedule: {
|
||||
@@ -391,6 +399,7 @@ export class BookingsService {
|
||||
returnDestinationStationId: dto.returnDestinationStationId,
|
||||
returnHoldId: dto.returnHoldId,
|
||||
returnSeatClassId: dto.returnSeatClassId,
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
seats: {
|
||||
create: passengersData.map(p => ({
|
||||
seat: { connect: { id: p.outboundSeatId } },
|
||||
@@ -406,7 +415,7 @@ export class BookingsService {
|
||||
displayCurrency
|
||||
}))
|
||||
}
|
||||
},
|
||||
} as any,
|
||||
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
|
||||
});
|
||||
|
||||
@@ -560,76 +569,69 @@ export class BookingsService {
|
||||
destStopSeq?: number,
|
||||
): Promise<number> {
|
||||
const now = new Date();
|
||||
|
||||
// Get schedule with route info
|
||||
|
||||
// 1. SegmentFareRule — most specific explicit price
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
include: { route: true },
|
||||
select: { routeId: true, originStationId: true, destinationStationId: true },
|
||||
});
|
||||
|
||||
// Try segment fare rule first (most specific) if route info available
|
||||
|
||||
if (schedule?.routeId && originStopSeq !== undefined && destStopSeq !== undefined) {
|
||||
// Try with nationality first
|
||||
const segmentFare = await this.prisma.segmentFareRule.findFirst({
|
||||
where: {
|
||||
routeId: schedule.routeId,
|
||||
originStopSequence: originStopSeq,
|
||||
destinationStopSequence: destStopSeq,
|
||||
seatClassId,
|
||||
nationality: nationality || null,
|
||||
nationality: nationality ?? null,
|
||||
validFrom: { lte: now },
|
||||
OR: [
|
||||
{ validUntil: null },
|
||||
{ validUntil: { gte: now } },
|
||||
],
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
},
|
||||
});
|
||||
|
||||
if (segmentFare) {
|
||||
return segmentFare.baseFareMinor;
|
||||
}
|
||||
|
||||
// If no segment fare with nationality, try without nationality filter
|
||||
if (nationality) {
|
||||
const segmentFareAny = await this.prisma.segmentFareRule.findFirst({
|
||||
where: {
|
||||
routeId: schedule.routeId,
|
||||
originStopSequence: originStopSeq,
|
||||
destinationStopSequence: destStopSeq,
|
||||
seatClassId,
|
||||
nationality: null,
|
||||
validFrom: { lte: now },
|
||||
OR: [
|
||||
{ validUntil: null },
|
||||
{ validUntil: { gte: now } },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (segmentFareAny) return segmentFareAny.baseFareMinor;
|
||||
}
|
||||
}) ?? (nationality ? await this.prisma.segmentFareRule.findFirst({
|
||||
where: {
|
||||
routeId: schedule.routeId,
|
||||
originStopSequence: originStopSeq,
|
||||
destinationStopSequence: destStopSeq,
|
||||
seatClassId,
|
||||
nationality: null,
|
||||
validFrom: { lte: now },
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
},
|
||||
}) : null);
|
||||
|
||||
if (segmentFare) return segmentFare.baseFareMinor;
|
||||
}
|
||||
|
||||
// Fall back to fare rules if no segment fare found
|
||||
|
||||
// 2. FareRule table — explicit override rules
|
||||
const candidates = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
seatClassId,
|
||||
validFrom: { lte: now },
|
||||
OR: [
|
||||
{ validUntil: null },
|
||||
{ validUntil: { gte: now } },
|
||||
],
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
},
|
||||
});
|
||||
const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
|
||||
if (bestMatch) return bestMatch.baseFareMinor;
|
||||
|
||||
const bestMatch = this.selectBestFareRule(
|
||||
candidates,
|
||||
scheduleId,
|
||||
segmentRoute,
|
||||
fullRoute,
|
||||
nationality,
|
||||
// 3. FareEngine — distance × rate-per-km from the schedule's route
|
||||
if (schedule?.routeId) {
|
||||
try {
|
||||
const fare = await this.fareEngine.calculate({
|
||||
routeId: schedule.routeId,
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
seatClassId,
|
||||
nationality,
|
||||
});
|
||||
return fare.baseFarePerPassengerMinor;
|
||||
} catch {
|
||||
// FareEngine throws if distanceKm is missing; fall through to error
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestException(
|
||||
`No fare configured for this schedule and seat class. Please set up fare rules or route distances.`,
|
||||
);
|
||||
|
||||
return bestMatch?.baseFareMinor ?? 35000;
|
||||
}
|
||||
|
||||
async getByRef(bookingRef: string) {
|
||||
@@ -646,7 +648,11 @@ export class BookingsService {
|
||||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||
totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount,
|
||||
displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined,
|
||||
bookingType: booking.bookingType, createdAt: booking.createdAt,
|
||||
bookingType: booking.bookingType,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
|
||||
returnBoardedAt: (booking as any).returnBoardedAt ?? null,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
number: booking.schedule.train.number,
|
||||
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
|
||||
@@ -751,6 +757,37 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Mark round-trip bookings where the return train has departed but the return leg
|
||||
// was never scanned. Runs every minute; only acts on CONFIRMED bookings whose
|
||||
// returnSchedule.departureAt is in the past and returnBoardedAt is still null.
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async markReturnLegNoShows() {
|
||||
const now = new Date();
|
||||
const graceCutoff = new Date(now.getTime() - 30 * 60 * 1000);
|
||||
|
||||
const candidates = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
bookingType: 'ROUND_TRIP',
|
||||
status: 'CONFIRMED',
|
||||
returnLegStatus: 'NEITHER_USED' as any,
|
||||
outboundBoardedAt: { not: null },
|
||||
returnBoardedAt: null,
|
||||
returnScheduleId: { not: null },
|
||||
},
|
||||
include: { returnSchedule: { select: { departureAt: true } } },
|
||||
} as any);
|
||||
|
||||
for (const b of candidates) {
|
||||
const returnDep: Date | undefined = (b as any).returnSchedule?.departureAt;
|
||||
if (returnDep && returnDep < graceCutoff) {
|
||||
await this.prisma.booking.update({
|
||||
where: { id: b.id },
|
||||
data: { returnLegStatus: 'OUTBOUND_ONLY' } as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private selectBestFareRule(
|
||||
candidates: any[],
|
||||
scheduleId: string,
|
||||
|
||||
@@ -4,9 +4,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Currency, IdDocumentType } from '@prisma/client';
|
||||
|
||||
export class GuestPassengerDto {
|
||||
@ApiProperty({ example: 'seat-id-uuid' })
|
||||
@ApiProperty({ example: 'seat-id-uuid', description: 'Outbound seat ID (or only seat for ONE_WAY)' })
|
||||
@IsString() seatId: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'seat-id-uuid', description: 'Return seat ID (ROUND_TRIP only)' })
|
||||
@IsOptional() @IsString() returnSeatId?: string;
|
||||
|
||||
@ApiProperty({ example: 'Abebe Kebede' })
|
||||
@IsString() passengerName: string;
|
||||
|
||||
@@ -36,24 +39,42 @@ export class GuestPassengerDto {
|
||||
}
|
||||
|
||||
export class CreateGuestBookingDto {
|
||||
@ApiProperty({ example: 'schedule-uuid' })
|
||||
@ApiPropertyOptional({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP'], default: 'ONE_WAY' })
|
||||
@IsOptional() @IsString() bookingType?: 'ONE_WAY' | 'ROUND_TRIP';
|
||||
|
||||
@ApiProperty({ example: 'schedule-uuid', description: 'Outbound schedule UUID' })
|
||||
@IsString() scheduleId: string;
|
||||
|
||||
@ApiProperty({ example: 'hold-uuid' })
|
||||
@ApiProperty({ example: 'hold-uuid', description: 'Outbound seat hold UUID' })
|
||||
@IsString() holdId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' })
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID' })
|
||||
@IsString() originStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' })
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. First passenger details used for contact.' })
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[];
|
||||
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' })
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID' })
|
||||
@IsString() seatClassId: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP only: return schedule UUID' })
|
||||
@IsOptional() @IsString() returnScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP only: return seat hold UUID' })
|
||||
@IsOptional() @IsString() returnHoldId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP only: return origin station UUID' })
|
||||
@IsOptional() @IsString() returnOriginStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP only: return destination station UUID' })
|
||||
@IsOptional() @IsString() returnDestinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP only: return seat class UUID (defaults to outbound seatClassId)' })
|
||||
@IsOptional() @IsString() returnSeatClassId?: string;
|
||||
|
||||
@ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. For ROUND_TRIP each passenger must include returnSeatId.' })
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[];
|
||||
|
||||
@ApiPropertyOptional({ example: 'WEEKEND15' })
|
||||
@IsOptional() @IsString() promoCode?: string;
|
||||
|
||||
@@ -66,7 +87,7 @@ export class CreateGuestBookingDto {
|
||||
@ApiPropertyOptional({ example: 'password123', description: 'Password if createAccount is true' })
|
||||
@IsOptional() @IsString() password?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings (requires createAccount)' })
|
||||
@ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings' })
|
||||
@IsOptional() @IsBoolean() savePassengerDetails?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' })
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
@@ -28,10 +29,18 @@ export class GuestBookingService {
|
||||
private seatsService: SeatsService,
|
||||
private verifaydaService: VerifaydaService,
|
||||
private currencyService: CurrencyService,
|
||||
private fareEngine: FareEngineService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
async createGuestBooking(dto: CreateGuestBookingDto) {
|
||||
if (dto.bookingType === 'ROUND_TRIP') {
|
||||
return this.createGuestRoundTripBooking(dto);
|
||||
}
|
||||
return this.createGuestOneWayBooking(dto);
|
||||
}
|
||||
|
||||
private async createGuestOneWayBooking(dto: CreateGuestBookingDto) {
|
||||
// Validate hold
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||||
if (!hold || hold.expiresAt < new Date()) {
|
||||
@@ -157,76 +166,7 @@ export class GuestBookingService {
|
||||
|
||||
// Create or get guest passenger
|
||||
const firstPassenger = passengersData[0];
|
||||
let guestPassenger = null;
|
||||
let userId = null;
|
||||
let createdAccount = false;
|
||||
|
||||
// Optional account creation
|
||||
if (dto.createAccount && firstPassenger.email && dto.password) {
|
||||
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('Email already registered. Please login instead.');
|
||||
}
|
||||
|
||||
let accountPhone = firstPassenger.phone || null;
|
||||
if (accountPhone) {
|
||||
const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
|
||||
if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
|
||||
}
|
||||
if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
fullName: firstPassenger.passengerName,
|
||||
email: firstPassenger.email,
|
||||
phone: accountPhone,
|
||||
passwordHash,
|
||||
nationality: firstPassenger.nationality,
|
||||
nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
|
||||
passportNumber: firstPassenger.passportNumber,
|
||||
},
|
||||
});
|
||||
|
||||
guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } });
|
||||
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
|
||||
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
|
||||
|
||||
userId = user.id;
|
||||
createdAccount = true;
|
||||
} else {
|
||||
// Create anonymous guest passenger with minimal data
|
||||
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
// Check if email exists and use a unique guest email if it does
|
||||
let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
|
||||
if (firstPassenger.email) {
|
||||
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
|
||||
if (existingUser) {
|
||||
// Email exists, use guest email instead for anonymous booking
|
||||
guestEmail = `guest-${uniqueId}@edr-platform.com`;
|
||||
}
|
||||
}
|
||||
|
||||
// Use a guaranteed-unique guest phone to avoid constraint collisions
|
||||
let guestPhone = firstPassenger.phone || null;
|
||||
if (guestPhone) {
|
||||
const existingPhone = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
|
||||
if (existingPhone) guestPhone = null;
|
||||
}
|
||||
if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
|
||||
|
||||
const tempUser = await this.prisma.user.create({
|
||||
data: {
|
||||
fullName: firstPassenger.passengerName,
|
||||
email: guestEmail,
|
||||
phone: guestPhone,
|
||||
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
|
||||
role: 'PASSENGER',
|
||||
},
|
||||
});
|
||||
guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } });
|
||||
}
|
||||
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger);
|
||||
|
||||
// Save passenger details for future use (if requested)
|
||||
if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) {
|
||||
@@ -302,6 +242,258 @@ export class GuestBookingService {
|
||||
};
|
||||
}
|
||||
|
||||
private async createGuestRoundTripBooking(dto: CreateGuestBookingDto) {
|
||||
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
|
||||
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
|
||||
}
|
||||
|
||||
// Validate both holds
|
||||
const [outboundHold, returnHold] = await Promise.all([
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
|
||||
]);
|
||||
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired or not found');
|
||||
if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired or not found');
|
||||
|
||||
// Validate passengers have returnSeatId
|
||||
for (const p of dto.passengers) {
|
||||
if (!p.returnSeatId) throw new BadRequestException(`returnSeatId is required for each passenger in a ROUND_TRIP booking (missing for ${p.passengerName})`);
|
||||
}
|
||||
|
||||
// Load both schedules
|
||||
const [outboundSchedule, returnSchedule] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
|
||||
}),
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.returnScheduleId },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
|
||||
}),
|
||||
]);
|
||||
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
|
||||
if (!returnSchedule) throw new NotFoundException('Return schedule not found');
|
||||
|
||||
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
|
||||
const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId);
|
||||
if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule');
|
||||
if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');
|
||||
|
||||
const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`;
|
||||
const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`;
|
||||
const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`;
|
||||
const returnFullRoute = `${returnSchedule.originStation.code}-${returnSchedule.destinationStation.code}`;
|
||||
|
||||
// Process passengers (verify identity once — same person travels both legs)
|
||||
const passengersData: any[] = [];
|
||||
let adultCount = 0, childCount = 0;
|
||||
|
||||
for (const passenger of dto.passengers) {
|
||||
const dateOfBirth = new Date(passenger.dateOfBirth);
|
||||
const age = calculateAge(dateOfBirth);
|
||||
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||||
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
|
||||
|
||||
let passengerName = passenger.passengerName;
|
||||
let verifaydaVerified = false;
|
||||
let verifaydaData: Record<string, any> | undefined;
|
||||
let nationality = passenger.nationality;
|
||||
|
||||
const isEthiopian = passenger.nationality === 'Ethiopian' ||
|
||||
passenger.nationality === 'ETHIOPIAN' ||
|
||||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
|
||||
|
||||
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||||
if (passenger.idDocumentNumber) {
|
||||
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
||||
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
|
||||
passengerName = verification.passengerData?.fullName || passengerName;
|
||||
verifaydaVerified = true;
|
||||
verifaydaData = verification.passengerData?.profileData;
|
||||
}
|
||||
nationality = 'Ethiopian';
|
||||
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||||
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
|
||||
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||||
} else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||||
nationality = 'Ethiopian';
|
||||
} else {
|
||||
nationality = nationality || 'Other';
|
||||
}
|
||||
|
||||
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||||
}
|
||||
|
||||
// Calculate fares for both legs
|
||||
const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId;
|
||||
const primaryNationality = passengersData[0]?.nationality;
|
||||
|
||||
const [outboundBaseFare, returnBaseFare] = await Promise.all([
|
||||
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality),
|
||||
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality),
|
||||
]);
|
||||
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount;
|
||||
const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * paidChildrenCount;
|
||||
const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
|
||||
|
||||
let discountMinor = 0;
|
||||
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(combinedBaseFareMinor * promo.percentOff / 100)
|
||||
: (promo.amountOffMinor ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
|
||||
// Create or resolve guest passenger (same as one-way)
|
||||
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
|
||||
|
||||
// Create booking with outbound seats; return seats confirmed separately
|
||||
const outboundSeatIds = dto.passengers.map(p => p.seatId);
|
||||
const returnSeatIds = dto.passengers.map(p => p.returnSeatId!);
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: guestPassenger.id,
|
||||
scheduleId: dto.scheduleId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP',
|
||||
totalMinor,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
returnScheduleId: dto.returnScheduleId,
|
||||
returnOriginStationId: dto.returnOriginStationId,
|
||||
returnDestinationStationId: dto.returnDestinationStationId,
|
||||
returnHoldId: dto.returnHoldId,
|
||||
returnSeatClassId,
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
userAgent: dto.deviceId,
|
||||
seats: {
|
||||
create: passengersData.map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT
|
||||
? outboundBaseFare + returnBaseFare
|
||||
: (paidChildrenCount > 0 ? outboundBaseFare + returnBaseFare : 0),
|
||||
displayCurrency,
|
||||
})),
|
||||
},
|
||||
} as any,
|
||||
include: {
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(outboundSeatIds),
|
||||
this.seatsService.confirmSeats(returnSeatIds),
|
||||
]);
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
return {
|
||||
...booking,
|
||||
createdAccount,
|
||||
userId,
|
||||
fareBreakdown: {
|
||||
outboundBaseFareMinor: outboundBaseFare,
|
||||
returnBaseFareMinor: returnBaseFare,
|
||||
adultCount,
|
||||
childCount,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
paidChildrenCount,
|
||||
combinedBaseFareMinor,
|
||||
discountMinor,
|
||||
taxesFeesMinor: taxesMinor,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveGuestPassenger(
|
||||
dto: Pick<CreateGuestBookingDto, 'createAccount' | 'password' | 'deviceId'>,
|
||||
firstPassenger: any,
|
||||
): Promise<{ guestPassenger: any; userId: string | null; createdAccount: boolean }> {
|
||||
if (dto.createAccount && firstPassenger.email && dto.password) {
|
||||
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
|
||||
if (existingUser) throw new BadRequestException('Email already registered. Please login instead.');
|
||||
|
||||
let accountPhone = firstPassenger.phone || null;
|
||||
if (accountPhone) {
|
||||
const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
|
||||
if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
|
||||
}
|
||||
if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
fullName: firstPassenger.passengerName,
|
||||
email: firstPassenger.email,
|
||||
phone: accountPhone,
|
||||
passwordHash: await bcrypt.hash(dto.password, 10),
|
||||
nationality: firstPassenger.nationality,
|
||||
nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
|
||||
passportNumber: firstPassenger.passportNumber,
|
||||
},
|
||||
});
|
||||
const guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } });
|
||||
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
|
||||
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
|
||||
return { guestPassenger, userId: user.id, createdAccount: true };
|
||||
}
|
||||
|
||||
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
|
||||
if (firstPassenger.email) {
|
||||
const existing = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
|
||||
if (existing) guestEmail = `guest-${uniqueId}@edr-platform.com`;
|
||||
}
|
||||
let guestPhone = firstPassenger.phone || null;
|
||||
if (guestPhone) {
|
||||
const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
|
||||
if (existing) guestPhone = null;
|
||||
}
|
||||
if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
|
||||
|
||||
const tempUser = await this.prisma.user.create({
|
||||
data: {
|
||||
fullName: firstPassenger.passengerName,
|
||||
email: guestEmail,
|
||||
phone: guestPhone,
|
||||
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
|
||||
role: 'PASSENGER',
|
||||
},
|
||||
});
|
||||
const guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } });
|
||||
return { guestPassenger, userId: null, createdAccount: false };
|
||||
}
|
||||
|
||||
async getSavedPassengers(userId?: string, deviceId?: string): Promise<SavedPassengerProfileDto[]> {
|
||||
if (!userId && !deviceId) {
|
||||
throw new BadRequestException('Either userId or deviceId is required');
|
||||
@@ -343,6 +535,8 @@ export class GuestBookingService {
|
||||
nationality?: string,
|
||||
): Promise<number> {
|
||||
const now = new Date();
|
||||
|
||||
// 1. FareRule table — explicit override rules
|
||||
const candidates = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
seatClassId,
|
||||
@@ -368,14 +562,34 @@ export class GuestBookingService {
|
||||
|
||||
for (const priority of priorities) {
|
||||
const match = candidates.find(
|
||||
(c) =>
|
||||
c.tripId === priority.tripId &&
|
||||
c.route === priority.route &&
|
||||
c.nationality === priority.nationality,
|
||||
(c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality,
|
||||
);
|
||||
if (match) return match.baseFareMinor;
|
||||
}
|
||||
|
||||
return 35000; // Default fallback
|
||||
// 2. FareEngine — distance × rate-per-km from the schedule's route
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: { routeId: true, originStationId: true, destinationStationId: true },
|
||||
});
|
||||
|
||||
if (schedule?.routeId) {
|
||||
try {
|
||||
const fare = await this.fareEngine.calculate({
|
||||
routeId: schedule.routeId,
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
seatClassId,
|
||||
nationality,
|
||||
});
|
||||
return fare.baseFarePerPassengerMinor;
|
||||
} catch {
|
||||
// FareEngine throws if distanceKm is missing; fall through to error
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestException(
|
||||
`No fare configured for this schedule and seat class. Please set up fare rules or route distances.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ export class EmailClientService implements OnApplicationBootstrap {
|
||||
private readonly emailServiceClient: ClientProxy,
|
||||
) {}
|
||||
|
||||
private readonly enabled = process.env.RABBITMQ_ENABLED !== 'false';
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
if (!this.enabled) return;
|
||||
this.emailServiceClient
|
||||
.connect()
|
||||
.then(() => this.logger.log('Connected to Email service'))
|
||||
@@ -19,10 +22,8 @@ export class EmailClientService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
async sendEmail(dto: SendEmail) {
|
||||
this.emailServiceClient.emit('send-email', {
|
||||
...dto,
|
||||
appKey: 'EDR-PASSENGER-API',
|
||||
});
|
||||
if (!this.enabled) return {};
|
||||
this.emailServiceClient.emit('send-email', { ...dto, appKey: 'EDR-PASSENGER-API' });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DynamicModule, Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ClientsModule, Transport } from '@nestjs/microservices';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
@@ -38,17 +38,37 @@ import { SmsClientService } from './sms-client.service';
|
||||
queueOptions: { durable: true },
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
controllers: [NotificationsController],
|
||||
providers: [
|
||||
NotificationsService,
|
||||
EmailAdapter,
|
||||
SmsAdapter,
|
||||
PushAdapter,
|
||||
EmailClientService,
|
||||
SmsClientService,
|
||||
],
|
||||
exports: [NotificationsService, EmailClientService, SmsClientService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
@Module({})
|
||||
export class NotificationsModule {
|
||||
static register(): DynamicModule {
|
||||
const rmqEnabled = process.env.RABBITMQ_ENABLED !== 'false';
|
||||
|
||||
return {
|
||||
module: NotificationsModule,
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
...(rmqEnabled ? [rmqClientsModule] : []),
|
||||
],
|
||||
controllers: [NotificationsController],
|
||||
providers: [
|
||||
NotificationsService,
|
||||
EmailAdapter,
|
||||
SmsAdapter,
|
||||
PushAdapter,
|
||||
...(rmqEnabled
|
||||
? [EmailClientService, SmsClientService]
|
||||
: [
|
||||
{ provide: 'EMAIL_SERVICE', useValue: null },
|
||||
{ provide: 'SMS_SERVICE', useValue: null },
|
||||
EmailClientService,
|
||||
SmsClientService,
|
||||
]),
|
||||
],
|
||||
exports: [NotificationsService, EmailClientService, SmsClientService],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
private smsClient: ClientProxy,
|
||||
) {}
|
||||
|
||||
private readonly enabled = process.env.RABBITMQ_ENABLED !== 'false';
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
if (!this.enabled) return;
|
||||
this.smsClient
|
||||
.connect()
|
||||
.then(() => {
|
||||
@@ -28,18 +31,14 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
async sendSms(dto: SendMessage) {
|
||||
this.smsClient.emit('send-sms', {
|
||||
...dto,
|
||||
appKey: 'EDR-PASSENGER-API',
|
||||
});
|
||||
if (!this.enabled) return {};
|
||||
this.smsClient.emit('send-sms', { ...dto, appKey: 'EDR-PASSENGER-API' });
|
||||
return {};
|
||||
}
|
||||
|
||||
async sendBulkMessages(dto: BulkMessagesDto) {
|
||||
this.smsClient.emit('ozeking-bulk-sms', {
|
||||
...dto,
|
||||
appKey: 'EDR-PASSENGER-API',
|
||||
});
|
||||
if (!this.enabled) return {};
|
||||
this.smsClient.emit('ozeking-bulk-sms', { ...dto, appKey: 'EDR-PASSENGER-API' });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,10 @@ export class BulkCreateSchedulesDto {
|
||||
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' })
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||
plannedTimes?: PlannedStopTimeDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' })
|
||||
@IsOptional() @IsArray() @IsString({ each: true })
|
||||
coachIds?: string[];
|
||||
}
|
||||
|
||||
export class BulkSchedulesResponseDto {
|
||||
|
||||
@@ -44,6 +44,15 @@ export class SchedulesService {
|
||||
|
||||
const schedule = await this.createSchedule(createDto);
|
||||
scheduleIds.push(schedule.id);
|
||||
|
||||
// Assign coaches if provided
|
||||
if (dto.coachIds && dto.coachIds.length > 0) {
|
||||
await this.assignCoaches(
|
||||
schedule.id,
|
||||
dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })),
|
||||
);
|
||||
}
|
||||
|
||||
scheduleCount++;
|
||||
} catch (error) {
|
||||
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
|
||||
@@ -244,7 +244,8 @@ export class SearchService {
|
||||
nationality,
|
||||
);
|
||||
|
||||
const baseFareMinor = bestMatch?.baseFareMinor ?? this.defaultFare(dto.seatClassName);
|
||||
const baseFareMinor = bestMatch?.baseFareMinor
|
||||
?? await this.resolveScheduleFare(dto.scheduleId, seatClass?.id, dto.seatClassName);
|
||||
|
||||
const adultCount = dto.adultCount;
|
||||
const childCount = dto.childCount ?? 0;
|
||||
@@ -379,11 +380,8 @@ export class SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`);
|
||||
return seatClasses.map(sc => ({
|
||||
seatClassName: sc.name,
|
||||
baseFareMinor: this.getDefaultFareForClass(sc.name),
|
||||
}));
|
||||
console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
private async buildCoachTypeDetails(
|
||||
@@ -420,11 +418,10 @@ export class SearchService {
|
||||
const classes = Array.from(classNames)
|
||||
.map((className) => {
|
||||
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
|
||||
return {
|
||||
name: className,
|
||||
baseFareMinor: fareInfo?.baseFareMinor ?? this.getDefaultFareForClass(className),
|
||||
};
|
||||
if (!fareInfo) return null;
|
||||
return { name: className, baseFareMinor: fareInfo.baseFareMinor };
|
||||
})
|
||||
.filter((c): c is { name: string; baseFareMinor: number } => c !== null)
|
||||
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
|
||||
|
||||
result.push({
|
||||
@@ -442,22 +439,28 @@ export class SearchService {
|
||||
});
|
||||
}
|
||||
|
||||
private getDefaultFareForClass(className: string): number {
|
||||
const defaults: Record<string, number> = {
|
||||
'Economy Regular': 35000,
|
||||
'Economy Bed': 49000,
|
||||
'VIP Bed': 63000,
|
||||
};
|
||||
return defaults[className] ?? 35000;
|
||||
private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise<number> {
|
||||
if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`);
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: { routeId: true, originStationId: true, destinationStationId: true },
|
||||
});
|
||||
if (!schedule?.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
|
||||
const fare = await this.fareEngine.calculate({
|
||||
routeId: schedule.routeId,
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
seatClassId,
|
||||
});
|
||||
return fare.baseFarePerPassengerMinor;
|
||||
}
|
||||
|
||||
private defaultFare(seatClassName: string): number {
|
||||
const fares: Record<string, number> = {
|
||||
'Economy Regular': 45000,
|
||||
'Economy Bed': 65000,
|
||||
'VIP Bed': 95000,
|
||||
};
|
||||
return fares[seatClassName] ?? 45000;
|
||||
private getDefaultFareForClass(_className: string): never {
|
||||
throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead');
|
||||
}
|
||||
|
||||
private defaultFare(_seatClassName: string): never {
|
||||
throw new Error('defaultFare should not be called — use resolveScheduleFare instead');
|
||||
}
|
||||
|
||||
private selectBestFareRule(
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('SeatsService - Auto Assign', () => {
|
||||
|
||||
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
|
||||
|
||||
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR', 'ACCESSIBLE');
|
||||
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@@ -30,6 +30,10 @@ export class TicketsController {
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List all tickets with optional filters' })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'ACTIVE | USED | CANCELLED' })
|
||||
@ApiQuery({ name: 'skip', required: false })
|
||||
@ApiQuery({ name: 'take', required: false })
|
||||
listTickets(
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@@ -77,14 +81,26 @@ export class TicketsController {
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Validate ticket at gate with audit logging',
|
||||
description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.'
|
||||
description: 'Validates ticket QR/barcode at station gate. For round-trip bookings, supply `leg` (OUTBOUND or RETURN) to record which leg is being used. Defaults to OUTBOUND if omitted. Records validation in audit log with timestamp, gate, and validator.'
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['validatorId'],
|
||||
properties: {
|
||||
validatorId: { type: 'string', example: 'agent-uuid' },
|
||||
gateId: { type: 'string', example: 'gate-01' },
|
||||
leg: { type: 'string', enum: ['OUTBOUND', 'RETURN'], description: 'Required for round-trip bookings' },
|
||||
},
|
||||
},
|
||||
})
|
||||
validate(
|
||||
@Param('bookingRef') ref: string,
|
||||
@Body('validatorId') validatorId: string,
|
||||
@Body('gateId') gateId?: string
|
||||
@Body('gateId') gateId?: string,
|
||||
@Body('leg') leg?: 'OUTBOUND' | 'RETURN',
|
||||
) {
|
||||
return this.service.validate(ref, validatorId, gateId);
|
||||
return this.service.validate(ref, validatorId, gateId, leg);
|
||||
}
|
||||
|
||||
@Get(':ticketId/validation-logs')
|
||||
@@ -106,7 +122,31 @@ export class TicketsController {
|
||||
@Post('validate/offline')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Batch import offline validations' })
|
||||
@ApiOperation({
|
||||
summary: 'Batch import offline validations',
|
||||
description: 'Processes validations collected offline. Each entry may include an optional `leg` field (OUTBOUND | RETURN) for round-trip tickets. Deduplication is per bookingRef+leg combination so both legs of the same booking can be submitted in one batch.'
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
validations: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['bookingRef', 'validatorId', 'validatedAt'],
|
||||
properties: {
|
||||
bookingRef: { type: 'string' },
|
||||
validatorId: { type: 'string' },
|
||||
gateId: { type: 'string' },
|
||||
validatedAt: { type: 'string', format: 'date-time' },
|
||||
leg: { type: 'string', enum: ['OUTBOUND', 'RETURN'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
validateOfflineBatch(@Body() body: { validations: any[] }) {
|
||||
return this.service.validateOfflineBatch(body.validations);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ interface OfflineValidation {
|
||||
validatorId: string;
|
||||
gateId?: string;
|
||||
validatedAt: string;
|
||||
leg?: 'OUTBOUND' | 'RETURN';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -49,6 +50,10 @@ export class TicketsService {
|
||||
booking: {
|
||||
bookingRef: t.booking.bookingRef,
|
||||
status: t.booking.status,
|
||||
bookingType: t.booking.bookingType,
|
||||
returnLegStatus: (t.booking as any).returnLegStatus ?? null,
|
||||
outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
|
||||
returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
|
||||
totalMinor: t.booking.totalMinor,
|
||||
currency: t.booking.currency,
|
||||
displayCurrency: t.booking.displayCurrency,
|
||||
@@ -213,22 +218,67 @@ export class TicketsService {
|
||||
};
|
||||
}
|
||||
|
||||
async validate(bookingRef: string, validatorId: string, gateId?: string) {
|
||||
async validate(bookingRef: string, validatorId: string, gateId?: string, leg?: 'OUTBOUND' | 'RETURN') {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
if (ticket.validatedAt) {
|
||||
|
||||
const isRoundTrip = booking.bookingType === 'ROUND_TRIP';
|
||||
// For one-way bookings use the original single-validation guard
|
||||
if (!isRoundTrip) {
|
||||
if (ticket.validatedAt) {
|
||||
await this.prisma.gateValidationLog.create({
|
||||
data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' },
|
||||
});
|
||||
throw new BadRequestException('Ticket already validated');
|
||||
}
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
|
||||
await this.prisma.gateValidationLog.create({
|
||||
data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' }
|
||||
data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' },
|
||||
});
|
||||
throw new BadRequestException('Ticket already validated');
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
|
||||
}
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
|
||||
|
||||
// Round-trip: track which leg is being boarded
|
||||
const resolvedLeg = leg ?? 'OUTBOUND';
|
||||
const now = new Date();
|
||||
const bookingData: Record<string, any> = {};
|
||||
|
||||
if (resolvedLeg === 'OUTBOUND') {
|
||||
if ((booking as any).outboundBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({
|
||||
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any,
|
||||
});
|
||||
throw new BadRequestException('Outbound leg already used');
|
||||
}
|
||||
bookingData.outboundBoardedAt = now;
|
||||
// Stamp the ticket's first validation
|
||||
if (!ticket.validatedAt) {
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } });
|
||||
}
|
||||
} else {
|
||||
if ((booking as any).returnBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({
|
||||
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any,
|
||||
});
|
||||
throw new BadRequestException('Return leg already used');
|
||||
}
|
||||
bookingData.returnBoardedAt = now;
|
||||
}
|
||||
|
||||
// Derive the new composite status
|
||||
const outboundUsed = resolvedLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
|
||||
const returnUsed = resolvedLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt;
|
||||
if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED';
|
||||
else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; // return pending/no-show
|
||||
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
|
||||
|
||||
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
|
||||
await this.prisma.gateValidationLog.create({
|
||||
data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' }
|
||||
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any,
|
||||
});
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
|
||||
}
|
||||
|
||||
async getValidationLogs(ticketId: string) {
|
||||
@@ -256,6 +306,8 @@ export class TicketsService {
|
||||
coachLabel: b.seats[0]?.seat.coach.number,
|
||||
qrPayload: b.ticket?.qrPayload,
|
||||
status: b.status,
|
||||
bookingType: b.bookingType,
|
||||
returnLegStatus: (b as any).returnLegStatus ?? null,
|
||||
validatedAt: b.ticket?.validatedAt,
|
||||
}));
|
||||
}
|
||||
@@ -265,11 +317,13 @@ export class TicketsService {
|
||||
const processedRefs = new Set<string>();
|
||||
|
||||
for (const v of validations) {
|
||||
if (processedRefs.has(v.bookingRef)) {
|
||||
const offlineLeg = v.leg;
|
||||
const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef;
|
||||
if (processedRefs.has(dedupKey)) {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
processedRefs.add(v.bookingRef);
|
||||
processedRefs.add(dedupKey);
|
||||
|
||||
try {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
|
||||
@@ -286,11 +340,21 @@ export class TicketsService {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ticket.validatedAt) {
|
||||
if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP') {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// For round-trip, check per-leg duplication
|
||||
if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) {
|
||||
const alreadyUsed =
|
||||
offlineLeg === 'OUTBOUND' ? !!(booking as any).outboundBoardedAt : !!(booking as any).returnBoardedAt;
|
||||
if (alreadyUsed) {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
|
||||
@@ -301,11 +365,24 @@ export class TicketsService {
|
||||
ticketId: ticket.id,
|
||||
validatorId: v.validatorId,
|
||||
gateId: v.gateId,
|
||||
leg: v.leg ?? null,
|
||||
status: 'APPROVED',
|
||||
validatedAt: new Date(v.validatedAt),
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
// update returnLegStatus for round-trip offline validations
|
||||
if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) {
|
||||
const bookingData: Record<string, any> =
|
||||
offlineLeg === 'OUTBOUND' ? { outboundBoardedAt: new Date(v.validatedAt) } : { returnBoardedAt: new Date(v.validatedAt) };
|
||||
const outboundUsed = offlineLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
|
||||
const returnUsed = offlineLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt;
|
||||
if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED';
|
||||
else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
|
||||
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
|
||||
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
|
||||
}
|
||||
|
||||
results.success++;
|
||||
} catch (err) {
|
||||
results.failed++;
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function BookingsPage() {
|
||||
},
|
||||
{
|
||||
key: 'bookingType',
|
||||
label: 'Class',
|
||||
label: 'Type',
|
||||
sortable: true,
|
||||
render: (booking: any) => booking.bookingType || 'ONE_WAY',
|
||||
},
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function CoachesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null }>({ isOpen: false, item: null });
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Coach Types Queries
|
||||
@@ -252,12 +252,17 @@ export default function CoachesPage() {
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteConfirm.item?.isCoachType) {
|
||||
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
|
||||
} else {
|
||||
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
|
||||
try {
|
||||
if (deleteConfirm.item?.isCoachType) {
|
||||
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
|
||||
} else {
|
||||
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
|
||||
}
|
||||
setDeleteConfirm({ isOpen: false, item: null });
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || err?.message || 'Delete failed';
|
||||
setDeleteConfirm((prev) => ({ ...prev, error: msg }));
|
||||
}
|
||||
setDeleteConfirm({ isOpen: false, item: null });
|
||||
};
|
||||
|
||||
const coachTypesArray = Array.isArray(coachTypesData) ? coachTypesData : (coachTypesData as any)?.items || (coachTypesData as any)?.data || [];
|
||||
@@ -536,6 +541,8 @@ export default function CoachesPage() {
|
||||
message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`}
|
||||
confirmText="Delete"
|
||||
isDanger={true}
|
||||
isLoading={deleteCoachTypeMutation.isPending || deleteCoachMutation.isPending}
|
||||
error={deleteConfirm.error}
|
||||
warning={
|
||||
deleteConfirm.item?.isCoachType
|
||||
? 'This coach type may have coaches assigned. Deleting it may impact these systems.'
|
||||
|
||||
@@ -362,12 +362,16 @@ export default function RoutesPage() {
|
||||
type="text"
|
||||
name="code"
|
||||
className="input"
|
||||
value={generateRouteCode(originStationId, destinationStationId)}
|
||||
readOnly
|
||||
defaultValue={editingRoute ? editingRoute.code : undefined}
|
||||
key={editingRoute ? `code-edit-${editingRoute.id}` : `code-new-${originStationId}-${destinationStationId}`}
|
||||
placeholder={generateRouteCode(originStationId, destinationStationId) || 'e.g. ADD-DJI'}
|
||||
required
|
||||
placeholder="Select stations to generate"
|
||||
disabled={!!editingRoute}
|
||||
/>
|
||||
{!editingRoute && originStationId && destinationStationId && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Suggested: <button type="button" className="text-primary underline" onClick={(e) => { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=code]') as HTMLInputElement); if (inp) inp.value = generateRouteCode(originStationId, destinationStationId); }}>{generateRouteCode(originStationId, destinationStationId)}</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Route Name *</label>
|
||||
@@ -375,11 +379,16 @@ export default function RoutesPage() {
|
||||
type="text"
|
||||
name="name"
|
||||
className="input"
|
||||
value={generateRouteName(originStationId, destinationStationId)}
|
||||
readOnly
|
||||
defaultValue={editingRoute ? editingRoute.name : undefined}
|
||||
key={editingRoute ? `name-edit-${editingRoute.id}` : `name-new-${originStationId}-${destinationStationId}`}
|
||||
placeholder={generateRouteName(originStationId, destinationStationId) || 'e.g. Addis Ababa - Djibouti'}
|
||||
required
|
||||
placeholder="Select stations to generate"
|
||||
/>
|
||||
{!editingRoute && originStationId && destinationStationId && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Suggested: <button type="button" className="text-primary underline" onClick={(e) => { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=name]') as HTMLInputElement); if (inp) inp.value = generateRouteName(originStationId, destinationStationId); }}>{generateRouteName(originStationId, destinationStationId)}</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -318,8 +318,8 @@ export default function SchedulesPage() {
|
||||
label: 'Train',
|
||||
sortable: true,
|
||||
render: (schedule: Schedule) => (
|
||||
<div className="font-medium">
|
||||
{schedule.train?.name} ({schedule.train?.number})
|
||||
<div className="font-medium font-mono">
|
||||
{schedule.train?.number}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -548,8 +548,11 @@ export default function SeatsPage() {
|
||||
|
||||
{coachesWithSeats.map((coach: any, index: number) => {
|
||||
const coachData = coachTypesData?.items?.find((c: any) => c.id === coach.id) || coach;
|
||||
const coachTypeName = coachData?.coachType?.type || 'Coach';
|
||||
const isBedCoach = coachTypeName.toLowerCase().includes('bed');
|
||||
const coachTypeName = coachData?.coachType?.type || coachData?.coachType?.name || 'Coach';
|
||||
const seatClassName = coachData?.seatClass?.name || coach?.seatClass?.name || coach?.coachClass || '';
|
||||
const isBedCoach = seatClassName.toLowerCase().includes('bed') ||
|
||||
coachTypeName.toLowerCase().includes('bed') ||
|
||||
(coach.seats || []).some((s: any) => s.bedPosition);
|
||||
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||
const isExpanded = expandedCoaches.has(coach.id);
|
||||
const seatOrBedLabel = isBedCoach ? 'beds' : 'seats';
|
||||
|
||||
@@ -204,15 +204,31 @@ export default function TicketsPage() {
|
||||
key: 'boarded',
|
||||
label: 'Boarded',
|
||||
render: (ticket: any) => (
|
||||
ticket.boardedAt ? (
|
||||
ticket.validatedAt ? (
|
||||
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<span className="text-sm">{formatDateTime(ticket.boardedAt)}</span>
|
||||
<span className="text-sm">{formatDateTime(ticket.validatedAt)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Not boarded</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'returnLegStatus',
|
||||
label: 'Return Leg',
|
||||
render: (ticket: any) => {
|
||||
const status = ticket.booking?.returnLegStatus;
|
||||
if (!status || status === 'NOT_APPLICABLE') return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const map: Record<string, { label: string; cls: string }> = {
|
||||
NEITHER_USED: { label: 'Neither Used', cls: 'edr-badge-warning' },
|
||||
OUTBOUND_ONLY: { label: 'Outbound Only', cls: 'edr-badge-info' },
|
||||
INBOUND_ONLY: { label: 'Inbound Only', cls: 'edr-badge-danger' },
|
||||
BOTH_USED: { label: 'Both Used', cls: 'edr-badge-success' },
|
||||
};
|
||||
const entry = map[status] ?? { label: status, cls: 'edr-badge-info' };
|
||||
return <span className={`edr-badge ${entry.cls}`}>{entry.label}</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
@@ -444,10 +460,30 @@ export default function TicketsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTicket.boardedAt && (
|
||||
{selectedTicket.validatedAt && (
|
||||
<div className="border-t pt-4 bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Boarded At</p>
|
||||
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.boardedAt)}</p>
|
||||
<p className="text-sm text-muted-foreground">Validated At</p>
|
||||
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.validatedAt)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTicket.booking?.returnLegStatus && selectedTicket.booking.returnLegStatus !== 'NOT_APPLICABLE' && (
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="font-semibold mb-3">Round-Trip Leg Status</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Leg Status</p>
|
||||
<p className="font-medium">{selectedTicket.booking.returnLegStatus.replace(/_/g, ' ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Outbound Boarded</p>
|
||||
<p className="font-medium">{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Return Boarded</p>
|
||||
<p className="font-medium">{selectedTicket.booking.returnBoardedAt ? formatDateTime(selectedTicket.booking.returnBoardedAt) : '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ interface ConfirmDialogProps {
|
||||
isLoading?: boolean;
|
||||
isDanger?: boolean;
|
||||
warning?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
@@ -28,6 +29,7 @@ export default function ConfirmDialog({
|
||||
isLoading = false,
|
||||
isDanger = false,
|
||||
warning,
|
||||
error,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||
@@ -47,6 +49,12 @@ export default function ConfirmDialog({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 flex gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-red-800 dark:text-red-300 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton variant="secondary" onClick={onClose} disabled={isLoading}>
|
||||
{cancelText}
|
||||
|
||||
Reference in New Issue
Block a user