Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/priority

This commit is contained in:
marshal
2026-06-18 10:29:42 +03:00
34 changed files with 1303 additions and 334 deletions

View File

@@ -69,8 +69,8 @@ jobs:
fi
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api")
echo "$CHANGED" | grep -q "^apps/edr-freight-web-portal/" && SERVICES+=("freight-portal")
echo "$CHANGED" | grep -q "^apps/edr-freight-web-backoffice/" && SERVICES+=("freight-backoffice")
echo "$CHANGED" | grep -q "^apps/edr-freight-web/portal/" && SERVICES+=("freight-portal")
echo "$CHANGED" | grep -q "^apps/edr-freight-web/backoffice/" && SERVICES+=("freight-backoffice")
echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")

View File

@@ -44,7 +44,6 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
@@ -56,6 +55,7 @@ import { ContainersModule } from './modules/container-management/containers.modu
import { CargoesModule } from './modules/cargoes/cargoes.module';
import { RoutesModule } from './modules/routes/routes.module';
import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
@Module({
imports: [
@@ -113,12 +113,12 @@ import { OverviewModule } from './modules/overview/overview.module';
CargoesModule,
RoutesModule,
OverviewModule,
VehiclesModule,
],
providers: [
EdrOrgSeeder,
DemoUsersSeeder,
FreightStaffUsersSeeder,
DemoBookingsSeeder,
PricingDataSeeder,
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateVehiclesTable1770000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vehicles' AND table_schema = 'freight') THEN
CREATE TABLE freight.vehicles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plate_number VARCHAR NOT NULL UNIQUE,
registration_number VARCHAR NOT NULL UNIQUE,
vehicle_type VARCHAR NOT NULL,
manufacturer VARCHAR NOT NULL,
model VARCHAR NOT NULL,
year INTEGER NOT NULL,
fuel_type VARCHAR NOT NULL,
capacity NUMERIC NOT NULL,
status VARCHAR DEFAULT 'ACTIVE' NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at TIMESTAMP NULL
);
CREATE INDEX idx_vehicles_plate_number ON freight.vehicles(plate_number);
CREATE INDEX idx_vehicles_registration_number ON freight.vehicles(registration_number);
CREATE INDEX idx_vehicles_status ON freight.vehicles(status);
CREATE INDEX idx_vehicles_vehicle_type ON freight.vehicles(vehicle_type);
CREATE INDEX idx_vehicles_manufacturer ON freight.vehicles(manufacturer);
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.vehicles CASCADE;`);
}
}

View File

@@ -0,0 +1,32 @@
import { IsString, IsEnum, IsNumber, IsOptional } from 'class-validator';
import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity';
export class CreateVehicleDto {
@IsString()
plateNumber!: string;
@IsEnum(VehicleType)
vehicleType!: VehicleType;
@IsString()
manufacturer!: string;
@IsString()
model!: string;
@IsNumber()
year!: number;
@IsEnum(FuelType)
fuelType!: FuelType;
@IsNumber()
capacity!: number;
@IsEnum(VehicleStatus)
status!: VehicleStatus;
@IsOptional()
@IsString()
description?: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateVehicleDto } from './create-vehicle.dto';
export class UpdateVehicleDto extends PartialType(CreateVehicleDto) {}

View File

@@ -0,0 +1,64 @@
import { Entity, Column, Index } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
export enum VehicleType {
TRUCK = 'TRUCK',
VAN = 'VAN',
CAR = 'CAR',
BUS = 'BUS',
TRAILER = 'TRAILER',
TANKER = 'TANKER',
FLATBED = 'FLATBED',
}
export enum FuelType {
PETROL = 'PETROL',
DIESEL = 'DIESEL',
ELECTRIC = 'ELECTRIC',
HYBRID = 'HYBRID',
}
export enum VehicleStatus {
ACTIVE = 'ACTIVE',
MAINTENANCE = 'MAINTENANCE',
RETIRED = 'RETIRED',
OUT_OF_SERVICE = 'OUT_OF_SERVICE',
}
@Entity({ name: 'vehicles', schema: 'freight' })
@Index(['plateNumber'])
@Index(['registrationNumber'])
@Index(['status'])
@Index(['vehicleType'])
@Index(['manufacturer'])
export class Vehicle extends BaseEntity {
@Column({ name: 'plate_number', unique: true })
plateNumber!: string;
@Column({ name: 'registration_number', unique: true })
registrationNumber!: string;
@Column({ name: 'vehicle_type', type: 'varchar' })
vehicleType!: VehicleType;
@Column()
manufacturer!: string;
@Column()
model!: string;
@Column()
year!: number;
@Column({ name: 'fuel_type', type: 'varchar' })
fuelType!: FuelType;
@Column()
capacity!: number;
@Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE })
status!: VehicleStatus;
@Column({ type: 'text', nullable: true })
description!: string | null;
}

View File

@@ -0,0 +1,74 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
Query,
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { VehiclesService } from './vehicles.service';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
@ApiTags('vehicles')
@ApiBearerAuth()
@Controller('vehicles')
@FleetView()
export class VehiclesController {
constructor(private readonly vehiclesService: VehiclesService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Create a new vehicle' })
create(@Body() createVehicleDto: CreateVehicleDto) {
return this.vehiclesService.create(createVehicleDto);
}
@Get()
@ApiOperation({ summary: 'Get all vehicles with filters' })
findAll(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('sortBy') sortBy?: string,
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
) {
return this.vehiclesService.findAll({
search,
status: status as any,
page: page ? parseInt(page) : undefined,
limit: limit ? parseInt(limit) : undefined,
sortBy,
sortOrder,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get vehicle by id' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.vehiclesService.findById(id);
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a vehicle' })
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateVehicleDto: UpdateVehicleDto,
) {
return this.vehiclesService.update(id, updateVehicleDto);
}
@Delete(':id')
@FleetManage()
@ApiOperation({ summary: 'Delete a vehicle' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.vehiclesService.remove(id);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Vehicle } from './entities/vehicle.entity';
import { VehiclesService } from './vehicles.service';
import { VehiclesController } from './vehicles.controller';
@Module({
imports: [TypeOrmModule.forFeature([Vehicle])],
providers: [VehiclesService],
controllers: [VehiclesController],
exports: [VehiclesService],
})
export class VehiclesModule {}

View File

@@ -0,0 +1,15 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Vehicle } from './entities/vehicle.entity';
@Injectable()
export class VehiclesRepository extends BaseRepository<Vehicle> {
constructor(
@InjectRepository(Vehicle)
repository: Repository<Vehicle>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,109 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
import { Vehicle, VehicleStatus } from './entities/vehicle.entity';
@Injectable()
export class VehiclesService {
constructor(
@InjectRepository(Vehicle)
private readonly vehicleRepo: Repository<Vehicle>,
) {}
async create(dto: CreateVehicleDto): Promise<Vehicle> {
const existing = await this.vehicleRepo.findOne({
where: { plateNumber: dto.plateNumber },
});
if (existing) {
throw new ConflictException(
`Vehicle with plate number ${dto.plateNumber} already exists`,
);
}
const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
const vehicle = this.vehicleRepo.create({
...dto,
registrationNumber,
});
return this.vehicleRepo.save(vehicle);
}
async findAll(query: {
search?: string;
status?: VehicleStatus | string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
} = {}): Promise<{ data: Vehicle[]; total: number; page: number; limit: number }> {
const page = query.page || 1;
const limit = query.limit || 10;
const skip = (page - 1) * limit;
const where: any = {};
if (query.status) where.status = query.status;
let qb = this.vehicleRepo.createQueryBuilder('v');
if (query.search) {
qb = qb.where(
'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search',
{ search: `%${query.search}%` },
);
}
if (query.status) {
qb = qb.andWhere('v.status = :status', { status: query.status });
}
const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes(
query.sortBy ?? '',
)
? query.sortBy
: 'createdAt';
const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase();
const [data, total] = await qb
.orderBy(`v.${sortBy}`, sortOrder as 'ASC' | 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
return { data, total, page, limit };
}
async findById(id: string): Promise<Vehicle> {
const vehicle = await this.vehicleRepo.findOne({ where: { id } });
if (!vehicle) {
throw new NotFoundException(`Vehicle ${id} not found`);
}
return vehicle;
}
async update(id: string, dto: UpdateVehicleDto): Promise<Vehicle> {
const vehicle = await this.findById(id);
if (dto.plateNumber && dto.plateNumber !== vehicle.plateNumber) {
const existing = await this.vehicleRepo.findOne({
where: { plateNumber: dto.plateNumber },
});
if (existing) {
throw new ConflictException(
`Vehicle with plate number ${dto.plateNumber} already exists`,
);
}
}
Object.assign(vehicle, dto);
return this.vehicleRepo.save(vehicle);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.vehicleRepo.softDelete(id);
}
}

View File

@@ -0,0 +1,38 @@
-- Fix missing columns from 20260617 migration (failed due to missing schema prefix)
ALTER TABLE "passenger"."Booking"
ADD COLUMN IF NOT EXISTS "returnScheduleId" TEXT,
ADD COLUMN IF NOT EXISTS "returnOriginStationId" TEXT,
ADD COLUMN IF NOT EXISTS "returnDestinationStationId" TEXT,
ADD COLUMN IF NOT EXISTS "returnHoldId" TEXT,
ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT;
ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
CREATE INDEX IF NOT EXISTS "Booking_bookingType_idx" ON "passenger"."Booking"("bookingType");
-- Transit leg-2 columns (never migrated)
ALTER TABLE "passenger"."Booking"
ADD COLUMN IF NOT EXISTS "leg2ScheduleId" TEXT,
ADD COLUMN IF NOT EXISTS "leg2OriginStationId" TEXT,
ADD COLUMN IF NOT EXISTS "leg2DestinationStationId" TEXT,
ADD COLUMN IF NOT EXISTS "leg2SeatClassId" TEXT,
ADD COLUMN IF NOT EXISTS "returnLeg2ScheduleId" TEXT,
ADD COLUMN IF NOT EXISTS "returnLeg2OriginStationId" TEXT,
ADD COLUMN IF NOT EXISTS "returnLeg2DestStationId" TEXT,
ADD COLUMN IF NOT EXISTS "returnLeg2SeatClassId" TEXT;
-- ReturnLegStatus enum + columns (from 20260625 migration, may have also failed)
DO $$ BEGIN
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM (
'NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
ALTER TABLE "passenger"."Booking"
ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3);
ALTER TABLE "passenger"."GateValidationLog"
ADD COLUMN IF NOT EXISTS "leg" TEXT;

View File

@@ -35,14 +35,16 @@ async function bootstrap() {
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
## 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).
- **TRANSIT & ROUND_TRIP_TRANSIT Booking Types:** Full multi-leg booking support. TRANSIT = single journey via connecting train (single PNR). ROUND_TRIP_TRANSIT = round trip where one or both directions use a connecting train (4 holds, 4 seat sets).
- **returnSeatId on Passenger Payloads:** For ROUND_TRIP and ROUND_TRIP_TRANSIT bookings each passenger object must include \`returnSeatId\` (the seat on the return leg-1). Guest and authenticated booking endpoints both enforce this.
- **Unified Booking Type Matrix:** bookingType field on Booking now accepts ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT across all create endpoints (POST /bookings and POST /bookings/guest).
- **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 | RETURN | LEG1 | LEG2 | OUTBOUND_LEG1 | OUTBOUND_LEG2 | RETURN_LEG1 | RETURN_LEG2).
- **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.
@@ -61,11 +63,13 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Multi-segment journey support
- Cross-border journeys via Dire Dawa transit (Ethiopia to Djibouti)
- Round-trip booking with return journey scheduling
- Transit booking (single journey via connecting train, single PNR, single ticket)
- Round-trip transit booking (round trip where one or both directions use a connecting train)
- 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: returnLegStatus field tracks which legs of a round-trip were used
- NEW: GET /bookings?returnLegStatus=OUTBOUND_ONLY filters no-show returns in back-office
- Booking type field: ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT
- Display currency and converted pricing per booking
- returnLegStatus field tracks which legs of a round-trip were used
- GET /bookings?returnLegStatus=OUTBOUND_ONLY filters no-show returns in back-office
### Passenger Verification
1. Ethiopian Nationals:
@@ -111,21 +115,33 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
- NEW: Complete audit trail per leg for compliance and reporting
### Round-Trip Leg Tracking (NEW)
### Booking Type Matrix
| bookingType | Holds required | Passenger seat fields | Legs in DB |
|---|---|---|---|
| ONE_WAY | holdId | seatId | 1 |
| ROUND_TRIP | holdId + returnHoldId | seatId + returnSeatId | 2 (leg=1 outbound, leg=2 return) |
| TRANSIT | holdId + leg2HoldId | seatId + leg2SeatId | 2 (leg=1, leg=2 on same direction) |
| ROUND_TRIP_TRANSIT | holdId + leg2HoldId + returnHoldId + returnLeg2HoldId | seatId + leg2SeatId + returnSeatId + returnLeg2SeatId | 4 |
### Round-Trip Leg Tracking
- 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)
- Gate validation POST /tickets/:ref/validate accepts optional leg field:
- ONE_WAY: omit
- TRANSIT: LEG1 | LEG2
- ROUND_TRIP: OUTBOUND | RETURN
- ROUND_TRIP_TRANSIT: OUTBOUND_LEG1 | OUTBOUND_LEG2 | RETURN_LEG1 | RETURN_LEG2
- 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
### 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
### Round-Trip & Transit Bookings
- ONE_WAY and ROUND_TRIP for direct routes
- TRANSIT for single connecting journey (Dire Dawa hub), single PNR
- ROUND_TRIP_TRANSIT for round trips via connecting trains
- Combined pricing: total = sum of all leg base fares, single promo/loyalty deduction
- Separate seat management per leg; each leg stored with its scheduleId and leg number
- returnLegStatus tracks which legs have been boarded for no-show management
### Loyalty Program
- 4 tiers: Bronze, Silver, Gold, Platinum
@@ -198,26 +214,37 @@ Used for agent, fraud, and reporting endpoints. Requires corporate IAM token.
## Passenger Booking Flow
### Step 1: Search Trips
\`POST /search\` with origin, destination, date, passenger counts, and nationality
\`POST /search\` with origin, destination, date, passenger counts, and nationality.
For round-trips also pass \`journeyType=ROUND_TRIP\` and \`returnDate\`.
### Step 2: Get Fare Quote
\`POST /search/fare-quote\` with passenger counts and display currency
\`POST /search/fare-quote\` with passenger counts and display currency.
For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`returnDestinationStationId\`.
### 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
\`POST /passengers/register-international\` Passport information collection
### Step 4: View Seat Map
\`GET /seats/seatmap/{scheduleId}\` - Show available coaches and seats
\`GET /seats/seatmap/{scheduleId}\` Show available coaches and seats.
For round-trips, call this twice: once for outbound scheduleId, once for return scheduleId.
### Step 5: Login & Hold Seats
\`POST /auth/login\` then \`POST /seats/hold\` to reserve seats for 15 minutes
### Step 5: Hold Seats
\`POST /seats/hold\` to reserve seats for 15 minutes.
- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\`
- TRANSIT leg-2: second hold call → \`leg2HoldId\`
- ROUND_TRIP return: second hold call → \`returnHoldId\`
- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
### Step 6: Create Booking
\`POST /bookings/guest\` with verified passenger details and held seats
Choose the right endpoint and bookingType:
- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\`
- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\`
- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\`
- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\`
### Step 7: Process Payment
\`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian)
@@ -268,7 +295,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. Supports returnLegStatus filter for round-trip no-show management")
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT booking types. 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")
@@ -285,7 +312,6 @@ Payment providers send notifications to:
.addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation")
.addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking")
.addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards")
.addTag("Round Trip", "Round-trip bookings, return scheduling, combined pricing, and management (NEW)")
.addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance")
.addTag("Schedule", "Trip schedules, availability windows, status tracking, and timing")
.addTag("Search", "Trip search, fare quotes, coach types, and real-time availability")
@@ -294,8 +320,8 @@ 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 with per-leg tracking (OUTBOUND/RETURN), and audit trails")
.addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)")
.addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN/LEG1/LEG2/OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2), and audit trails")
.addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, TRANSIT and ROUND_TRIP_TRANSIT bookings")
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger")
//.addServer('http://localhost:4000', 'Development')
// .addServer("https://api.edr-platform.com", "Production")

View File

@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger';
import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service';
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
@@ -100,35 +100,153 @@ export class BookingsController {
@Post('guest')
@ApiOperation({
summary: 'Create guest booking without login (optional account creation)',
description: `Creates a booking without requiring login. Features:
**Guest Checkout:**
- No login required
- Contact details from first passenger
- Booking confirmation sent to email/phone
summary: 'Create guest booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT (no login required)',
description: `Creates a booking without requiring login. Supports all four booking types.
**Optional Account Creation:**
- Set createAccount=true with password
- Account created using first passenger details
- Automatic login after booking
- Loyalty points and wallet created
**bookingType: ONE_WAY (default)**
- scheduleId, holdId, originStationId, destinationStationId, seatClassId
- passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … }
**Passenger Details Storage:**
- savePassengerDetails=true: Save for future bookings
- Stored by userId (if account created) or deviceId
- Retrieve saved passengers for quick booking
**bookingType: ROUND_TRIP**
- Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
- passengers[]: each must include returnSeatId (seat on the return leg)
**Verifayda Verification:**
- Ethiopian nationals: National ID verified via Verifayda
- Other nationals: Passport details (no verification)
**bookingType: TRANSIT**
- scheduleId/holdId (leg-1) + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId
- passengers[]: each must include leg2SeatId
**Age-Based Pricing:**
- ADULT (≥5 years): Full fare
- CHILD (<5 years): First child FREE, subsequent children full fare`
**bookingType: ROUND_TRIP_TRANSIT**
- All TRANSIT outbound fields + returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId/returnLeg2ScheduleId/returnLeg2HoldId/returnTransitStationId/returnLeg2DestinationStationId
- passengers[]: each must include leg2SeatId, returnSeatId, returnLeg2SeatId
**Optional account creation:** set createAccount=true with password — creates account from first passenger details, loyalty + wallet initialised.
**Verifayda:** Ethiopian nationals verified; international passengers require passportNumber + passportCountry.`
})
@ApiResponse({ status: 201, description: 'Booking created successfully' })
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid data' })
@ApiBody({
type: CreateGuestBookingDto,
examples: {
ONE_WAY: {
summary: 'ONE_WAY — single direct journey (guest)',
value: {
scheduleId: 'schedule-uuid',
holdId: 'hold-uuid',
originStationId: 'station-uuid',
destinationStationId: 'station-uuid',
seatClassId: 'seat-class-uuid',
bookingType: 'ONE_WAY',
displayCurrency: 'ETB',
passengers: [{
seatId: 'seat-uuid',
passengerName: 'Abebe Kebede',
dateOfBirth: '1990-05-15',
idDocumentType: 'NATIONAL_ID',
idDocumentNumber: 'ET123456789',
nationality: 'Ethiopian',
phone: '+251911234567',
email: 'abebe@email.com',
}],
savePassengerDetails: true,
deviceId: 'device-uuid-123',
},
},
ROUND_TRIP: {
summary: 'ROUND_TRIP — outbound + return, single PNR (guest)',
value: {
scheduleId: 'outbound-schedule-uuid',
holdId: 'outbound-hold-uuid',
originStationId: 'addis-station-uuid',
destinationStationId: 'djibouti-station-uuid',
seatClassId: 'seat-class-uuid',
bookingType: 'ROUND_TRIP',
returnScheduleId: 'return-schedule-uuid',
returnHoldId: 'return-hold-uuid',
returnOriginStationId: 'djibouti-station-uuid',
returnDestinationStationId: 'addis-station-uuid',
returnSeatClassId: 'seat-class-uuid',
displayCurrency: 'ETB',
passengers: [{
seatId: 'outbound-seat-uuid',
returnSeatId: 'return-seat-uuid',
passengerName: 'Abebe Kebede',
dateOfBirth: '1990-05-15',
idDocumentType: 'NATIONAL_ID',
idDocumentNumber: 'ET123456789',
nationality: 'Ethiopian',
phone: '+251911234567',
}],
savePassengerDetails: true,
deviceId: 'device-uuid-123',
},
},
TRANSIT: {
summary: 'TRANSIT — connecting train, single PNR (guest)',
value: {
scheduleId: 'leg1-schedule-uuid',
holdId: 'leg1-hold-uuid',
originStationId: 'addis-station-uuid',
destinationStationId: 'diredawa-station-uuid',
seatClassId: 'seat-class-uuid',
bookingType: 'TRANSIT',
leg2ScheduleId: 'leg2-schedule-uuid',
leg2HoldId: 'leg2-hold-uuid',
transitStationId: 'diredawa-station-uuid',
leg2DestinationStationId: 'djibouti-station-uuid',
displayCurrency: 'ETB',
passengers: [{
seatId: 'leg1-seat-uuid',
leg2SeatId: 'leg2-seat-uuid',
passengerName: 'Abebe Kebede',
dateOfBirth: '1990-05-15',
idDocumentType: 'NATIONAL_ID',
idDocumentNumber: 'ET123456789',
nationality: 'Ethiopian',
phone: '+251911234567',
}],
deviceId: 'device-uuid-123',
},
},
ROUND_TRIP_TRANSIT: {
summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds (guest)',
value: {
scheduleId: 'ob-leg1-schedule-uuid',
holdId: 'ob-leg1-hold-uuid',
originStationId: 'addis-station-uuid',
destinationStationId: 'diredawa-station-uuid',
seatClassId: 'seat-class-uuid',
bookingType: 'ROUND_TRIP_TRANSIT',
leg2ScheduleId: 'ob-leg2-schedule-uuid',
leg2HoldId: 'ob-leg2-hold-uuid',
transitStationId: 'diredawa-station-uuid',
leg2DestinationStationId: 'djibouti-station-uuid',
returnScheduleId: 'ret-leg1-schedule-uuid',
returnHoldId: 'ret-leg1-hold-uuid',
returnOriginStationId: 'djibouti-station-uuid',
returnDestinationStationId: 'diredawa-station-uuid',
returnLeg2ScheduleId: 'ret-leg2-schedule-uuid',
returnLeg2HoldId: 'ret-leg2-hold-uuid',
returnTransitStationId: 'diredawa-station-uuid',
returnLeg2DestinationStationId: 'addis-station-uuid',
displayCurrency: 'ETB',
passengers: [{
seatId: 'ob-leg1-seat-uuid',
leg2SeatId: 'ob-leg2-seat-uuid',
returnSeatId: 'ret-leg1-seat-uuid',
returnLeg2SeatId: 'ret-leg2-seat-uuid',
passengerName: 'Abebe Kebede',
dateOfBirth: '1990-05-15',
idDocumentType: 'NATIONAL_ID',
idDocumentNumber: 'ET123456789',
nationality: 'Ethiopian',
phone: '+251911234567',
}],
deviceId: 'device-uuid-123',
},
},
},
})
@ApiResponse({ status: 201, description: 'Booking created successfully with fareBreakdown' })
@ApiResponse({ status: 400, description: 'Missing required seat IDs for bookingType, or Verifayda verification failed' })
createGuest(@Body() dto: CreateGuestBookingDto) {
return this.guestService.createGuestBooking(dto);
}
@@ -147,24 +265,149 @@ export class BookingsController {
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Create booking (one-way or round-trip)',
description: `Creates a one-way or round-trip booking for logged-in users.
ONE_WAY booking:
- scheduleId, holdId, originStationId, destinationStationId
- passengers: array of PassengerInputDto with seatId
- Single PNR, single payment
summary: 'Create booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT',
description: `Creates a booking for a logged-in passenger. bookingType controls which fields are required.
ROUND_TRIP booking:
- Outbound: scheduleId, holdId, originStationId, destinationStationId, seatClassId
- Return: returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
- passengers: array of RoundTripPassengerDto with outboundSeatId and returnSeatId
- Combined PNR, single payment for both legs
- Fare = outbound_fare + return_fare, single total, single promo, single loyalty deduction`
**ONE_WAY**
- scheduleId, holdId, originStationId, destinationStationId, seatClassId
- passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … }
**ROUND_TRIP**
- Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
- passengers[]: { seatId (outbound), returnSeatId (return), passengerName, … }
- Combined fare = outbound fare + return fare; single promo/loyalty deduction
**TRANSIT** (connecting train, single PNR)
- scheduleId/holdId for leg-1 + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId
- passengers[]: { seatId (leg-1), leg2SeatId (leg-2), passengerName, … }
**ROUND_TRIP_TRANSIT** (round trip, each direction via connecting train)
- All TRANSIT outbound fields + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnLeg2ScheduleId, returnLeg2HoldId, returnTransitStationId, returnLeg2DestinationStationId
- passengers[]: { seatId, leg2SeatId, returnSeatId, returnLeg2SeatId, passengerName, … }
- 4 holds required, 4 seat sets per passenger, single PNR, single payment
**Age-Based Pricing (all types)**
- ADULT (≥5 years): full fare per leg
- CHILD (<5 years): first child FREE per booking, subsequent children full fare`
})
@ApiBody({
type: CreateBookingDto,
examples: {
ONE_WAY: {
summary: 'ONE_WAY — single direct journey',
value: {
passengerId: 'passenger-uuid',
scheduleId: 'schedule-uuid',
holdId: 'hold-uuid',
originStationId: 'station-uuid',
destinationStationId: 'station-uuid',
seatClassId: 'seat-class-uuid',
bookingType: 'ONE_WAY',
displayCurrency: 'ETB',
passengers: [{
seatId: 'seat-uuid',
passengerName: 'Abebe Kebede',
dateOfBirth: '1990-05-15',
idDocumentType: 'NATIONAL_ID',
idDocumentNumber: 'ET123456789',
nationality: 'Ethiopian',
}],
},
},
ROUND_TRIP: {
summary: 'ROUND_TRIP — outbound + return, single PNR',
value: {
passengerId: 'passenger-uuid',
scheduleId: 'outbound-schedule-uuid',
holdId: 'outbound-hold-uuid',
originStationId: 'addis-station-uuid',
destinationStationId: 'djibouti-station-uuid',
seatClassId: 'seat-class-uuid',
bookingType: 'ROUND_TRIP',
returnScheduleId: 'return-schedule-uuid',
returnHoldId: 'return-hold-uuid',
returnOriginStationId: 'djibouti-station-uuid',
returnDestinationStationId: 'addis-station-uuid',
returnSeatClassId: 'seat-class-uuid',
displayCurrency: 'ETB',
passengers: [{
seatId: 'outbound-seat-uuid',
returnSeatId: 'return-seat-uuid',
passengerName: 'Abebe Kebede',
dateOfBirth: '1990-05-15',
idDocumentType: 'NATIONAL_ID',
idDocumentNumber: 'ET123456789',
nationality: 'Ethiopian',
}],
},
},
TRANSIT: {
summary: 'TRANSIT — connecting train, single PNR',
value: {
passengerId: 'passenger-uuid',
scheduleId: 'leg1-schedule-uuid',
holdId: 'leg1-hold-uuid',
originStationId: 'addis-station-uuid',
destinationStationId: 'diredawa-station-uuid',
seatClassId: 'seat-class-uuid',
bookingType: 'TRANSIT',
leg2ScheduleId: 'leg2-schedule-uuid',
leg2HoldId: 'leg2-hold-uuid',
transitStationId: 'diredawa-station-uuid',
leg2DestinationStationId: 'djibouti-station-uuid',
displayCurrency: 'ETB',
passengers: [{
seatId: 'leg1-seat-uuid',
leg2SeatId: 'leg2-seat-uuid',
passengerName: 'Abebe Kebede',
dateOfBirth: '1990-05-15',
idDocumentType: 'NATIONAL_ID',
idDocumentNumber: 'ET123456789',
nationality: 'Ethiopian',
}],
},
},
ROUND_TRIP_TRANSIT: {
summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds',
value: {
passengerId: 'passenger-uuid',
scheduleId: 'ob-leg1-schedule-uuid',
holdId: 'ob-leg1-hold-uuid',
originStationId: 'addis-station-uuid',
destinationStationId: 'diredawa-station-uuid',
seatClassId: 'seat-class-uuid',
bookingType: 'ROUND_TRIP_TRANSIT',
leg2ScheduleId: 'ob-leg2-schedule-uuid',
leg2HoldId: 'ob-leg2-hold-uuid',
transitStationId: 'diredawa-station-uuid',
leg2DestinationStationId: 'djibouti-station-uuid',
returnScheduleId: 'ret-leg1-schedule-uuid',
returnHoldId: 'ret-leg1-hold-uuid',
returnOriginStationId: 'djibouti-station-uuid',
returnDestinationStationId: 'diredawa-station-uuid',
returnLeg2ScheduleId: 'ret-leg2-schedule-uuid',
returnLeg2HoldId: 'ret-leg2-hold-uuid',
returnTransitStationId: 'diredawa-station-uuid',
returnLeg2DestinationStationId: 'addis-station-uuid',
displayCurrency: 'ETB',
passengers: [{
seatId: 'ob-leg1-seat-uuid',
leg2SeatId: 'ob-leg2-seat-uuid',
returnSeatId: 'ret-leg1-seat-uuid',
returnLeg2SeatId: 'ret-leg2-seat-uuid',
passengerName: 'Abebe Kebede',
dateOfBirth: '1990-05-15',
idDocumentType: 'NATIONAL_ID',
idDocumentNumber: 'ET123456789',
nationality: 'Ethiopian',
}],
},
},
},
})
@ApiResponse({ status: 201, description: 'Booking created with fare breakdown' })
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' })
@ApiResponse({ status: 404, description: 'Trip or seat hold not found' })
@ApiResponse({ status: 400, description: 'Missing required fields for bookingType, or Verifayda verification failed' })
@ApiResponse({ status: 404, description: 'Schedule or seat hold not found' })
create(@Body() dto: CreateBookingDto) {
return this.service.create(dto);
}

View File

@@ -4,8 +4,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
export class PassengerInputDto {
@ApiProperty() @IsString() seatId: string;
@ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Seat ID on leg-2 schedule' }) @IsOptional() @IsString() leg2SeatId?: string;
@ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' }) @IsString() seatId: string;
@ApiPropertyOptional({ example: 'leg2-seat-uuid', description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID' }) @IsOptional() @IsString() leg2SeatId?: string;
@ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID' }) @IsOptional() @IsString() returnSeatId?: string;
@ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) @IsOptional() @IsString() returnLeg2SeatId?: string;
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first free), Age ≥5 = ADULT (full fare)' }) @IsDateString() dateOfBirth: string;
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
@@ -76,16 +78,16 @@ export class CreateBookingDto {
@ApiProperty({ description: 'Passenger ID' })
@IsString() passengerId: string;
@ApiProperty({ description: 'Outbound schedule ID' })
@ApiProperty({ description: 'Outbound / leg-1 schedule ID' })
@IsString() scheduleId: string;
@ApiProperty({ description: 'Outbound seat hold ID' })
@ApiProperty({ description: 'Outbound / leg-1 seat hold ID' })
@IsString() holdId: string;
@ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID (must match the hold)' })
@ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID (must match the hold)' })
@ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID' })
@IsString() destinationStationId: string;
@ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
@@ -94,14 +96,32 @@ export class CreateBookingDto {
@ApiProperty({
example: 'ONE_WAY',
enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'],
description: `Booking type:\n\n**ONE_WAY:** Single journey\n\n**ROUND_TRIP:** Outbound + return, single PNR\n\n**TRANSIT:** Single journey via connecting train, single PNR, single ticket\n\n**ROUND_TRIP_TRANSIT:** Round trip where one or both directions use a connecting train`,
description: `Booking type:
**ONE_WAY:** Single direct journey — needs: scheduleId, holdId. Passenger: seatId.
**ROUND_TRIP:** Outbound + return, single PNR — needs above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId. Passenger: seatId + returnSeatId.
**TRANSIT:** Single journey via connecting train, single PNR — needs above + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId. Passenger: seatId + leg2SeatId.
**ROUND_TRIP_TRANSIT:** Round trip via connecting trains — needs all 4 hold sets + all station fields. Passenger: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId.`,
default: 'ONE_WAY'
})
@IsOptional() @IsString() bookingType?: string;
@ApiProperty({
type: [PassengerInputDto],
description: `Passenger array - type depends on bookingType:\n\n**For ONE_WAY:** PassengerInputDto[]\n- Each passenger has: seatId, passengerName, dateOfBirth, etc.\n\n**For ROUND_TRIP:** RoundTripPassengerDto[]\n- Each passenger has: outboundSeatId, returnSeatId, passengerName, dateOfBirth, etc.\n\n**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare`
description: `Passenger array — required seat fields vary by bookingType:
**ONE_WAY:** { seatId, passengerName, dateOfBirth, idDocumentType, … }
**ROUND_TRIP:** { seatId (outbound leg-1), returnSeatId (return leg-1), passengerName, … }
**TRANSIT:** { seatId (leg-1), leg2SeatId (leg-2), passengerName, … }
**ROUND_TRIP_TRANSIT:** { seatId, leg2SeatId, returnSeatId, returnLeg2SeatId, passengerName, … }
**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare.`
})
@IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto)
passengers: PassengerInputDto[];

View File

@@ -202,7 +202,7 @@ export class BookingsService {
}
if (status) where.status = status;
if (returnLegStatus) where.returnLegStatus = returnLegStatus;
if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
@@ -233,6 +233,8 @@ export class BookingsService {
contactPhone: booking.contactPhone,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt,
passenger: booking.passenger?.user,
schedule: {

View File

@@ -4,16 +4,16 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
export class GuestPassengerDto {
@ApiProperty({ example: 'seat-id-uuid', description: 'Outbound seat ID (or only seat for ONE_WAY)' })
@ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' })
@IsString() seatId: string;
@ApiPropertyOptional({ example: 'seat-id-uuid', description: 'Return seat ID (ROUND_TRIP / ROUND_TRIP_TRANSIT outbound leg-1)' })
@ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID. Required for ROUND_TRIP and ROUND_TRIP_TRANSIT.' })
@IsOptional() @IsString() returnSeatId?: string;
@ApiPropertyOptional({ example: 'seat-id-uuid', description: 'Leg-2 seat ID (TRANSIT / ROUND_TRIP_TRANSIT outbound leg-2)' })
@ApiPropertyOptional({ example: 'leg2-seat-uuid', description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID. Required for TRANSIT and ROUND_TRIP_TRANSIT.' })
@IsOptional() @IsString() leg2SeatId?: string;
@ApiPropertyOptional({ example: 'seat-id-uuid', description: 'ROUND_TRIP_TRANSIT: return journey leg-2 seat ID' })
@ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID. Required for ROUND_TRIP_TRANSIT.' })
@IsOptional() @IsString() returnLeg2SeatId?: string;
@ApiProperty({ example: 'Abebe Kebede' })
@@ -45,7 +45,16 @@ export class GuestPassengerDto {
}
export class CreateGuestBookingDto {
@ApiPropertyOptional({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'], default: 'ONE_WAY' })
@ApiPropertyOptional({
example: 'ONE_WAY',
enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'],
default: 'ONE_WAY',
description: `Booking type:
**ONE_WAY:** scheduleId + holdId. Passenger: seatId.
**ROUND_TRIP:** above + returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId. Passenger: seatId + returnSeatId.
**TRANSIT:** above + leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId. Passenger: seatId + leg2SeatId.
**ROUND_TRIP_TRANSIT:** all 4 hold sets + all station fields. Passenger: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId.`
})
@IsOptional() @IsString() bookingType?: 'ONE_WAY' | 'ROUND_TRIP' | 'TRANSIT' | 'ROUND_TRIP_TRANSIT';
@ApiProperty({ example: 'schedule-uuid', description: 'Outbound / leg-1 schedule UUID' })
@@ -108,7 +117,14 @@ export class CreateGuestBookingDto {
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 seat class UUID' })
@IsOptional() @IsString() returnLeg2SeatClassId?: string;
@ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. For ROUND_TRIP each passenger must include returnSeatId.' })
@ApiProperty({
type: [GuestPassengerDto],
description: `Passenger array. Required seat fields vary by bookingType:
- ONE_WAY: seatId
- ROUND_TRIP: seatId + returnSeatId
- TRANSIT: seatId + leg2SeatId
- ROUND_TRIP_TRANSIT: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId`
})
@IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[];
@ApiPropertyOptional({ example: 'WEEKEND15' })

View File

@@ -19,6 +19,24 @@ export class SendMessage {
from?: string;
}
export class SingleMessageDto {
@ApiProperty({
description: 'Recipient phone number',
example: '+1234567890',
})
@IsString()
@IsNotEmpty()
to: string;
@ApiProperty({
description: 'Message content',
example: 'Test Single SMS from',
})
@IsString()
@IsNotEmpty()
sms: string;
}
export class BulkMessagesDto {
@ApiProperty({ type: [SendMessage] })
@IsArray()

View File

@@ -1,29 +1,39 @@
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { SendEmail } from './dtos/email.dto';
import {
Inject,
Injectable,
Logger,
OnApplicationBootstrap,
} from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { SendEmail } from "./dtos/email.dto";
@Injectable()
export class EmailClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(EmailClientService.name);
constructor(
@Inject('EMAIL_SERVICE')
@Inject("EMAIL_SERVICE")
private readonly emailServiceClient: ClientProxy,
) {}
private readonly enabled = process.env.RABBITMQ_ENABLED !== 'false';
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'))
.catch((err) => this.logger.error('Error connecting to Email service', err));
.then(() => this.logger.log("Connected to Email service"))
.catch((err) =>
this.logger.error("Error connecting to Email service", err),
);
}
async sendEmail(dto: SendEmail) {
if (!this.enabled) return {};
this.emailServiceClient.emit('send-email', { ...dto, appKey: 'EDR-PASSENGER-API' });
this.emailServiceClient.emit("send-email", {
...dto,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
return {};
}
}

View File

@@ -7,7 +7,7 @@ import { TestNotificationDto } from './notifications.dto';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
import { SendEmail } from './dtos/email.dto';
import { BulkMessagesDto, SendMessage } from './dtos/sms.dto';
import { BulkMessagesDto, SingleMessageDto } from './dtos/sms.dto';
@ApiTags('Notifications')
@Controller('notifications')
@@ -51,8 +51,8 @@ export class NotificationsController {
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' })
@ApiBody({ type: SendMessage })
sendSms(@Body() dto: SendMessage) {
@ApiBody({ type: SingleMessageDto })
sendSms(@Body() dto: SingleMessageDto) {
return this.smsClient.sendSms(dto);
}

View File

@@ -21,7 +21,7 @@ export class NotificationsService {
) {
this.channels = new Map<NotificationChannelType, NotificationChannel>([
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }],
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }],
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, sms: body }).then(() => true) }],
['PUSH', this.pushAdapter as NotificationChannel],
]);
}

View File

@@ -3,42 +3,48 @@ import {
Injectable,
Logger,
OnApplicationBootstrap,
} from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { BulkMessagesDto, SendMessage } from './dtos/sms.dto';
} from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { BulkMessagesDto, SingleMessageDto } from "./dtos/sms.dto";
@Injectable()
export class SmsClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(SmsClientService.name);
constructor(
@Inject('SMS_SERVICE')
@Inject("SMS_SERVICE")
private smsClient: ClientProxy,
) {}
private readonly enabled = process.env.RABBITMQ_ENABLED !== 'false';
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
async onApplicationBootstrap() {
if (!this.enabled) return;
this.smsClient
.connect()
.then(() => {
this.logger.log('connected to SMS service');
this.logger.log("connected to SMS service");
})
.catch((err) => {
console.error('Error happened at SMS service', err);
console.error("Error happened at SMS service", err);
});
}
async sendSms(dto: SendMessage) {
async sendSms(dto: SingleMessageDto) {
if (!this.enabled) return {};
this.smsClient.emit('send-sms', { ...dto, appKey: 'EDR-PASSENGER-API' });
this.smsClient.emit("send-sms", {
...dto,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
return {};
}
async sendBulkMessages(dto: BulkMessagesDto) {
if (!this.enabled) return {};
this.smsClient.emit('ozeking-bulk-sms', { ...dto, appKey: 'EDR-PASSENGER-API' });
this.smsClient.emit("ozeking-bulk-sms", {
...dto,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
return {};
}
}

View File

@@ -21,10 +21,11 @@ export class PassengersService {
const { search, verified, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
const where: any = { user: { role: 'PASSENGER' } };
if (search) {
where.user = {
...where.user,
OR: [
{ fullName: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
@@ -68,7 +69,7 @@ export class PassengersService {
userId: passenger.userId,
fullName: user.fullName,
email: user.email,
phone: user.phone,
phone: user.phone?.startsWith('+guest-') ? null : user.phone,
nationalId: user.nationalId,
nationality: user.nationality,
dateOfBirth: user.dateOfBirth ?? null,

View File

@@ -547,16 +547,14 @@ export class SeatsService {
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
for (const hold of expired) {
const now = new Date();
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: now } } });
if (expired.length === 0) return;
const expiredIds = expired.map(h => h.id);
for (const hold of expired) {
await this.releaseSeats(hold.seatIds);
try {
await this.prisma.seatHold.delete({ where: { id: hold.id } });
} catch (err) {
if (err instanceof Error && !err.message.includes('P2025')) {
throw err;
}
}
}
await this.prisma.seatHold.deleteMany({ where: { id: { in: expiredIds } } });
}
}

View File

@@ -31,18 +31,27 @@ export class TicketsController {
@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: 'status', required: false })
@ApiQuery({ name: 'originStationId', required: false })
@ApiQuery({ name: 'destinationStationId', required: false })
@ApiQuery({ name: 'arrivalDate', required: false })
@ApiQuery({ name: 'skip', required: false })
@ApiQuery({ name: 'take', required: false })
listTickets(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('originStationId') originStationId?: string,
@Query('destinationStationId') destinationStationId?: string,
@Query('arrivalDate') arrivalDate?: string,
@Query('skip') skip?: string,
@Query('take') take?: string,
) {
return this.service.listTickets({
search,
status,
originStationId,
destinationStationId,
arrivalDate,
skip: skip ? parseInt(skip) : 0,
take: take ? parseInt(take) : 50,
});
@@ -60,17 +69,8 @@ export class TicketsController {
}
@Get(':bookingRef')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Get ticket with QR code and passenger details',
description: `Returns ticket information including:
- QR code for gate scanning
- Barcode for offline validation
- Passenger details (name, age category, nationality)
- Journey details (origin, destination, seat, coach)
- Fare breakdown with currency
- PDF download link`
summary: 'Get ticket with QR code and passenger details (public)',
})
getByRef(@Param('bookingRef') ref: string) {
return this.service.getByRef(ref);

View File

@@ -14,7 +14,7 @@ interface OfflineValidation {
export class TicketsService {
constructor(private prisma: PrismaService) {}
async listTickets(filters: { search?: string; status?: string; skip: number; take: number }) {
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) {
const where: any = {};
if (filters.search) {
where.OR = [
@@ -24,7 +24,19 @@ export class TicketsService {
];
}
if (filters.status) {
where.booking = { status: filters.status };
where.booking = { ...where.booking, status: filters.status };
}
if (filters.originStationId) {
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
}
if (filters.destinationStationId) {
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } };
}
if (filters.arrivalDate) {
const start = new Date(filters.arrivalDate);
const end = new Date(filters.arrivalDate);
end.setDate(end.getDate() + 1);
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } };
}
const tickets = await this.prisma.ticket.findMany({
where,
@@ -32,7 +44,7 @@ export class TicketsService {
booking: {
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } },
},
},
@@ -235,7 +247,16 @@ export class TicketsService {
};
}
async validate(bookingRef: string, validatorId: string, gateId?: string, leg?: string) {
async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) {
// Accept either a ticket UUID or a bookingRef
let bookingRef = ticketIdOrRef;
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ticketIdOrRef);
if (isUuid) {
const ticket = await this.prisma.ticket.findUnique({ where: { id: ticketIdOrRef }, select: { bookingRef: true } });
if (!ticket) throw new NotFoundException('Ticket not found');
bookingRef = ticket.bookingRef;
}
const resolvedValidatorId = validatorId || 'BACKOFFICE';
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 } });
@@ -247,11 +268,10 @@ export class TicketsService {
// ── ONE_WAY / TRANSIT (single scan) ───────────────────────────────────
if (type === 'ONE_WAY') {
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');
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } });
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
return { validated: true, ticketId: ticket.id, validatedAt: now };
}
@@ -264,28 +284,32 @@ export class TicketsService {
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
const alreadyValidated = logs.some(l => l.leg === resolvedLeg);
if (alreadyValidated) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
throw new BadRequestException(`${resolvedLeg} already validated`);
}
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
// ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ────────────────────────
if (type === 'ROUND_TRIP') {
const resolvedLeg = (leg ?? 'OUTBOUND').toUpperCase();
let resolvedLeg = (leg ?? '').toUpperCase();
// Auto-detect next unused leg when called from backoffice without a leg param
if (!resolvedLeg) {
resolvedLeg = !(booking as any).outboundBoardedAt ? 'OUTBOUND' : 'RETURN';
}
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 });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any });
throw new BadRequestException('Outbound leg already used');
}
bookingData.outboundBoardedAt = now;
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } });
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
} else if (resolvedLeg === 'RETURN') {
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 });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any });
throw new BadRequestException('Return leg already used');
}
bookingData.returnBoardedAt = now;
@@ -298,7 +322,7 @@ export class TicketsService {
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 });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
@@ -311,7 +335,7 @@ export class TicketsService {
}
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
if (logs.some(l => l.leg === resolvedLeg)) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
throw new BadRequestException(`${resolvedLeg} already validated`);
}
const bookingData: Record<string, any> = {};
@@ -327,18 +351,17 @@ export class TicketsService {
else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_ONLY';
if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
// Fallback for unknown booking types — single scan
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');
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } });
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
return { validated: true, ticketId: ticket.id, validatedAt: now };
}
@@ -354,7 +377,7 @@ export class TicketsService {
where: { scheduleId: tripId, status: 'CONFIRMED' },
include: {
ticket: true,
seats: { include: { seat: { include: { coach: true } } } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } },
},
});

View File

@@ -24,6 +24,19 @@ export default function BookingsPage() {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
const [successMessage, setSuccessMessage] = useState('');
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
bookingRef: true,
passenger: true,
status: true,
bookingType: false,
passengerCount: false,
totalMinor: true,
paymentStatus: true,
createdAt: true,
});
const queryClient = useQueryClient();
@@ -80,22 +93,23 @@ export default function BookingsPage() {
}
};
const handleExportBookings = async () => {
const selectedColumns = prompt(
'Select columns to export (comma-separated):\n\n' +
'Available: bookingRef, passenger, status, bookingType, passengerCount, totalMinor, paymentStatus, createdAt\n\n' +
'Default: bookingRef, passenger, status, totalMinor, paymentStatus, createdAt',
'bookingRef, passenger, status, totalMinor, paymentStatus, createdAt'
);
if (!selectedColumns) return;
const cols = selectedColumns.split(',').map(c => c.trim());
const confirmExport = () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (cols.length === 0) { alert('Please select at least one column'); return; }
const exportItems = (data?.items || []).filter((b: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
cols.join(','),
...data?.items?.map((booking: any) => {
...exportItems.map((booking: any) => {
const values = cols.map(col => {
switch(col) {
switch (col) {
case 'bookingRef': return booking.bookingRef;
case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest';
case 'status': return booking.status;
@@ -108,28 +122,29 @@ export default function BookingsPage() {
}
});
return values.map(v => `"${v}"`).join(',');
}) || []
}),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
setExportModalOpen(false);
};
const columns = [
{
key: 'bookingRef',
{
key: 'bookingRef',
label: 'Reference',
sortable: true,
render: (booking: any) => (
<span className="font-mono font-semibold">{booking.bookingRef}</span>
),
},
{
key: 'passenger',
{
key: 'passenger',
label: 'Passenger',
render: (booking: any) => (
<div>
@@ -138,32 +153,39 @@ export default function BookingsPage() {
</div>
),
},
{
key: 'bookingType',
{
key: 'bookingType',
label: 'Type',
sortable: true,
render: (booking: any) => booking.bookingType || 'ONE_WAY',
},
{
{
key: 'passengerCount',
label: 'Passengers',
render: (booking: any) => `${(booking.adultCount || 0) + (booking.childCount || 0)}`,
render: (booking: any) => {
const adults = booking.adultCount || 0;
const children = booking.childCount || 0;
if (adults === 0 && children === 0) return '—';
const parts = [`Adult: ${adults}`];
if (children > 0) parts.push(`Child: ${children}`);
return parts.join(' / ');
},
},
{
key: 'status',
{
key: 'status',
label: 'Status',
render: (booking: any) => (
<Badge variant="status" status={booking.status}>{booking.status}</Badge>
),
},
{
key: 'totalMinor',
{
key: 'totalMinor',
label: 'Amount',
sortable: true,
render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency),
},
{
key: 'paymentStatus',
{
key: 'paymentStatus',
label: 'Payment',
render: (booking: any) => (
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>
@@ -171,8 +193,8 @@ export default function BookingsPage() {
</Badge>
),
},
{
key: 'createdAt',
{
key: 'createdAt',
label: 'Created',
sortable: true,
render: (booking: any) => formatDateTime(booking.createdAt),
@@ -208,7 +230,7 @@ export default function BookingsPage() {
<h1 className="text-2xl font-bold">Bookings</h1>
<p className="text-muted-foreground">Manage all passenger bookings</p>
</div>
<ActionButton variant="export" icon={Download} onClick={handleExportBookings}>Export</ActionButton>
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
<div className="card">
@@ -253,7 +275,7 @@ export default function BookingsPage() {
loading={isLoading}
emptyMessage="No bookings found"
/>
{data?.meta && (
<Pagination
currentPage={data.meta.page}
@@ -264,15 +286,9 @@ export default function BookingsPage() {
</div>
{/* Booking Details Modal */}
<Modal
isOpen={!!selectedBooking}
onClose={() => setSelectedBooking(null)}
title="Booking Details"
size="xl"
>
<Modal isOpen={!!selectedBooking} onClose={() => setSelectedBooking(null)} title="Booking Details" size="xl">
{selectedBooking && (
<div className="space-y-6">
{/* Booking Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Booking Reference</label>
@@ -281,9 +297,7 @@ export default function BookingsPage() {
<div>
<label className="text-sm font-medium text-muted-foreground">Status</label>
<div className="mt-1">
<Badge variant="status" status={selectedBooking.status}>
{selectedBooking.status}
</Badge>
<Badge variant="status" status={selectedBooking.status}>{selectedBooking.status}</Badge>
</div>
</div>
<div>
@@ -298,7 +312,6 @@ export default function BookingsPage() {
<hr className="border-muted" />
{/* Passenger Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -323,7 +336,6 @@ export default function BookingsPage() {
<hr className="border-muted" />
{/* Booking Details */}
<div>
<h3 className="text-lg font-semibold mb-3">Journey Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -348,7 +360,6 @@ export default function BookingsPage() {
<hr className="border-muted" />
{/* Payment Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Payment Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -377,7 +388,6 @@ export default function BookingsPage() {
<hr className="border-muted" />
{/* Additional Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Additional Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -393,12 +403,7 @@ export default function BookingsPage() {
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
variant="secondary"
onClick={() => setSelectedBooking(null)}
>
Close
</ActionButton>
<ActionButton variant="secondary" onClick={() => setSelectedBooking(null)}>Close</ActionButton>
</div>
</div>
)}
@@ -407,10 +412,7 @@ export default function BookingsPage() {
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => {
setDeleteConfirmOpen(false);
setBookingToDelete(null);
}}
onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); }}
onConfirm={handleConfirmDelete}
title="Delete Booking"
message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`}
@@ -419,6 +421,53 @@ export default function BookingsPage() {
isLoading={deleteMutation.isPending}
isDanger={true}
/>
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From (Created)</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To (Created)</label>
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2 max-h-56 overflow-y-auto">
{[
{ key: 'bookingRef', label: 'Booking Reference' },
{ key: 'passenger', label: 'Passenger' },
{ key: 'status', label: 'Status' },
{ key: 'bookingType', label: 'Booking Type' },
{ key: 'passengerCount', label: 'Passenger Count' },
{ key: 'totalMinor', label: 'Amount' },
{ key: 'paymentStatus', label: 'Payment Status' },
{ key: 'createdAt', label: 'Created At' },
].map((col) => (
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
<input
type="checkbox"
checked={exportColumns[col.key] || false}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -22,6 +22,12 @@ export default function PassengersPage() {
});
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
fullName: true, email: true, phone: true, gender: true, nationality: true, verified: true,
});
const queryClient = useQueryClient();
@@ -52,22 +58,23 @@ export default function PassengersPage() {
console.error('Passengers API Error:', error);
}
const handleExportPassengers = async () => {
const selectedColumns = prompt(
'Select columns to export (comma-separated):\n\n' +
'Available: fullName, email, phone, dateOfBirth, gender, nationality, verified\n\n' +
'Default: fullName, email, phone, gender, nationality, verified',
'fullName, email, phone, gender, nationality, verified'
);
if (!selectedColumns) return;
const cols = selectedColumns.split(',').map(c => c.trim());
const confirmExportPassengers = () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (cols.length === 0) { alert('Please select at least one column'); return; }
const exportItems = (data?.items || []).filter((p: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
cols.join(','),
...data?.items?.map((passenger: any) => {
...exportItems.map((passenger: any) => {
const values = cols.map(col => {
switch(col) {
switch (col) {
case 'fullName': return passenger.fullName;
case 'email': return passenger.email || '';
case 'phone': return passenger.phone || '';
@@ -79,15 +86,16 @@ export default function PassengersPage() {
}
});
return values.map(v => `"${v}"`).join(',');
}) || []
}),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
setExportModalOpen(false);
};
const columns = [
@@ -160,7 +168,7 @@ export default function PassengersPage() {
<p className="text-muted-foreground">Manage passenger profiles and verification</p>
</div>
<div className="flex gap-2">
<ActionButton variant="export" icon={Download} onClick={handleExportPassengers}>Export</ActionButton>
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
</div>
@@ -381,6 +389,51 @@ export default function PassengersPage() {
</div>
)}
</Modal>
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Passengers" size="md">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From (Registered)</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To (Registered)</label>
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2 max-h-56 overflow-y-auto">
{[
{ key: 'fullName', label: 'Full Name' },
{ key: 'email', label: 'Email' },
{ key: 'phone', label: 'Phone' },
{ key: 'dateOfBirth', label: 'Date of Birth' },
{ key: 'gender', label: 'Gender' },
{ key: 'nationality', label: 'Nationality' },
{ key: 'verified', label: 'Verified' },
].map((col) => (
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
<input
type="checkbox"
checked={exportColumns[col.key] || false}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExportPassengers}>Export CSV</ActionButton>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -6,27 +6,76 @@ import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { paymentsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function PaymentsPage() {
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
reference: true, booking: true, amount: true, method: true, status: true, createdAt: true,
});
const { data, isLoading } = useQuery({
queryKey: ['payments', filters],
queryFn: () => paymentsApi.getAll(filters),
queryFn: () => paymentsApi.getAll({
search: filters.search || undefined,
status: filters.status || undefined,
method: filters.method || undefined,
}),
});
const columns = [
{ key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> },
{ key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' },
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) },
{ key: 'method', label: 'Method', render: (payment: any) => <Badge>{payment.method}</Badge> },
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
];
const confirmExport = () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (cols.length === 0) { alert('Please select at least one column'); return; }
const actions: any[] = [];
const items = ((data as any)?.items || (Array.isArray(data) ? data : [])) as any[];
const exportItems = items.filter((p: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
cols.join(','),
...exportItems.map((payment: any) => {
const values = cols.map(col => {
switch (col) {
case 'reference': return payment.reference || payment.id?.substring(0, 8) || '';
case 'booking': return payment.booking?.bookingRef || 'N/A';
case 'amount': return formatCurrency(payment.amountMinor, payment.currency);
case 'method': return payment.method || '';
case 'status': return payment.status || '';
case 'createdAt': return payment.createdAt || '';
default: return '';
}
});
return values.map(v => `"${v}"`).join(',');
}),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `payments-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
setExportModalOpen(false);
};
const columns = [
{ key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> },
{ key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' },
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) },
{ key: 'method', label: 'Method', render: (payment: any) => <Badge>{payment.method}</Badge> },
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
];
return (
<div className="space-y-6">
@@ -35,36 +84,91 @@ export default function PaymentsPage() {
<h1 className="text-2xl font-bold text-foreground">Payments</h1>
<p className="text-muted-foreground">Manage payment transactions and refunds</p>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="PENDING">Pending</option>
<option value="COMPLETED">Completed</option>
<option value="FAILED">Failed</option>
</select>
</div>
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="PENDING">Pending</option>
<option value="COMPLETED">Completed</option>
<option value="FAILED">Failed</option>
</select>
</div>
<div>
<label className="label">Method</label>
<select className="input" value={filters.method} onChange={(e) => setFilters({ ...filters, method: e.target.value })}>
<option value="">All Methods</option>
<option value="TELEBIRR">Telebirr</option>
<option value="CBE_BIRR">CBE Birr</option>
<option value="EBIRR">eBirr</option>
<option value="CARD">Card</option>
<option value="WALLET">Wallet</option>
<option value="CASH">Cash</option>
</select>
</div>
</div>
</div>
<DataTable
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
columns={columns}
actions={actions}
actions={[]}
loading={isLoading}
emptyMessage="No payments found"
/>
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To</label>
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2">
{[
{ key: 'reference', label: 'Reference' },
{ key: 'booking', label: 'Booking Reference' },
{ key: 'amount', label: 'Amount' },
{ key: 'method', label: 'Payment Method' },
{ key: 'status', label: 'Status' },
{ key: 'createdAt', label: 'Created At' },
].map((col) => (
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
<input
type="checkbox"
checked={exportColumns[col.key] || false}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -228,7 +228,7 @@ export default function SeatsPage() {
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
if (isBedCoach && hasBedPositionData) {
if (isBedCoach) {
const arrangement = parseSeatArrangement(coach.seatArrangement);
const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
const allSeatsForLayout = [...validSeats, ...removedSeats];

View File

@@ -9,11 +9,11 @@ import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal';
import { ticketsApi, apiClient, schedulesApi, stationsApi } from '@/lib/api';
import { ticketsApi, apiClient, stationsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', tripDate: '' });
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
@@ -22,6 +22,8 @@ export default function TicketsPage() {
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [selectedColumns, setSelectedColumns] = useState<Record<string, boolean>>({
ticketNumber: true,
booking: true,
@@ -35,7 +37,15 @@ export default function TicketsPage() {
const { data, isLoading, error } = useQuery({
queryKey: ['tickets', filters],
queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }),
queryFn: () => ticketsApi.getAll({
search: filters.search || undefined,
status: filters.status || undefined,
originStationId: filters.originStationId || undefined,
destinationStationId: filters.destinationStationId || undefined,
arrivalDate: filters.arrivalDate || undefined,
skip: 0,
take: 50,
}),
});
const { data: stationsData } = useQuery({
@@ -94,25 +104,31 @@ export default function TicketsPage() {
}
};
const handleExportTickets = async () => {
setExportModalOpen(true);
};
const confirmExport = () => {
const cols = Object.entries(selectedColumns)
.filter(([, selected]) => selected)
.map(([col]) => col);
if (cols.length === 0) {
alert('Please select at least one column');
return;
}
const exportItems = (data?.items || []).filter((ticket: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = ticket.schedule?.arrivalAt
? new Date(ticket.schedule.arrivalAt).toISOString().split('T')[0]
: null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
cols.join(','),
...data?.items?.map((ticket: any) => {
...exportItems.map((ticket: any) => {
const values = cols.map(col => {
switch(col) {
switch (col) {
case 'ticketNumber': return ticket.ticketNumber || '';
case 'booking': return ticket.booking?.bookingRef || '';
case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`;
@@ -126,9 +142,9 @@ export default function TicketsPage() {
}
});
return values.map(v => `"${v}"`).join(',');
}) || []
}),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -175,12 +191,12 @@ export default function TicketsPage() {
},
{
key: 'seat',
label: 'Seat',
label: 'Seat/Bed',
sortable: true,
render: (ticket: any) => (
<div>
<div className="font-mono font-semibold">Coach {ticket.seat?.coach?.number || 'N/A'} - Seat {ticket.seat?.seatNumber || 'N/A'}</div>
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.name || 'N/A'}</div>
<div className="font-mono font-semibold">{ticket.seat?.coach?.number || 'N/A'} - {ticket.seat?.seatNumber || 'N/A'}</div>
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.type || 'N/A'}</div>
</div>
),
},
@@ -264,7 +280,7 @@ export default function TicketsPage() {
<h1 className="text-2xl font-bold text-foreground">Tickets</h1>
<p className="text-muted-foreground">Manage tickets and validations</p>
</div>
<ActionButton icon={Download} variant="secondary" onClick={handleExportTickets}>Export</ActionButton>
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
{/* Filters */}
@@ -317,12 +333,12 @@ export default function TicketsPage() {
</select>
</div>
<div>
<label className="label">Trip Date</label>
<label className="label">Arrival Date</label>
<input
type="date"
className="input"
value={filters.tripDate}
onChange={(e) => setFilters({ ...filters, tripDate: e.target.value })}
value={filters.arrivalDate}
onChange={(e) => setFilters({ ...filters, arrivalDate: e.target.value })}
/>
</div>
<div>
@@ -353,10 +369,7 @@ export default function TicketsPage() {
{/* Board Confirmation Dialog */}
<ConfirmDialog
isOpen={boardConfirmOpen}
onClose={() => {
setBoardConfirmOpen(false);
setTicketToBoard(null);
}}
onClose={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}
onConfirm={handleConfirmBoard}
title="Board Ticket"
message={`Are you sure you want to board ticket ${ticketToBoard?.ticketNumber}? This will mark the ticket as USED.`}
@@ -368,10 +381,7 @@ export default function TicketsPage() {
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => {
setDeleteConfirmOpen(false);
setTicketToDelete(null);
}}
onClose={() => { setDeleteConfirmOpen(false); setTicketToDelete(null); }}
onConfirm={handleConfirmDelete}
title="Delete Ticket"
message={`Are you sure you want to permanently delete ticket ${ticketToDelete?.ticketNumber}? This action cannot be undone.`}
@@ -384,10 +394,7 @@ export default function TicketsPage() {
{/* Ticket Details Modal */}
<Modal
isOpen={detailsModalOpen}
onClose={() => {
setDetailsModalOpen(false);
setSelectedTicket(null);
}}
onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
title="Ticket Details"
size="lg"
>
@@ -488,13 +495,7 @@ export default function TicketsPage() {
)}
<div className="flex justify-end gap-2 pt-4">
<ActionButton
variant="secondary"
onClick={() => {
setDetailsModalOpen(false);
setSelectedTicket(null);
}}
>
<ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>
Close
</ActionButton>
</div>
@@ -502,49 +503,55 @@ export default function TicketsPage() {
)}
</Modal>
{/* Export Columns Modal */}
{/* Export Modal */}
<Modal
isOpen={exportModalOpen}
onClose={() => setExportModalOpen(false)}
title="Export Tickets - Select Columns"
title="Export Tickets"
size="md"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Select which columns to include in the export</p>
<div className="space-y-3 max-h-96 overflow-y-auto">
{[
{ key: 'ticketNumber', label: 'Ticket Number' },
{ key: 'booking', label: 'Booking Reference & Passenger' },
{ key: 'trip', label: 'Trip (Origin → Destination)' },
{ key: 'coach', label: 'Coach Number' },
{ key: 'seat', label: 'Seat Number' },
{ key: 'seatClass', label: 'Seat Class' },
{ key: 'amount', label: 'Amount' },
{ key: 'status', label: 'Status' },
{ key: 'boarded', label: 'Boarded Status' },
].map((col) => (
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
<input
type="checkbox"
checked={selectedColumns[col.key] || false}
onChange={(e) =>
setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From (Arrival)</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To (Arrival)</label>
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2 max-h-56 overflow-y-auto">
{[
{ key: 'ticketNumber', label: 'Ticket Number' },
{ key: 'booking', label: 'Booking Reference & Passenger' },
{ key: 'trip', label: 'Trip (Origin → Destination)' },
{ key: 'coach', label: 'Coach Number' },
{ key: 'seat', label: 'Seat Number' },
{ key: 'seatClass', label: 'Seat Class' },
{ key: 'amount', label: 'Amount' },
{ key: 'status', label: 'Status' },
{ key: 'boarded', label: 'Boarded Status' },
].map((col) => (
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
<input
type="checkbox"
checked={selectedColumns[col.key] || false}
onChange={(e) => setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked })}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>
Cancel
</ActionButton>
<ActionButton onClick={confirmExport}>
Export CSV
</ActionButton>
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
</div>
</div>
</Modal>

View File

@@ -285,6 +285,7 @@ export default function ReviewPage() {
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
return {
seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''),
...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }),
passengerName: p.name,
dateOfBirth: p.dateOfBirth,
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
@@ -323,6 +324,7 @@ export default function ReviewPage() {
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
return {
seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''),
...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }),
passengerName: p.name,
dateOfBirth: p.dateOfBirth,
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',

View File

@@ -10,10 +10,10 @@ export default registerAs("app", () => ({
serviceAuthToken: process.env.SERVICE_AUTH_TOKEN ?? "",
reconciliation: {
/** How often the stale-intent sweep runs. */
sweepIntervalMs: parseInt(
process.env.RECONCILE_SWEEP_INTERVAL_MS ?? "60000",
10,
),
// TODO(demo): revert to the env-driven line below after the demo. Temporarily FORCED to 30s
// here so the .env (RECONCILE_SWEEP_INTERVAL_MS) cannot override it.
// sweepIntervalMs: parseInt(process.env.RECONCILE_SWEEP_INTERVAL_MS ?? "60000", 10),
sweepIntervalMs: 30_000,
/** An intent is "stale" when non-terminal and untouched for this long. */
staleAfterMs: parseInt(process.env.RECONCILE_STALE_AFTER_MS ?? "60000", 10),
batchSize: parseInt(process.env.RECONCILE_BATCH_SIZE ?? "20", 10),

View File

@@ -13,7 +13,10 @@ export default registerAs("waafi", () => ({
// Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front.
paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? "MWALLET_ACCOUNT",
// Waafi has no ETB; when set this overrides the asserted currency (USD/DJF/SLSH).
currency: process.env.WAAFI_CURRENCY ?? "DJF",
// TODO(demo): revert to the env-driven line below after the demo. Temporarily FORCED to USD
// here so the .env (WAAFI_CURRENCY) cannot override it.
// currency: process.env.WAAFI_CURRENCY ?? "DJF",
currency: "USD",
// Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth).
successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? "",
failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? "",

View File

@@ -42,7 +42,7 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy {
private readonly cacBankProvider: CacBankProvider,
) {
this.intervalMs =
config.get<number>("app.reconciliation.sweepIntervalMs") ?? 60_000;
config.get<number>("app.reconciliation.sweepIntervalMs") ?? 30_000;
this.staleAfterMs =
config.get<number>("app.reconciliation.staleAfterMs") ?? 60_000;
this.batchSize = config.get<number>("app.reconciliation.batchSize") ?? 20;