merge conflict

This commit is contained in:
marshal
2026-06-10 16:45:45 +03:00
451 changed files with 47783 additions and 6194 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
**/node_modules
**/dist
**/.turbo
**/.git
**/.github
**/.vscode
**/.idea
**/.env
**/.env.*
!**/.env.example
**/coverage
**/*.tsbuildinfo
**/*.log
.DS_Store

92
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,92 @@
name: Deploy Stacks
on:
push:
branches:
- main
- dev
- staging
paths:
- "apps/edr-freight-api/**"
- "apps/edr-freight-web/**"
- "apps/edr-passenger-api/**"
- "apps/edr-passenger-web/**"
- "packages/**"
- "infrastructure/docker/Dockerfile.web"
- "infrastructure/nginx/**"
- "docker-compose.yaml"
- "pnpm-lock.yaml"
- "scripts/deploy/**"
- ".github/workflows/deploy.yml"
workflow_dispatch:
concurrency:
group: deploy-${{ github.ref_name }}
cancel-in-progress: true
jobs:
deploy:
name: Deploy ${{ matrix.service }}
runs-on: self-hosted
strategy:
fail-fast: false
matrix:
include:
- project: edr-freight
build_env_file: freight-web.build.env
service: freight-api
# - project: edr-freight
# build_env_file: freight-web.build.env
# service: freight-portal
# - project: edr-freight
# build_env_file: freight-web.build.env
# service: freight-backoffice
- project: edr-passenger
build_env_file: passenger-web.build.env
service: passenger-api
- project: edr-passenger
build_env_file: passenger-web.build.env
service: passenger-portal
- project: edr-passenger
build_env_file: passenger-web.build.env
service: passenger-backoffice
env:
PROJECT: ${{ matrix.project }}
BRANCH: ${{ github.ref_name }}
DEPLOY_USER: tria
BUILD_ENV_FILE: ${{ matrix.build_env_file }}
DOCKER_BUILDKIT: "1"
COMPOSE_DOCKER_CLI_BUILD: "1"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Sync environment from server
run: |
chmod +x scripts/deploy/*.sh
./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}"
- name: Set compose project name
run: |
set -euo pipefail
branch_slug=$(echo "${BRANCH}" | tr "[:upper:]" "[:lower:]" | sed -E "s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//")
echo "COMPOSE_PROJECT_NAME=${PROJECT}-${branch_slug}" >> "${GITHUB_ENV}"
- name: Configure npm auth for Docker builds
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: ./scripts/deploy/create-npmrc.sh
- name: Build ${{ matrix.service }}
run: |
set -euo pipefail
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}"
- name: Deploy ${{ matrix.service }}
run: |
set -euo pipefail
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}"
- name: Remove npm credentials from workspace
if: always()
run: rm -f .npmrc .npmrc_temp

4
.gitignore vendored
View File

@@ -22,6 +22,10 @@ coverage/
.DS_Store
.idea/
.vscode/
.npmrc
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat
# emacs cache files
*~

187
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,187 @@
# Deployment Runbook
This document explains how deployments work for the EDR platform using Docker, GitHub Actions, and self-hosted runners.
## Overview
- Monorepo contains 6 deployable services:
- `freight-api`
- `freight-portal`
- `freight-backoffice`
- `passenger-api`
- `passenger-portal`
- `passenger-backoffice`
- Deployments run through one workflow: `.github/workflows/deploy.yml`
- Each service is built/deployed independently in parallel (matrix jobs).
- Docker Compose project names are branch-aware to avoid environment collisions on the same host.
## Prerequisites
- Docker Engine with Compose plugin on the self-hosted runner.
- GitHub self-hosted runner registered for this repository.
- Repository secret configured:
- `NPM_TOKEN` (for private `@tria-plc/*` package install during Docker build)
- Server-side env files created for each branch/environment.
## Server Environment Files
`sync-env-from-server.sh` reads env files from:
`/home/<DEPLOY_USER>/environment/edr/<branch-slug>/<project>/`
Where:
- `<DEPLOY_USER>` defaults to `tria` (overridable by `DEPLOY_USER`)
- `<branch-slug>` is derived from Git branch (lowercase, non-alphanumeric replaced with `-`)
- `<project>` is `edr-freight` or `edr-passenger`
### Required files per project
For `edr-freight`:
- `freight-api.env`
- `freight-portal.env`
- `freight-backoffice.env`
- optional: `freight-web.build.env`
For `edr-passenger`:
- `passenger-api.env`
- `passenger-portal.env`
- `passenger-backoffice.env`
- optional: `passenger-web.build.env`
### Required env key
Each service env file must contain:
- `PORT=<number>`
The sync script validates this and fails if missing.
### Build env files (optional)
Used for build-time variables (example: Vite API URLs), with `export` syntax:
```bash
export FREIGHT_VITE_API_URL=https://freight-api.example.com/api
export PASSENGER_VITE_API_URL=https://passenger-api.example.com
```
These are injected into `GITHUB_ENV` during workflow execution.
## Docker Compose Port Mapping
`docker-compose.yaml` uses per-service env variables for host/container port mappings:
- `FREIGHT_API_PORT`
- `PASSENGER_API_PORT`
- `FREIGHT_PORTAL_PORT`
- `FREIGHT_BACKOFFICE_PORT`
- `PASSENGER_PORTAL_PORT`
- `PASSENGER_BACKOFFICE_PORT`
`scripts/deploy/sync-env-from-server.sh` extracts `PORT` from each synced `.env` and exports the corresponding `*_PORT` variable to `GITHUB_ENV`.
## GitHub Actions Deployment Flow
Workflow file: `.github/workflows/deploy.yml`
### 1) `prepare` job
- Checks out repository once.
- Creates workspace artifact (`workspace.tgz`) and uploads it.
### 2) `deploy` matrix job (parallel)
For each service:
- Downloads and extracts workspace artifact.
- Syncs that service env file from server path.
- Computes branch slug and sets:
- `COMPOSE_PROJECT_NAME=<project>-<branch-slug>`
- Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`.
- Runs:
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" build <service>`
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d <service>`
- Cleans `.npmrc`/`.npmrc_temp`.
## Branch/Environment Isolation
Compose project name is generated as:
`<project>-<branch-slug>`
Examples:
- `edr-freight-main`
- `edr-freight-staging`
- `edr-passenger-dev`
This prevents container/network/volume name collisions between branches.
## Local Manual Deployment (Optional)
From repo root:
```bash
DOCKER_BUILDKIT=1 docker compose build <service>
docker compose up -d <service>
```
If private packages are required locally, create `.npmrc`:
```bash
cat <<EOF > .npmrc
@tria-plc:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=<YOUR_TOKEN>
always-auth=true
EOF
```
## Passenger API Startup Behavior
Passenger container entrypoint runs on startup:
1. `npm run prisma:generate`
2. `npm run prisma:migrate` (deploy mode)
3. `npm run prisma:seed`
4. starts API process
## Troubleshooting
### Missing env file
Error:
- `Missing env file: ...`
Fix:
- Create the required file in the server env directory for that project/branch slug.
### Missing PORT in env file
Error:
- `Missing required PORT in env file: ...`
Fix:
- Add `PORT=<number>` to that service env file.
### Private package install fails
Check:
- `NPM_TOKEN` exists in repo secrets.
- Workflow created `.npmrc` successfully.
### Prisma seed/migrate failures (passenger)
Check:
- `DATABASE_URL` in `passenger-api.env`
- DB reachability from runner host/container network
- migration history consistency

766
README.md
View File

@@ -1,3 +1,98 @@
# EDR Platform - Ethio-Djibouti Railway Passenger API
Enterprise-grade NestJS REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with TypeScript, PostgreSQL, and Prisma ORM.
## 🚀 Features
### 🆕 NEW: Age-Based Pricing, Verifayda 2.0 & Multi-Currency
#### Age-Based Pricing
- **ADULT** (≥5 years): Pay 100% of base fare
- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100%
- Automatic age calculation from date of birth
- Example: 2 adults + 3 children = 4× base fare (first child free)
#### Verifayda 2.0 Integration
- Real-time Ethiopian national ID verification
- Retrieves passenger data from government database
- National IDs NOT stored (policy compliant)
- Non-Ethiopians use passport (no verification required)
- Booking fails if verification unsuccessful
#### Multi-Currency Support
- **Transaction Currency**: ETB (Ethiopian Birr)
- **Display Currencies**: ETB, DJF (Djiboutian Franc), USD (US Dollar)
- Real-time exchange rate conversion
- Prices shown in user's preferred currency
- Exchange rates: ETBDJF=3.25, ETBUSD=0.018
### Core Modules
- **Authentication & Authorization** - Dual authentication system:
- **Passenger Auth**: JWT-based auth with OTP verification, password reset, account lockout
- **Corporate IAM**: Integration with @tria-plc corporate identity system for back-office operations (agents, supervisors, admins)
- Role-based access control (RBAC) with granular permissions
- **Age-Based Pricing** - Smart passenger categorization:
- **ADULT** (≥5 years): Full fare
- **CHILD** (<5 years): First child free, subsequent children full fare
- Automatic age calculation from date of birth
- **Verifayda 2.0 Integration** - Ethiopian national ID verification:
- Real-time verification via government API
- Retrieves passenger data (name, DOB, nationality)
- National IDs NOT stored (policy compliant)
- Non-Ethiopians use passport (no verification)
- **Multi-Currency Support** - Display prices in multiple currencies:
- **ETB** (Ethiopian Birr) - Transaction currency
- **DJF** (Djiboutian Franc) - Display option
- **USD** (US Dollar) - Display option
- Real-time exchange rate conversion
- **Booking Management** - Complete booking lifecycle:
- **Guest Booking**: Book without login, optional account creation
- **Saved Passengers**: Store passenger details for quick rebooking
- Modification, cancellation, refunds, and fare breakdown
- Multi-segment journey support
- **Payment Integration** - Multi-provider support (Telebirr, CBE Birr, eBirr, Card, Wallet) with webhook handling
- **Seat Management** - Real-time seat inventory:
- Seat holds with 5-minute expiry
- Seat releases and blocking with coach/class management
- Segment-based seat availability (partial journey bookings)
- Auto-assign seats with contiguous algorithm
- CSV import/export for seat configurations
- **Ticketing** - QR code and barcode generation, PDF tickets, gate validation with audit logs
- **Agent Operations** - Counter booking, shift management, commission tracking, and reconciliation
- **Passenger Services** - Profile management, traveler profiles, saved routes, and preferences
- **Loyalty Program** - Points accumulation, tier management (Bronze/Silver/Gold/Platinum), and rewards
- **Wallet System** - Balance management, top-up, transaction ledger
- **Live Tracking** - Real-time trip status, location updates, delay notifications, crowd signals
- **Notifications** - Multi-channel (Email, SMS, Push) with templating engine
- **Support System** - FAQ management, live chat conversations
- **Reports & Analytics** - Revenue reports, occupancy analytics, agent sales tracking
- **Route Management** - Route configuration, stops, fare rules, baggage allowance
### Technical Features
- **Security** - Password hashing (bcrypt), JWT tokens, rate limiting, audit logging
- **Validation** - Request validation with class-validator, DTO transformation
- **Documentation** - Auto-generated Swagger/OpenAPI docs at `/api-docs`
- **Error Handling** - Global exception filters with standardized error responses
- **Database** - PostgreSQL with Prisma ORM, migrations, and comprehensive seeding
- **Scheduling** - Cron jobs for automated tasks (seat release, report generation)
- **Event System** - Event-driven architecture with @nestjs/event-emitter
## 📋 Prerequisites
- **Node.js** >= 20.x
- **pnpm** >= 9.x (`npm install -g pnpm`)
- **PostgreSQL** >= 15.x
- **Git**
## 🛠️ Installation & Setup
### 1. Clone Repository
```bash
git clone <repository-url>
cd edr-platform
```
### 2. Install Dependencies
# EDR Platform
Monorepo for the **Ethio-Djibouti Railway** digital platform. Hosts two product lines — **Freight Management** and **Passenger Management** — each with a NestJS API plus React portal and back-office web apps, sharing TypeScript types, NestJS utilities, and a React component library.
@@ -185,6 +280,676 @@ Authentication is provided by an external `@edr/iamui-common` / `@tria-plc/iamap
pnpm install
```
<<<<<<< HEAD
### 3. Environment Configuration
```bash
# Copy environment template
cp apps/edr-passenger-api/.env.example apps/edr-passenger-api/.env
# Edit .env file with your configuration
```
#### Required Environment Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `NODE_ENV` | Environment mode | `development` |
| `PORT` | HTTP server port | `4000` |
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/edr_passenger` |
| `JWT_SECRET` | JWT signing secret (change in production) | `your-secret-key` |
| `JWT_EXPIRES_IN` | JWT token expiry | `7d` |
| `PORTAL_URL` | Web app CORS origin | `http://localhost:3000` |
| `BACK_OFFICE_URL` | Admin portal CORS origin | `http://localhost:3001` |
| `SENDGRID_API_KEY` | SendGrid API key (optional) | `SG.xxx` |
| `SENDGRID_FROM_EMAIL` | Email sender address | `noreply@edr-platform.com` |
#### Verifayda 2.0 Configuration (Ethiopian National ID Verification)
| Variable | Description | Example |
|----------|-------------|---------|
| `VERIFAYDA_ENABLED` | Enable Verifayda integration | `true` or `false` |
| `VERIFAYDA_API_URL` | Verifayda API endpoint | `https://api.verifayda.gov.et/v2` |
| `VERIFAYDA_API_KEY` | API key for Verifayda service | `your-verifayda-api-key` |
**Note:** When `VERIFAYDA_ENABLED=false`, verification is skipped (development mode only).
#### Corporate IAM Configuration (Back-office Authentication)
| Variable | Description | Example |
|----------|-------------|---------|
| `IAM_ENABLED` | Enable corporate IAM integration | `true` or `false` |
| `IAM_API_URL` | Corporate IAM API endpoint | `https://iam.tria-plc.com/api` |
| `IAM_API_KEY` | API key for IAM service | `your-iam-api-key` |
**Note:** When `IAM_ENABLED=false`, IAM-protected routes allow access without validation (development mode only).
#### Optional: Payment Provider Configuration
```bash
# Telebirr Configuration
TELEBIRR_BASE_URL=https://api.telebirr.com
TELEBIRR_MERCHANT_CODE=your-merchant-code
TELEBIRR_APP_SECRET=your-app-secret
# ... see .env.example for complete list
```
### 4. Database Setup
#### Start PostgreSQL
```bash
# Using Docker (recommended)
docker run --name edr-postgres \
-e POSTGRES_USER=edr \
-e POSTGRES_PASSWORD=edr_secret \
-e POSTGRES_DB=edr_passenger \
-p 5432:5432 \
-d postgres:15
# Or use your local PostgreSQL installation
```
#### Generate Prisma Client
```bash
pnpm --filter @edr/passenger-api run prisma:generate
```
#### Run Migrations
```bash
pnpm --filter @edr/passenger-api run prisma:migrate:dev
```
#### Seed Database
```bash
pnpm --filter @edr/passenger-api run prisma:seed
```
**Seed Data Includes:**
- 21 Stations (Complete Ethiopian-Djibouti Railway with country codes)
- 1 Route with 21 stops and fare rules
- 2 Train services with 4 trips
- 360 seats across 12 coaches (Economy Regular, Economy Bed, VIP Bed classes)
- 3 User accounts (Admin, Passenger, Agent)
- Fare rules for ADULT and CHILD passenger categories
- Currency exchange rates (ETB, DJF, USD)
- Baggage allowance rules
- Notification templates
- Promotions and FAQ content
- Menu items and station crowd signals
- Fraud detection rules
- Saved passenger profiles for testing
### 5. Start Development Server
```bash
pnpm --filter @edr/passenger-api run dev
```
**API Server:** http://localhost:4000
**Swagger Docs:** http://localhost:4000/api-docs
## 🔑 Default Credentials
After seeding, use these credentials to test the API:
| Role | Email | Password | Description |
|------|-------|----------|-------------|
| **Admin** | `admin@edr-platform.com` | `admin123` | Full system access, reports, agent management |
| **Passenger** | `kelemu@email.com` | `password123` | Regular user with loyalty (Silver) and wallet |
| **Agent** | `agent@edr-platform.com` | `agent123` | Counter booking agent with commission tracking |
## 📚 API Documentation
### Swagger UI
Interactive API documentation available at: **http://localhost:4000/api-docs**
### Authentication Methods
The API uses two authentication schemes:
#### 1. JWT Authentication (Passenger-facing)
- **Used for**: Passenger bookings, profile management, wallet, loyalty
- **Header**: `Authorization: Bearer <jwt-token>`
- **Obtain token**: `POST /auth/login` with passenger credentials
- **Swagger Security**: `JWT-auth`
#### 2. IAM Authentication (Back-office)
- **Used for**: Agent operations, fraud detection, reports, admin functions
- **Header**: `Authorization: Bearer <iam-token>`
- **Obtain token**: From corporate IAM system (https://iam.tria-plc.com)
- **Swagger Security**: `IAM-auth`
- **Roles**: AGENT, SUPERVISOR, ADMIN, STAFF
### API Endpoints Overview
| Module | Base Path | Auth Type | Description |
|--------|-----------|-----------|-------------|
| **Auth** | `/auth` | Public/JWT | Register, login, OTP verification, password reset |
| **Passengers** | `/passengers` | Public/JWT | Verifayda verification, international registration, profiles |
| **Search** | `/search` | Public | Trip search, availability, fare quotes |
| **Stations** | `/stations` | Public/JWT | Station directory and information |
| **Seats** | `/seats` | JWT/IAM | Seat maps, holds, releases, blocking |
| **Bookings** | `/bookings` | Public/JWT | Guest booking, create, modify, cancel bookings |
| **Payments** | `/payments` | JWT/Public | Payment initiation, webhooks, refunds |
| **Tickets** | `/tickets` | JWT/IAM | Ticket generation, QR/barcode, validation |
| **Notifications** | `/notifications` | JWT | In-app notifications, preferences |
| **Loyalty** | `/loyalty` | JWT | Points, tiers, rewards redemption |
| **Wallet** | `/wallet` | JWT | Balance, top-up, transaction history |
| **Promotions** | `/promos` | Public/JWT | Active promotions, promo code validation |
| **Live Tracking** | `/live` | Public/JWT | Real-time trip status, crowd signals |
| **Support** | `/support` | Public/JWT | FAQ, chat conversations |
| **Dashboard** | `/dashboard` | JWT | Home screen aggregated data |
| **Routes** | `/routes` | JWT/IAM | Reusable route templates with ordered stops |
| **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates |
| **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations |
| **Seat Classes** | `/seat-classes` | Public/JWT/IAM | Seat class management and configuration |
| **Segment Seats** | `/segments/seats` | Public/JWT | Segment-based seat availability and booking |
| **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation |
| **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking |
| **Reports** | `/reports` | IAM | Revenue, occupancy, agent sales analytics |
### Example API Calls
#### 1. Register Passenger
```bash
POST /auth/register
Content-Type: application/json
{
"email": "user@example.com",
"phone": "+251911234567",
"fullName": "John Doe",
"password": "SecurePass123"
}
```
#### 2. Login (Passenger)
```bash
POST /auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123"
}
# Response includes JWT token
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": { "id": "uuid", "role": "PASSENGER" }
}
```
#### 3. Verify Ethiopian National ID (Verifayda)
```bash
POST /passengers/verify-fayda
Content-Type: application/json
{
"nationalId": "ET123456789"
}
# Response with verified passenger data
{
"verified": true,
"passengerData": {
"fullName": "Abebe Kebede",
"dateOfBirth": "1985-03-15T00:00:00.000Z",
"gender": "Male",
"nationality": "Ethiopian"
}
}
```
#### 4. Universal Passenger Registration (NEW)
```bash
# Guest Ethiopian with Fayda verification
POST /passengers/register
Content-Type: application/json
{
"passengerName": "Abebe Kebede",
"dateOfBirth": "1985-03-15",
"nationalId": "ET123456789",
"phone": "+251911234567",
"deviceId": "device-uuid-123"
}
# Logged-in user with JWT token
POST /passengers/register
Authorization: Bearer <jwt-token>
Content-Type: application/json
{
"passengerName": "Abebe Kebede",
"dateOfBirth": "1985-03-15",
"nationalId": "ET123456789",
"phone": "+251911234567"
}
# International passenger (passport)
POST /passengers/register
Content-Type: application/json
{
"passengerName": "John Smith",
"dateOfBirth": "1990-07-20",
"passportNumber": "P1234567",
"passportCountry": "Kenya",
"nationality": "Kenyan",
"phone": "+254712345678",
"email": "john@example.com",
"deviceId": "device-uuid-123"
}
```
#### 5. Get User Profile (NEW)
```bash
GET /auth/profile
Authorization: Bearer <jwt-token>
# Response includes user, passenger, loyalty, and wallet details
{
"id": "uuid",
"email": "user@example.com",
"phone": "+251911234567",
"fullName": "John Doe",
"role": "PASSENGER",
"nationality": "Ethiopian",
"faydaVerified": true,
"faydaVerifiedAt": "2024-01-15T10:30:00.000Z",
"passenger": {
"id": "uuid",
"loyalty": {
"tier": "SILVER",
"pointsBalance": 1500,
"lifetimePoints": 3000
},
"wallet": {
"balanceMinor": 50000,
"currency": "ETB"
}
}
}
```
#### 6. Search Trips
```bash
POST /search
Content-Type: application/json
{
"originStationId": "uuid",
"destinationStationId": "uuid",
"date": "2026-06-15",
"adultCount": 2,
"childCount": 1
}
```
#### 7. Get Fare Quote
```bash
POST /search/fare-quote
Content-Type: application/json
{
"tripId": "uuid",
"serviceClass": "ECONOMY_REGULAR",
"adultCount": 2,
"childCount": 1,
"displayCurrency": "USD"
}
# Response includes age-based pricing breakdown
{
"baseFareMinor": 35000,
"adultCount": 2,
"adultFareMinor": 70000,
"childCount": 1,
"freeChildrenCount": 1,
"paidChildrenCount": 0,
"childFareMinor": 0,
"totalMinor": 73500,
"currency": "ETB",
"displayCurrency": "USD",
"displayTotalMinor": 1323
}
```
#### 8. Guest Booking (No Login Required)
```bash
POST /bookings/guest
Content-Type: application/json
{
"tripId": "uuid",
"holdId": "uuid",
"serviceClass": "ECONOMY_REGULAR",
"displayCurrency": "ETB",
"passengers": [
{
"seatId": "uuid",
"passengerName": "Abebe Kebede",
"dateOfBirth": "1985-03-15",
"idDocumentType": "NATIONAL_ID",
"idDocumentNumber": "ET123456789"
}
],
"createAccount": false,
"savePassengerDetails": true,
"deviceId": "device-uuid"
}
```
#### 9. Agent Booking (IAM Auth)
```bash
POST /agents/bookings
Authorization: Bearer <iam-token>
Content-Type: application/json
{
"tripId": "uuid",
"seats": [...],
"paymentMethod": "CASH",
"cashReceived": 50000
}
```
## 🏗️ Project Structure
```
apps/edr-passenger-api/
├── prisma/
│ ├── schema.prisma # Database schema (40+ models)
│ ├── seed.ts # Comprehensive seed script
│ └── migrations/ # Database migrations
├── src/
│ ├── common/ # Shared utilities
│ │ ├── filters/ # Exception filters
│ │ ├── interceptors/ # Response interceptors
│ │ ├── pipes/ # Validation pipes
│ │ ├── i18n/ # Internationalization
│ │ ├── jwt.guard.ts # JWT authentication guard (passengers)
│ │ ├── jwt.strategy.ts # Passport JWT strategy
│ │ ├── iam-adapter.ts # Corporate IAM guard (back-office)
│ │ ├── iam.module.ts # IAM module
│ │ ├── roles.guard.ts # RBAC authorization guard
│ │ ├── roles.decorator.ts # Roles decorator
│ │ ├── prisma.service.ts # Prisma client service
│ │ └── prisma.module.ts # Prisma module
│ ├── config/ # Configuration files
│ │ ├── app.config.ts # App configuration
│ │ ├── database.config.ts # Database configuration
│ │ └── telebirr.config.ts # Payment provider config
│ ├── modules/ # Feature modules
│ │ ├── auth/ # Authentication & authorization (JWT)
│ │ ├── agents/ # Agent operations (IAM-protected)
│ │ ├── bookings/ # Booking management (JWT)
│ │ ├── currency/ # Currency conversion service
│ │ ├── dashboard/ # Dashboard aggregations (JWT)
│ │ ├── fleet/ # Train fleet management (JWT/IAM)
│ │ ├── fraud/ # Fraud detection (IAM-protected)
│ │ ├── live/ # Live tracking (JWT)
│ │ ├── loyalty/ # Loyalty program (JWT)
│ │ ├── notifications/ # Notification system (JWT)
│ │ ├── passengers/ # Passenger management (JWT)
│ │ ├── payments/ # Payment processing (JWT/Webhooks)
│ │ ├── promos/ # Promotions (JWT)
│ │ ├── reports/ # Reports & analytics (IAM-protected)
│ │ ├── schedules/ # Trip schedules (JWT/IAM)
│ │ ├── search/ # Trip search (JWT)
│ │ ├── seats/ # Seat management (JWT/IAM)
│ │ ├── segments/ # Journey segments (JWT)
│ │ ├── stations/ # Station management (JWT)
│ │ ├── support/ # Customer support (JWT)
│ │ ├── tickets/ # Ticketing (JWT/IAM)
│ │ ├── verifayda/ # Verifayda 2.0 integration
│ │ └── wallet/ # Wallet system (JWT)
│ ├── app.module.ts # Root application module
│ └── main.ts # Application entry point
├── test/ # E2E tests
├── .env.example # Environment template
├── Dockerfile # Docker configuration
├── nest-cli.json # NestJS CLI configuration
├── package.json # Dependencies & scripts
├── tsconfig.json # TypeScript configuration
└── tsconfig.build.json # Build configuration
```
## 🗄️ Database Schema
### Key Models (40+ total)
**Core Entities:**
- `User`, `Session`, `Passenger`, `Agent`
- `Station`, `Route`, `RouteStop`, `RouteFareRule`
- `TrainService`, `Trip`, `TripStopTime`, `Coach`, `Seat`
- `Booking`, `BookingSeat`, `Ticket`
- `PaymentIntent`, `PaymentRefund`, `PaymentWebhookEvent`
**Enhanced Features:**
- `OtpCode`, `PasswordResetToken` (Auth)
- `AgentBooking`, `AgentShift`, `AgentCommission` (Agents)
- `BookingModification`, `BookingCancellation` (Booking lifecycle)
- `GateValidationLog` (Ticket validation)
- `BaggageAllowance`, `BaggageBooking` (Baggage)
- `LoyaltyAccount`, `LoyaltyLedgerEntry`, `LoyaltyReward`
- `WalletAccount`, `WalletLedgerEntry`
- `Notification`, `NotificationTemplate`
- `AuditLog`, `OperationalReport`
- `SeatBlock`, `SeatHold`
- `CurrencyExchangeRate` (Multi-currency)
- `VerifaydaVerification` (National ID verification)
- `SavedPassengerProfile` (Guest booking)
- `SeatClass` (Seat class configuration)
- `JourneySegment` (Multi-segment journeys)
## 🔧 Available Scripts
```bash
# Development
pnpm --filter @edr/passenger-api run dev # Start with hot-reload
# Build
pnpm --filter @edr/passenger-api run build # Compile TypeScript
# Production
pnpm --filter @edr/passenger-api run start # Run compiled code
# Testing
pnpm --filter @edr/passenger-api run test # Unit tests
pnpm --filter @edr/passenger-api run test:e2e # E2E tests
# Code Quality
pnpm --filter @edr/passenger-api run lint # ESLint
pnpm --filter @edr/passenger-api run type-check # TypeScript check
# Database
pnpm --filter @edr/passenger-api run prisma:generate # Generate Prisma client
pnpm --filter @edr/passenger-api run prisma:migrate:dev # Run migrations (local dev)
pnpm --filter @edr/passenger-api run prisma:seed # Seed database
```
## 🐳 Docker Deployment
All six apps build from Dockerfiles: each API has its own (`apps/edr-freight-api/Dockerfile`, `apps/edr-passenger-api/Dockerfile`); Vite frontends share `infrastructure/docker/Dockerfile.web` and are served with **nginx**. APIs run on **Node 22**.
**Prerequisites**
- Docker with BuildKit enabled
- A local [`.npmrc`](.gitignore) with GitHub Packages auth for `@tria-plc/*` (required for **freight** API and web images)
- External Postgres for each API (compose does **not** include databases)
- Copy `apps/edr-freight-api/.env.example` `.env` and `apps/edr-passenger-api/.env.example` `.env` with real connection strings
### Build and run (all apps)
```bash
# From monorepo root
DOCKER_BUILDKIT=1 pnpm docker:build
pnpm docker:up
```
Or without pnpm scripts:
```bash
DOCKER_BUILDKIT=1 docker compose build
docker compose up -d
```
| Service | URL (default) |
|---------|----------------|
| Freight API | http://localhost:3001 |
| Passenger API | http://localhost:4000 |
| Freight portal | http://localhost:5173 |
| Freight backoffice | http://localhost:5183 |
| Passenger portal | http://localhost:5174 |
| Passenger backoffice | http://localhost:5184 |
### Build a single service
```bash
docker compose build freight-api
docker compose build passenger-portal
```
Freight images mount `.npmrc` as a BuildKit secret during `pnpm install`. Passenger web images do not require private packages.
### `VITE_API_URL` (frontends)
API URLs are **baked in at image build time** (`import.meta.env.VITE_API_URL`). Defaults in [`docker-compose.yaml`](docker-compose.yaml) use `http://localhost:3001/api` (freight) and `http://localhost:4000` (passenger) for local smoke tests. Override build args for production, e.g.:
```bash
docker compose build freight-portal \
--build-arg VITE_API_URL=https://freight-api.example.com/api
```
### Migrations
- **Freight API:** TypeORM migrations are not run on container startup apply them separately before deploy.
- **Passenger API:** On each container start, the entrypoint runs `npm run prisma:migrate` and `npm run prisma:seed` (same `package.json` scripts as `pnpm run`) before starting the server. Ensure `DATABASE_URL` in `.env` points at a reachable Postgres instance.
For local development, use `pnpm --filter @edr/passenger-api run prisma:migrate:dev` instead of `prisma:migrate`.
### GitHub Actions (self-hosted runner)
Two workflows deploy independently on push to `main`, `develop`, or `staging`:
| Workflow | Services | Server env root |
|----------|----------|-----------------|
| [`.github/workflows/deploy-freight.yml`](.github/workflows/deploy-freight.yml) | freight-api, freight-portal, freight-backoffice | `/home/user/environmen/edr-freight/<branch>/` |
| [`.github/workflows/deploy-passenger.yml`](.github/workflows/deploy-passenger.yml) | passenger-api, passenger-portal, passenger-backoffice | `/home/user/environmen/edr-passenger/<branch>/` |
**On the runner**, place env files before the first deploy (example for branch `main`):
```text
/home/user/environmen/edr-freight/main/
freight-api.env
freight-portal.env # optional runtime env for Vite/nginx
freight-backoffice.env
freight-web.build.env # exports FREIGHT_VITE_API_URL=...
/home/user/environmen/edr-passenger/main/
passenger-api.env
passenger-portal.env
passenger-backoffice.env
passenger-web.build.env # exports PASSENGER_VITE_API_URL=...
```
Example `freight-web.build.env`:
```bash
export FREIGHT_VITE_API_URL=https://freight-api.example.com/api
```
The workflow copies `*.env` into each app directory, creates `.npmrc` from the `NPM_TOKEN` repository secret, then runs `docker compose build` and `docker compose up -d` for that stack.
## 🔒 Security Best Practices
1. **Environment Variables** - Never commit `.env` files. Use secrets management in production.
2. **JWT Secret** - Use strong, randomly generated secrets (min 32 characters).
3. **Password Hashing** - Bcrypt with salt rounds (default: 10).
4. **Rate Limiting** - Implement rate limiting for auth endpoints.
5. **CORS** - Configure allowed origins in production.
6. **HTTPS** - Always use HTTPS in production.
7. **Database** - Use connection pooling and prepared statements (Prisma handles this).
8. **Audit Logging** - All sensitive operations are logged in `AuditLog` table.
9. **Dual Authentication** - Passenger routes use JWT, back-office routes use corporate IAM.
10. **IAM Integration** - Corporate IAM validates tokens against centralized identity service.
11. **Role-Based Access** - Granular permissions enforced via IAM roles (AGENT, SUPERVISOR, ADMIN).
12. **Token Validation** - IAM tokens validated in real-time with 5-second timeout.
## 📊 Monitoring & Logging
- **Application Logs** - NestJS built-in logger
- **Database Queries** - Prisma query logging (enable in development)
- **Audit Trail** - All user actions logged in `AuditLog` table
- **Error Tracking** - Global exception filters with detailed error responses
## 🧪 Testing
```bash
# Unit tests
pnpm --filter @edr/passenger-api run test
# E2E tests
pnpm --filter @edr/passenger-api run test:e2e
# Test coverage
pnpm --filter @edr/passenger-api run test:cov
```
## 🚀 Production Deployment
### Pre-deployment Checklist
- [ ] Update environment variables (JWT_SECRET, DATABASE_URL, etc.)
- [ ] Configure IAM integration (IAM_ENABLED=true, IAM_API_URL, IAM_API_KEY)
- [ ] Configure Verifayda integration (VERIFAYDA_ENABLED=true, VERIFAYDA_API_KEY)
- [ ] Set up currency exchange rate sync (external API)
- [ ] Set NODE_ENV=production
- [ ] Configure CORS origins (PORTAL_URL, BACK_OFFICE_URL)
- [ ] Set up SSL/TLS certificates
- [ ] Configure database connection pooling
- [ ] Set up monitoring and logging
- [ ] Configure backup strategy
- [ ] Test payment provider integrations
- [ ] Verify IAM token validation endpoint
- [ ] Test Verifayda verification with real national IDs
- [ ] Verify currency conversion accuracy
- [ ] Test age-based pricing calculations
- [ ] Review security settings and audit logs
- [ ] Test both JWT and IAM authentication flows
### Deployment Steps
```bash
# 1. Build application
pnpm --filter @edr/passenger-api run build
# 2. Run migrations
pnpm --filter @edr/passenger-api run prisma:migrate
# 3. Start production server
NODE_ENV=production pnpm --filter @edr/passenger-api run start:prod
```
## 🤝 Contributing
1. Fork the repository
2. Create feature branch (`git checkout -b feature/amazing-feature`)
3. Commit changes (`git commit -m 'Add amazing feature'`)
4. Push to branch (`git push origin feature/amazing-feature`)
5. Open Pull Request
## 📝 License
This project is proprietary and confidential.
## 📧 Support
For technical support or questions:
- Email: support@edr-platform.com
- Documentation: http://localhost:4000/api-docs
---
**Built with ❤️ for Ethio-Djibouti Railway**
=======
### Start local databases
```bash
@@ -257,3 +1022,4 @@ pnpm dev:passenger # passenger API + portal + backoffice
- **One DB per domain** no cross-database joins.
See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions.
>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467

View File

@@ -1,14 +1,24 @@
# App
NODE_ENV=development
# Copy to .env for local/docker compose (not committed).
PORT=3001
# Database
DB_HOST=localhost
DB_PORT=5433
DB_NAME=edr_freight
DB_USER=postgres
DB_PASSWORD=
DB_NAME=edr_freight
# Telebirr payment gateway (freight merchant credentials)
TELEBIRR_BASE_URL=
TELEBIRR_WEB_BASE_URL=
TELEBIRR_FABRIC_APP_ID=
TELEBIRR_APP_SECRET=
TELEBIRR_MERCHANT_APP_ID=
TELEBIRR_MERCHANT_CODE=
TELEBIRR_NOTIFY_URL=https://freight-api.edr.et/payments/webhooks/telebirr
TELEBIRR_RETURN_URL=
TELEBIRR_TIMEOUT_EXPRESS=15m
TELEBIRR_PRIVATE_KEY=
TELEBIRR_PUBLIC_KEY=
TELEBIRR_INSECURE_TLS=false
# JWT (used by @tria-plc/api-common SharedAuthModule)
JWT_SECRET=
JWT_ACCESS_TOKEN_SECRET=

View File

@@ -1,26 +1,37 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# syntax=docker/dockerfile:1
# Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile .
FROM node:24.15.0-alpine AS base
RUN apk add --no-cache libc6-compat
RUN corepack enable
WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/edr-freight-api/package.json ./apps/edr-freight-api/
COPY packages ./packages
RUN pnpm install --frozen-lockfile --filter @edr/freight-api...
FROM base AS pruner
COPY . .
RUN pnpm dlx turbo prune "@edr/freight-api" --docker
FROM deps AS build
COPY apps/edr-freight-api ./apps/edr-freight-api
RUN pnpm --filter @edr/freight-api build
FROM base AS installer
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
pnpm install --frozen-lockfile
FROM node:20-alpine AS runtime
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app/apps/edr-freight-api
FROM base AS builder
COPY --from=installer /app/ .
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo build --filter="@edr/freight-api..."
FROM base AS deployer
COPY --from=builder /app/ .
RUN pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
FROM node:24.15.0-alpine AS runner
RUN apk add --no-cache libc6-compat
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./../../node_modules
COPY --from=deps /app/apps/edr-freight-api/node_modules ./node_modules
COPY --from=build /app/apps/edr-freight-api/dist ./dist
COPY --from=build /app/apps/edr-freight-api/package.json ./package.json
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs nestjs
COPY --from=deployer --chown=nestjs:nodejs /deploy .
USER nestjs
EXPOSE 3001
CMD ["node", "dist/main.js"]

View File

@@ -3,7 +3,7 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"deleteOutDir": false,
"assets": [
{
"include": "migrations/**/*",
@@ -20,4 +20,4 @@
],
"watchAssets": true
}
}
}

View File

@@ -4,7 +4,10 @@
"private": true,
"description": "EDR Freight Management API",
"scripts": {
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
"predev": "pnpm run clean",
"dev": "nest start --watch",
"prebuild": "pnpm run clean",
"build": "nest build",
"start": "node dist/main.js",
"lint": "eslint src",
@@ -15,11 +18,13 @@
},
"dependencies": {
"@edr/api-common": "workspace:*",
"@edr/payment-providers": "workspace:*",
"@edr/types": "workspace:*",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/mapped-types": "^2.1.1",
"@nestjs/microservices": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
@@ -61,7 +66,7 @@
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typeorm": "^1.0.0",
"typeorm": "^0.3.30",
"typescript": "^5.5.4"
},
"jest": {
@@ -81,4 +86,4 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
}

View File

@@ -8,12 +8,13 @@ import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.
import appConfig from "./config/app.config";
import databaseConfig from "./config/database.config";
import telebirrConfig from "./config/telebirr.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
import { FilesModule } from "./modules/files/files.module";
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
//import { TrainsModule } from "./modules/trains/trains.module";
// import { TrainsModule } from "./modules/trains/trains.module";
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
@@ -55,8 +56,9 @@ import { OverviewModule } from './modules/overview/overview.module';
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig],
load: [appConfig, databaseConfig, telebirrConfig],
}),
// EventEmitterModule.forRoot(),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>

View File

@@ -0,0 +1,10 @@
import { registerAs } from "@nestjs/config";
export default registerAs("dmoney", () => ({
baseUrl: process.env.DMONEY_BASE_URL ?? "",
appId: process.env.DMONEY_APP_ID ?? "",
appSecret: process.env.DMONEY_APP_SECRET ?? "",
publicKey: process.env.DMONEY_PUBLIC_KEY ?? "",
privateKey: process.env.DMONEY_PRIVATE_KEY ?? "",
notifyUrl: process.env.DMONEY_NOTIFY_URL ?? ""
}));

View File

@@ -0,0 +1,16 @@
import { registerAs } from "@nestjs/config";
export default registerAs("telebirr", () => ({
baseUrl: process.env.TELEBIRR_BASE_URL ?? "",
webBaseUrl: process.env.TELEBIRR_WEB_BASE_URL ?? "",
fabricAppId: process.env.TELEBIRR_FABRIC_APP_ID ?? "",
appSecret: process.env.TELEBIRR_APP_SECRET ?? "",
merchantAppId: process.env.TELEBIRR_MERCHANT_APP_ID ?? "",
merchantCode: process.env.TELEBIRR_MERCHANT_CODE ?? "",
notifyUrl: process.env.TELEBIRR_NOTIFY_URL ?? "",
returnUrl: process.env.TELEBIRR_RETURN_URL ?? "",
timeoutExpress: process.env.TELEBIRR_TIMEOUT_EXPRESS ?? "15m",
privateKey: process.env.TELEBIRR_PRIVATE_KEY ?? "",
publicKey: process.env.TELEBIRR_PUBLIC_KEY ?? "",
insecureTls: process.env.TELEBIRR_INSECURE_TLS === "true",
}));

View File

@@ -4,56 +4,44 @@ import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
import { PaymentService } from '../payment/payment.service';
import { PaymentStatus } from '../payment/entities/payment.entity';
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
"action-required",
"processing",
"success",
];
@Injectable()
export class BookingPaymentService {
constructor(private readonly bookingsRepository: BookingsRepository, private readonly paymentService: PaymentService) { }
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly paymentService: PaymentService,
) { }
async pay(
bookingId: string,
): Promise<{ redirectUrl: string }> {
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED']);
assertBookingStatus(booking, ['FULLY_EXECUTED', '']);
// const receipt = this.buildMockReceipt(booking);
// const updated = await this.bookingsRepository.update(bookingId, {
// status: 'PAID',
// paymentStatus: 'PAID',
// } as never);
const resp = await this.paymentService.pay(booking.totalAmount, "ETB", "telebirr", "payment for booking", 'booking', (_) => {
return new Promise((resp, _) => {
resp({
id: booking.id,
type: "booking"
})
});
})
return {
redirectUrl: resp.clientAction.type == "REDIRECT" ? `http://localhost:3001/api/payments/telebirr/${booking.id}` : ""
const existing = await this.paymentService.findBookingById(bookingId);
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
if (existing.clientAction) {
const action = existing.clientAction as { type?: string; url?: string };
if (action.type === "REDIRECT" && action.url) {
return { redirectUrl: action.url };
}
}
}
const resp = await this.paymentService.initBookingTelebirr(bookingId, "web");
return {
redirectUrl:
resp.redirectUrl ?? "",
};
}
// private buildMockReceipt(booking: Booking): InAppPaymentReceipt {
// const timestamp = Date.now();
// const isEtb = booking.paymentCurrency === 'ETB';
// const prefix = isEtb ? 'TB' : 'CARD';
// const provider = isEtb ? 'TELEBIRR' : 'CARD';
// return {
// success: true,
// provider,
// providerRef: `${prefix}-${booking.reference}-${timestamp}`,
// amount: booking.totalAmount,
// currency: booking.paymentCurrency,
// paidAt: new Date().toISOString(),
// };
// }
private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findById(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);

View File

@@ -1,6 +0,0 @@
import { IsString } from "class-validator";
export class InitiateBookingPayment {
@IsString()
bookingId!: string;
}

View File

@@ -4,7 +4,7 @@ import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn }
type PaymentType = "booking"
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr"
type Currency = "ETB" | "USD"
type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
@Entity({ schema: 'freight', name: 'payments' })
export class PaymentEntity extends BaseEntity {

View File

@@ -1,53 +1,31 @@
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
import { Controller, Get, NotFoundException, Param, Post, Res } from "@nestjs/common";
import { PaymentService } from "./payment.service";
import { Public } from "@edr/api-common";
// import { randomUUID } from "crypto";
import { Response } from "express"
@Public()
@Controller("payments")
export class PaymentController {
constructor(private readonly paymentService: PaymentService,) { }
constructor(private readonly paymentService: PaymentService,) { }
@Post("/initiate")
initiate() {
return this.paymentService.initBookingTelebirr("123", "web")
}
// @Get("/receipts/:orderId/html")
// async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) {
// const filled = await this.paymentService.genReceiptHtml(orderId);
// return res.send(filled)
// }
@Post("/bookings/check-payment/:orderId")
checkPayment(@Param("orderId") orderId: string) {
return this.paymentService.checkStatusAndUpdate(orderId)
}
// @Post("/initiate/booking")
// async initiatePayment() {
// //Only for testing..
// const description = "Booking for contact"
// const price = 2000
// const data = await this.paymentService.pay(price, "ETB", "telebirr", description, "booking", (_) => {
// return new Promise((resp, _) => {
// resp({
// id: randomUUID(),
// type: "booking"
// })
// });
// })
// return data
// }
@Post("/bookings/check-payment/:orderId")
checkPayment(@Param("orderId") orderId: string) {
return this.paymentService.checkStatusAndUpdate(orderId)
@Get("/bookings/telebirr/redirect/:orderId")
async pay(@Param("orderId") orderId: string, @Res() res: Response) {
const payment = await this.paymentService.getActivePaymentByOrderIdAndMethod(orderId, "telebirr")
if (!payment) {
throw new NotFoundException('payment not found')
}
@Get("/telebirr/:refId")
async pay(@Param("refId", ParseUUIDPipe) refId: string, @Res() res: Response) {
const payment = await this.paymentService.getActivePaymentByRefIdAndMethod(refId, "telebirr")
if (!payment) {
throw new NotFoundException('payment not found')
}
return res.send(`
return res.send(`
<!DOCTYPE html>
<html>
<head>
@@ -62,6 +40,5 @@ export class PaymentController {
</body>
</html>
`);
}
}
}

View File

@@ -1,5 +1,4 @@
import { Module } from "@nestjs/common";
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
import { PaymentService } from "./payment.service";
import { HttpModule } from "@nestjs/axios";
import { PaymentController } from "./payment.controller";
@@ -7,10 +6,11 @@ import { ConfigModule } from "@nestjs/config";
import { PaymentRepository } from "./payment.repository";
import { WebhookController } from "./webhooks/webhook.controller";
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
import { TelebirrProvider } from "@edr/payment-providers";
@Module({
imports: [HttpModule, ConfigModule],
providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService],
providers: [PaymentRepository, PaymentService, TelebirrWebhookService, TelebirrProvider],
controllers: [PaymentController, WebhookController],
exports: [PaymentService]
})

View File

@@ -14,6 +14,13 @@ export class PaymentRepository {
return qr.manager.save(payment)
}
async create(data: Pick<PaymentEntity, "amount" | "method" | "currency" | "type" | "refId" | "merchantOrderId" | "rawInitiation" | "clientAction" | "expiresAt" | "reason">): Promise<PaymentEntity> {
const payment = this.paymentRepo.create(data)
return this.paymentRepo.save(payment)
}
findOneBy(options: FindOptionsWhere<PaymentEntity> | FindOptionsWhere<PaymentEntity>[]): Promise<PaymentEntity | null> {
return this.paymentRepo.findOneBy(options);
}
@@ -36,4 +43,20 @@ export class PaymentRepository {
getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]) {
return this.paymentRepo
.createQueryBuilder('payment')
.where('payment.method = :method', { method })
.andWhere('payment.merchantOrderId = :orderId', { orderId })
.andWhere('payment.status IN (:...statuses)', {
statuses: ['action-required'],
})
.andWhere('payment.expiresAt > :now', { now: new Date() })
.getOne();
}
}

View File

@@ -1,16 +1,12 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
NotFoundException,
BadRequestException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from "@nestjs/common";
import { DataSource, QueryRunner } from "typeorm";
import { DataSource } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentStrategy } from "./strategies/payment.strategy";
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
import { PaymentRepository } from "./payment.repository";
import { ClientAction, PaymentPlatform } from "./strategies/payments.types";
import * as crypto from "crypto";
import * as fs from "fs";
import * as path from "path";
@@ -19,66 +15,54 @@ import { ConfigService } from "@nestjs/config";
import { SchedulingStatus } from "@edr/types";
import { Booking } from "../bookings/entities/booking.entity";
type PaymentMethod = PaymentEntity["method"];
type CurrencyType = PaymentEntity["currency"];
import {
ClientAction,
createMerchantOrderId,
ProviderPaymentStatus,
TelebirrProvider,
} from "@edr/payment-providers";
import { ProviderInitiationInput } from "@edr/types"
import { InitiateResponseDto, PaymentPlatformDto } from "./payments.dto";
const DEFAULT_CURRENCY = "ETB";
@Injectable()
export class PaymentService {
private strategies: Map<PaymentMethod, PaymentStrategy>;
constructor(
private readonly configService: ConfigService,
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrProvider: TelebirrProvider,
) { }
constructor(
private readonly configService: ConfigService,
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrPaymentStategy: PaymentTelebirrStrategy,
) {
this.strategies = new Map([
["telebirr", this.telebirrPaymentStategy as PaymentStrategy],
]);
}
async initBookingTelebirr(
bookingId: string,
platform: PaymentPlatformDto,
): Promise<{ redirectUrl: string }> {
// const booking = await this.datasource.getRepository(Booking).findOneBy({ id: bookingId });
// if (!booking) throw new NotFoundException("Booking not found");
async pay(
amount: number,
currency: CurrencyType,
method: PaymentMethod,
reason: string,
type: PaymentEntity["type"],
cb: (
qr: QueryRunner,
) => Promise<{ id: string; type: PaymentEntity["type"] }>,
payform: PaymentPlatform = "web",
): Promise<{
refId: string;
clientAction: ClientAction;
status: PaymentEntity["status"];
paidAt?: string;
failureCode?: string;
failureMessage?: string;
}> {
const strategy = this.strategies.get(method);
if (!strategy) {
throw new NotFoundException("strategy not found");
}
// const booking = new Booking()
// booking.totalAmount = 20
// booking.id = randomUUID
const amount = 20
const merchantOrderId = createMerchantOrderId();
const redirectBase = this.configService.get<string>("TELEBIRR_SUCCESS_BOOKING_REDIRECT_BASE_URL");
const redirectUrl = `${redirectBase}/${merchantOrderId}`;
const amountMinor = Math.round(Number(amount) * 100);
const orderId = `${Date.now()}${crypto.randomBytes(4).toString("hex")}`; //todo: make it dynamic
let redirectUrl: string;
switch (type) {
case "booking":
const url = this.configService.get<string>(
"TELEBIRR_SUCCESS_REDIRECT_BASE_URL",
);
redirectUrl = `${url}/${orderId}`;
break;
}
const input: ProviderInitiationInput = {
merchantOrderId,
orderRef: bookingId,
amountMinor,
currency: DEFAULT_CURRENCY,
platform: platform || "web",
redirectUrl,
};
const paymentResp = await strategy.pay({
redirectUrl,
amountMinor: amount,
currency: currency,
merchantOrderId: orderId,
platform: payform,
});
const result = await this.telebirrProvider.initiate(input);
<<<<<<< HEAD
const queryRunner = this.datasource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
@@ -178,20 +162,102 @@ export class PaymentService {
holdExpiresAt: holdExpires,
});
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" });
=======
const payment = await this.paymentRepo.create({
amount: amount,
currency: DEFAULT_CURRENCY,
method: "telebirr",
refId: bookingId,
type: "booking",
merchantOrderId,
rawInitiation: result.rawInitiation,
clientAction: result.clientAction as Record<string, unknown>,
expiresAt: result.expiresAt,
reason: `Payment for booking`,
>>>>>>> eda21e22d872344b74c0c72308f87ce7435b299f
});
}
return {
status: result.status,
};
} catch {
// Telebirr API unavailable — fall back to current DB payment status
const dbStatus =
resp.status === "success"
? "success"
: resp.status === "failed"
? "failed"
: "processing";
return { status: dbStatus };
return {
redirectUrl: `${this.configService.get<string>("TELEBIRR_REDIRECT_BASE_URL")}/${payment.merchantOrderId}`
}
}
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method)
}
async genReceiptHtml(orderId: string) {
const payment = await this.paymentRepo.findOneBy({
merchantOrderId: orderId,
status: "success"
})
if (!payment) {
throw new BadRequestException()
}
const filePath = path.join(__dirname, "templates", "receipt.hbs");
if (!fs.existsSync(filePath)) {
throw new InternalServerErrorException()
}
const source = fs.readFileSync(filePath, "utf8");
const template = Handlebars.compile(source);
const html = template({
vendorName: "Ethio Djibouti Railway Ticket Booking",
vendorAddress: "Addis Ababa",
receiptDate: payment.paidAt,
paymentMethod: payment?.method,
subtotal: payment?.amount.toString(),
total: payment?.amount.toString(),
currency: payment?.currency,
reason: payment?.reason
});
return html;
}
async checkStatusAndUpdate(orderId: string) {
const resp = await this.paymentRepo.findOneBy({ merchantOrderId: orderId })
if (!resp) {
throw new NotFoundException("order id not found")
}
const result = await this.telebirrProvider.queryStatus(resp.merchantOrderId)
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
await this.datasource.transaction(async (mg) => {
await mg.update(Booking, { id: resp.refId }, { status: "PAID" })
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" })
})
}
return {
status: result.status
}
}
findBookingById(id: string) {
return this.paymentRepo.findOneBy({ refId: id, type: "booking" })
}
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
const clientAction =
intent.clientAction && typeof intent.clientAction === "object"
? (intent.clientAction as unknown as ClientAction)
: undefined;
const statusMap: Record<string, ProviderPaymentStatus> = {
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
"processing": ProviderPaymentStatus.PROCESSING,
"success": ProviderPaymentStatus.SUCCEEDED,
"failed": ProviderPaymentStatus.FAILED,
"canceled": ProviderPaymentStatus.CANCELLED,
"refunded": ProviderPaymentStatus.CANCELLED,
};
return {
intentId: intent.id,
status: statusMap[intent.status] ?? ProviderPaymentStatus.PROCESSING,
clientAction,
merchantOrderId: intent.merchantOrderId ?? undefined,
};
}
}
}

View File

@@ -0,0 +1,62 @@
import { ProviderPaymentStatus } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString } from "class-validator";
export type PaymentPlatformDto = "web" | "mobile";
export class InitiatePaymentDto {
@ApiProperty({ example: "booking-uuid" })
@IsString()
bookingId!: string;
@ApiProperty({ enum: ["TELEBIRR"], example: "TELEBIRR" })
@IsIn(["TELEBIRR"])
method!: "TELEBIRR";
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
@IsOptional()
@IsIn(["web", "mobile"])
platform?: PaymentPlatformDto;
}
export class ClientActionDto {
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] })
type!: "REDIRECT" | "LAUNCH_APP";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
appId?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
receiveCode?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
shortCode?: string;
}
export class InitiateResponseDto {
@ApiProperty()
intentId!: string;
@ApiProperty({ enum: ProviderPaymentStatus })
status!: ProviderPaymentStatus;
@ApiPropertyOptional({ type: ClientActionDto })
clientAction?: ClientActionDto;
@ApiPropertyOptional()
merchantOrderId?: string;
}
export class IntentStatusDto extends InitiateResponseDto {
@ApiPropertyOptional()
paidAt?: string;
@ApiPropertyOptional()
failureCode?: string;
@ApiPropertyOptional()
failureMessage?: string;
}

View File

@@ -1,8 +0,0 @@
import { Injectable } from "@nestjs/common";
import { ProviderInitiationInput, ProviderInitiationResult } from "./payments.types";
@Injectable()
export abstract class PaymentStrategy {
abstract pay(data: ProviderInitiationInput): Promise<ProviderInitiationResult>
}

View File

@@ -1,304 +0,0 @@
import { Injectable, Logger } from "@nestjs/common";
import { PaymentStrategy } from "./payment.strategy";
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as https from 'node:https';
import { PaymentEntity } from "../entities/payment.entity";
import { ProviderInitiationInput, ProviderInitiationResult, ProviderStatus } from "./payments.types";
import { CreateOrderRequest, CreateOrderResponse, FabricTokenResponse, QueryOrderResponse } from "./telebirr/telebirr.types";
import { createNonceStr, createTimestamp, signRequestObject, verifyRequestObject } from "./telebirr/telebirr.crypto";
// type PaymentCurrency = PaymentEntity["currency"]
type PaymentIntentStatus = PaymentEntity["status"]
const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
@Injectable()
export class PaymentTelebirrStrategy implements PaymentStrategy {
async pay(data: ProviderInitiationInput): Promise<any> {
// const refId = randomUUID()
// const orderId = createMerchantOrderId()
const resp = await this.initiate(data)
return resp;
}
// readonly method = PaymentMethodType.TELEBIRR;
private readonly logger = new Logger(PaymentTelebirrStrategy.name);
private readonly httpsAgent: https.Agent;
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {
const insecure = this.config.get<boolean>('telebirr.insecureTls');
if (insecure) {
this.logger.warn('TELEBIRR_INSECURE_TLS=true — TLS verification disabled for Telebirr calls. DEV ONLY.');
}
this.httpsAgent = new https.Agent({
rejectUnauthorized: !insecure,
secureProtocol: 'TLSv1_2_method',
});
}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const fabricToken = await this.applyFabricToken();
const requestBody = this.buildCreateOrderRequest(input);
const response = await this.requestCreateOrder(fabricToken, requestBody);
const prepayId = response.biz_content?.prepay_id;
if (!prepayId) {
throw new Error(
`Telebirr createOrder returned no prepay_id: ${JSON.stringify(response)}`,
);
}
const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express);
const platform = input.platform ?? 'web';
const clientAction =
platform === 'mobile'
? {
type: 'LAUNCH_APP' as const,
prepayId,
receiveCode: response.biz_content?.receiveCode,
shortCode: this.merchantCode,
}
: { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) };
return {
providerOrderId: prepayId,
clientAction,
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const fabricToken = await this.applyFabricToken();
const requestBody = this.buildQueryOrderRequest(merchantOrderId);
const response = await this.postJson<QueryOrderResponse>(
`${this.baseUrl}/payment/v1/merchant/queryOrder`,
requestBody,
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
Authorization: fabricToken,
},
);
const tradeStatus = response.biz_content?.trade_status;
const providerTxnId =
response.biz_content?.trans_id ?? response.biz_content?.payment_order_id;
const mapped = this.mapTradeStatus(tradeStatus);
return {
status: mapped,
providerTxnId,
failureCode:
mapped === "failed" && tradeStatus ? tradeStatus : undefined,
rawResponse: response as Record<string, unknown>,
};
}
mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
switch (tradeStatus) {
case 'PAY_SUCCESS':
return "success";
case 'PAY_FAILED':
case 'ORDER_CLOSED':
return "failed";
case 'WAIT_PAY':
return "action-required";
case 'PAYING':
return "processing";
default:
return "processing";
}
}
mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
switch (tradeStatus) {
case 'Completed':
return "success";
case 'Failure':
case 'Expired':
return "failed";
case 'Paying':
case 'Pending':
return "processing";
default:
return "processing";
}
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
if (!this.publicKey) {
this.logger.error('TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks');
return false;
}
return verifyRequestObject(payload, this.publicKey);
}
private async applyFabricToken(): Promise<string> {
console.log(this.baseUrl, "base url")
const response = await this.postJson<FabricTokenResponse>(
`${this.baseUrl}/payment/v1/token`,
{ appSecret: this.appSecret },
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
},
);
if (!response?.token) {
throw new Error(`Telebirr token request failed: ${JSON.stringify(response)}`);
}
return response.token;
}
private async requestCreateOrder(
fabricToken: string,
body: CreateOrderRequest,
): Promise<CreateOrderResponse> {
return this.postJson<CreateOrderResponse>(
`${this.baseUrl}/payment/v1/inapp/createOrder`,
body,
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
Authorization: fabricToken,
},
);
}
private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest {
// const totalAmount = String(input.amountMinor / 100);
const totalAmount = String(input.amountMinor)
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
method: 'payment.preorder' as const,
version: '1.0' as const,
biz_content: {
notify_url: this.notifyUrl,
appid: this.merchantAppId,
redirect_url: input.redirectUrl,
merch_code: this.merchantCode,
merch_order_id: input.merchantOrderId,
trade_type: 'Checkout' as const,
title: `EDR Booking`,
total_amount: totalAmount,
trans_currency: input.currency,
timeout_express: this.timeoutExpress,
},
};
const sign = signRequestObject(req as unknown as Record<string, unknown>, this.privateKey);
return { ...req, sign, sign_type: 'SHA256WithRSA' };
}
private buildQueryOrderRequest(merchantOrderId: string): Record<string, unknown> {
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
method: 'payment.queryorder',
version: '1.0',
biz_content: {
appid: this.merchantAppId,
merch_code: this.merchantCode,
merch_order_id: merchantOrderId,
},
};
const sign = signRequestObject(req as Record<string, unknown>, this.privateKey);
return { ...req, sign, sign_type: 'SHA256WithRSA' };
}
private buildCheckoutUrl(prepayId: string): string {
const map: Record<string, string> = {
appid: this.merchantAppId,
merch_code: this.merchantCode,
nonce_str: createNonceStr(),
prepay_id: prepayId,
timestamp: createTimestamp(),
};
const sign = signRequestObject(map, this.privateKey);
const rawRequest = [
`appid=${map.appid}`,
`merch_code=${map.merch_code}`,
`nonce_str=${map.nonce_str}`,
`prepay_id=${map.prepay_id}`,
`timestamp=${map.timestamp}`,
'sign_type=SHA256WithRSA',
`sign=${sign}`,
'version=1.0',
'trade_type=Checkout',
].join('&');
return `${this.webBaseUrl}${rawRequest}`;
}
private computeExpiresAt(timeoutExpress: string): Date {
const match = /^(\d+)([smhd])$/.exec(timeoutExpress);
const minutes = match ? this.toMinutes(parseInt(match[1], 10), match[2]) : 15;
return new Date(Date.now() + minutes * 60_000);
}
private toMinutes(n: number, unit: string): number {
switch (unit) {
case 's': return Math.max(1, Math.round(n / 60));
case 'm': return n;
case 'h': return n * 60;
case 'd': return n * 60 * 24;
default: return 15;
}
}
private async postJson<T>(
url: string,
body: unknown,
headers: Record<string, string>,
): Promise<T> {
const config: AxiosRequestConfig = {
headers,
timeout: TELEBIRR_HTTP_TIMEOUT_MS,
httpsAgent: this.httpsAgent,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(`Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`Telebirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
);
} else {
this.logger.error(`Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private sanitize(body: CreateOrderRequest): Record<string, unknown> {
const { sign: _sign, ...rest } = body;
return rest;
}
private get baseUrl(): string { return this.config.get<string>('telebirr.baseUrl') ?? ''; }
private get webBaseUrl(): string { return this.config.get<string>('telebirr.webBaseUrl') ?? ''; }
private get fabricAppId(): string { return this.config.get<string>('telebirr.fabricAppId') ?? ''; }
private get appSecret(): string { return this.config.get<string>('telebirr.appSecret') ?? ''; }
private get merchantAppId(): string { return this.config.get<string>('telebirr.merchantAppId') ?? ''; }
private get merchantCode(): string { return this.config.get<string>('telebirr.merchantCode') ?? ''; }
private get notifyUrl(): string { return this.config.get<string>('telebirr.notifyUrl') ?? ''; }
private get timeoutExpress(): string { return this.config.get<string>('telebirr.timeoutExpress') ?? '15m'; }
private get privateKey(): string { return this.config.get<string>('telebirr.privateKey') ?? ''; }
private get publicKey(): string {
return this.config.get<string>('telebirr.publicKey') ?? '';
}
}

View File

@@ -1,40 +0,0 @@
import { PaymentEntity } from "../entities/payment.entity";
type PaymentIntentStatus = PaymentEntity["status"]
type PaymentMethodType = PaymentEntity["method"]
export type PaymentPlatform = 'web' | 'mobile';
export type ClientAction =
| { type: 'REDIRECT'; url: string }
| { type: 'LAUNCH_APP'; prepayId: string; receiveCode?: string; shortCode: string };
export interface ProviderInitiationInput {
redirectUrl: string;
merchantOrderId: string;
// bookingRef: string;
amountMinor: number;
currency: string;
platform?: PaymentPlatform;
}
export interface ProviderInitiationResult {
providerOrderId: string;
clientAction: ClientAction;
expiresAt: Date;
rawInitiation: Record<string, unknown>;
}
export interface ProviderStatus {
status: PaymentIntentStatus;
providerTxnId?: string;
failureCode?: string;
failureMessage?: string;
rawResponse: Record<string, unknown>;
}
export interface PaymentProvider {
readonly method: PaymentMethodType;
initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult>;
queryStatus(merchantOrderId: string): Promise<ProviderStatus>;
}

View File

@@ -1,98 +0,0 @@
import * as crypto from 'crypto';
const EXCLUDE_FIELDS = new Set([
'sign',
'sign_type',
'header',
'refund_info',
'openType',
'raw_request',
'biz_content',
]);
const NONCE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export function buildCanonicalString(requestObject: Record<string, unknown>): string {
const fieldMap: Record<string, unknown> = {};
for (const key of Object.keys(requestObject)) {
if (EXCLUDE_FIELDS.has(key)) continue;
fieldMap[key] = requestObject[key];
}
const biz = requestObject['biz_content'];
if (biz && typeof biz === 'object') {
for (const key of Object.keys(biz as Record<string, unknown>)) {
if (EXCLUDE_FIELDS.has(key)) continue;
fieldMap[key] = (biz as Record<string, unknown>)[key];
}
}
return Object.keys(fieldMap)
.sort()
.map((k) => `${k}=${fieldMap[k]}`)
.join('&');
}
export function signRequestObject(
requestObject: Record<string, unknown>,
privateKey: string,
): string {
return signString(buildCanonicalString(requestObject), privateKey);
}
export function verifyRequestObject(
requestObject: Record<string, unknown>,
publicKey: string,
): boolean {
const signature = requestObject['sign'];
if (typeof signature !== 'string' || signature.length === 0) return false;
return verifySignature(buildCanonicalString(requestObject), signature, publicKey);
}
export function signString(text: string, privateKey: string): string {
const signature = crypto.sign('sha256', Buffer.from(text), {
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
});
return signature.toString('base64');
}
export function verifySignature(
text: string,
signatureBase64: string,
publicKey: string,
): boolean {
try {
return crypto.verify(
'sha256',
Buffer.from(text),
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
},
Buffer.from(signatureBase64, 'base64'),
);
} catch {
return false;
}
}
export function createTimestamp(): string {
return Math.round(Date.now() / 1000).toString();
}
export function createNonceStr(length = 32): string {
const bytes = crypto.randomBytes(length);
let out = '';
for (let i = 0; i < length; i++) {
out += NONCE_CHARS[bytes[i] % NONCE_CHARS.length];
}
return out;
}
export function createMerchantOrderId(): string {
return `${Date.now()}${crypto.randomBytes(4).toString('hex')}`;
}

View File

@@ -1,69 +0,0 @@
export interface FabricTokenResponse {
token: string;
expires_in?: number | string;
}
export interface CreateOrderBizContent {
notify_url: string;
appid: string;
merch_code: string;
merch_order_id: string;
trade_type: 'Checkout' | 'InApp' | 'MiniApp';
title: string;
total_amount: string;
trans_currency: string;
timeout_express: string;
}
export interface CreateOrderRequest {
timestamp: string;
nonce_str: string;
method: 'payment.preorder';
version: '1.0';
biz_content: CreateOrderBizContent;
sign: string;
sign_type: 'SHA256WithRSA';
}
export interface CreateOrderResponse {
code?: string;
msg?: string;
biz_content?: {
prepay_id?: string;
receiveCode?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export type TelebirrTradeStatus =
| 'PAY_SUCCESS'
| 'PAY_FAILED'
| 'WAIT_PAY'
| 'ORDER_CLOSED'
| 'PAYING'
| 'ACCEPTED'
| 'REFUNDING'
| 'REFUND_SUCCESS'
| 'REFUND_FAILED';
export interface QueryOrderResponse {
result?: 'SUCCESS' | 'FAIL';
code?: string;
msg?: string;
nonce_str?: string;
sign?: string;
sign_type?: string;
biz_content?: {
merch_order_id?: string;
order_status?: string;
trade_status?: TelebirrTradeStatus | string;
payment_order_id?: string;
trans_id?: string;
trans_time?: string;
trans_currency?: string;
total_amount?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}

View File

@@ -1,82 +1,53 @@
import { Injectable, } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from "crypto"
import { Injectable, Logger } from '@nestjs/common';
import { TelebirrDto } from '../dto/telebirr.dto';
import { PaymentRepository } from '../../payment.repository';
import { DataSource } from 'typeorm';
import { Booking } from 'src/modules/bookings/entities/booking.entity';
import { Booking } from '../../../bookings/entities/booking.entity';
import { TelebirrProvider, ProviderPaymentStatus } from '@edr/payment-providers';
@Injectable()
export class TelebirrWebhookService {
// private readonly logger = new Logger(TelebirrWebhookService.name);
private readonly logger = new Logger(TelebirrWebhookService.name);
constructor(
private readonly datasource: DataSource,
private readonly config: ConfigService,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrProvider: TelebirrProvider,
) { }
verifyTelebirrNotification(payload: TelebirrDto) {
// 1. Extract the signature provided by Telebirr
const { sign, ...bizContent } = payload;
if (!sign) {
throw new Error("Missing 'sign' field from Telebirr payload");
}
// 2. Sort the remaining keys alphabetically to rebuild the raw string
const sortedKeys = Object.keys(bizContent).sort();
const signString = sortedKeys
.map(key => `${key}=${typeof bizContent[key] === 'object' ? JSON.stringify(bizContent[key]) : bizContent[key]}`)
.join('&');
// 3. Convert Telebirr's public key into an object specifying RSA-PSS padding
const publicKey = {
key: this.config.get<string>("telebirr.publicKey") ?? "",
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: 32 // Telebirr standard salt length
};
// 4. Verify the signature against the sorted string
const isVerified = crypto.verify(
"sha256",
Buffer.from(signString),
publicKey,
Buffer.from(sign, 'base64')
);
return isVerified;
return this.telebirrProvider.verifyWebhookSignature(payload as unknown as Record<string, unknown>);
}
async handle(payload: TelebirrDto): Promise<void> {
const payment = await this.paymentRepo.findOneBy({ merchantOrderId: payload.merch_order_id })
if (!payment) {
throw new Error("payment not found")
this.logger.warn(`Webhook received for unknown merchantOrderId: ${payload.merch_order_id}`);
return;
}
switch (payload.trade_status) {
case "SUCCEEDED":
await this.paymentRepo.update({ id: payment.id }, { status: "success", paidAt: new Date() })
switch (payment.type) {
case "booking":
await this.datasource.manager.update(Booking, { id: payment.refId }, { paymentStatus: "PAID", })
// await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID", })
break;
const mapped = this.telebirrProvider.mapWebhookTradeStatus(payload.trade_status);
switch (mapped) {
case ProviderPaymentStatus.SUCCEEDED:
await this.paymentRepo.update(
{ id: payment.id },
{ status: "success", paidAt: new Date() },
);
if (payment.type === "booking") {
await this.datasource.manager.update(
Booking,
{ id: payment.refId },
{ paymentStatus: "PAID" },
);
}
break;
case "FAILED":
await this.paymentRepo.update({ id: payment.id }, { status: "failed" })
case ProviderPaymentStatus.FAILED:
await this.paymentRepo.update({ id: payment.id }, { status: "failed" });
break;
case "CANCELLED":
await this.paymentRepo.update({ id: payment.id }, { status: "canceled" })
case ProviderPaymentStatus.PROCESSING:
await this.paymentRepo.update({ id: payment.id }, { status: "processing" });
break;
case "PROCESSING":
await this.paymentRepo.update({ id: payment.id }, { status: "processing" })
break;
case "REFUNDED":
await this.paymentRepo.update({ id: payment.id }, { status: "refunded" })
break;
}
}
}

View File

@@ -22,14 +22,12 @@ export class WebhookController {
);
try {
// const verified = this.telebirr.verifyTelebirrNotification(payload)
// if (!verified) {
// throw new Error("not valid")
// }
// const merchantOrderId = payload.merch_order_id;
const verified = this.telebirr.verifyTelebirrNotification(payload)
if (!verified) {
throw new Error("Telebirr webhook signature verification failed")
}
await this.telebirr.handle(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Telebirr webhook handler threw: ${message}`);

View File

@@ -11,42 +11,60 @@ import {
Min,
} from 'class-validator';
const parseLoadTypes = (value: unknown): string[] => {
const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? value : Number(value);
const toOptionalNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? undefined : Number(value);
const toBoolean = ({ value }: { value: unknown }) => {
if (typeof value === 'boolean') return value;
if (value === 'true') return true;
if (value === 'false') return false;
return value;
};
const toStringArray = ({ value }: { value: unknown }) => {
if (Array.isArray(value)) {
return value.map((item) => String(item).trim()).filter(Boolean);
return value.map((entry) => String(entry).trim()).filter(Boolean);
}
if (typeof value === 'string') {
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
return [];
if (typeof value !== 'string') return [];
return value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
};
export class CreateWagonTypeDto {
@ApiProperty({ description: 'Display name, e.g. "Flat Wagon"', maxLength: 100 })
@ApiProperty({ maxLength: 32, example: 'NW5' })
@IsString()
@MaxLength(32)
code!: string;
@ApiProperty({ maxLength: 100, example: 'Flat wagon container' })
@IsString()
@MaxLength(100)
name!: string;
@ApiProperty({ description: 'Maximum payload capacity in metric tons' })
@ApiProperty({ description: 'Maximum payload capacity in metric tons', example: 70 })
@Transform(toNumber)
@IsNumber()
@Min(0.001)
@Transform(({ value }) => Number(value))
capacityTons!: number;
@ApiProperty({ description: 'Wagon length in meters' })
@ApiProperty({ description: 'Wagon length in meters', example: 14 })
@Transform(toNumber)
@IsNumber()
@Min(0.001)
@Transform(({ value }) => Number(value))
lengthMeters!: number;
@ApiPropertyOptional({ description: 'Maximum wagons of this type per train' })
@ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 53 })
@IsOptional()
@Transform(toOptionalNumber)
@IsInt()
@Min(1)
@Transform(({ value }) => (value === '' || value === null || value === undefined ? undefined : Number(value)))
maxWagonsPerTrain?: number;
@ApiPropertyOptional({
@@ -55,13 +73,14 @@ export class CreateWagonTypeDto {
default: [],
})
@IsOptional()
@Transform(toStringArray)
@IsArray()
@IsString({ each: true })
@Transform(({ value }) => parseLoadTypes(value))
supportedLoadTypes?: string[];
@ApiPropertyOptional({ default: true })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
isActive?: boolean;
}

View File

@@ -28,11 +28,18 @@ export class WagonTypesController {
@Get()
@RuleEngineView('wagon-types')
@ApiOperation({ summary: 'List wagon types' })
findAll(@Query() query: Record<string, string>) {
findAll(@Query() query: Record<string, string | undefined>) {
return this.wagonTypesService.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
isActive:
query.isActive === 'all'
? undefined
: query.isActive !== undefined
? query.isActive === 'true'
: true,
page: query.page ? parseInt(query.page, 10) : undefined,
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
sortBy: query.sortBy,
sortOrder: query.sortOrder,
});
}

View File

@@ -1,38 +1,39 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { generateCode } from '../../common/utils/generate-code.util';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsOrder } from 'typeorm';
import { CreateWagonTypeDto } from './dto/create-wagon-type.dto';
import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
import { WagonType } from './entities/wagon-type.entity';
import { WagonTypesRepository } from './wagon-types.repository';
type WagonTypeListFilter = {
isActive?: boolean;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: string;
};
@Injectable()
export class WagonTypesService {
constructor(private readonly wagonTypesRepository: WagonTypesRepository) {}
async findAll(filter: {
isActive?: boolean;
page?: number;
pageSize?: number;
} = {}): Promise<{
async findAll(filter: WagonTypeListFilter = {}): Promise<{
data: WagonType[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) {
where.isActive = filter.isActive;
}
const pageSize = filter.pageSize ?? 500;
const sortBy = ['code', 'name', 'capacityTons', 'lengthMeters', 'isActive'].includes(
filter.sortBy ?? '',
)
? (filter.sortBy as keyof WagonType)
: 'code';
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const [data, total] = await this.wagonTypesRepository.findAndCount({
where,
order: { code: 'ASC' },
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
order: { [sortBy]: sortOrder } as FindOptionsOrder<WagonType>,
skip: (page - 1) * pageSize,
take: pageSize,
});
@@ -50,9 +51,11 @@ export class WagonTypesService {
async findById(id: string): Promise<WagonType> {
const wagonType = await this.wagonTypesRepository.findById(id);
if (!wagonType) {
throw new NotFoundException(`Wagon type ${id} not found`);
}
return wagonType;
}
@@ -65,17 +68,16 @@ export class WagonTypesService {
}
async create(dto: CreateWagonTypeDto): Promise<WagonType> {
const code = generateCode(dto.name);
const code = dto.code.trim().toUpperCase();
const existing = await this.wagonTypesRepository.findByCode(code);
if (existing) {
throw new ConflictException(
`Wagon type with name "${dto.name}" conflicts with existing code "${code}"`,
);
throw new ConflictException(`Wagon type code "${code}" already exists`);
}
return this.wagonTypesRepository.create({
code,
name: dto.name,
name: dto.name.trim(),
capacityTons: dto.capacityTons,
lengthMeters: dto.lengthMeters,
maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null,
@@ -85,11 +87,29 @@ export class WagonTypesService {
}
async update(id: string, dto: UpdateWagonTypeDto): Promise<WagonType> {
await this.findById(id);
const updated = await this.wagonTypesRepository.update(id, dto);
const wagonType = await this.findById(id);
const nextCode = dto.code?.trim().toUpperCase();
if (nextCode && nextCode !== wagonType.code) {
const existing = await this.wagonTypesRepository.findByCode(nextCode);
if (existing) {
throw new ConflictException(`Wagon type code "${nextCode}" already exists`);
}
}
const updated = await this.wagonTypesRepository.update(id, {
...dto,
...(nextCode ? { code: nextCode } : {}),
...(dto.name ? { name: dto.name.trim() } : {}),
maxWagonsPerTrain:
dto.maxWagonsPerTrain === undefined ? undefined : dto.maxWagonsPerTrain ?? null,
supportedLoadTypes: dto.supportedLoadTypes ?? undefined,
});
if (!updated) {
throw new NotFoundException(`Wagon type ${id} not found`);
}
return updated;
}

View File

@@ -1,18 +0,0 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/edr-freight-web/backoffice/package.json ./apps/edr-freight-web/backoffice/
COPY packages ./packages
RUN pnpm install --frozen-lockfile --filter @edr/freight-backoffice...
FROM deps AS build
COPY apps/edr-freight-web/backoffice ./apps/edr-freight-web/backoffice
RUN pnpm --filter @edr/freight-backoffice build
FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/apps/edr-freight-web/backoffice/dist /usr/share/nginx/html
EXPOSE 5183
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,18 +0,0 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/edr-freight-web/portal/package.json ./apps/edr-freight-web/portal/
COPY packages ./packages
RUN pnpm install --frozen-lockfile --filter @edr/freight-portal...
FROM deps AS build
COPY apps/edr-freight-web/portal ./apps/edr-freight-web/portal
RUN pnpm --filter @edr/freight-portal build
FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/apps/edr-freight-web/portal/dist /usr/share/nginx/html
EXPOSE 5173
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -95,6 +95,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/trains",
icon: <Train />,
},
{
label: "Wagon types",
href: "/dashboard/wagon-types",
icon: <Boxes />,
},
{
label: "Wagons",
href: "/dashboard/wagons",

View File

@@ -137,6 +137,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
},
},
...rulesRouteMeta,
{
prefix: "/dashboard/wagon-types",
meta: {
title: "Wagon Types",
subtitle: "Manage wagon type capacity and supported load configuration",
},
},
{
prefix: "/dashboard/user1",
meta: {

View File

@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { wagonTypesService } from '@/services/wagon-types.service';
export const WAGON_TYPES_QUERY_KEY = ['wagon-types'];
@@ -7,6 +7,30 @@ export function useWagonTypes() {
return useQuery({
queryKey: WAGON_TYPES_QUERY_KEY,
queryFn: () => wagonTypesService.getWagonTypes(),
staleTime: Infinity,
});
}
}
export function useCreateWagonType() {
const qc = useQueryClient();
return useMutation({
mutationFn: wagonTypesService.create,
onSuccess: () => qc.invalidateQueries({ queryKey: WAGON_TYPES_QUERY_KEY }),
});
}
export function useUpdateWagonType() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
wagonTypesService.update(id, data),
onSuccess: () => qc.invalidateQueries({ queryKey: WAGON_TYPES_QUERY_KEY }),
});
}
export function useDeleteWagonType() {
const qc = useQueryClient();
return useMutation({
mutationFn: wagonTypesService.delete,
onSuccess: () => qc.invalidateQueries({ queryKey: WAGON_TYPES_QUERY_KEY }),
});
}

View File

@@ -0,0 +1,826 @@
import { FormEvent, ReactNode, useMemo, useState } from 'react';
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useCargoTypes } from '@/hooks/use-cargo-types';
import { useContainerTypes } from '@/hooks/use-container-types';
import {
useCreateWagonType,
useDeleteWagonType,
useUpdateWagonType,
useWagonTypes,
} from '@/hooks/use-wagon-types';
import { useToast } from '@/hooks/use-toast';
import { useCreateCargo, useDeleteCargo, useCargoes, useUpdateCargo } from '@/hooks/useCargoes';
import {
useContainers,
useCreateContainer,
useDeleteContainer,
useUpdateContainer,
} from '@/hooks/useContainers';
import { useCreateTrain, useDeleteTrain, useTrains, useUpdateTrain } from '@/hooks/useTrains';
import { useCreateWagon, useDeleteWagon, useUpdateWagon, useWagons } from '@/hooks/useWagons';
import {
useCreateLocomotive,
useDecommissionLocomotive,
useLocomotives,
useUpdateLocomotive,
} from '@/hooks/useLocomotives';
import type { Cargo } from '@/services/cargoService';
import type { Container } from '@/services/containerService';
import type { Locomotive } from '@/services/locomotives.service';
import type { Train } from '@/services/trains.service';
import type { Wagon } from '@/services/wagon.service';
import type { WagonType } from '@/services/wagon-types.service';
type FormValue = string | number | boolean | string[];
type Field = {
key: string;
label: string;
type?: 'text' | 'number' | 'select';
required?: boolean;
options?: { value: string; label: string }[];
placeholder?: string;
onValueChange?: (
value: string,
current: Record<string, FormValue>,
) => Partial<Record<string, FormValue>>;
};
type Column<T> = {
key: keyof T | string;
label: string;
render?: (item: T) => ReactNode;
};
type FleetCrudPageProps<T extends { id: string }> = {
title: string;
description: string;
addLabel: string;
entityLabel?: string;
data?: T[];
isLoading: boolean;
columns: Column<T>[];
fields: Field[];
emptyValues: Record<string, FormValue>;
searchText: (item: T) => string;
create: { mutateAsync: (data: Record<string, unknown>) => Promise<unknown>; isPending: boolean };
update: { mutateAsync: (data: { id: string; data: Record<string, unknown> }) => Promise<unknown>; isPending: boolean };
remove: { mutateAsync: (id: string) => Promise<unknown>; isPending: boolean };
removeActionLabel?: string;
removeConfirmMessage?: string;
removeSuccessMessage?: string;
hideViewAction?: boolean;
};
const normalizePayload = (values: Record<string, FormValue>) =>
Object.fromEntries(
Object.entries(values)
.map(([key, value]) => [
key,
key === 'supportedLoadTypes' && typeof value === 'string'
? value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
: Array.isArray(value)
? value
: typeof value === 'string'
? value.trim()
: value,
])
.filter(([, value]) => value !== '' && !(Array.isArray(value) && value.length === 0)),
);
const extractBackendErrors = (error: unknown) => {
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
const data = responseData && typeof responseData === 'object' ? responseData as Record<string, unknown> : undefined;
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
const rawErrors = data?.errors;
const fieldErrors: Record<string, string> = {};
if (rawErrors && typeof rawErrors === 'object' && !Array.isArray(rawErrors)) {
Object.entries(rawErrors as Record<string, unknown>).forEach(([field, value]) => {
fieldErrors[field] = Array.isArray(value) ? value.join(', ') : String(value);
});
}
const message = Array.isArray(rawMessage)
? rawMessage.join(', ')
: rawMessage
? String(rawMessage)
: 'Save failed';
return { message, fieldErrors };
};
const validateForm = (fields: Field[], values: Record<string, FormValue>) => {
const errors: Record<string, string> = {};
fields.forEach((field) => {
const value = values[field.key];
const stringValue = typeof value === 'string' ? value.trim() : String(value ?? '');
if (field.required && stringValue === '') {
errors[field.key] = `${field.label} is required`;
return;
}
if (field.type === 'number' && stringValue !== '' && !Number.isFinite(Number(value))) {
errors[field.key] = `${field.label} must be a valid number`;
}
});
return errors;
};
function FleetCrudPage<T extends { id: string }>({
title,
description,
addLabel,
entityLabel,
data,
isLoading,
columns,
fields,
emptyValues,
searchText,
create,
update,
remove,
removeActionLabel = 'Delete',
removeConfirmMessage,
removeSuccessMessage,
hideViewAction = false,
}: FleetCrudPageProps<T>) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [sortKey, setSortKey] = useState<string>('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<T | null>(null);
const [viewing, setViewing] = useState<T | null>(null);
const [form, setForm] = useState(emptyValues);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const { toast } = useToast();
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return data ?? [];
return (data ?? []).filter((item) => searchText(item).toLowerCase().includes(query));
}, [data, search, searchText]);
const sorted = useMemo(() => {
if (!sortKey) return filtered;
return [...filtered].sort((a, b) => {
const left = (a as Record<string, unknown>)[sortKey];
const right = (b as Record<string, unknown>)[sortKey];
const result = String(left ?? '').localeCompare(String(right ?? ''), undefined, { numeric: true });
return sortDirection === 'asc' ? result : -result;
});
}, [filtered, sortDirection, sortKey]);
const pageSize = 10;
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
const toggleSort = (key: string) => {
setPage(1);
if (sortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
return;
}
setSortKey(key);
setSortDirection('asc');
};
const openCreate = () => {
setEditing(null);
setForm(emptyValues);
setFieldErrors({});
setFormOpen(true);
};
const openEdit = (item: T) => {
setEditing(item);
setForm(
Object.fromEntries(
Object.keys(emptyValues).map((key) => [
key,
(item as Record<string, FormValue | null | undefined>)[key] ?? '',
]),
),
);
setFieldErrors({});
setFormOpen(true);
};
const closeForm = () => {
setFormOpen(false);
setEditing(null);
setForm(emptyValues);
setFieldErrors({});
};
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
const validationErrors = validateForm(fields, form);
if (Object.keys(validationErrors).length > 0) {
setFieldErrors(validationErrors);
toast({
title: 'Save failed',
description: Object.values(validationErrors)[0],
variant: 'destructive',
});
return;
}
const payload = normalizePayload(form);
setFieldErrors({});
try {
if (editing) {
await update.mutateAsync({ id: editing.id, data: payload });
toast({ title: `${title.slice(0, -1)} updated` });
} else {
await create.mutateAsync(payload);
toast({ title: `${title.slice(0, -1)} created` });
}
closeForm();
} catch (error) {
const { message, fieldErrors: backendFieldErrors } = extractBackendErrors(error);
setFieldErrors(backendFieldErrors);
toast({ title: 'Save failed', description: message, variant: 'destructive' });
}
};
const handleDelete = async (item: T) => {
const normalizedEntityLabel = entityLabel ?? title.slice(0, -1);
if (!window.confirm(removeConfirmMessage ?? `${removeActionLabel} this ${normalizedEntityLabel.toLowerCase()}?`)) return;
try {
await remove.mutateAsync(item.id);
toast({ title: removeSuccessMessage ?? `${normalizedEntityLabel} ${removeActionLabel.toLowerCase()}ed` });
} catch {
toast({ title: `${removeActionLabel} failed`, description: 'This record may still be referenced.', variant: 'destructive' });
}
};
const isSaving = create.isPending || update.isPending;
return (
<div className="space-y-5 p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
<Button onClick={openCreate}>
<Plus className="size-4" />
{addLabel}
</Button>
</div>
<div className="flex max-w-md items-center gap-2 rounded-md border bg-background px-3">
<Search className="size-4 text-muted-foreground" />
<Input
className="border-0 px-0 shadow-none focus-visible:ring-0"
placeholder={`Search ${title.toLowerCase()}`}
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
/>
</div>
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead key={String(column.key)}>
<button
type="button"
className="inline-flex items-center gap-1 font-medium"
onClick={() => toggleSort(String(column.key))}
>
{column.label}
{sortKey === column.key ? (sortDirection === 'asc' ? 'ASC' : 'DESC') : null}
</button>
</TableHead>
))}
<TableHead className="w-[150px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paged.map((item) => (
<TableRow key={item.id}>
{columns.map((column) => (
<TableCell key={String(column.key)}>
{column.render ? column.render(item) : String((item as Record<string, unknown>)[column.key] ?? '-')}
</TableCell>
))}
<TableCell>
<div className="flex justify-end gap-1">
{!hideViewAction ? (
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
<Eye className="size-4" />
</Button>
) : null}
<Button variant="ghost" size="icon" onClick={() => openEdit(item)} title="Edit">
<Edit className="size-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(item)} title={removeActionLabel}>
<Trash2 className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{!isLoading && filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length + 1} className="h-28 text-center text-muted-foreground">
No records found.
</TableCell>
</TableRow>
) : null}
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length + 1} className="h-28 text-center text-muted-foreground">
Loading...
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of {sorted.length}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page === 1} onClick={() => setPage((current) => current - 1)}>
Previous
</Button>
<Button variant="outline" size="sm" disabled={page === pageCount} onClick={() => setPage((current) => current + 1)}>
Next
</Button>
</div>
</div>
<Dialog open={formOpen} onOpenChange={(open) => (!open ? closeForm() : setFormOpen(true))}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? `Edit ${title.slice(0, -1)}` : addLabel}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
{fields.map((field) => {
const value = form[field.key] ?? '';
const inputValue = field.type === 'number' && value !== '' && !Number.isFinite(Number(value))
? ''
: Array.isArray(value)
? value.join(', ')
: typeof value === 'boolean'
? String(value)
: value;
return (
<div key={field.key} className="space-y-2">
<Label htmlFor={field.key}>{field.label}</Label>
{field.type === 'select' ? (
<Select
value={String(value)}
onValueChange={(selectedValue) =>
setForm((current) => ({
...current,
[field.key]: selectedValue,
...(field.onValueChange?.(selectedValue, current) ?? {}),
}))
}
>
<SelectTrigger id={field.key}>
<SelectValue placeholder={field.placeholder ?? `Select ${field.label.toLowerCase()}`} />
</SelectTrigger>
<SelectContent>
{field.options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={field.key}
type={field.type ?? 'text'}
value={inputValue}
onChange={(event) =>
setForm((current) => ({
...current,
[field.key]: field.type === 'number' && event.target.value !== ''
? Number(event.target.value)
: event.target.value,
}))
}
/>
)}
{fieldErrors[field.key] ? (
<p className="text-sm text-destructive">{fieldErrors[field.key]}</p>
) : null}
</div>
);
})}
<DialogFooter>
<Button type="button" variant="outline" onClick={closeForm}>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog open={Boolean(viewing)} onOpenChange={(open) => (!open ? setViewing(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title.slice(0, -1)} details</DialogTitle>
</DialogHeader>
<div className="grid gap-3 text-sm">
{viewing
? Object.entries(viewing).map(([key, value]) => (
<div key={key} className="grid grid-cols-[150px,1fr] gap-3 border-b pb-2">
<span className="font-medium">{key}</span>
<span className="break-all text-muted-foreground">{value == null ? '-' : String(value)}</span>
</div>
))
: null}
</div>
</DialogContent>
</Dialog>
</div>
);
}
const statusBadge = (status?: string) => <Badge variant="outline">{status ?? '-'}</Badge>;
const activeBadge = (isActive?: boolean) => (
<Badge variant={isActive === false ? 'secondary' : 'outline'}>
{isActive === false ? 'Inactive' : 'Active'}
</Badge>
);
const optionLabel = (options: { value: string; label: string }[], value?: string | null) =>
options.find((option) => option.value === value)?.label ?? value ?? '-';
export function TrainMasterDataPage() {
const query = useTrains();
return (
<FleetCrudPage<Train>
title="Trains"
description="Manage train master data independently from train scheduling."
addLabel="Add Train"
data={query.data}
isLoading={query.isLoading}
create={useCreateTrain()}
update={useUpdateTrain()}
remove={useDeleteTrain()}
searchText={(train) => [train.code, train.trainNumber, train.trainName, train.status].join(' ')}
columns={[
{ key: 'code', label: 'Code' },
{ key: 'trainNumber', label: 'Number', render: (train) => train.trainNumber || '-' },
{ key: 'trainName', label: 'Name', render: (train) => train.trainName || '-' },
{ key: 'capacityTons', label: 'Capacity (tons)' },
{ key: 'status', label: 'Status', render: (train) => statusBadge(train.status) },
]}
fields={[
{ key: 'code', label: 'Code', required: true },
{ key: 'capacityTons', label: 'Capacity (tons)', type: 'number', required: true },
{ key: 'trainNumber', label: 'Train number' },
{ key: 'trainName', label: 'Train name' },
{ key: 'locomotiveNumber', label: 'Locomotive number' },
{ key: 'status', label: 'Status' },
{ key: 'notes', label: 'Notes' },
{ key: 'remarks', label: 'Remarks' },
]}
emptyValues={{ code: '', capacityTons: 0, trainNumber: '', trainName: '', locomotiveNumber: '', status: 'AVAILABLE', notes: '', remarks: '' }}
/>
);
}
export function WagonTypesCrudPage() {
const query = useWagonTypes();
return (
<FleetCrudPage<WagonType>
title="Wagon Types"
description="Manage wagon type capacities and load compatibility used by wagon master data."
addLabel="Add Wagon Type"
data={query.data}
isLoading={query.isLoading}
create={useCreateWagonType()}
update={useUpdateWagonType()}
remove={useDeleteWagonType()}
searchText={(type) =>
[type.code, type.name, type.supportedLoadTypes?.join(' '), String(type.isActive)].join(' ')
}
columns={[
{ key: 'code', label: 'Code' },
{ key: 'name', label: 'Name' },
{ key: 'capacityTons', label: 'Capacity (tons)' },
{ key: 'lengthMeters', label: 'Length (m)' },
{
key: 'supportedLoadTypes',
label: 'Load types',
render: (type) => type.supportedLoadTypes?.join(', ') || '-',
},
{ key: 'isActive', label: 'Status', render: (type) => activeBadge(type.isActive) },
]}
fields={[
{ key: 'code', label: 'Code', required: true },
{ key: 'name', label: 'Name', required: true },
{ key: 'capacityTons', label: 'Capacity (tons)', type: 'number', required: true },
{ key: 'lengthMeters', label: 'Length (meters)', type: 'number', required: true },
{ key: 'maxWagonsPerTrain', label: 'Max wagons per train', type: 'number' },
{
key: 'supportedLoadTypes',
label: 'Supported load types',
placeholder: 'container, break-bulk',
},
{
key: 'isActive',
label: 'Status',
type: 'select',
options: [
{ value: 'true', label: 'Active' },
{ value: 'false', label: 'Inactive' },
],
onValueChange: (value) => ({ isActive: value === 'true' }),
},
]}
emptyValues={{
code: '',
name: '',
capacityTons: 0,
lengthMeters: 0,
maxWagonsPerTrain: '',
supportedLoadTypes: '',
isActive: true,
}}
/>
);
}
export function WagonsCrudPage() {
const query = useWagons();
const { data: wagonTypes = [] } = useWagonTypes();
const wagonTypeOptions = wagonTypes.map((type: any) => ({
value: type.id,
label: `${type.code} - ${type.name}`,
}));
return (
<FleetCrudPage<Wagon>
title="Wagons"
description="Manage wagon master data. Booking-based train assignment is handled in train scheduling."
addLabel="Add Wagon"
data={query.data}
isLoading={query.isLoading}
create={useCreateWagon()}
update={useUpdateWagon()}
remove={useDeleteWagon()}
searchText={(wagon) => [wagon.wagonNumber, wagon.wagonTypeId, wagon.trainId, wagon.status].join(' ')}
columns={[
{ key: 'wagonNumber', label: 'Number' },
{ key: 'wagonTypeId', label: 'Type', render: (wagon) => optionLabel(wagonTypeOptions, wagon.wagonTypeId) },
{ key: 'maxPayloadWeight', label: 'Max payload' },
{ key: 'status', label: 'Status', render: (wagon) => statusBadge(wagon.status) },
]}
fields={[
{ key: 'wagonNumber', label: 'Wagon number', required: true },
{
key: 'wagonTypeId',
label: 'Wagon type',
type: 'select',
required: true,
options: wagonTypeOptions,
onValueChange: (value, current) => {
const selectedType = wagonTypes.find((type: any) => type.id === value);
if (!selectedType || Number(current.maxPayloadWeight) > 0) return {};
return { maxPayloadWeight: Number(selectedType.capacityTons) };
},
},
{ key: 'tareWeight', label: 'Tare weight', type: 'number', required: true },
{ key: 'maxPayloadWeight', label: 'Max payload weight', type: 'number', required: true },
{ key: 'status', label: 'Status' },
{ key: 'notes', label: 'Notes' },
]}
emptyValues={{ wagonNumber: '', wagonTypeId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }}
/>
);
}
export function ContainersCrudPage() {
const query = useContainers();
const { data: containerTypes = [] } = useContainerTypes();
const { data: wagons = [] } = useWagons();
const containerTypeOptions = containerTypes.map((type: any) => ({
value: type.id,
label: type.label ?? type.name ?? type.code,
}));
const wagonOptions = wagons.map((wagon: Wagon) => ({
value: wagon.id,
label: wagon.wagonNumber,
}));
return (
<FleetCrudPage<Container>
title="Containers"
description="Manage container master data and wagon assignments."
addLabel="Add Container"
data={query.data}
isLoading={query.isLoading}
create={useCreateContainer()}
update={useUpdateContainer()}
remove={useDeleteContainer()}
searchText={(container) => [container.containerNumber, container.containerTypeId, container.wagonId, container.status].join(' ')}
columns={[
{ key: 'containerNumber', label: 'Number' },
{ key: 'containerTypeId', label: 'Type', render: (container) => optionLabel(containerTypeOptions, container.containerTypeId) },
{ key: 'wagonId', label: 'Wagon', render: (container) => optionLabel(wagonOptions, container.wagonId) },
{ key: 'maxGrossWeight', label: 'Max gross' },
{ key: 'status', label: 'Status', render: (container) => statusBadge(container.status) },
]}
fields={[
{ key: 'containerNumber', label: 'Container number', required: true },
{
key: 'containerTypeId',
label: 'Container type',
type: 'select',
required: true,
options: containerTypeOptions,
},
{
key: 'wagonId',
label: 'Wagon',
type: 'select',
options: [{ value: 'none', label: 'Unassigned' }, ...wagonOptions],
onValueChange: (value) => (value === 'none' ? { wagonId: '' } : {}),
},
{ key: 'position', label: 'Position', type: 'number' },
{ key: 'tareWeight', label: 'Tare weight', type: 'number', required: true },
{ key: 'maxGrossWeight', label: 'Max gross weight', type: 'number', required: true },
{ key: 'sealNumber', label: 'Seal number' },
{ key: 'status', label: 'Status' },
]}
emptyValues={{ containerNumber: '', containerTypeId: '', wagonId: '', position: '', tareWeight: 0, maxGrossWeight: 0, sealNumber: '', status: 'AVAILABLE' }}
/>
);
}
export function CargoesCrudPage() {
const query = useCargoes();
const { data: cargoTypes = [] } = useCargoTypes();
const { data: containers = [] } = useContainers();
const cargoTypeOptions = cargoTypes.map((type: any) => ({
value: type.id,
label: type.cargoTypeName ?? type.cargo_type_name ?? type.name ?? type.code,
}));
const containerOptions = containers.map((container: Container) => ({
value: container.id,
label: container.containerNumber,
}));
return (
<FleetCrudPage<Cargo>
title="Cargoes"
description="Manage cargo records linked to containers."
addLabel="Add Cargo"
data={query.data}
isLoading={query.isLoading}
create={useCreateCargo()}
update={useUpdateCargo()}
remove={useDeleteCargo()}
searchText={(cargo) => [cargo.cargoReference, cargo.description, cargo.containerId, cargo.status].join(' ')}
columns={[
{ key: 'cargoReference', label: 'Reference' },
{ key: 'cargoTypeId', label: 'Cargo type', render: (cargo) => optionLabel(cargoTypeOptions, cargo.cargoTypeId) },
{ key: 'containerId', label: 'Container', render: (cargo) => optionLabel(containerOptions, cargo.containerId) },
{ key: 'quantity', label: 'Quantity' },
{ key: 'weight', label: 'Weight' },
{ key: 'status', label: 'Status', render: (cargo) => statusBadge(cargo.status) },
]}
fields={[
{ key: 'cargoReference', label: 'Cargo reference', required: true },
{ key: 'shipmentId', label: 'Shipment ID', required: true },
{
key: 'containerId',
label: 'Container',
type: 'select',
required: true,
options: containerOptions,
},
{
key: 'cargoTypeId',
label: 'Cargo type',
type: 'select',
options: cargoTypeOptions,
},
{ key: 'description', label: 'Description' },
{ key: 'quantity', label: 'Quantity', type: 'number', required: true },
{ key: 'weight', label: 'Weight', type: 'number', required: true },
{ key: 'volume', label: 'Volume', type: 'number' },
{ key: 'status', label: 'Status' },
]}
emptyValues={{ cargoReference: '', shipmentId: '', containerId: '', cargoTypeId: '', description: '', quantity: 0, weight: 0, volume: '', status: 'PENDING' }}
/>
);
}
export function LocomotivesCrudPage() {
const query = useLocomotives();
return (
<FleetCrudPage<Locomotive>
title="Locomotives"
entityLabel="Locomotive"
description="Manage locomotive master data used by train scheduling and fleet operations."
addLabel="Add Locomotive"
data={query.data}
isLoading={query.isLoading}
create={useCreateLocomotive()}
update={useUpdateLocomotive()}
remove={useDecommissionLocomotive()}
removeActionLabel="Decommission"
removeConfirmMessage="Decommission this locomotive?"
removeSuccessMessage="Locomotive decommissioned"
searchText={(locomotive) =>
[
locomotive.code,
locomotive.name,
locomotive.locomotiveType,
locomotive.status,
].join(' ')
}
columns={[
{ key: 'code', label: 'Code' },
{ key: 'name', label: 'Name', render: (locomotive) => locomotive.name || '-' },
{ key: 'locomotiveType', label: 'Type' },
{ key: 'status', label: 'Status', render: (locomotive) => statusBadge(locomotive.status) },
{ key: 'maxPullWeightTons', label: 'Max pull (tons)' },
{ key: 'maxTrainLengthMeters', label: 'Max length (m)' },
]}
fields={[
{ key: 'code', label: 'Code', required: true },
{ key: 'name', label: 'Name' },
{
key: 'locomotiveType',
label: 'Locomotive type',
type: 'select',
required: true,
options: [
{ value: 'DIESEL', label: 'Diesel' },
{ value: 'ELECTRIC', label: 'Electric' },
],
},
{
key: 'status',
label: 'Status',
type: 'select',
required: true,
options: [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
],
},
{ key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true },
{ key: 'powerKw', label: 'Power (kW)', type: 'number' },
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' },
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' },
]}
emptyValues={{
code: '',
name: '',
locomotiveType: 'DIESEL',
status: 'AVAILABLE',
maxPullWeightTons: 0,
maxTrainLengthMeters: 760,
powerKw: '',
tractionForceKn: '',
maxSpeedKmh: '',
}}
/>
);
}

View File

@@ -2,14 +2,28 @@ import { api } from "../auth/http";
type ListResponse<T> = T[] | { data: T[] };
export interface WagonType {
id: string;
code: string;
name: string;
capacityTons: number;
lengthMeters: number;
maxWagonsPerTrain?: number | null;
supportedLoadTypes: string[];
isActive: boolean;
}
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const wagonTypesService = {
async getWagonTypes() {
const response = await api.get<ListResponse<unknown>>('/wagon-types', {
params: { isActive: true, pageSize: 500 },
const response = await api.get<ListResponse<WagonType>>('/wagon-types', {
params: { isActive: 'all', pageSize: 500 },
});
return asList(response.data);
},
create: (data: Partial<WagonType>) => api.post('/wagon-types', data),
update: (id: string, data: Partial<WagonType>) => api.patch(`/wagon-types/${id}`, data),
delete: (id: string) => api.delete(`/wagon-types/${id}`),
};

View File

@@ -2,16 +2,105 @@
NODE_ENV=development
PORT=3002
# Database (local Docker: run `pnpm dev:db` from edr-platform, then copy to .env)
DB_HOST=localhost
DB_PORT=5434
DB_NAME=edr_passenger
DB_USER=postgres
DB_PASSWORD=postgres
# Database (Prisma)
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger?schema=edr_passenger
# JWT (provided by external auth package — placeholder only)
JWT_SECRET=
# CORS
FRONTEND_URL=http://localhost:5174
BACK_OFFICE_URL=http://localhost:5184
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
# JWT
JWT_SECRET=edr-platform-secret-change-in-production
JWT_EXPIRES_IN=7d
# SendGrid
SENDGRID_API_KEY=
SENDGRID_FROM_EMAIL=noreply@edr-platform.com
# SMS Configuration
SMS_PROVIDER=twilio
SMS_API_KEY=
# Twilio (if SMS_PROVIDER=twilio)
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_FROM_NUMBER=
# Africa's Talking (if SMS_PROVIDER=africastalking)
AFRICASTALKING_USERNAME=
AFRICASTALKING_FROM=
# Telebirr
TELEBIRR_BASE_URL=
TELEBIRR_WEB_BASE_URL=
TELEBIRR_FABRIC_APP_ID=
TELEBIRR_APP_SECRET=
TELEBIRR_MERCHANT_APP_ID=
TELEBIRR_MERCHANT_CODE=
TELEBIRR_NOTIFY_URL=
TELEBIRR_RETURN_URL=
TELEBIRR_TIMEOUT_EXPRESS=15m
TELEBIRR_PRIVATE_KEY=
TELEBIRR_PUBLIC_KEY=
TELEBIRR_INSECURE_TLS=false
# CBE Birr
CBE_BASE_URL=
CBE_MERCHANT_ID=
CBE_SECRET_KEY=
CBE_NOTIFY_URL=
CBE_RETURN_URL=
# eBirr
EBIRR_BASE_URL=
EBIRR_MERCHANT_CODE=
EBIRR_SECRET_KEY=
EBIRR_NOTIFY_URL=
EBIRR_RETURN_URL=
# Card Gateway (Stripe-like)
CARD_BASE_URL=
CARD_API_KEY=
CARD_WEBHOOK_SECRET=
CARD_WEBHOOK_URL=
CARD_RETURN_URL=
# Waafi (Djibouti Mobile Money)
WAAFI_BASE_URL=https://api.waafipay.net
WAAFI_MERCHANT_UID=
WAAFI_API_USER_ID=
WAAFI_API_KEY=
WAAFI_NOTIFY_URL=
WAAFI_RETURN_URL=
# Payment Configuration
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI
# Session Configuration
SESSION_INACTIVITY_MINUTES=30
# i18n Configuration
DEFAULT_LOCALE=en
SUPPORTED_LOCALES=en,am,fr,om
# Corporate IAM Configuration (for back-office authentication)
IAM_ENABLED=false
IAM_API_URL=https://iam.tria-plc.com/api
IAM_API_KEY=
# --- VeriFayda 2.0 (eSignet) OIDC integration ---
FAYDA_ENABLED=true
FAYDA_CLIENT_ID=
FAYDA_AUTHORIZATION_ENDPOINT=
FAYDA_TOKEN_ENDPOINT=
FAYDA_USERINFO_ENDPOINT=
# Base64 of the RSA private JWK (JSON). Secret — never commit a real value.
FAYDA_PRIVATE_KEY_BASE64=
FAYDA_REDIRECT_URI=
# Optional (defaults shown)
FAYDA_SCOPE=openid profile email
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
FAYDA_CLAIMS_LOCALES=en am
FAYDA_SESSION_TTL_MINUTES=10
GITHUB_PACKAGE_TOKEN=

View File

@@ -0,0 +1 @@
module.exports = require('@edr/eslint-config/nestjs');

View File

@@ -0,0 +1,6 @@
# GitHub Packages configuration for @tria-plc scope
@tria-plc:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_PACKAGE_TOKEN}
# Default registry for other packages
registry=https://registry.npmjs.org/

View File

@@ -1,26 +1,51 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# syntax=docker/dockerfile:1
# Build from monorepo root: docker build -f apps/edr-passenger-api/Dockerfile .
# On start: runs prisma migrate deploy + seed, then the API.
FROM node:24.15.0-alpine AS base
RUN apk add --no-cache libc6-compat
RUN corepack enable
WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/edr-passenger-api/package.json ./apps/edr-passenger-api/
COPY packages ./packages
RUN pnpm install --frozen-lockfile --filter @edr/passenger-api...
FROM base AS pruner
COPY . .
RUN pnpm dlx turbo prune "@edr/passenger-api" --docker
FROM deps AS build
COPY apps/edr-passenger-api ./apps/edr-passenger-api
RUN pnpm --filter @edr/passenger-api build
FROM base AS installer
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
--mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
FROM node:20-alpine AS runtime
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app/apps/edr-passenger-api
FROM base AS builder
COPY --from=installer /app/ .
COPY --from=pruner /app/out/full/ .
RUN pnpm --filter "@edr/passenger-api" exec prisma generate
RUN pnpm turbo build --filter="@edr/passenger-api..."
FROM base AS deployer
COPY --from=builder /app/ .
RUN pnpm deploy --filter="@edr/passenger-api" --legacy /deploy
RUN if [ -d node_modules/.prisma ]; then \
mkdir -p /deploy/node_modules && \
cp -r node_modules/.prisma /deploy/node_modules/.prisma; \
fi
FROM node:24.15.0-alpine AS runner
RUN apk add --no-cache libc6-compat
RUN corepack enable && corepack prepare pnpm@11.1.1 --activate
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./../../node_modules
COPY --from=deps /app/apps/edr-passenger-api/node_modules ./node_modules
COPY --from=build /app/apps/edr-passenger-api/dist ./dist
COPY --from=build /app/apps/edr-passenger-api/package.json ./package.json
EXPOSE 3002
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs nestjs
COPY --from=deployer /deploy .
COPY apps/edr-passenger-api/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh \
&& chown -R nestjs:nodejs /app
USER nestjs
ENV CI=true
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
EXPOSE 4000
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["node", "dist/main.js"]

View File

@@ -0,0 +1,11 @@
#!/bin/sh
set -e
cd /app
# npm run executes the same package.json scripts as pnpm run (pnpm reinstalls in deploy layout)
npm run prisma:generate
npm run prisma:migrate
npm run prisma:seed
exec "$@"

View File

@@ -3,6 +3,9 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"plugins": ["@nestjs/swagger"],
"tsConfigPath": "tsconfig.build.json",
"watchAssets": true
}
}

View File

@@ -1,54 +1,74 @@
{
"name": "@edr/passenger-api",
"version": "0.0.0",
"version": "1.0.0",
"private": true,
"description": "EDR Passenger Management API",
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"build": "prisma generate && nest build",
"start": "node dist/main.js",
"start:prod": "node dist/main.js",
"lint": "eslint src",
"test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node prisma/seed-complete.ts",
"prisma:seed-full": "ts-node prisma/seed.ts",
"prisma:backfill": "ts-node prisma/backfill-fields.ts",
"prisma:verify": "ts-node prisma/verify-backfill.ts"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@edr/api-common": "workspace:*",
"@edr/payment-providers": "workspace:*",
"@edr/types": "workspace:*",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@nestjs/config": "^4.0.0",
"@nestjs/microservices": "^11.0.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^7.4.0",
"@prisma/client": "^6.19.3",
"@sendgrid/mail": "^8.1.0",
"axios": "^1.7.7",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"pg": "^8.13.0",
"class-validator": "^0.14.0",
"express": "^4.18.2",
"jose": "^5.10.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"qrcode": "^1.5.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20"
"swagger-ui-express": "^5.0.0",
"tsconfig-paths": "^4.2.0"
},
"devDependencies": {
"@edr/eslint-config": "workspace:*",
"@edr/tsconfig": "workspace:*",
"@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.2.2",
"@nestjs/testing": "^10.4.6",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.13",
"@types/node": "^20.14.0",
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.6",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.6",
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5",
"@types/supertest": "^6.0.2",
"jest": "^29.7.0",
"prisma": "^6.19.3",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.4"
"typescript": "^5.3.3"
},
"jest": {
"moduleFileExtensions": [

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "PaymentMethodType" ADD VALUE 'WAAFI';
-- AlterTable
ALTER TABLE "FareRule" ADD COLUMN "nationality" TEXT;

View File

@@ -0,0 +1,28 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "contactEmail" TEXT,
ADD COLUMN "contactPhone" TEXT;
-- CreateTable
CREATE TABLE "SavedPassengerProfile" (
"id" TEXT NOT NULL,
"userId" TEXT,
"deviceId" TEXT,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3) NOT NULL,
"idDocumentType" "IdDocumentType" NOT NULL,
"passportNumber" TEXT,
"passportCountry" TEXT,
"nationality" TEXT,
"phone" TEXT,
"email" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SavedPassengerProfile_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SavedPassengerProfile_userId_idx" ON "SavedPassengerProfile"("userId");
-- CreateIndex
CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "SavedPassengerProfile"("deviceId");

View File

@@ -0,0 +1,55 @@
/*
Warnings:
- A unique constraint covering the columns `[faydaSub]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN "faydaSub" TEXT,
ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3),
ADD COLUMN "faydaVerifiedName" TEXT;
-- AlterTable
ALTER TABLE "passenger"."User" ADD COLUMN "faydaSub" TEXT,
ADD COLUMN "faydaVerified" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "passenger"."FaydaVerificationSession" (
"id" TEXT NOT NULL,
"state" TEXT NOT NULL,
"codeVerifier" TEXT NOT NULL,
"purpose" TEXT NOT NULL DEFAULT 'PURCHASE',
"saveToAccount" BOOLEAN NOT NULL DEFAULT false,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"errorCode" TEXT,
"errorDescription" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"completedAt" TIMESTAMP(3),
"userId" TEXT,
"bookingId" TEXT,
CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "passenger"."FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_userId_idx" ON "passenger"."FaydaVerificationSession"("userId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "passenger"."FaydaVerificationSession"("bookingId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_state_idx" ON "passenger"."FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "passenger"."FaydaVerificationSession"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "User_faydaSub_key" ON "passenger"."User"("faydaSub");
-- AddForeignKey
ALTER TABLE "passenger"."FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,26 @@
/*
Warnings:
- You are about to drop the column `maskedHint` on the `PaymentMethod` table. All the data in the column will be lost.
- You are about to drop the column `userId` on the `PaymentMethod` table. All the data in the column will be lost.
- A unique constraint covering the columns `[type]` on the table `PaymentMethod` will be added. If there are existing duplicate values, this will fail.
- Added the required column `updatedAt` to the `PaymentMethod` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL');
-- DropIndex
DROP INDEX "PaymentMethod_userId_isDefault_idx";
-- AlterTable
ALTER TABLE "PaymentMethod" DROP COLUMN "maskedHint",
DROP COLUMN "userId",
ADD COLUMN "currency" TEXT NOT NULL DEFAULT 'ETB',
ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "region" "PaymentRegion" NOT NULL DEFAULT 'GLOBAL',
ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type");

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "FaydaVerificationSession" ADD COLUMN "authCode" TEXT,
ADD COLUMN "platform" TEXT NOT NULL DEFAULT 'WEB';

View File

@@ -0,0 +1,2 @@
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,30 @@
-- Add contact fields to Booking table
ALTER TABLE "passenger"."Booking"
ADD COLUMN "contactEmail" TEXT,
ADD COLUMN "contactPhone" TEXT;
-- Create SavedPassengerProfile table
CREATE TABLE "passenger"."SavedPassengerProfile" (
"id" TEXT NOT NULL,
"userId" TEXT,
"deviceId" TEXT,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3) NOT NULL,
"idDocumentType" "passenger"."IdDocumentType" NOT NULL,
"passportNumber" TEXT,
"passportCountry" TEXT,
"nationality" TEXT,
"phone" TEXT,
"email" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SavedPassengerProfile_pkey" PRIMARY KEY ("id")
);
-- Create indexes
CREATE INDEX "SavedPassengerProfile_userId_idx" ON "passenger"."SavedPassengerProfile"("userId");
CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "passenger"."SavedPassengerProfile"("deviceId");
-- Add comment
COMMENT ON TABLE "passenger"."SavedPassengerProfile" IS 'Stores passenger details for quick rebooking (by userId or deviceId)';

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,231 @@
import { PrismaClient, SeatKind } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Starting complete seed...\n');
// 1. STATIONS
console.log('📍 Seeding stations...');
const stationData = [
{ code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9167, lng: 38.6167 },
{ code: 'LBU', name: 'Labu', city: 'Labu', countryCode: 'ET', lat: 8.8500, lng: 38.7000 },
{ code: 'IND', name: 'Indode', city: 'Indode', countryCode: 'ET', lat: 8.7800, lng: 38.8200 },
{ code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7500, lng: 38.9833 },
{ code: 'MJO', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6000, lng: 39.1200 },
{ code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5400, lng: 39.2675 },
{ code: 'DDW', name: 'Diredawa', city: 'Diredawa', countryCode: 'ET', lat: 9.5931, lng: 41.8661 },
{ code: 'NGD', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 },
];
const stations = [];
for (const s of stationData) {
stations.push(await prisma.station.upsert({ where: { code: s.code }, update: {}, create: s }));
}
console.log(`${stations.length} stations\n`);
// 2. SEAT CLASSES
console.log('💺 Seeding seat classes...');
const scEconomy = await prisma.seatClass.upsert({
where: { name: 'Economy Regular' },
update: {},
create: { name: 'Economy Regular', description: 'Standard economy', basePrice: 25000, isActive: true },
});
const scBed = await prisma.seatClass.upsert({
where: { name: 'Economy Bed' },
update: {},
create: { name: 'Economy Bed', description: 'Economy bed', basePrice: 35000, isActive: true },
});
console.log(`✅ 2 seat classes\n`);
// 3. ROUTES
console.log('🛤️ Seeding routes...');
const route1 = await prisma.route.upsert({
where: { code: 'SBT-NGD' },
update: {},
create: { code: 'SBT-NGD', name: 'Sebeta-Nagad Express', effectiveFrom: new Date('2026-01-01'), active: true },
});
await prisma.routeStop.createMany({
data: [
{ routeId: route1.id, stationId: stations[0].id, sequence: 1, distanceKm: 0 },
{ routeId: route1.id, stationId: stations[1].id, sequence: 2, distanceKm: 15 },
{ routeId: route1.id, stationId: stations[2].id, sequence: 3, distanceKm: 28 },
{ routeId: route1.id, stationId: stations[3].id, sequence: 4, distanceKm: 45 },
{ routeId: route1.id, stationId: stations[4].id, sequence: 5, distanceKm: 73 },
{ routeId: route1.id, stationId: stations[5].id, sequence: 6, distanceKm: 99 },
{ routeId: route1.id, stationId: stations[6].id, sequence: 7, distanceKm: 378 },
{ routeId: route1.id, stationId: stations[7].id, sequence: 8, distanceKm: 756 },
],
skipDuplicates: true,
});
await prisma.routeFareRule.createMany({
data: [
{ routeId: route1.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT', baseFareMinor: 65000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD', baseFareMinor: 65000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, seatClassId: scBed.id, passengerCategory: 'ADULT', baseFareMinor: 91000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, seatClassId: scBed.id, passengerCategory: 'CHILD', baseFareMinor: 91000, validFrom: new Date('2026-01-01') },
],
skipDuplicates: true,
});
console.log(`✅ 1 route with stops and fares\n`);
// 4. TRAINS
console.log('🚂 Seeding trains...');
const train = await prisma.train.upsert({
where: { number: '301' },
update: {},
create: { number: '301', name: 'Express 301', description: 'Main Express' },
});
console.log(`✅ 1 train\n`);
// 5. COACHES & SEATS
console.log('🚃 Seeding coaches...');
const coach1 = await prisma.coach.upsert({
where: { coachNumber: 'C-A1' },
update: {},
create: { coachNumber: 'C-A1', label: 'A', seatClassId: scEconomy.id, mode: 'seat', totalUnits: 20 },
});
const existingSeats = await prisma.seat.count({ where: { coachId: coach1.id } });
if (existingSeats === 0) {
const seats = [];
for (let row = 1; row <= 5; row++) {
for (const col of ['A', 'B', 'C', 'D']) {
seats.push({
coachId: coach1.id,
row,
col,
label: `${row}${col}`,
seatNumber: `A${row}${col}`,
kind: 'STANDARD' as SeatKind,
});
}
}
await prisma.seat.createMany({ data: seats });
}
console.log(`✅ 1 coach with 20 seats\n`);
// 6. SCHEDULE
console.log('📅 Seeding schedule...');
const existingSchedules = await prisma.trainSchedule.findMany({ where: { trainId: train.id }, select: { id: true } });
if (existingSchedules.length > 0) {
const scheduleIds = existingSchedules.map(s => s.id);
const bookingIds = (
await prisma.booking.findMany({ where: { scheduleId: { in: scheduleIds } }, select: { id: true } })
).map(b => b.id);
// Delete booking children in FK-safe order before deleting the bookings themselves
await prisma.foodOrderItem.deleteMany({ where: { order: { bookingId: { in: bookingIds } } } });
await prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.booking.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await prisma.fareRule.deleteMany({ where: { tripId: { in: scheduleIds } } });
await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await prisma.trainSchedule.deleteMany({ where: { trainId: train.id } });
}
const schedule = await prisma.trainSchedule.create({
data: {
trainId: train.id,
routeId: route1.id,
originStationId: stations[0].id,
destinationStationId: stations[7].id,
departureAt: new Date('2026-06-15T06:00:00Z'),
arrivalAt: new Date('2026-06-15T22:00:00Z'),
durationMinutes: 960,
stopsCount: 8,
},
});
await prisma.coachAssignment.create({
data: { scheduleId: schedule.id, coachId: coach1.id, positionNumber: 1 },
});
await prisma.tripStopTime.createMany({
data: [
{ scheduleId: schedule.id, stationId: stations[0].id, sequence: 1, plannedDepartureAt: new Date('2026-06-15T06:00:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[1].id, sequence: 2, plannedArrivalAt: new Date('2026-06-15T07:00:00Z'), plannedDepartureAt: new Date('2026-06-15T07:05:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[2].id, sequence: 3, plannedArrivalAt: new Date('2026-06-15T08:00:00Z'), plannedDepartureAt: new Date('2026-06-15T08:05:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[3].id, sequence: 4, plannedArrivalAt: new Date('2026-06-15T09:00:00Z'), plannedDepartureAt: new Date('2026-06-15T09:10:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[4].id, sequence: 5, plannedArrivalAt: new Date('2026-06-15T10:00:00Z'), plannedDepartureAt: new Date('2026-06-15T10:10:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[5].id, sequence: 6, plannedArrivalAt: new Date('2026-06-15T11:00:00Z'), plannedDepartureAt: new Date('2026-06-15T11:15:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[6].id, sequence: 7, plannedArrivalAt: new Date('2026-06-15T15:00:00Z'), plannedDepartureAt: new Date('2026-06-15T15:20:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[7].id, sequence: 8, plannedArrivalAt: new Date('2026-06-15T22:00:00Z'), status: 'UPCOMING' },
],
});
console.log(`✅ 1 schedule with stops\n`);
// 7. USERS
console.log('👥 Seeding users...');
const adminHash = await bcrypt.hash('admin123', 10);
const userHash = await bcrypt.hash('password123', 10);
await prisma.user.upsert({
where: { email: 'admin@edr-platform.com' },
update: {},
create: { fullName: 'Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash: adminHash, role: 'ADMIN' },
});
const user = await prisma.user.upsert({
where: { email: 'abebe@email.com' },
update: {},
create: { fullName: 'Abebe Kebede', email: 'abebe@email.com', phone: '+251912345678', passwordHash: userHash, nationality: 'Ethiopian' },
});
let passenger = await prisma.passenger.findUnique({ where: { userId: user.id } });
if (!passenger) {
passenger = await prisma.passenger.create({ data: { userId: user.id } });
await prisma.loyaltyAccount.create({ data: { passengerId: passenger.id, pointsBalance: 1000, tier: 'BRONZE' } });
await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 100000 } });
}
console.log(`✅ 2 users\n`);
// 8. SUPPORTING DATA
console.log('📦 Seeding supporting data...');
await prisma.paymentMethod.upsert({
where: { type: 'TELEBIRR' },
update: {},
create: { type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA', currency: 'ETB', enabled: true, sortOrder: 1 },
});
await prisma.currencyExchangeRate.deleteMany({});
await prisma.currencyExchangeRate.createMany({
data: [
{ fromCurrency: 'ETB', toCurrency: 'ETB', rate: 1.0, effectiveDate: new Date() },
{ fromCurrency: 'ETB', toCurrency: 'USD', rate: 0.018, effectiveDate: new Date() },
{ fromCurrency: 'ETB', toCurrency: 'DJF', rate: 3.2, effectiveDate: new Date() },
],
});
console.log(`✅ Payment methods and currencies\n`);
console.log('✅ SEED COMPLETE!\n');
console.log('📋 Summary:');
console.log(' - 8 Stations');
console.log(' - 2 Seat Classes');
console.log(' - 1 Route with 8 stops');
console.log(' - 1 Train with 1 schedule');
console.log(' - 1 Coach with 20 seats');
console.log(' - 2 Users (Admin + Passenger)');
console.log('\n🔑 Credentials:');
console.log(' Admin: admin@edr-platform.com / admin123');
console.log(' User: abebe@email.com / password123');
}
main()
.catch((e) => {
console.error('❌ Error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,832 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
// ============================================================================
// SECTION 1: STATIONS (18 STATIONS)
// ============================================================================
async function seedStations() {
console.log('📍 Seeding 18 stations...');
const stations = [
{ code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9167, lng: 38.6167 },
{ code: 'LBU', name: 'Labu', city: 'Labu', countryCode: 'ET', lat: 8.8500, lng: 38.7000 },
{ code: 'IND', name: 'Indode', city: 'Indode', countryCode: 'ET', lat: 8.7800, lng: 38.8200 },
{ code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7500, lng: 38.9833 },
{ code: 'MJO', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6000, lng: 39.1200 },
{ code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5400, lng: 39.2675 },
{ code: 'FTO', name: 'Feto', city: 'Feto', countryCode: 'ET', lat: 8.4500, lng: 39.4000 },
{ code: 'MTH', name: 'Metahara', city: 'Metahara', countryCode: 'ET', lat: 8.9000, lng: 39.9167 },
{ code: 'MSO', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 9.2400, lng: 40.7500 },
{ code: 'BKE', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.4200, lng: 41.2000 },
{ code: 'DDW', name: 'Diredawa', city: 'Diredawa', countryCode: 'ET', lat: 9.5931, lng: 41.8661 },
{ code: 'ARW', name: 'Arawa', city: 'Arawa', countryCode: 'ET', lat: 10.2000, lng: 42.1500 },
{ code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 10.8500, lng: 42.4000 },
{ code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 11.5500, lng: 42.7167 },
{ code: 'DWL', name: 'Dawanle', city: 'Dawanle', countryCode: 'DJ', lat: 11.4000, lng: 42.9500 },
{ code: 'ALI', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', lat: 11.1667, lng: 42.7167 },
{ code: 'HOL', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', lat: 11.3500, lng: 43.0500 },
{ code: 'NGD', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 },
];
const created = [];
for (const station of stations) {
const s = await prisma.station.upsert({
where: { code: station.code },
update: {},
create: station,
});
created.push(s);
}
console.log(` ✅ Created ${created.length} stations`);
return created;
}
// ============================================================================
// SECTION 2: SEAT CLASSES
// ============================================================================
async function seedSeatClasses() {
console.log('💺 Seeding seat classes...');
const classes = [
{ name: 'Economy Regular', description: 'Standard economy seating', basePrice: 25000 },
{ name: 'Economy Bed', description: 'Economy bed lower berth', basePrice: 35000 },
{ name: 'VIP Bed', description: 'First class VIP bed', basePrice: 55000 },
];
const created = [];
for (const cls of classes) {
const c = await prisma.seatClass.upsert({
where: { name: cls.name },
update: {},
create: { ...cls, isActive: true },
});
created.push(c);
}
console.log(` ✅ Created ${created.length} seat classes`);
return created;
}
// ============================================================================
// SECTION 3: TRAINS
// ============================================================================
async function seedTrains() {
console.log('🚂 Seeding trains...');
const trains = [
{ number: '301', name: 'Express 301', description: 'Sebeta-Nagad Express' },
{ number: '302', name: 'Express 302', description: 'Nagad-Sebeta Express' },
{ number: '303', name: 'Local 303', description: 'Regional Service' },
];
const created = [];
for (const train of trains) {
const t = await prisma.train.upsert({
where: { number: train.number },
update: {},
create: train,
});
created.push(t);
}
console.log(` ✅ Created ${created.length} trains`);
return created;
}
// ============================================================================
// SECTION 4: COACHES & SEATS
// ============================================================================
async function seedCoachesAndSeats(seatClasses: any[]) {
console.log('🚃 Seeding coaches and seats...');
const [scEconomy, scEconomyBed, scVip] = seatClasses;
const coachConfigs = [
{ coachNumber: 'C-A1', label: 'A', seatClassId: scEconomy.id, mode: 'seat', totalUnits: 60 },
{ coachNumber: 'C-B1', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 },
{ coachNumber: 'C-C1', label: 'C', seatClassId: scVip.id, mode: 'bed', totalUnits: 20 },
{ coachNumber: 'C-A2', label: 'A', seatClassId: scEconomy.id, mode: 'seat', totalUnits: 60 },
{ coachNumber: 'C-B2', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 },
{ coachNumber: 'C-C2', label: 'C', seatClassId: scVip.id, mode: 'bed', totalUnits: 20 },
];
const coaches = [];
for (const config of coachConfigs) {
const coach = await prisma.coach.upsert({
where: { coachNumber: config.coachNumber },
update: {},
create: config,
});
coaches.push(coach);
// Create seats for this coach
const existingSeats = await prisma.seat.count({ where: { coachId: coach.id } });
if (existingSeats === 0) {
const seats = [];
const rows = Math.ceil(config.totalUnits / 4);
for (let row = 1; row <= rows; row++) {
for (const col of ['A', 'B', 'C', 'D']) {
if (seats.length >= config.totalUnits) break;
seats.push({
coachId: coach.id,
row,
col,
label: `${row}${col}`,
seatNumber: `${config.label}${row}${col}`,
kind: row === 1 && col === 'A' ? 'ACCESSIBLE' : 'STANDARD',
});
}
}
await prisma.seat.createMany({ data: seats as any });
}
}
console.log(` ✅ Created ${coaches.length} coaches with seats`);
return coaches;
}
// ============================================================================
// SECTION 5: SCHEDULES (15+ SEGMENTS)
// ============================================================================
async function seedSchedules(trains: any[], stations: any[], routes: any[]) {
console.log('📅 Seeding schedules with 15+ segments...');
const [train301, train302, train303] = trains;
const [sebeta, labu, indode, bishoftu, mojo, adama, feto, metahara, mieso, bike, diredawa, arawa, adigala, aysha, dawanle, alisabieh, holhol, nagad] = stations;
const [fullRoute, regionalRoute] = routes;
// Clean up existing schedules
const existingScheduleIds = (await prisma.trainSchedule.findMany({
where: { trainId: { in: [train301.id, train302.id, train303.id] } },
select: { id: true },
})).map((s: { id: string }) => s.id);
if (existingScheduleIds.length > 0) {
// Delete in correct order to avoid foreign key constraints
await prisma.bookingSeat.deleteMany({
where: {
booking: {
scheduleId: { in: existingScheduleIds }
}
}
});
await prisma.booking.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } });
await prisma.fareRule.deleteMany({ where: { tripId: { in: existingScheduleIds } } });
await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } });
await prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } });
await prisma.trainSchedule.deleteMany({ where: { id: { in: existingScheduleIds } } });
}
const schedules = [
// Full route: Sebeta to Nagad (18 stations)
{
trainId: train301.id,
routeId: fullRoute.id,
originStationId: sebeta.id,
destinationStationId: nagad.id,
departureAt: new Date('2026-06-15T06:00:00Z'),
arrivalAt: new Date('2026-06-15T22:00:00Z'),
durationMinutes: 960,
stopsCount: 18,
},
// Return route: Nagad to Sebeta
{
trainId: train302.id,
routeId: fullRoute.id,
originStationId: nagad.id,
destinationStationId: sebeta.id,
departureAt: new Date('2026-06-16T07:00:00Z'),
arrivalAt: new Date('2026-06-16T23:30:00Z'),
durationMinutes: 990,
stopsCount: 18,
},
// Regional service: Sebeta to Diredawa
{
trainId: train303.id,
routeId: regionalRoute.id,
originStationId: sebeta.id,
destinationStationId: diredawa.id,
departureAt: new Date('2026-06-17T08:00:00Z'),
arrivalAt: new Date('2026-06-17T18:00:00Z'),
durationMinutes: 600,
stopsCount: 11,
},
// Additional schedules for next day
{
trainId: train301.id,
routeId: fullRoute.id,
originStationId: sebeta.id,
destinationStationId: nagad.id,
departureAt: new Date('2026-06-18T06:30:00Z'),
arrivalAt: new Date('2026-06-18T22:45:00Z'),
durationMinutes: 975,
stopsCount: 18,
},
{
trainId: train302.id,
routeId: fullRoute.id,
originStationId: nagad.id,
destinationStationId: sebeta.id,
departureAt: new Date('2026-06-19T07:15:00Z'),
arrivalAt: new Date('2026-06-19T23:45:00Z'),
durationMinutes: 990,
stopsCount: 18,
},
];
const created = [];
for (const schedule of schedules) {
const s = await prisma.trainSchedule.create({ data: schedule });
created.push(s);
}
console.log(` ✅ Created ${created.length} schedules`);
return created;
}
// ============================================================================
// SECTION 6: COACH ASSIGNMENTS
// ============================================================================
async function seedCoachAssignments(schedules: any[], coaches: any[]) {
console.log('🔗 Seeding coach assignments...');
const [coachA1, coachB1, coachC1, coachA2, coachB2, coachC2] = coaches;
const [schedule1, schedule2, schedule3] = schedules;
const assignments = [
{ scheduleId: schedule1.id, coachId: coachA1.id, positionNumber: 1 },
{ scheduleId: schedule1.id, coachId: coachB1.id, positionNumber: 2 },
{ scheduleId: schedule1.id, coachId: coachC1.id, positionNumber: 3 },
{ scheduleId: schedule2.id, coachId: coachA2.id, positionNumber: 1 },
{ scheduleId: schedule2.id, coachId: coachB2.id, positionNumber: 2 },
{ scheduleId: schedule2.id, coachId: coachC2.id, positionNumber: 3 },
{ scheduleId: schedule3.id, coachId: coachA1.id, positionNumber: 1 },
{ scheduleId: schedule3.id, coachId: coachB1.id, positionNumber: 2 },
{ scheduleId: schedule3.id, coachId: coachC1.id, positionNumber: 3 },
];
await prisma.coachAssignment.createMany({ data: assignments, skipDuplicates: true });
console.log(` ✅ Created ${assignments.length} coach assignments`);
}
// ============================================================================
// SECTION 7: STOP TIMES (ALL 18 STATIONS)
// ============================================================================
async function seedStopTimes(schedules: any[], stations: any[]) {
console.log('⏱️ Seeding stop times for all stations...');
const [sebeta, labu, indode, bishoftu, mojo, adama, feto, metahara, mieso, bike, diredawa, arawa, adigala, aysha, dawanle, alisabieh, holhol, nagad] = stations;
const [schedule1, schedule2, schedule3] = schedules;
// Full route stop times (Sebeta to Nagad)
const fullRouteStops = [
{ scheduleId: schedule1.id, stationId: sebeta.id, sequence: 1, plannedDepartureAt: new Date('2026-06-15T06:00:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: labu.id, sequence: 2, plannedArrivalAt: new Date('2026-06-15T06:30:00Z'), plannedDepartureAt: new Date('2026-06-15T06:35:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: indode.id, sequence: 3, plannedArrivalAt: new Date('2026-06-15T07:00:00Z'), plannedDepartureAt: new Date('2026-06-15T07:05:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: bishoftu.id, sequence: 4, plannedArrivalAt: new Date('2026-06-15T07:30:00Z'), plannedDepartureAt: new Date('2026-06-15T07:40:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: mojo.id, sequence: 5, plannedArrivalAt: new Date('2026-06-15T08:15:00Z'), plannedDepartureAt: new Date('2026-06-15T08:25:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: adama.id, sequence: 6, plannedArrivalAt: new Date('2026-06-15T09:00:00Z'), plannedDepartureAt: new Date('2026-06-15T09:15:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: feto.id, sequence: 7, plannedArrivalAt: new Date('2026-06-15T09:45:00Z'), plannedDepartureAt: new Date('2026-06-15T09:50:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: metahara.id, sequence: 8, plannedArrivalAt: new Date('2026-06-15T10:30:00Z'), plannedDepartureAt: new Date('2026-06-15T10:45:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: mieso.id, sequence: 9, plannedArrivalAt: new Date('2026-06-15T12:00:00Z'), plannedDepartureAt: new Date('2026-06-15T12:10:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: bike.id, sequence: 10, plannedArrivalAt: new Date('2026-06-15T13:30:00Z'), plannedDepartureAt: new Date('2026-06-15T13:40:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: diredawa.id, sequence: 11, plannedArrivalAt: new Date('2026-06-15T15:00:00Z'), plannedDepartureAt: new Date('2026-06-15T15:20:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: arawa.id, sequence: 12, plannedArrivalAt: new Date('2026-06-15T16:30:00Z'), plannedDepartureAt: new Date('2026-06-15T16:35:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: adigala.id, sequence: 13, plannedArrivalAt: new Date('2026-06-15T17:45:00Z'), plannedDepartureAt: new Date('2026-06-15T17:50:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: aysha.id, sequence: 14, plannedArrivalAt: new Date('2026-06-15T18:30:00Z'), plannedDepartureAt: new Date('2026-06-15T18:40:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: dawanle.id, sequence: 15, plannedArrivalAt: new Date('2026-06-15T19:15:00Z'), plannedDepartureAt: new Date('2026-06-15T19:20:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: alisabieh.id, sequence: 16, plannedArrivalAt: new Date('2026-06-15T20:00:00Z'), plannedDepartureAt: new Date('2026-06-15T20:05:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: holhol.id, sequence: 17, plannedArrivalAt: new Date('2026-06-15T21:00:00Z'), plannedDepartureAt: new Date('2026-06-15T21:05:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule1.id, stationId: nagad.id, sequence: 18, plannedArrivalAt: new Date('2026-06-15T22:00:00Z'), status: 'UPCOMING' as const },
];
// Regional route stop times (Sebeta to Diredawa)
const regionalStops = [
{ scheduleId: schedule3.id, stationId: sebeta.id, sequence: 1, plannedDepartureAt: new Date('2026-06-17T08:00:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: labu.id, sequence: 2, plannedArrivalAt: new Date('2026-06-17T08:30:00Z'), plannedDepartureAt: new Date('2026-06-17T08:35:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: indode.id, sequence: 3, plannedArrivalAt: new Date('2026-06-17T09:00:00Z'), plannedDepartureAt: new Date('2026-06-17T09:05:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: bishoftu.id, sequence: 4, plannedArrivalAt: new Date('2026-06-17T09:30:00Z'), plannedDepartureAt: new Date('2026-06-17T09:40:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: mojo.id, sequence: 5, plannedArrivalAt: new Date('2026-06-17T10:15:00Z'), plannedDepartureAt: new Date('2026-06-17T10:25:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: adama.id, sequence: 6, plannedArrivalAt: new Date('2026-06-17T11:00:00Z'), plannedDepartureAt: new Date('2026-06-17T11:15:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: feto.id, sequence: 7, plannedArrivalAt: new Date('2026-06-17T11:45:00Z'), plannedDepartureAt: new Date('2026-06-17T11:50:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: metahara.id, sequence: 8, plannedArrivalAt: new Date('2026-06-17T12:30:00Z'), plannedDepartureAt: new Date('2026-06-17T12:45:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: mieso.id, sequence: 9, plannedArrivalAt: new Date('2026-06-17T14:00:00Z'), plannedDepartureAt: new Date('2026-06-17T14:10:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: bike.id, sequence: 10, plannedArrivalAt: new Date('2026-06-17T15:30:00Z'), plannedDepartureAt: new Date('2026-06-17T15:40:00Z'), status: 'UPCOMING' as const },
{ scheduleId: schedule3.id, stationId: diredawa.id, sequence: 11, plannedArrivalAt: new Date('2026-06-17T18:00:00Z'), status: 'UPCOMING' as const },
];
const allStops = [...fullRouteStops, ...regionalStops];
await prisma.tripStopTime.createMany({ data: allStops });
console.log(` ✅ Created ${allStops.length} stop times`);
}
// ============================================================================
// SECTION 8: FARE RULES (COMPREHENSIVE SEGMENTS)
// ============================================================================
async function seedFareRules(schedules: any[], seatClasses: any[]) {
console.log('💰 Seeding comprehensive fare rules...');
const [scEconomy, scEconomyBed, scVip] = seatClasses;
// Segment-based fare rules (15+ segments)
const segmentRules = [
// Short segments (1-3 stations)
{ route: 'SBT-LBU', seatClassId: scEconomy.id, baseFareMinor: 5000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'LBU-IND', seatClassId: scEconomy.id, baseFareMinor: 4500, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'IND-BSH', seatClassId: scEconomy.id, baseFareMinor: 5500, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'BSH-MJO', seatClassId: scEconomy.id, baseFareMinor: 6000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'MJO-ADM', seatClassId: scEconomy.id, baseFareMinor: 7000, validFrom: new Date('2026-01-01'), refundable: true },
// Medium segments (3-6 stations)
{ route: 'SBT-BSH', seatClassId: scEconomy.id, baseFareMinor: 12000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'SBT-ADM', seatClassId: scEconomy.id, baseFareMinor: 18000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'ADM-MTH', seatClassId: scEconomy.id, baseFareMinor: 8500, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'MTH-MSO', seatClassId: scEconomy.id, baseFareMinor: 9500, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'MSO-BKE', seatClassId: scEconomy.id, baseFareMinor: 8000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'BKE-DDW', seatClassId: scEconomy.id, baseFareMinor: 7500, validFrom: new Date('2026-01-01'), refundable: true },
// Long segments (6+ stations)
{ route: 'SBT-DDW', seatClassId: scEconomy.id, baseFareMinor: 35000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'DDW-AYS', seatClassId: scEconomy.id, baseFareMinor: 15000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'AYS-NGD', seatClassId: scEconomy.id, baseFareMinor: 18000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'SBT-NGD', seatClassId: scEconomy.id, baseFareMinor: 65000, validFrom: new Date('2026-01-01'), refundable: true },
// Cross-border segments
{ route: 'DDW-DWL', seatClassId: scEconomy.id, baseFareMinor: 22000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'DWL-ALI', seatClassId: scEconomy.id, baseFareMinor: 12000, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'ALI-HOL', seatClassId: scEconomy.id, baseFareMinor: 8500, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'HOL-NGD', seatClassId: scEconomy.id, baseFareMinor: 6000, validFrom: new Date('2026-01-01'), refundable: true },
];
// Add Economy Bed prices (40% higher)
const bedRules = segmentRules.map(rule => ({
...rule,
seatClassId: scEconomyBed.id,
baseFareMinor: Math.round(rule.baseFareMinor * 1.4),
}));
// Add VIP prices (80% higher)
const vipRules = segmentRules.map(rule => ({
...rule,
seatClassId: scVip.id,
baseFareMinor: Math.round(rule.baseFareMinor * 1.8),
}));
const allRules = [...segmentRules, ...bedRules, ...vipRules];
await prisma.fareRule.createMany({ data: allRules, skipDuplicates: true });
// Nationality-specific discounts
const nationalityRules = [
// Ethiopian nationals - 10% discount on domestic routes
{ route: 'SBT-DDW', nationality: 'Ethiopian', seatClassId: scEconomy.id, baseFareMinor: 31500, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'SBT-ADM', nationality: 'Ethiopian', seatClassId: scEconomy.id, baseFareMinor: 16200, validFrom: new Date('2026-01-01'), refundable: true },
// Djiboutian nationals - 5% discount on cross-border routes
{ route: 'DDW-NGD', nationality: 'Djiboutian', seatClassId: scEconomy.id, baseFareMinor: 42750, validFrom: new Date('2026-01-01'), refundable: true },
{ route: 'SBT-NGD', nationality: 'Djiboutian', seatClassId: scEconomy.id, baseFareMinor: 61750, validFrom: new Date('2026-01-01'), refundable: true },
];
await prisma.fareRule.createMany({ data: nationalityRules, skipDuplicates: true });
console.log(` ✅ Created ${allRules.length + nationalityRules.length} fare rules`);
}
// ============================================================================
// SECTION 9: USERS & PASSENGERS
// ============================================================================
async function seedUsers() {
console.log('👥 Seeding users...');
const hash = await bcrypt.hash('password123', 10);
const adminHash = await bcrypt.hash('admin123', 10);
const agentHash = await bcrypt.hash('agent123', 10);
// Admin
await prisma.user.upsert({
where: { email: 'admin@edr-platform.com' },
update: { passwordHash: adminHash, role: 'ADMIN' },
create: {
fullName: 'EDR Admin',
email: 'admin@edr-platform.com',
phone: '+251900000000',
passwordHash: adminHash,
role: 'ADMIN',
},
});
// Ethiopian Passenger
const ethiopianUser = await prisma.user.upsert({
where: { email: 'abebe@email.com' },
update: {},
create: {
fullName: 'Abebe Kebede',
email: 'abebe@email.com',
phone: '+251912345678',
passwordHash: hash,
nationality: 'Ethiopian',
nationalId: 'ET123456789',
},
});
let ethiopianPassenger = await prisma.passenger.findUnique({ where: { userId: ethiopianUser.id } });
if (!ethiopianPassenger) {
ethiopianPassenger = await prisma.passenger.create({ data: { userId: ethiopianUser.id } });
await prisma.loyaltyAccount.create({ data: { passengerId: ethiopianPassenger.id, pointsBalance: 2450, tier: 'SILVER' } });
await prisma.walletAccount.create({ data: { passengerId: ethiopianPassenger.id, balanceMinor: 125000 } });
}
await prisma.userPreferences.upsert({
where: { userId: ethiopianUser.id },
update: {},
create: { userId: ethiopianUser.id, language: 'en' },
});
// Djiboutian Passenger
const djiboutianUser = await prisma.user.upsert({
where: { email: 'ahmed@email.com' },
update: {},
create: {
fullName: 'Ahmed Hassan',
email: 'ahmed@email.com',
phone: '+25377123456',
passwordHash: hash,
nationality: 'Djiboutian',
passportNumber: 'DJ1234567',
},
});
let djiboutianPassenger = await prisma.passenger.findUnique({ where: { userId: djiboutianUser.id } });
if (!djiboutianPassenger) {
djiboutianPassenger = await prisma.passenger.create({ data: { userId: djiboutianUser.id } });
await prisma.loyaltyAccount.create({ data: { passengerId: djiboutianPassenger.id, pointsBalance: 1200, tier: 'BRONZE' } });
await prisma.walletAccount.create({ data: { passengerId: djiboutianPassenger.id, balanceMinor: 85000 } });
}
await prisma.userPreferences.upsert({
where: { userId: djiboutianUser.id },
update: {},
create: { userId: djiboutianUser.id, language: 'fr' },
});
// Agent
const agentUser = await prisma.user.upsert({
where: { email: 'agent@edr-platform.com' },
update: { passwordHash: agentHash, role: 'AGENT' },
create: {
fullName: 'Agent Abebe',
email: 'agent@edr-platform.com',
phone: '+251911111111',
passwordHash: agentHash,
role: 'AGENT',
},
});
const stations = await prisma.station.findMany();
await prisma.agent.upsert({
where: { userId: agentUser.id },
update: {},
create: {
userId: agentUser.id,
agentCode: 'AG001',
stationId: stations[0].id,
commissionRate: 5,
active: true,
},
});
console.log(` ✅ Created 4 users (Admin, Ethiopian, Djiboutian, Agent)`);
}
// ============================================================================
// SECTION 10: ROUTES
// ============================================================================
async function seedRoutes(stations: any[], seatClasses: any[]) {
console.log('🛤️ Seeding routes...');
const [sebeta, labu, indode, bishoftu, mojo, adama, feto, metahara, mieso, bike, diredawa, arawa, adigala, aysha, dawanle, alisabieh, holhol, nagad] = stations;
const [scEconomy, scEconomyBed, scVip] = seatClasses;
// Route 1: Full Line (Sebeta to Nagad)
const fullRoute = await prisma.route.upsert({
where: { code: 'SBT-NGD-FULL' },
update: {},
create: {
code: 'SBT-NGD-FULL',
name: 'Sebeta - Nagad Express',
description: 'Complete Ethio-Djibouti Railway route from Sebeta to Nagad',
effectiveFrom: new Date('2026-01-01'),
active: true,
},
});
// Create stops for full route
const fullRouteStops = [
{ routeId: fullRoute.id, stationId: sebeta.id, sequence: 1, distanceKm: 0 },
{ routeId: fullRoute.id, stationId: labu.id, sequence: 2, distanceKm: 15 },
{ routeId: fullRoute.id, stationId: indode.id, sequence: 3, distanceKm: 28 },
{ routeId: fullRoute.id, stationId: bishoftu.id, sequence: 4, distanceKm: 45 },
{ routeId: fullRoute.id, stationId: mojo.id, sequence: 5, distanceKm: 73 },
{ routeId: fullRoute.id, stationId: adama.id, sequence: 6, distanceKm: 99 },
{ routeId: fullRoute.id, stationId: feto.id, sequence: 7, distanceKm: 125 },
{ routeId: fullRoute.id, stationId: metahara.id, sequence: 8, distanceKm: 168 },
{ routeId: fullRoute.id, stationId: mieso.id, sequence: 9, distanceKm: 245 },
{ routeId: fullRoute.id, stationId: bike.id, sequence: 10, distanceKm: 312 },
{ routeId: fullRoute.id, stationId: diredawa.id, sequence: 11, distanceKm: 378 },
{ routeId: fullRoute.id, stationId: arawa.id, sequence: 12, distanceKm: 445 },
{ routeId: fullRoute.id, stationId: adigala.id, sequence: 13, distanceKm: 512 },
{ routeId: fullRoute.id, stationId: aysha.id, sequence: 14, distanceKm: 578 },
{ routeId: fullRoute.id, stationId: dawanle.id, sequence: 15, distanceKm: 625 },
{ routeId: fullRoute.id, stationId: alisabieh.id, sequence: 16, distanceKm: 672 },
{ routeId: fullRoute.id, stationId: holhol.id, sequence: 17, distanceKm: 718 },
{ routeId: fullRoute.id, stationId: nagad.id, sequence: 18, distanceKm: 756 },
];
await prisma.routeStop.createMany({ data: fullRouteStops, skipDuplicates: true });
// Fare rules for full route
const fullRouteFares = [
{ routeId: fullRoute.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT' as const, baseFareMinor: 65000, validFrom: new Date('2026-01-01') },
{ routeId: fullRoute.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD' as const, baseFareMinor: 65000, validFrom: new Date('2026-01-01') },
{ routeId: fullRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'ADULT' as const, baseFareMinor: 91000, validFrom: new Date('2026-01-01') },
{ routeId: fullRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'CHILD' as const, baseFareMinor: 91000, validFrom: new Date('2026-01-01') },
{ routeId: fullRoute.id, seatClassId: scVip.id, passengerCategory: 'ADULT' as const, baseFareMinor: 117000, validFrom: new Date('2026-01-01') },
{ routeId: fullRoute.id, seatClassId: scVip.id, passengerCategory: 'CHILD' as const, baseFareMinor: 117000, validFrom: new Date('2026-01-01') },
];
await prisma.routeFareRule.createMany({ data: fullRouteFares, skipDuplicates: true });
// Route 2: Regional (Sebeta to Diredawa)
const regionalRoute = await prisma.route.upsert({
where: { code: 'SBT-DDW-REG' },
update: {},
create: {
code: 'SBT-DDW-REG',
name: 'Sebeta - Diredawa Regional',
description: 'Regional service from Sebeta to Diredawa',
effectiveFrom: new Date('2026-01-01'),
active: true,
},
});
const regionalStops = [
{ routeId: regionalRoute.id, stationId: sebeta.id, sequence: 1, distanceKm: 0 },
{ routeId: regionalRoute.id, stationId: labu.id, sequence: 2, distanceKm: 15 },
{ routeId: regionalRoute.id, stationId: indode.id, sequence: 3, distanceKm: 28 },
{ routeId: regionalRoute.id, stationId: bishoftu.id, sequence: 4, distanceKm: 45 },
{ routeId: regionalRoute.id, stationId: mojo.id, sequence: 5, distanceKm: 73 },
{ routeId: regionalRoute.id, stationId: adama.id, sequence: 6, distanceKm: 99 },
{ routeId: regionalRoute.id, stationId: feto.id, sequence: 7, distanceKm: 125 },
{ routeId: regionalRoute.id, stationId: metahara.id, sequence: 8, distanceKm: 168 },
{ routeId: regionalRoute.id, stationId: mieso.id, sequence: 9, distanceKm: 245 },
{ routeId: regionalRoute.id, stationId: bike.id, sequence: 10, distanceKm: 312 },
{ routeId: regionalRoute.id, stationId: diredawa.id, sequence: 11, distanceKm: 378 },
];
await prisma.routeStop.createMany({ data: regionalStops, skipDuplicates: true });
const regionalFares = [
{ routeId: regionalRoute.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT' as const, baseFareMinor: 35000, validFrom: new Date('2026-01-01') },
{ routeId: regionalRoute.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD' as const, baseFareMinor: 35000, validFrom: new Date('2026-01-01') },
{ routeId: regionalRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'ADULT' as const, baseFareMinor: 49000, validFrom: new Date('2026-01-01') },
{ routeId: regionalRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'CHILD' as const, baseFareMinor: 49000, validFrom: new Date('2026-01-01') },
{ routeId: regionalRoute.id, seatClassId: scVip.id, passengerCategory: 'ADULT' as const, baseFareMinor: 63000, validFrom: new Date('2026-01-01') },
{ routeId: regionalRoute.id, seatClassId: scVip.id, passengerCategory: 'CHILD' as const, baseFareMinor: 63000, validFrom: new Date('2026-01-01') },
];
await prisma.routeFareRule.createMany({ data: regionalFares, skipDuplicates: true });
// Route 3: Short Distance (Sebeta to Adama)
const shortRoute = await prisma.route.upsert({
where: { code: 'SBT-ADM-SHORT' },
update: {},
create: {
code: 'SBT-ADM-SHORT',
name: 'Sebeta - Adama Commuter',
description: 'Short distance commuter service',
effectiveFrom: new Date('2026-01-01'),
active: true,
},
});
const shortStops = [
{ routeId: shortRoute.id, stationId: sebeta.id, sequence: 1, distanceKm: 0 },
{ routeId: shortRoute.id, stationId: labu.id, sequence: 2, distanceKm: 15 },
{ routeId: shortRoute.id, stationId: indode.id, sequence: 3, distanceKm: 28 },
{ routeId: shortRoute.id, stationId: bishoftu.id, sequence: 4, distanceKm: 45 },
{ routeId: shortRoute.id, stationId: mojo.id, sequence: 5, distanceKm: 73 },
{ routeId: shortRoute.id, stationId: adama.id, sequence: 6, distanceKm: 99 },
];
await prisma.routeStop.createMany({ data: shortStops, skipDuplicates: true });
const shortFares = [
{ routeId: shortRoute.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT' as const, baseFareMinor: 18000, validFrom: new Date('2026-01-01') },
{ routeId: shortRoute.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD' as const, baseFareMinor: 18000, validFrom: new Date('2026-01-01') },
{ routeId: shortRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'ADULT' as const, baseFareMinor: 25200, validFrom: new Date('2026-01-01') },
{ routeId: shortRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'CHILD' as const, baseFareMinor: 25200, validFrom: new Date('2026-01-01') },
{ routeId: shortRoute.id, seatClassId: scVip.id, passengerCategory: 'ADULT' as const, baseFareMinor: 32400, validFrom: new Date('2026-01-01') },
{ routeId: shortRoute.id, seatClassId: scVip.id, passengerCategory: 'CHILD' as const, baseFareMinor: 32400, validFrom: new Date('2026-01-01') },
];
await prisma.routeFareRule.createMany({ data: shortFares, skipDuplicates: true });
// Route 4: Cross-Border (Diredawa to Nagad)
const crossBorderRoute = await prisma.route.upsert({
where: { code: 'DDW-NGD-INTL' },
update: {},
create: {
code: 'DDW-NGD-INTL',
name: 'Diredawa - Nagad International',
description: 'Cross-border service from Ethiopia to Djibouti',
effectiveFrom: new Date('2026-01-01'),
active: true,
},
});
const crossBorderStops = [
{ routeId: crossBorderRoute.id, stationId: diredawa.id, sequence: 1, distanceKm: 0 },
{ routeId: crossBorderRoute.id, stationId: arawa.id, sequence: 2, distanceKm: 67 },
{ routeId: crossBorderRoute.id, stationId: adigala.id, sequence: 3, distanceKm: 134 },
{ routeId: crossBorderRoute.id, stationId: aysha.id, sequence: 4, distanceKm: 200 },
{ routeId: crossBorderRoute.id, stationId: dawanle.id, sequence: 5, distanceKm: 247 },
{ routeId: crossBorderRoute.id, stationId: alisabieh.id, sequence: 6, distanceKm: 294 },
{ routeId: crossBorderRoute.id, stationId: holhol.id, sequence: 7, distanceKm: 340 },
{ routeId: crossBorderRoute.id, stationId: nagad.id, sequence: 8, distanceKm: 378 },
];
await prisma.routeStop.createMany({ data: crossBorderStops, skipDuplicates: true });
const crossBorderFares = [
{ routeId: crossBorderRoute.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT' as const, baseFareMinor: 45000, validFrom: new Date('2026-01-01') },
{ routeId: crossBorderRoute.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD' as const, baseFareMinor: 45000, validFrom: new Date('2026-01-01') },
{ routeId: crossBorderRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'ADULT' as const, baseFareMinor: 63000, validFrom: new Date('2026-01-01') },
{ routeId: crossBorderRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'CHILD' as const, baseFareMinor: 63000, validFrom: new Date('2026-01-01') },
{ routeId: crossBorderRoute.id, seatClassId: scVip.id, passengerCategory: 'ADULT' as const, baseFareMinor: 81000, validFrom: new Date('2026-01-01') },
{ routeId: crossBorderRoute.id, seatClassId: scVip.id, passengerCategory: 'CHILD' as const, baseFareMinor: 81000, validFrom: new Date('2026-01-01') },
];
await prisma.routeFareRule.createMany({ data: crossBorderFares, skipDuplicates: true });
console.log(` ✅ Created 4 routes with stops and fare rules`);
return [fullRoute, regionalRoute, shortRoute, crossBorderRoute];
}
// ============================================================================
// SECTION 11: SUPPORTING DATA
// ============================================================================
async function seedSupportingData(seatClasses: any[]) {
console.log('📦 Seeding supporting data...');
// Baggage Allowance
await prisma.baggageAllowance.deleteMany({});
await prisma.baggageAllowance.createMany({
data: [
{ seatClassId: seatClasses[0].id, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 500 },
{ seatClassId: seatClasses[1].id, maxWeightKg: 25, maxPiecesCount: 2, excessFeePerKg: 450 },
{ seatClassId: seatClasses[2].id, maxWeightKg: 30, maxPiecesCount: 3, excessFeePerKg: 400 },
],
});
// Supported Payment Methods (platform-wide catalog)
const paymentMethods = [
{ type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA', currency: 'ETB', sortOrder: 1, isDefault: true },
{ type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA', currency: 'ETB', sortOrder: 2 },
{ type: 'EBIRR', displayName: 'E-Birr', region: 'ETHIOPIA', currency: 'ETB', sortOrder: 3 },
{ type: 'WAAFI', displayName: 'Waafi', region: 'DJIBOUTI', currency: 'DJF', sortOrder: 4 },
{ type: 'CARD', displayName: 'Credit / Debit Card', region: 'INTERNATIONAL', currency: 'USD', sortOrder: 5 },
{ type: 'WALLET', displayName: 'EDR Wallet', region: 'GLOBAL', currency: 'ETB', sortOrder: 6 },
] as const;
for (const pm of paymentMethods) {
await prisma.paymentMethod.upsert({
where: { type: pm.type as any },
update: { displayName: pm.displayName, region: pm.region as any, currency: pm.currency, sortOrder: pm.sortOrder, enabled: true },
create: { ...pm, region: pm.region as any, type: pm.type as any },
});
}
// Notification Templates
await prisma.notificationTemplate.upsert({
where: { code: 'BOOKING_CONFIRMED' },
update: {},
create: {
code: 'BOOKING_CONFIRMED',
channel: 'EMAIL',
subject: 'Booking Confirmed',
bodyTemplate: 'Your booking {{bookingRef}} is confirmed for {{tripDate}}.',
active: true,
},
});
await prisma.notificationTemplate.upsert({
where: { code: 'booking.created' },
update: {},
create: {
code: 'booking.created',
channel: 'EMAIL',
subject: 'Booking Created',
bodyTemplate: 'Your booking {{bookingRef}} has been created successfully.',
active: true,
},
});
await prisma.notificationTemplate.upsert({
where: { code: 'PAYMENT_SUCCESS' },
update: {},
create: {
code: 'PAYMENT_SUCCESS',
channel: 'SMS',
bodyTemplate: 'Payment successful for {{bookingRef}}. Amount: {{amount}} ETB',
active: true,
},
});
// Promotions
await prisma.promotion.upsert({
where: { code: 'WEEKEND15' },
update: {},
create: {
title: 'Weekend Sale',
subtitle: '15% off all trips',
code: 'WEEKEND15',
percentOff: 15,
validUntil: new Date('2026-12-31'),
ctaLabel: 'Book Now',
active: true,
},
});
// Currency Exchange Rates
await prisma.currencyExchangeRate.deleteMany({});
await prisma.currencyExchangeRate.createMany({
data: [
{ fromCurrency: 'ETB', toCurrency: 'ETB', rate: 1.0, effectiveDate: new Date() },
{ fromCurrency: 'ETB', toCurrency: 'USD', rate: 0.018, effectiveDate: new Date() },
{ fromCurrency: 'ETB', toCurrency: 'DJF', rate: 3.2, effectiveDate: new Date() },
{ fromCurrency: 'DJF', toCurrency: 'ETB', rate: 0.3125, effectiveDate: new Date() },
{ fromCurrency: 'DJF', toCurrency: 'DJF', rate: 1.0, effectiveDate: new Date() },
],
});
// Fraud Rules
await prisma.fraudRule.upsert({
where: { type: 'VELOCITY' },
update: {},
create: {
type: 'VELOCITY',
enabled: true,
threshold: 3,
config: { windowMinutes: 60, action: 'FLAG' },
},
});
console.log(` ✅ Created supporting data`);
}
// ============================================================================
// MAIN SEED FUNCTION
// ============================================================================
async function main() {
console.log('🌱 Starting comprehensive modular seed with 18 stations...\n');
const stations = await seedStations();
const seatClasses = await seedSeatClasses();
const trains = await seedTrains();
const coaches = await seedCoachesAndSeats(seatClasses);
const routes = await seedRoutes(stations, seatClasses);
const schedules = await seedSchedules(trains, stations, routes);
await seedCoachAssignments(schedules, coaches);
await seedStopTimes(schedules, stations);
await seedFareRules(schedules, seatClasses);
await seedUsers();
await seedSupportingData(seatClasses);
console.log('\n✅ Comprehensive seed complete!\n');
console.log('📋 Seed Summary:');
console.log(' - 18 Stations: SBT, LBU, IND, BSH, MJO, ADM, FTO, MTH, MSO, BKE, DDW, ARW, ADG, AYS, DWL, ALI, HOL, NGD');
console.log(' - 3 Seat Classes (Economy Regular, Economy Bed, VIP Bed)');
console.log(' - 3 Trains (Express 301, Express 302, Local 303)');
console.log(' - 6 Physical Coaches with seats');
console.log(' - 5 Train Schedules covering full and regional routes');
console.log(' - 4 Routes with stops and fare rules');
console.log(' - 15+ Fare Segments with nationality-based pricing');
console.log(' - 4 Users: Admin, Ethiopian Passenger, Djiboutian Passenger, Agent');
console.log(' - Currency rates: ETB, USD, DJF');
console.log('\n🔑 Login Credentials:');
console.log(' Admin: admin@edr-platform.com / admin123');
console.log(' Ethiopian Passenger: abebe@email.com / password123');
console.log(' Djiboutian Passenger: ahmed@email.com / password123');
console.log(' Agent: agent@edr-platform.com / agent123');
console.log('\n💰 Booking Flow Ready:');
console.log(' - Search: 18 stations with multiple route combinations');
console.log(' - Select: 3 seat classes with dynamic pricing');
console.log(' - Book: Complete passenger details and payment');
console.log(' - Pay: Multiple payment methods (Telebirr, CBE, Card, Wallet)');
console.log(' - Ticket: QR code generation and validation');
console.log('\n🚂 Sample Routes:');
console.log(' - Full Route: Sebeta → Nagad (18 stations, 756 km)');
console.log(' - Regional: Sebeta → Diredawa (11 stations, 378 km)');
console.log(' - Short: Sebeta → Adama (6 stations, 99 km)');
console.log(' - Cross-border: Diredawa → Nagad (8 stations, 378 km)');
}
main()
.catch((e) => {
console.error('❌ Seed failed:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -1,36 +1,92 @@
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import appConfig from "./config/app.config";
import databaseConfig from "./config/database.config";
import { TicketsModule } from "./modules/tickets/tickets.module";
import { SchedulesModule } from "./modules/schedules/schedules.module";
import { PassengersModule } from "./modules/passengers/passengers.module";
import { SeatsModule } from "./modules/seats/seats.module";
import { StationsModule } from "./modules/stations/stations.module";
import { PaymentsModule } from "./modules/payments/payments.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
// import { EventEmitterModule } from '@nestjs/event-emitter';
import { PrismaModule } from './common/prisma.module';
import { I18nModule } from './common/i18n/i18n.module';
import { IamModule } from './common/iam.module';
import { LocaleMiddleware } from './common/i18n/locale.middleware';
import appConfig from './config/app.config';
import dbConfig from './config/database.config';
import telebirrConfig from './config/telebirr.config';
import cbeConfig from './config/cbe.config';
import ebirrConfig from './config/ebirr.config';
import cardConfig from './config/card.config';
import waafiConfig from './config/waafi.config';
import faydaConfig from './config/fayda.config';
import { AuthModule } from './modules/auth/auth.module';
import { StationsModule } from './modules/stations/stations.module';
import { FleetModule } from './modules/fleet/fleet.module';
import { SchedulesModule } from './modules/schedules/schedules.module';
import { SearchModule } from './modules/search/search.module';
import { SeatsModule } from './modules/seats/seats.module';
import { BookingsModule } from './modules/bookings/bookings.module';
import { PaymentsModule } from './modules/payments/payments.module';
import { TicketsModule } from './modules/tickets/tickets.module';
import { PassengersModule } from './modules/passengers/passengers.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { LoyaltyModule } from './modules/loyalty/loyalty.module';
import { WalletModule } from './modules/wallet/wallet.module';
import { PromosModule } from './modules/promos/promos.module';
import { LiveModule } from './modules/live/live.module';
import { SupportModule } from './modules/support/support.module';
import { DashboardModule } from './modules/dashboard/dashboard.module';
import { SegmentsModule } from './modules/segments/segments.module';
import { AgentsModule } from './modules/agents/agents.module';
import { ReportsModule } from './modules/reports/reports.module';
import { FraudModule } from './modules/fraud/fraud.module';
import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig],
load: [
appConfig,
dbConfig,
telebirrConfig,
cbeConfig,
ebirrConfig,
cardConfig,
waafiConfig,
faydaConfig,
],
}),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
config.get<TypeOrmModuleOptions>("database")!,
}),
TicketsModule,
SchedulesModule,
PassengersModule,
SeatsModule,
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
PrismaModule,
I18nModule,
IamModule,
AuthModule,
StationsModule,
FleetModule,
SchedulesModule,
SearchModule,
SeatsModule,
BookingsModule,
PaymentsModule,
TicketsModule,
PassengersModule,
NotificationsModule,
LoyaltyModule,
WalletModule,
PromosModule,
LiveModule,
SupportModule,
DashboardModule,
SegmentsModule,
AgentsModule,
ReportsModule,
FraudModule,
SeatClassesModule,
FareEngineModule,
VerifaydaModule,
],
})
export class AppModule {}
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LocaleMiddleware).forRoutes('*');
}
}

View File

@@ -1 +1,53 @@
export { HttpExceptionFilter } from "@edr/api-common";
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const messageRaw =
exception instanceof HttpException
? exception.getResponse()
: 'Internal server error';
const message =
typeof messageRaw === 'string'
? messageRaw
: ((messageRaw as { message?: string }).message ?? 'Unexpected error');
if (status >= 500) {
this.logger.error(
`${request.method} ${request.url} -> ${status}`,
exception instanceof Error ? exception.stack : JSON.stringify(exception),
);
console.error('Full error details:', exception);
} else {
this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`);
}
response.status(status).json({
success: false,
statusCode: status,
message,
error: exception instanceof Error ? exception.name : 'Error',
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

View File

@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { I18nService } from './i18n.service';
@Global()
@Module({
providers: [I18nService],
exports: [I18nService],
})
export class I18nModule {}

View File

@@ -0,0 +1,49 @@
import { Test, TestingModule } from '@nestjs/testing';
import { I18nService } from './i18n.service';
describe('I18nService', () => {
let service: I18nService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [I18nService],
}).compile();
service = module.get<I18nService>(I18nService);
});
it('should translate English keys', () => {
expect(service.translate('common.welcome', 'en')).toBe('Welcome');
expect(service.translate('booking.created', 'en')).toBe('Booking created successfully');
});
it('should translate Amharic keys', () => {
expect(service.translate('common.welcome', 'am')).toBe('እንኳን ደህና መጡ');
});
it('should translate French keys', () => {
expect(service.translate('common.welcome', 'fr')).toBe('Bienvenue');
});
it('should translate Oromo keys', () => {
expect(service.translate('common.welcome', 'om')).toBe('Baga nagaan dhuftan');
});
it('should fallback to English for unsupported locale', () => {
expect(service.translate('common.welcome', 'de')).toBe('Welcome');
});
it('should return key if translation not found', () => {
expect(service.translate('nonexistent.key', 'en')).toBe('nonexistent.key');
});
it('should interpolate parameters', () => {
const result = service.translate('common.welcome', 'en', { name: 'John' });
expect(result).toBeDefined();
});
it('should return supported locales', () => {
const locales = service.getSupportedLocales();
expect(locales).toEqual(['en', 'am', 'fr', 'om']);
});
});

View File

@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
type TranslationMap = Record<string, any>;
@Injectable()
export class I18nService {
private translations: Map<string, TranslationMap> = new Map();
private readonly supportedLocales = ['en', 'am', 'fr', 'om'];
private readonly defaultLocale = 'en';
constructor() {
this.loadTranslations();
}
private loadTranslations() {
for (const locale of this.supportedLocales) {
const filePath = path.join(__dirname, 'translations', `${locale}.json`);
try {
const content = fs.readFileSync(filePath, 'utf-8');
this.translations.set(locale, JSON.parse(content));
} catch (err) {
console.warn(`Failed to load translation file for locale: ${locale}`);
}
}
}
translate(key: string, locale: string = this.defaultLocale, params?: Record<string, string>): string {
const normalizedLocale = this.normalizeLocale(locale);
const translations = this.translations.get(normalizedLocale) || this.translations.get(this.defaultLocale);
if (!translations) return key;
const keys = key.split('.');
let value: any = translations;
for (const k of keys) {
value = value?.[k];
if (value === undefined) return key;
}
if (typeof value !== 'string') return key;
if (params) {
return Object.entries(params).reduce(
(text, [param, val]) => text.replace(new RegExp(`{{${param}}}`, 'g'), val),
value
);
}
return value;
}
private normalizeLocale(locale: string): string {
const normalized = locale.toLowerCase().split('-')[0];
return this.supportedLocales.includes(normalized) ? normalized : this.defaultLocale;
}
getSupportedLocales(): string[] {
return this.supportedLocales;
}
}

View File

@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { LOCALE_KEY } from './locale.middleware';
export const Locale = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
return request[LOCALE_KEY] || 'en';
},
);

View File

@@ -0,0 +1,16 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
export const LOCALE_KEY = 'locale';
@Injectable()
export class LocaleMiddleware implements NestMiddleware {
use(req: any, res: any, next: () => void) {
const locale =
req.query.lang as string ||
req.headers['accept-language']?.split(',')[0]?.split('-')[0] ||
'en';
req[LOCALE_KEY] = locale;
next();
}
}

View File

@@ -0,0 +1,23 @@
{
"common": {
"welcome": "እንኳን ደህና መጡ",
"error": "ስህተት ተከስቷል",
"success": "ተሳክቷል"
},
"booking": {
"created": "ቦታ ማስያዝ በተሳካ ሁኔታ ተፈጥሯል",
"notFound": "ቦታ ማስያዝ አልተገኘም",
"cancelled": "ቦታ ማስያዝ ተሰርዟል",
"confirmed": "ቦታ ማስያዝ ተረጋግጧል"
},
"payment": {
"succeeded": "ክፍያ ተሳክቷል",
"failed": "ክፍያ አልተሳካም",
"pending": "ክፍያ በመጠባበቅ ላይ"
},
"ticket": {
"issued": "ትኬት ተሰጥቷል",
"validated": "ትኬት ተረጋግጧል",
"alreadyValidated": "ትኬት ቀድሞውኑ ተረጋግጧል"
}
}

View File

@@ -0,0 +1,23 @@
{
"common": {
"welcome": "Welcome",
"error": "An error occurred",
"success": "Success"
},
"booking": {
"created": "Booking created successfully",
"notFound": "Booking not found",
"cancelled": "Booking cancelled",
"confirmed": "Booking confirmed"
},
"payment": {
"succeeded": "Payment successful",
"failed": "Payment failed",
"pending": "Payment pending"
},
"ticket": {
"issued": "Ticket issued",
"validated": "Ticket validated",
"alreadyValidated": "Ticket already validated"
}
}

View File

@@ -0,0 +1,23 @@
{
"common": {
"welcome": "Bienvenue",
"error": "Une erreur s'est produite",
"success": "Succès"
},
"booking": {
"created": "Réservation créée avec succès",
"notFound": "Réservation introuvable",
"cancelled": "Réservation annulée",
"confirmed": "Réservation confirmée"
},
"payment": {
"succeeded": "Paiement réussi",
"failed": "Échec du paiement",
"pending": "Paiement en attente"
},
"ticket": {
"issued": "Billet émis",
"validated": "Billet validé",
"alreadyValidated": "Billet déjà validé"
}
}

View File

@@ -0,0 +1,23 @@
{
"common": {
"welcome": "Baga nagaan dhuftan",
"error": "Dogongora uumame",
"success": "Milkaa'ina"
},
"booking": {
"created": "Bakka qabachuu milkaa'inaan uumame",
"notFound": "Bakka qabachuu hin argamne",
"cancelled": "Bakka qabachuu haqame",
"confirmed": "Bakka qabachuu mirkaneeffame"
},
"payment": {
"succeeded": "Kaffaltiin milkaa'e",
"failed": "Kaffaltiin hin milkoofne",
"pending": "Kaffaltiin eegaa jira"
},
"ticket": {
"issued": "Tiikeetiin kenname",
"validated": "Tiikeetiin mirkaneeffame",
"alreadyValidated": "Tiikeetiin duraan mirkaneeffame"
}
}

View File

@@ -0,0 +1,264 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { IamGuard } from './iam-adapter';
import { of, throwError } from 'rxjs';
describe('IamGuard', () => {
let guard: IamGuard;
let httpService: HttpService;
let configService: ConfigService;
let reflector: Reflector;
const mockConfigService = {
get: jest.fn((key: string) => {
const config: Record<string, string> = {
IAM_API_URL: 'https://iam.test.com/api',
IAM_ENABLED: 'true',
IAM_API_KEY: 'test-api-key',
};
return config[key];
}),
};
const mockHttpService = {
post: jest.fn(),
};
const mockReflector = {
get: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
IamGuard,
{ provide: ConfigService, useValue: mockConfigService },
{ provide: HttpService, useValue: mockHttpService },
{ provide: Reflector, useValue: mockReflector },
],
}).compile();
guard = module.get<IamGuard>(IamGuard);
httpService = module.get<HttpService>(HttpService);
configService = module.get<ConfigService>(ConfigService);
reflector = module.get<Reflector>(Reflector);
jest.clearAllMocks();
});
const createMockContext = (token?: string, roles?: string[]): ExecutionContext => {
const request = {
headers: token ? { authorization: `Bearer ${token}` } : {},
user: undefined,
};
return {
switchToHttp: () => ({
getRequest: () => request,
}),
getHandler: () => ({}),
} as ExecutionContext;
};
describe('canActivate', () => {
it('should allow access when IAM is disabled', async () => {
mockConfigService.get.mockReturnValueOnce('false'); // IAM_ENABLED
const context = createMockContext();
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should throw UnauthorizedException when no token provided', async () => {
const context = createMockContext();
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
it('should validate token and allow access', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'admin@test.com',
roles: ['ADMIN'],
permissions: ['read', 'write'],
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(null);
const context = createMockContext('valid-token');
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(mockHttpService.post).toHaveBeenCalledWith(
'https://iam.test.com/api/v1/auth/validate',
{ token: 'valid-token' },
expect.objectContaining({
headers: expect.objectContaining({
'X-API-Key': 'test-api-key',
}),
}),
);
});
it('should throw UnauthorizedException for invalid token', async () => {
const mockValidationResponse = {
data: {
valid: false,
error: 'Token expired',
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
const context = createMockContext('invalid-token');
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
it('should check required roles', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'agent@test.com',
roles: ['AGENT'],
permissions: [],
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(['ADMIN', 'SUPERVISOR']);
const context = createMockContext('valid-token');
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
});
it('should allow access when user has required role', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'admin@test.com',
roles: ['ADMIN'],
permissions: [],
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(['ADMIN', 'SUPERVISOR']);
const context = createMockContext('valid-token');
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should handle HTTP errors gracefully', async () => {
mockHttpService.post.mockReturnValue(
throwError(() => new Error('Network error')),
);
const context = createMockContext('valid-token');
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
it('should attach user to request', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'admin@test.com',
roles: ['ADMIN'],
permissions: ['read', 'write'],
organizationId: 'org-456',
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(null);
const context = createMockContext('valid-token');
await guard.canActivate(context);
const request = context.switchToHttp().getRequest();
expect(request.user).toEqual({
userId: 'user-123',
email: 'admin@test.com',
roles: ['ADMIN'],
permissions: ['read', 'write'],
organizationId: 'org-456',
});
});
});
describe('token extraction', () => {
it('should extract token from Bearer header', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'test@test.com',
roles: [],
permissions: [],
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(null);
const context = createMockContext('my-token-123');
await guard.canActivate(context);
expect(mockHttpService.post).toHaveBeenCalledWith(
expect.any(String),
{ token: 'my-token-123' },
expect.any(Object),
);
});
it('should reject malformed authorization header', async () => {
const request = {
headers: { authorization: 'InvalidFormat token' },
};
const context = {
switchToHttp: () => ({
getRequest: () => request,
}),
getHandler: () => ({}),
} as ExecutionContext;
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
});
});

View File

@@ -0,0 +1,144 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
/**
* IAM Adapter for @tria-plc corporate identity integration
*
* This adapter wraps the corporate IAM guards and provides a bridge
* between the corporate identity system and the EDR passenger API.
*
* For back-office roles (agent, supervisor, admin, staff), this guard
* validates tokens against the corporate IAM service.
*
* For passenger-facing routes, the existing JWT guard is used.
*/
export interface IamTokenPayload {
sub: string;
email: string;
roles: string[];
permissions: string[];
organizationId?: string;
exp: number;
iat: number;
}
export interface IamValidationResponse {
valid: boolean;
payload?: IamTokenPayload;
error?: string;
}
@Injectable()
export class IamGuard implements CanActivate {
private readonly iamApiUrl: string;
private readonly iamEnabled: boolean;
constructor(
private readonly reflector: Reflector,
private readonly config: ConfigService,
private readonly http: HttpService,
) {
this.iamApiUrl = this.config.get<string>('IAM_API_URL') || 'https://iam.tria-plc.com/api';
this.iamEnabled = this.config.get<string>('IAM_ENABLED') === 'true';
}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (!this.iamEnabled) {
// IAM disabled - allow access (for development)
return true;
}
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('No authentication token provided');
}
const validation = await this.validateToken(token);
if (!validation.valid || !validation.payload) {
throw new UnauthorizedException(validation.error || 'Invalid token');
}
// Check required roles
const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
if (requiredRoles && requiredRoles.length > 0) {
const hasRole = requiredRoles.some((role) => validation.payload!.roles.includes(role));
if (!hasRole) {
throw new ForbiddenException('Insufficient permissions');
}
}
// Attach user to request
request.user = {
userId: validation.payload.sub,
email: validation.payload.email,
roles: validation.payload.roles,
permissions: validation.payload.permissions,
organizationId: validation.payload.organizationId,
};
return true;
}
private extractToken(request: any): string | null {
const authHeader = request.headers.authorization;
if (!authHeader) return null;
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') return null;
return parts[1];
}
private async validateToken(token: string): Promise<IamValidationResponse> {
try {
const response = await firstValueFrom(
this.http.post<IamValidationResponse>(
`${this.iamApiUrl}/v1/auth/validate`,
{ token },
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.config.get<string>('IAM_API_KEY') || '',
},
timeout: 5000,
},
),
);
return response.data;
} catch (err) {
return {
valid: false,
error: err instanceof Error ? err.message : 'Token validation failed',
};
}
}
}
/**
* Decorator to mark routes as requiring IAM authentication
*/
export const UseIamAuth = () => {
// This is a marker decorator that can be used with @UseGuards(IamGuard)
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
// Marker only - actual guard is applied via @UseGuards
};
};
/**
* Decorator to specify required roles for IAM-protected routes
*/
export const IamRoles = (...roles: string[]) => {
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
if (descriptor) {
Reflect.defineMetadata('roles', roles, descriptor.value);
}
};
};

View File

@@ -0,0 +1,11 @@
import { Module, Global } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { IamGuard } from './iam-adapter';
@Global()
@Module({
imports: [HttpModule.register({ timeout: 5000 })],
providers: [IamGuard],
exports: [IamGuard],
})
export class IamModule {}

View File

@@ -1 +1,12 @@
export { ResponseTransformInterceptor } from "@edr/api-common";
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class ResponseTransformInterceptor<T> implements NestInterceptor<T, any> {
intercept(_ctx: ExecutionContext, next: CallHandler<T>): Observable<any> {
return next.handle().pipe(
map((data) => ({ success: true, data, timestamp: new Date().toISOString() })),
);
}
}

View File

@@ -0,0 +1,49 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, UnauthorizedException } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { PrismaService } from '../prisma.service';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class SessionActivityInterceptor implements NestInterceptor {
private readonly inactivityMinutes: number;
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
) {
this.inactivityMinutes = parseInt(this.config.get<string>('SESSION_INACTIVITY_MINUTES') || '30', 10);
}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
const user = request.user;
if (user?.userId) {
const session = await this.prisma.session.findFirst({
where: { userId: user.userId },
orderBy: { lastActivityAt: 'desc' },
});
if (session) {
const inactiveMinutes = (Date.now() - session.lastActivityAt.getTime()) / 60000;
if (inactiveMinutes > this.inactivityMinutes) {
await this.prisma.session.delete({ where: { id: session.id } });
throw new UnauthorizedException('Session expired due to inactivity');
}
const expiryWarningMinutes = Math.max(0, this.inactivityMinutes - inactiveMinutes);
response.setHeader('X-Session-Expiry-Warning', Math.floor(expiryWarningMinutes).toString());
await this.prisma.session.update({
where: { id: session.id },
data: { lastActivityAt: new Date() },
});
}
}
return next.handle().pipe(tap(() => {}));
}
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtGuard extends AuthGuard('jwt') {}

View File

@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get('JWT_SECRET'),
});
}
async validate(payload: any) {
return { userId: payload.sub, email: payload.email, role: payload.role, passengerId: payload.passengerId };
}
}

View File

@@ -1 +0,0 @@
export { createValidationPipe } from "@edr/api-common";

View File

@@ -0,0 +1,7 @@
import { Module, Global } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { SessionActivityInterceptor } from './interceptors/session-activity.interceptor';
@Global()
@Module({ providers: [PrismaService, SessionActivityInterceptor], exports: [PrismaService, SessionActivityInterceptor] })
export class PrismaModule {}

View File

@@ -0,0 +1,8 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
}

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,19 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '@prisma/client';
import { ROLES_KEY } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.role === role);
}
}

View File

@@ -1,7 +1,9 @@
import { registerAs } from "@nestjs/config";
import { registerAs } from '@nestjs/config';
export default registerAs("app", () => ({
env: process.env.NODE_ENV ?? "development",
port: parseInt(process.env.PORT ?? "3002", 10),
apiPrefix: "api",
export default registerAs('app', () => ({
port: parseInt(process.env.PORT ?? '4000', 10),
jwtSecret: process.env.JWT_SECRET ?? 'dev-secret',
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d',
frontendUrl: process.env.PORTAL_URL ?? 'http://localhost:3000',
portalUrl: process.env.BACK_OFFICE_URL ?? 'http://localhost:3001',
}));

View File

@@ -0,0 +1,9 @@
import { registerAs } from '@nestjs/config';
export default registerAs('card', () => ({
baseUrl: process.env.CARD_BASE_URL || '',
apiKey: process.env.CARD_API_KEY || '',
webhookSecret: process.env.CARD_WEBHOOK_SECRET || '',
webhookUrl: process.env.CARD_WEBHOOK_URL || '',
returnUrl: process.env.CARD_RETURN_URL || '',
}));

View File

@@ -0,0 +1,9 @@
import { registerAs } from '@nestjs/config';
export default registerAs('cbe', () => ({
baseUrl: process.env.CBE_BASE_URL || '',
merchantId: process.env.CBE_MERCHANT_ID || '',
secretKey: process.env.CBE_SECRET_KEY || '',
notifyUrl: process.env.CBE_NOTIFY_URL || '',
returnUrl: process.env.CBE_RETURN_URL || '',
}));

View File

@@ -1,18 +1,5 @@
import { registerAs } from "@nestjs/config";
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
import { registerAs } from '@nestjs/config';
export default registerAs(
"database",
(): TypeOrmModuleOptions => ({
type: "postgres",
host: process.env.DB_HOST ?? "localhost",
port: parseInt(process.env.DB_PORT ?? "5434", 10),
username: process.env.DB_USER ?? "postgres",
password: process.env.DB_PASSWORD ?? "",
database: process.env.DB_NAME ?? "edr_passenger",
entities: [__dirname + "/../**/*.entity.{ts,js}"],
migrations: [__dirname + "/../../migrations/*.{ts,js}"],
synchronize: process.env.NODE_ENV === "development",
logging: process.env.NODE_ENV === "development",
}),
);
export default registerAs('database', () => ({
url: process.env.DATABASE_URL,
}));

View File

@@ -0,0 +1,9 @@
import { registerAs } from '@nestjs/config';
export default registerAs('ebirr', () => ({
baseUrl: process.env.EBIRR_BASE_URL || '',
merchantCode: process.env.EBIRR_MERCHANT_CODE || '',
secretKey: process.env.EBIRR_SECRET_KEY || '',
notifyUrl: process.env.EBIRR_NOTIFY_URL || '',
returnUrl: process.env.EBIRR_RETURN_URL || '',
}));

View File

@@ -0,0 +1,118 @@
import { registerAs } from '@nestjs/config';
export interface FaydaJwk {
kty: 'RSA';
use?: string;
kid?: string;
alg?: string;
n: string;
e: string;
d: string;
p?: string;
q?: string;
dp?: string;
dq?: string;
qi?: string;
}
export type FaydaPlatform = 'WEB' | 'MOBILE';
export interface FaydaConfig {
enabled: boolean;
clientId: string;
authorizationEndpoint: string;
tokenEndpoint: string;
userInfoEndpoint: string;
redirectUri: string;
privateJwk: FaydaJwk;
scope: string;
acrValues: string;
claimsLocales: string;
sessionTtlMinutes: number;
}
const REQUIRED_VARS = [
'FAYDA_CLIENT_ID',
'FAYDA_AUTHORIZATION_ENDPOINT',
'FAYDA_TOKEN_ENDPOINT',
'FAYDA_USERINFO_ENDPOINT',
'FAYDA_PRIVATE_KEY_BASE64',
] as const;
function decodePrivateJwk(base64: string): FaydaJwk {
let jwk: unknown;
try {
const json = Buffer.from(base64, 'base64').toString('utf8');
jwk = JSON.parse(json);
} catch (err) {
throw new Error(
`FAYDA_PRIVATE_KEY_BASE64 is not valid Base64-encoded JSON: ${(err as Error).message}`,
);
}
if (!jwk || typeof jwk !== 'object') {
throw new Error('FAYDA_PRIVATE_KEY_BASE64 must decode to a JSON object');
}
const candidate = jwk as Partial<FaydaJwk>;
if (candidate.kty !== 'RSA') {
throw new Error('FAYDA_PRIVATE_KEY_BASE64 JWK must have kty="RSA"');
}
if (!candidate.n || !candidate.e || !candidate.d) {
throw new Error(
'FAYDA_PRIVATE_KEY_BASE64 JWK is missing required RSA private-key fields (n, e, d)',
);
}
return candidate as FaydaJwk;
}
export default registerAs('fayda', (): FaydaConfig => {
const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true';
const scope = process.env.FAYDA_SCOPE ?? 'openid profile email';
const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code';
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
if (!enabled) {
return {
enabled: false,
clientId: process.env.FAYDA_CLIENT_ID ?? '',
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT ?? '',
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '',
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
redirectUri,
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
scope,
acrValues,
claimsLocales,
sessionTtlMinutes: Number.isNaN(sessionTtl) || sessionTtl <= 0 ? 10 : sessionTtl,
};
}
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
if (missing.length > 0) {
throw new Error(
`Fayda integration is enabled (FAYDA_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`,
);
}
if (!redirectUri) {
throw new Error(
'Fayda integration is enabled but the redirect URI is missing: set FAYDA_REDIRECT_URI',
);
}
if (Number.isNaN(sessionTtl) || sessionTtl <= 0) {
throw new Error('FAYDA_SESSION_TTL_MINUTES must be a positive integer');
}
return {
enabled: true,
clientId: process.env.FAYDA_CLIENT_ID!,
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT!,
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!,
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
redirectUri,
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
scope,
acrValues,
claimsLocales,
sessionTtlMinutes: sessionTtl,
};
});

View File

@@ -0,0 +1,16 @@
import { registerAs } from '@nestjs/config';
export default registerAs('telebirr', () => ({
baseUrl: process.env.TELEBIRR_BASE_URL ?? '',
webBaseUrl: process.env.TELEBIRR_WEB_BASE_URL ?? '',
fabricAppId: process.env.TELEBIRR_FABRIC_APP_ID ?? '',
appSecret: process.env.TELEBIRR_APP_SECRET ?? '',
merchantAppId: process.env.TELEBIRR_MERCHANT_APP_ID ?? '',
merchantCode: process.env.TELEBIRR_MERCHANT_CODE ?? '',
notifyUrl: process.env.TELEBIRR_NOTIFY_URL ?? '',
returnUrl: process.env.TELEBIRR_RETURN_URL ?? '',
timeoutExpress: process.env.TELEBIRR_TIMEOUT_EXPRESS ?? '15m',
privateKey: process.env.TELEBIRR_PRIVATE_KEY ?? '',
publicKey: process.env.TELEBIRR_PUBLIC_KEY ?? '',
insecureTls: process.env.TELEBIRR_INSECURE_TLS === 'true',
}));

View File

@@ -0,0 +1,10 @@
import { registerAs } from '@nestjs/config';
export default registerAs('waafi', () => ({
baseUrl: process.env.WAAFI_BASE_URL ?? 'https://api.waafipay.net',
merchantUid: process.env.WAAFI_MERCHANT_UID ?? '',
apiUserId: process.env.WAAFI_API_USER_ID ?? '',
apiKey: process.env.WAAFI_API_KEY ?? '',
notifyUrl: process.env.WAAFI_NOTIFY_URL ?? '',
returnUrl: process.env.WAAFI_RETURN_URL ?? '',
}));

View File

@@ -1,35 +1,260 @@
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import {
HttpExceptionFilter,
ResponseTransformInterceptor,
createValidationPipe,
} from "@edr/api-common";
import { AppModule } from "./app.module";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor";
import { SessionActivityInterceptor } from "./common/interceptors/session-activity.interceptor";
async function bootstrap() {
const app = await NestFactory.create(AppModule, { cors: true });
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: [
process.env.PORTAL_URL ?? "http://localhost:5174",
process.env.BACK_OFFICE_URL ?? "http://localhost:5184",
],
});
app.setGlobalPrefix("api");
app.useGlobalPipes(createValidationPipe());
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseTransformInterceptor());
app.useGlobalInterceptors(
new ResponseTransformInterceptor(),
app.get(SessionActivityInterceptor),
);
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidUnknownValues: false }));
const config = new DocumentBuilder()
.setTitle("EDR Passenger API")
.setDescription("API for the EDR Passenger Management application")
.setVersion("0.1.0")
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("api/docs", app, document);
.setDescription(
`# Ethio-Djibouti Railway Passenger Booking API
const port = parseInt(process.env.PORT ?? "3002", 10);
await app.listen(port, "0.0.0.0");
// eslint-disable-next-line no-console
console.log(`[passenger-api] listening on port ${port}`);
## Overview
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
## Key Features
### 🎫 Booking Lifecycle
- Search trips with real-time availability
- Age-based passenger categorization (Adult ≥5 years, Child <5 years)
- Nationality-based verification (Ethiopian Fayda, International Passport)
- Passenger information collection with verification
- Coach and seat selection with real-time availability
- Seat holding (15-minute expiry)
- Create bookings with verified passenger data
- Modify bookings (seat changes, passenger updates)
- Cancel bookings with automatic refunds
- Multi-segment journey support
### 👤 Passenger Verification
1. **Ethiopian Nationals:**
- Automatic Fayda verification for adults (≥5 years)
- Real-time national ID verification via government database
- Retrieves verified passenger data (name, DOB, gender)
- National IDs not stored (policy compliant)
2. **International Passengers:**
- Passport information collection
- Manual verification for Djiboutian and other nationals
- No government database verification required
### 💰 Age-Based Pricing
- **ADULT** (≥5 years): Pay 100% of base fare
- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100%
- Automatic age calculation from date of birth
- Example: 2 adults + 3 children = 4× base fare (first child free)
### 💳 Payment Integration
1. **Ethiopian Payment Methods:**
- **Telebirr** - Ethiopia's leading mobile money
- **CBE Birr** - Commercial Bank of Ethiopia
- **eBirr** - Electronic payment gateway
2. **Djiboutian Payment Methods:**
- **Waafi** - Djibouti's mobile money service
3. **International Payment Methods:**
- **Card** - International card payments (Visa, Mastercard)
- **Wallet** - Internal wallet system
### 🪑 Seat Management
- Real-time seat availability by coach and class
- Seat holds with 15-minute expiry
- Auto-assign seats with contiguous algorithm
- Seat blocking for maintenance
- Coach-level seat maps
- Class-based seating (Economy Regular, Economy Bed, VIP Bed)
### 🎟️ Ticketing
- QR code and barcode generation
- PDF ticket generation
- Gate validation with audit logs
- Offline validation support
- Multi-passenger tickets
### 🏆 Loyalty Program
- 4 tiers: Bronze, Silver, Gold, Platinum
- Points accumulation on trips
- Reward redemption
- Tier-based benefits
### 💰 Wallet System
- Top-up via payment methods
- Pay with wallet balance
- Transaction ledger
- Refund to wallet
### 📍 Live Tracking
- Real-time trip status
- Location updates
- Delay notifications
- Station crowd signals
### 🔒 Fraud Detection
- Velocity checks (multiple bookings)
- High-value transaction monitoring
- Failed payment pattern detection
- Automatic user blocking
### 🌍 Internationalization
- Multi-language support (English, Amharic, French, Oromo)
- Locale-based responses
- Currency formatting (ETB, DJF, USD)
### 👨‍💼 Agent Operations
- Counter booking
- Shift management
- Commission tracking
- Cash reconciliation
## Authentication
### Passenger Authentication (JWT-auth)
Used for passenger-facing endpoints. Obtain token via \`POST /auth/login\`.
**Usage:** Add header \`Authorization: Bearer <token>\`
### Back-office Authentication (IAM-auth)
Used for agent, fraud, and reporting endpoints. Requires corporate IAM token.
**Usage:** Add header \`Authorization: Bearer <iam-token>\`
## Passenger Booking Flow
### Step 1: Search Trips
\`POST /search\` with origin, destination, date, passenger counts, and nationality
### Step 2: Get Fare Quote
\`POST /search/fare-quote\` with passenger counts and display currency
### Step 3: Passenger Information & Verification
**For Ethiopian Passengers:**
\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (≥5 years)
**For International Passengers:**
\`POST /passengers/register-international\` - Passport information collection
### Step 4: View Seat Map
\`GET /seats/seatmap/{scheduleId}\` - Show available coaches and seats
### Step 5: Login & Hold Seats
\`POST /auth/login\` then \`POST /seats/hold\` to reserve seats for 15 minutes
### Step 6: Create Booking
\`POST /bookings/guest\` with verified passenger details and held seats
### Step 7: Process Payment
\`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian)
### Step 8: Get Tickets
\`GET /payments/{paymentId}/status\` to confirm payment and retrieve tickets with QR codes
## Rate Limiting
- Auth endpoints: 5 requests/minute
- General endpoints: 100 requests/minute
- Webhook endpoints: No limit
## Error Handling
All errors follow standard format:
\`\`\`json
{
"statusCode": 400,
"message": "Validation failed",
"error": "Bad Request",
"timestamp": "2026-05-20T14:30:00.000Z",
"path": "/bookings"
}
\`\`\`
## Pagination
List endpoints support pagination:
- \`limit\`: Number of items (default: 20, max: 100)
- \`offset\`: Skip items (default: 0)
## Webhooks
Payment providers send notifications to:
- \`POST /payments/webhooks/telebirr\` (Ethiopia)
- \`POST /payments/webhooks/cbe-birr\` (Ethiopia)
- \`POST /payments/webhooks/ebirr\` (Ethiopia)
- \`POST /payments/webhooks/waafi\` (Djibouti)
- \`POST /payments/webhooks/card\` (International)
## Support
- **Email:** support@edr-platform.com
- **Documentation:** https://docs.edr-platform.com
- **Status Page:** https://status.edr-platform.com
`,
)
.setVersion("1.0.0")
.addBearerAuth(
{ type: "http", scheme: "bearer", bearerFormat: "JWT", in: "header" },
"JWT-auth",
)
.addTag("Agents", "Counter booking, shift management, and commission tracking")
.addTag("Auth", "User registration, login, and profile management")
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel")
.addTag("Dashboard", "Aggregated dashboard data for home screen")
.addTag("Fare Engine", "Distance-based fare calculator with multi-currency support")
.addTag("Fayda Verification", "Ethiopian national ID verification via government API")
.addTag("Fleet", "Train services, coaches, and seat configurations")
.addTag("Fraud Detection", "Fraud monitoring, alerts, and user blocking")
.addTag("Live Tracking", "Real-time trip status, delays, and station crowds")
.addTag("Loyalty", "Points accumulation, tiers, and reward redemption")
.addTag("Notifications", "Multi-channel notifications: email, SMS, push")
.addTag("Passengers", "Passenger registration, verification, and profiles")
.addTag("Payment", "Payment processing, intents, and refunds")
.addTag("Payment Webhooks", "Payment provider webhook handlers")
.addTag("Promotions", "Promo codes, campaigns, and discount management")
.addTag("Reports", "Sales reports, occupancy analytics, and metrics")
.addTag("Routes", "Route templates with stops and fare rules")
.addTag("Schedule", "Trip schedules, availability, and status updates")
.addTag("Search", "Trip search, availability checks, and fare quotes")
.addTag("Seat Classes", "Seat class management: Economy, VIP configurations")
.addTag("Seats", "Seat maps, holds, releases, and blocking")
.addTag("Segment-based Seats", "Segment-level seat allocation and availability")
.addTag("Stations", "Station directory and information")
.addTag("Support", "FAQ management and live chat support")
.addTag("Tickets", "QR ticket generation, PDFs, and gate validation")
.addTag("Wallet", "Wallet balance, top-ups, and transaction ledger")
//.addServer('http://localhost:4000', 'Development')
// .addServer("https://api.edr-platform.com", "Production")
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("api-docs", app, document, {
customSiteTitle: "EDR Passenger API",
swaggerOptions: {
persistAuthorization: true,
docExpansion: "none",
filter: true,
tagsSorter: "alpha",
operationsSorter: "alpha",
},
});
const port = process.env.PORT ?? 4000;
await app.listen(port);
console.log(`🚀 EDR Passenger API running on port ${port}`);
console.log(`📚 Swagger: http://localhost:${port}/api-docs`);
}
bootstrap();

View File

@@ -0,0 +1,57 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AgentsService } from './agents.service';
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { IamGuard, IamRoles } from '../../common/iam-adapter';
import { UserRole } from '@prisma/client';
@ApiTags('Agents')
@Controller('agents')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
export class AgentsController {
constructor(private service: AgentsService) {}
@Post('bookings')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Create agent booking with cash payment' })
createBooking(@Body() dto: CreateAgentBookingDto) {
return this.service.createAgentBooking(dto);
}
@Post('shifts/open')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Open agent shift' })
openShift(@Body() dto: OpenShiftDto) {
return this.service.openShift(dto);
}
@Post('shifts/close')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Close agent shift' })
closeShift(@Body() dto: CloseShiftDto) {
return this.service.closeShift(dto);
}
@Get(':agentId/commissions')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Get agent commissions' })
getCommissions(
@Param('agentId') agentId: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string
) {
return this.service.getCommissions(
agentId,
dateFrom ? new Date(dateFrom) : undefined,
dateTo ? new Date(dateTo) : undefined
);
}
@Get(':agentId/shifts')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Get agent shifts' })
getShifts(@Param('agentId') agentId: string) {
return this.service.getShifts(agentId);
}
}

View File

@@ -0,0 +1,33 @@
import { IsString, IsInt, IsBoolean, IsOptional, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class AgentPassengerDto {
@ApiProperty() @IsString() fullName: string;
@ApiProperty() @IsString() phone: string;
@ApiProperty() @IsString() email: string;
@ApiProperty() @IsString() seatId: string;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentType?: string;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
}
export class CreateAgentBookingDto {
@ApiProperty() @IsString() agentId: string;
@ApiProperty({ example: 'schedule-uuid' }) @IsString() scheduleId: string;
@ApiProperty({ type: [AgentPassengerDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => AgentPassengerDto) passengers: AgentPassengerDto[];
@ApiProperty() @IsString() paymentMethod: string;
@ApiPropertyOptional() @IsOptional() @IsInt() cashReceived?: number;
@ApiPropertyOptional() @IsOptional() @IsBoolean() paperTicket?: boolean;
@ApiPropertyOptional() @IsOptional() @IsString() serviceClass?: string;
}
export class OpenShiftDto {
@ApiProperty() @IsString() agentId: string;
@ApiPropertyOptional() @IsOptional() @IsInt() openingBalance?: number;
}
export class CloseShiftDto {
@ApiProperty() @IsString() shiftId: string;
@ApiProperty() @IsInt() closingBalance: number;
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { AgentsController } from './agents.controller';
import { AgentsService } from './agents.service';
@Module({
imports: [HttpModule],
controllers: [AgentsController],
providers: [AgentsService],
exports: [AgentsService]
})
export class AgentsModule {}

View File

@@ -0,0 +1,132 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { IdDocumentType } from '@prisma/client';
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
@Injectable()
export class AgentsService {
constructor(private prisma: PrismaService) {}
async createAgentBooking(dto: CreateAgentBookingDto) {
const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId }, include: { user: { include: { passenger: true } } } });
if (!agent || !agent.active) throw new NotFoundException('Agent not found or inactive');
if (!agent.user.passenger) throw new BadRequestException('Agent must have passenger account');
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId } });
if (!schedule) throw new NotFoundException('Schedule not found');
const seatIds = dto.passengers.map(p => p.seatId);
const seats = await this.prisma.seat.findMany({ where: { id: { in: seatIds } } });
if (seats.length !== seatIds.length) throw new BadRequestException('Invalid seat selection');
const baseFare = 45000 * dto.passengers.length;
const totalMinor = baseFare;
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: agent.user.passenger.id,
scheduleId: dto.scheduleId,
status: dto.paymentMethod === 'CASH' ? 'CONFIRMED' : 'PENDING_PAYMENT',
totalMinor,
seats: {
create: dto.passengers.map(p => ({
seat: { connect: { id: p.seatId } },
passengerName: p.fullName,
idDocumentType: p.idDocumentType as IdDocumentType | undefined,
idDocumentNumber: p.idDocumentNumber
}))
}
},
include: { seats: true }
});
await this.prisma.seat.updateMany({
where: { id: { in: seatIds } },
data: { status: 'BOOKED' }
});
const changeGiven = dto.cashReceived ? dto.cashReceived - totalMinor : 0;
await this.prisma.agentBooking.create({
data: {
agentId: dto.agentId,
bookingId: booking.id,
paymentMethod: dto.paymentMethod,
cashReceived: dto.cashReceived,
changeGiven,
paperTicket: dto.paperTicket ?? false
}
});
const commissionAmount = Math.floor(totalMinor * agent.commissionRate / 100);
await this.prisma.agentCommission.create({
data: {
agentId: dto.agentId,
bookingId: booking.id,
amountMinor: commissionAmount,
rate: agent.commissionRate
}
});
return { booking, commission: commissionAmount };
}
async openShift(dto: OpenShiftDto) {
const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId } });
if (!agent) throw new NotFoundException('Agent not found');
const openShift = await this.prisma.agentShift.findFirst({
where: { agentId: dto.agentId, closedAt: null }
});
if (openShift) throw new BadRequestException('Shift already open');
return this.prisma.agentShift.create({
data: {
agentId: dto.agentId,
openingBalance: dto.openingBalance ?? 0
}
});
}
async closeShift(dto: CloseShiftDto) {
const shift = await this.prisma.agentShift.findUnique({ where: { id: dto.shiftId } });
if (!shift) throw new NotFoundException('Shift not found');
if (shift.closedAt) throw new BadRequestException('Shift already closed');
return this.prisma.agentShift.update({
where: { id: dto.shiftId },
data: {
closedAt: new Date(),
closingBalance: dto.closingBalance,
notes: dto.notes,
reconciled: true
}
});
}
async getCommissions(agentId: string, dateFrom?: Date, dateTo?: Date) {
return this.prisma.agentCommission.findMany({
where: {
agentId,
createdAt: {
gte: dateFrom,
lte: dateTo
}
},
orderBy: { createdAt: 'desc' }
});
}
async getShifts(agentId: string) {
return this.prisma.agentShift.findMany({
where: { agentId },
orderBy: { openedAt: 'desc' },
take: 20
});
}
}

View File

@@ -0,0 +1,220 @@
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private service: AuthService) {}
@Post('register')
@ApiOperation({
summary: 'Register new passenger account',
description: 'Create a new passenger account with email, phone, and password. Returns user details and JWT token for immediate login.'
})
@ApiResponse({ status: 201, description: 'Account created successfully. Returns user object and JWT token.' })
@ApiResponse({ status: 400, description: 'Validation error (invalid email, weak password, etc.)' })
@ApiResponse({ status: 409, description: 'Email or phone already registered' })
@ApiBody({ type: RegisterDto })
register(@Body() dto: RegisterDto) { return this.service.register(dto); }
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Login with email and password',
description: 'Authenticate user and receive JWT token. Token expires in 7 days by default. Failed login attempts are tracked and account may be locked after 5 consecutive failures.'
})
@ApiResponse({ status: 200, description: 'Login successful. Returns JWT token and user details.' })
@ApiResponse({ status: 401, description: 'Invalid credentials or account locked' })
@ApiResponse({ status: 403, description: 'Account temporarily blocked due to fraud detection' })
@ApiBody({ type: LoginDto })
login(@Body() dto: LoginDto) { return this.service.login(dto); }
@Post('otp/request')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Request OTP verification code',
description: 'Send a 6-digit OTP code to user email. Code expires in 10 minutes. Used for registration verification, password reset, or two-factor authentication.'
})
@ApiResponse({ status: 200, description: 'OTP sent successfully to email' })
@ApiResponse({ status: 404, description: 'Email not found (for PASSWORD_RESET purpose)' })
@ApiResponse({ status: 429, description: 'Too many OTP requests. Please wait before requesting again.' })
@ApiBody({ type: RequestOtpDto })
requestOtp(@Body() dto: RequestOtpDto) { return this.service.requestOtp(dto); }
@Post('otp/verify')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Verify OTP code',
description: 'Validate the 6-digit OTP code sent to user email. Code must match and not be expired.'
})
@ApiResponse({ status: 200, description: 'OTP verified successfully' })
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
@ApiResponse({ status: 404, description: 'No OTP found for this email and purpose' })
@ApiBody({ type: VerifyOtpDto })
verifyOtp(@Body() dto: VerifyOtpDto) { return this.service.verifyOtp(dto); }
@Post('password/reset-request')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Request password reset link',
description: 'Send password reset link to user email. Link contains a secure token valid for 1 hour.'
})
@ApiResponse({ status: 200, description: 'Password reset email sent successfully' })
@ApiResponse({ status: 404, description: 'Email not found' })
@ApiResponse({ status: 429, description: 'Too many reset requests. Please wait before trying again.' })
@ApiBody({ type: RequestPasswordResetDto })
requestPasswordReset(@Body() dto: RequestPasswordResetDto) { return this.service.requestPasswordReset(dto); }
@Post('password/reset')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Reset password with token',
description: 'Reset user password using the token received via email. Token is single-use and expires after 1 hour.'
})
@ApiResponse({ status: 200, description: 'Password reset successfully' })
@ApiResponse({ status: 400, description: 'Invalid, expired, or already used token' })
@ApiResponse({ status: 404, description: 'User not found' })
@ApiBody({ type: ResetPasswordDto })
resetPassword(@Body() dto: ResetPasswordDto) { return this.service.resetPassword(dto); }
@Post('logout')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Logout current user',
description: `Logout the authenticated user and invalidate their session.
### What happens:
- Invalidates the current session token
- Records logout in audit log
- Frontend should clear stored token and redirect to home
### Authentication:
- **Required**: JWT Bearer Token
- Token will be invalidated after successful logout`
})
@ApiResponse({
status: 200,
description: 'Logout successful',
schema: {
example: {
success: true,
message: 'Logged out successfully'
}
}
})
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' })
logout(@Request() req: any) {
if (!req.user || !req.user.userId) {
throw new UnauthorizedException('User not authenticated');
}
return this.service.logout(req.user.userId);
}
@Get('profile')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Get current user profile',
description: `**Returns complete user profile with all connected data**
---
### Response Includes
#### User Information
- Basic details (id, email, phone, fullName, role)
- Nationality and document information
- Fayda verification status
- Account timestamps (created, last login)
#### Passenger Data (if role=PASSENGER)
- Passenger ID and preferences
- **Loyalty Account**: Tier, points balance, lifetime points
- **Wallet Account**: Balance (minor units), currency
#### User Preferences
- Language, notification settings, etc.
---
### Use Cases
1. **App Initialization**: Fetch on app load to get user context
2. **Profile Pre-fill**: Use data to auto-fill booking forms
3. **Verification Check**: Check \`faydaVerified\` before registration
4. **Loyalty Display**: Show tier and points in UI
5. **Wallet Balance**: Display available balance
---
### Authentication
- **Required**: JWT Bearer Token
- Token must be valid and not expired
- Returns profile for authenticated user only`,
})
@ApiResponse({
status: 200,
description: 'User profile retrieved successfully',
schema: {
example: {
id: 'user-uuid-123',
email: 'kelemu@email.com',
phone: '+251911234567',
fullName: 'Kelemu Abebe',
role: 'PASSENGER',
nationality: 'Ethiopian',
nationalityCode: 'ET',
nationalId: null,
passportNumber: null,
faydaVerified: true,
faydaVerifiedAt: '2024-01-15T10:30:00.000Z',
lastLoginAt: '2024-01-20T14:22:00.000Z',
createdAt: '2023-12-01T08:00:00.000Z',
passenger: {
id: 'passenger-uuid-456',
preferredLanguage: 'am',
loyalty: {
tier: 'SILVER',
pointsBalance: 1500,
lifetimePoints: 3000
},
wallet: {
balanceMinor: 50000,
currency: 'ETB'
}
},
preferences: {
emailNotifications: true,
smsNotifications: true,
language: 'am'
}
}
}
})
@ApiResponse({
status: 401,
description: 'Unauthorized - Invalid or missing JWT token',
schema: {
example: {
statusCode: 401,
message: 'Unauthorized'
}
}
})
getProfile(@Request() req: any) {
console.log('Profile request - User from JWT:', req.user);
if (!req.user || !req.user.userId) {
throw new UnauthorizedException('User not authenticated');
}
return this.service.getProfile(req.user.userId);
}
}

View File

@@ -0,0 +1,152 @@
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({
description: 'Full name of the passenger',
example: 'Kelemu Ketsela',
minLength: 2,
maxLength: 100
})
@IsString()
fullName: string;
@ApiProperty({
description: 'Email address (must be unique)',
example: 'kelemu@email.com',
format: 'email'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'Phone number with country code',
example: '+251912345678',
pattern: '^\\+[1-9]\\d{1,14}$'
})
@IsString()
phone: string;
@ApiProperty({
description: 'Password (minimum 8 characters)',
example: 'SecurePass123',
minLength: 8,
format: 'password'
})
@IsString()
@MinLength(8)
password: string;
@ApiPropertyOptional({
description: 'Nationality of the passenger',
example: 'Ethiopian'
})
@IsOptional()
@IsString()
nationality?: string;
@ApiPropertyOptional({
description: 'National ID number',
example: 'ET123456789'
})
@IsOptional()
@IsString()
nationalId?: string;
@ApiPropertyOptional({
description: 'Passport number for international travelers',
example: 'P1234567'
})
@IsOptional()
@IsString()
passportNumber?: string;
}
export class LoginDto {
@ApiProperty({
description: 'Registered email address',
example: 'kelemu@email.com',
format: 'email'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'Account password',
example: 'password123',
format: 'password'
})
@IsString()
password: string;
}
export class RequestOtpDto {
@ApiProperty({
description: 'Email address to send OTP',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'Purpose of OTP (REGISTRATION, PASSWORD_RESET, VERIFICATION)',
example: 'REGISTRATION',
enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION']
})
@IsString()
purpose: string;
}
export class VerifyOtpDto {
@ApiProperty({
description: 'Email address',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
@ApiProperty({
description: '6-digit OTP code',
example: '123456',
minLength: 6,
maxLength: 6
})
@IsString()
code: string;
@ApiProperty({
description: 'Purpose of OTP verification',
example: 'REGISTRATION',
enum: ['REGISTRATION', 'PASSWORD_RESET', 'VERIFICATION']
})
@IsString()
purpose: string;
}
export class RequestPasswordResetDto {
@ApiProperty({
description: 'Email address of the account',
example: 'kelemu@email.com'
})
@IsEmail()
email: string;
}
export class ResetPasswordDto {
@ApiProperty({
description: 'Password reset token received via email',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
@IsString()
token: string;
@ApiProperty({
description: 'New password (minimum 8 characters)',
example: 'NewSecurePass123',
minLength: 8,
format: 'password'
})
@IsString()
@MinLength(8)
newPassword: string;
}

View File

@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from '../../common/jwt.strategy';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (c: ConfigService) => ({
secret: c.get('JWT_SECRET'),
signOptions: { expiresIn: c.get('JWT_EXPIRES_IN', '7d') },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [JwtModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,233 @@
import { Injectable, UnauthorizedException, ConflictException, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../../common/prisma.service';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
@Injectable()
export class AuthService {
constructor(private prisma: PrismaService, private jwt: JwtService) {}
async register(dto: RegisterDto) {
const exists = await this.prisma.user.findFirst({
where: { OR: [{ email: dto.email }, { phone: dto.phone }] },
});
if (exists) throw new ConflictException('Email or phone already registered');
const passwordHash = await bcrypt.hash(dto.password, 10);
const user = await this.prisma.user.create({
data: {
fullName: dto.fullName,
email: dto.email,
phone: dto.phone,
passwordHash,
nationality: dto.nationality,
nationalId: dto.nationalId,
passportNumber: dto.passportNumber
},
});
const passenger = await this.prisma.passenger.create({ data: { userId: user.id } });
await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } });
await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } });
await this.prisma.userPreferences.create({ data: { userId: user.id } });
await this.createAuditLog(user.id, 'USER_REGISTERED', 'User', user.id, null, { email: user.email });
return await this.signToken(user.id, user.email, user.role, passenger.id);
}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
include: { passenger: true, agent: true },
});
if (!user) throw new UnauthorizedException('Invalid credentials');
if (user.lockedUntil && user.lockedUntil > new Date()) {
throw new UnauthorizedException(`Account locked until ${user.lockedUntil.toISOString()}`);
}
if (!(await bcrypt.compare(dto.password, user.passwordHash))) {
await this.prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: { increment: 1 },
lockedUntil: user.failedLoginAttempts >= 4 ? new Date(Date.now() + 15 * 60 * 1000) : null
}
});
throw new UnauthorizedException('Invalid credentials');
}
await this.prisma.user.update({
where: { id: user.id },
data: { failedLoginAttempts: 0, lockedUntil: null }
});
await this.createAuditLog(user.id, 'USER_LOGIN', 'User', user.id, null, null);
// Ensure passenger exists and get its ID
let passengerId = user.passenger?.id;
if (!passengerId) {
// If passenger doesn't exist, create it
const passenger = await this.prisma.passenger.create({
data: { userId: user.id }
});
passengerId = passenger.id;
// Also create loyalty and wallet accounts
await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } });
await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } });
}
return await this.signToken(user.id, user.email, user.role, passengerId, user.agent?.id);
}
async requestOtp(dto: RequestOtpDto) {
const code = Math.floor(100000 + Math.random() * 900000).toString();
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
await this.prisma.otpCode.create({
data: { email: dto.email, code, purpose: dto.purpose, expiresAt }
});
console.log(`[OTP] ${dto.email} - ${code} (${dto.purpose})`);
return { sent: true, expiresIn: 600 };
}
async verifyOtp(dto: VerifyOtpDto) {
const otp = await this.prisma.otpCode.findFirst({
where: { email: dto.email, code: dto.code, purpose: dto.purpose, verified: false, expiresAt: { gt: new Date() } },
orderBy: { createdAt: 'desc' }
});
if (!otp) throw new BadRequestException('Invalid or expired OTP');
await this.prisma.otpCode.update({ where: { id: otp.id }, data: { verified: true } });
return { verified: true };
}
async requestPasswordReset(dto: RequestPasswordResetDto) {
const user = await this.prisma.user.findUnique({ where: { email: dto.email } });
if (!user) return { sent: true };
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
await this.prisma.passwordResetToken.create({
data: { userId: user.id, token, expiresAt }
});
console.log(`[PASSWORD_RESET] ${dto.email} - ${token}`);
return { sent: true };
}
async resetPassword(dto: ResetPasswordDto) {
const resetToken = await this.prisma.passwordResetToken.findUnique({
where: { token: dto.token }
});
if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) {
throw new BadRequestException('Invalid or expired reset token');
}
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
await this.prisma.user.update({
where: { id: resetToken.userId },
data: { passwordHash, failedLoginAttempts: 0, lockedUntil: null }
});
await this.prisma.passwordResetToken.update({
where: { id: resetToken.id },
data: { used: true }
});
await this.createAuditLog(resetToken.userId, 'PASSWORD_RESET', 'User', resetToken.userId, null, null);
return { reset: true };
}
private async signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
// Get the full user data to include fullName
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, fullName: true, role: true }
});
const payload = { sub: userId, email, role, passengerId, agentId };
console.log('[AUTH] Creating JWT with payload:', payload);
const token = this.jwt.sign(payload);
console.log('[AUTH] JWT created, token length:', token.length);
const response = {
token,
user: {
id: userId,
email,
fullName: user?.fullName || email,
role,
passengerId,
agentId
}
};
console.log('[AUTH] Returning user object with passengerId:', response.user.passengerId);
return response;
}
private async createAuditLog(userId: string, action: string, entityType: string, entityId: string, oldData: any, newData: any) {
await this.prisma.auditLog.create({
data: { userId, action, entityType, entityId, oldData, newData }
});
}
async getProfile(userId: string) {
if (!userId) {
throw new UnauthorizedException('User ID not found in token');
}
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
passenger: {
include: {
loyalty: true,
wallet: true,
},
},
preferences: true,
},
});
if (!user) throw new UnauthorizedException('User not found');
return {
id: user.id,
email: user.email,
phone: user.phone,
fullName: user.fullName,
role: user.role,
nationality: user.nationality,
nationalityCode: user.nationalityCode,
nationalId: user.nationalId,
passportNumber: user.passportNumber,
faydaVerified: user.faydaVerified,
faydaVerifiedAt: user.faydaVerifiedAt,
lastLoginAt: user.lastLoginAt,
createdAt: user.createdAt,
passenger: user.passenger ? {
id: user.passenger.id,
preferredLanguage: user.passenger.preferredLanguage,
loyalty: user.passenger.loyalty ? {
tier: user.passenger.loyalty.tier,
pointsBalance: user.passenger.loyalty.pointsBalance,
lifetimePoints: user.passenger.loyalty.lifetimePoints,
} : null,
wallet: user.passenger.wallet ? {
balanceMinor: user.passenger.wallet.balanceMinor,
currency: user.passenger.wallet.currency,
} : null,
} : null,
preferences: user.preferences,
};
}
async logout(userId: string) {
// Invalidate all active sessions for this user
await this.prisma.session.deleteMany({
where: { userId }
});
// Log the logout action
await this.createAuditLog(userId, 'USER_LOGOUT', 'User', userId, null, null);
return {
success: true,
message: 'Logged out successfully'
};
}
}

View File

@@ -0,0 +1,199 @@
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service';
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { IamGuard } from '../../common/iam-adapter';
@ApiTags('Booking')
@Controller('bookings')
export class BookingsController {
constructor(
private service: BookingsService,
private guestService: GuestBookingService,
) {}
@Get('my/bookings')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Get logged-in user\'s booking history',
description: 'Returns all bookings for the authenticated user with schedule and payment details'
})
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference or station names' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
@ApiResponse({ status: 200, description: 'List of user bookings with schedule and passenger details' })
getMyBookings(
@Req() req: any,
@Query('search') search?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const passengerId = req.user?.passengerId;
if (!passengerId) throw new Error('Passenger ID not found in token');
return this.service.findByPassengerId(passengerId, {
search,
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20
});
}
@Get()
@ApiOperation({
summary: 'List all bookings with filters (Admin/Agent)',
description: 'Returns paginated list of bookings with search and status filters'
})
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
findAll(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.findAll({
search,
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20
});
}
@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
**Optional Account Creation:**
- Set createAccount=true with password
- Account created using first passenger details
- Automatic login after booking
- Loyalty points and wallet created
**Passenger Details Storage:**
- savePassengerDetails=true: Save for future bookings
- Stored by userId (if account created) or deviceId
- Retrieve saved passengers for quick booking
**Verifayda Verification:**
- Ethiopian nationals: National ID verified via Verifayda
- Other nationals: Passport details (no verification)
**Age-Based Pricing:**
- ADULT (≥5 years): Full fare
- CHILD (<5 years): First child FREE, subsequent children full fare`
})
@ApiResponse({ status: 201, description: 'Booking created successfully' })
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid data' })
createGuest(@Body() dto: CreateGuestBookingDto) {
return this.guestService.createGuestBooking(dto);
}
@Get('saved-passengers')
@ApiOperation({
summary: 'Get saved passenger profiles',
description: 'Retrieve saved passenger details by userId (if logged in) or deviceId (for guest users)'
})
@ApiResponse({ status: 200, description: 'List of saved passenger profiles' })
getSavedPassengers(@Query() query: GetSavedPassengersDto) {
return this.guestService.getSavedPassengers(undefined, query.deviceId);
}
@Post()
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Create booking (requires login)',
description: `Creates a booking for logged-in users with saved passenger profiles.
Use POST /bookings/guest for guest checkout without login.`
})
@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' })
create(@Body() dto: CreateBookingDto) {
return this.service.create(dto);
}
@Get(':bookingRef')
@ApiOperation({
summary: 'Get booking details by reference (no auth required)',
description: 'Returns booking with passenger categories, Verifayda verification status, and multi-currency amounts. Works for both guest and authenticated bookings.'
})
@ApiResponse({ status: 200, description: 'Booking details with adult/child counts and currency conversion' })
@ApiResponse({ status: 404, description: 'Booking not found' })
getByRef(@Param('bookingRef') ref: string) {
return this.service.getByRef(ref);
}
@Patch(':id')
@ApiOperation({
summary: 'Update booking details',
description: 'Updates booking information for admin/agent operations'
})
@ApiResponse({ status: 200, description: 'Booking updated successfully' })
@ApiResponse({ status: 404, description: 'Booking not found' })
update(@Param('id') id: string, @Body() dto: any) {
return this.service.update(id, dto);
}
@Patch(':bookingRef/modify')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Modify booking seats or trip',
description: 'Allows modification of confirmed bookings before departure'
})
@ApiResponse({ status: 200, description: 'Booking modified successfully' })
@ApiResponse({ status: 400, description: 'Cannot modify cancelled or past bookings' })
modify(@Body() dto: ModifyBookingDto) {
return this.service.modify(dto);
}
@Delete(':id')
@ApiOperation({
summary: 'Delete booking (admin only)',
description: 'Permanently deletes a booking record'
})
@ApiResponse({ status: 200, description: 'Booking deleted successfully' })
@ApiResponse({ status: 404, description: 'Booking not found' })
delete(@Param('id') id: string) {
return this.service.delete(id);
}
@Get(':id/usage')
@ApiOperation({
summary: 'Check if booking is in use',
description: 'Returns list of modules/data that reference this booking'
})
@ApiResponse({ status: 200, description: 'Usage information retrieved' })
@ApiResponse({ status: 404, description: 'Booking not found' })
checkUsage(@Param('id') id: string) {
return this.service.checkBookingUsage(id);
}
@Delete(':bookingRef')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Cancel booking with refund',
description: 'Cancels booking and processes refund (80% for confirmed bookings)'
})
@ApiResponse({ status: 200, description: 'Booking cancelled with refund amount' })
@ApiResponse({ status: 400, description: 'Booking already cancelled' })
cancel(@Param('bookingRef') ref: string, @Body() dto: CancelBookingDto) {
return this.service.cancel(ref, dto.reason);
}
}

View File

@@ -0,0 +1,42 @@
import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
export class PassengerInputDto {
@ApiProperty() @IsString() seatId: 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;
@ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string;
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string;
@ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string;
}
export class CreateBookingDto {
@ApiProperty() @IsString() passengerId: string;
@ApiProperty() @IsString() scheduleId: string;
@ApiProperty() @IsString() holdId: string;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID for this leg (must match the hold)' }) @IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg (must match the hold)' }) @IsString() destinationStationId: string;
@ApiProperty({ type: [PassengerInputDto], description: 'Array of passengers with age-based categorization. First child (<5 years) travels FREE.' }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[];
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
@IsString() seatClassId: string;
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
@ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string;
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
}
export class ModifyBookingDto {
@ApiProperty() @IsString() bookingRef: string;
@ApiProperty({ example: 'schedule-uuid' }) @IsString() newScheduleId: string;
@ApiProperty({ type: [String] }) @IsArray() newSeatIds: string[];
@ApiPropertyOptional() @IsOptional() @IsString() reason?: string;
}
export class CancelBookingDto {
@ApiProperty() @IsString() bookingRef: string;
@ApiPropertyOptional() @IsOptional() @IsString() reason?: string;
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { BookingsController } from './bookings.controller';
import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service';
import { SeatsModule } from '../seats/seats.module';
import { VerifaydaModule } from '../verifayda/verifayda.module';
import { CurrencyModule } from '../currency/currency.module';
@Module({
imports: [SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
controllers: [BookingsController],
providers: [BookingsService, GuestBookingService],
exports: [BookingsService, GuestBookingService]
})
export class BookingsModule {}

View File

@@ -0,0 +1,467 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
function calculateAge(dateOfBirth: Date): number {
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
const monthDiff = today.getMonth() - dateOfBirth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dateOfBirth.getDate())) age--;
return age;
}
interface BookingFilters {
search?: string;
status?: string;
page?: number;
pageSize?: number;
}
@Injectable()
export class BookingsService {
constructor(
private prisma: PrismaService,
private seatsService: SeatsService,
private eventEmitter: EventEmitter2,
private verifaydaService: VerifaydaService,
private currencyService: CurrencyService,
) {}
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = { passengerId };
if (search) {
where.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
];
}
if (status) {
where.status = status;
}
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true,
seats: { include: { seat: true } },
},
}),
this.prisma.booking.count({ where }),
]);
return {
items: items.map(booking => ({
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.totalMinor,
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
arrivalAt: booking.schedule.arrivalAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
})),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async findAll(filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
if (search) {
where.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ contactEmail: { contains: search, mode: 'insensitive' } },
{ contactPhone: { contains: search, mode: 'insensitive' } },
{ passenger: { user: { fullName: { contains: search, mode: 'insensitive' } } } },
];
}
if (status) {
where.status = status;
}
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
passenger: { include: { user: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true,
seats: { include: { seat: true } },
},
}),
this.prisma.booking.count({ where }),
]);
return {
items: items.map(booking => ({
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.totalMinor,
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
createdAt: booking.createdAt,
passenger: booking.passenger?.user,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
})),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async create(dto: CreateBookingDto) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: {
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
},
});
if (!schedule) throw new NotFoundException('Schedule not found');
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
const seatIds = dto.passengers.map((p) => p.seatId);
const passengersData = [];
let adultCount = 0, childCount = 0;
for (const passenger of dto.passengers) {
const dateOfBirth = new Date(passenger.dateOfBirth);
const age = calculateAge(dateOfBirth);
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
let passengerName = passenger.passengerName;
let verifaydaVerified = false;
let verifaydaData: Record<string, any> | undefined;
let nationality = passenger.nationality;
if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
passengerName = verification.passengerData?.fullName || passengerName;
verifaydaVerified = true;
verifaydaData = verification.passengerData?.profileData;
nationality = nationality || 'Ethiopian';
} else if (passenger.idDocumentType === IdDocumentType.PASSPORT) {
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
}
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
// Use first passenger's nationality for fare lookup (or allow per-passenger pricing)
const primaryNationality = passengersData[0]?.nationality;
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality);
const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1);
const childFareMinor = baseFareMinor * paidChildrenCount;
const totalBaseFareMinor = adultFareMinor + childFareMinor;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
}
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
bookingType: dto.bookingType ?? 'ONE_WAY',
seats: {
create: passengersData.map((p) => ({
seat: { connect: { id: p.seatId } },
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
idDocumentNumber: p.idDocumentType === IdDocumentType.NATIONAL_ID ? undefined : p.idDocumentNumber,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0),
displayCurrency,
})),
},
},
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
});
await this.seatsService.confirmSeats(seatIds);
this.eventEmitter.emit('booking.created', { booking });
return {
...booking,
fareBreakdown: { baseFareMinor, adultCount, adultFareMinor, childCount, freeChildrenCount: Math.min(childCount, 1), paidChildrenCount, childFareMinor, totalBaseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor, currency: 'ETB', displayCurrency, displayTotalMinor },
};
}
private async getBaseFare(
scheduleId: string,
seatClassId: string,
segmentRoute?: string,
fullRoute?: string,
nationality?: string,
): Promise<number> {
const now = new Date();
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
});
const bestMatch = this.selectBestFareRule(
candidates,
scheduleId,
segmentRoute,
fullRoute,
nationality,
);
return bestMatch?.baseFareMinor ?? 35000;
}
async getByRef(bookingRef: string) {
const booking = await this.prisma.booking.findUnique({
where: { bookingRef },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } },
paymentIntent: true, ticket: true,
},
});
if (!booking) throw new NotFoundException('Booking not found');
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount,
displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined,
bookingType: booking.bookingType, createdAt: booking.createdAt,
schedule: {
number: booking.schedule.train.number,
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city },
departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt,
},
passengers: booking.seats.map((bs) => ({
fullName: bs.passengerName, category: bs.passengerCategory, verifaydaVerified: bs.verifaydaVerified,
seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass.name },
})),
payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined,
};
}
async modify(dto: ModifyBookingDto) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: dto.bookingRef }, include: { seats: true, schedule: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status !== 'CONFIRMED') throw new BadRequestException('Only confirmed bookings can be modified');
if (booking.schedule.departureAt < new Date()) throw new BadRequestException('Cannot modify past bookings');
const oldSeats = booking.seats.map(s => s.seatId);
await this.prisma.bookingModification.create({
data: { bookingId: booking.id, modifiedBy: booking.passengerId, modificationType: 'SEAT_CHANGE', oldData: { scheduleId: booking.scheduleId, seatIds: oldSeats }, newData: { scheduleId: dto.newScheduleId, seatIds: dto.newSeatIds }, fareAdjustment: 0, reason: dto.reason },
});
await this.seatsService.releaseSeats(oldSeats);
await this.seatsService.confirmSeats(dto.newSeatIds);
return { modified: true, bookingRef: dto.bookingRef };
}
async cancel(bookingRef: string, reason?: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled');
const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0;
await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } });
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
}
async update(id: string, dto: any) {
const booking = await this.prisma.booking.findUnique({ where: { id } });
if (!booking) throw new NotFoundException('Booking not found');
return this.prisma.booking.update({
where: { id },
data: {
status: dto.status || booking.status,
totalMinor: dto.totalMinor !== undefined ? dto.totalMinor : booking.totalMinor,
displayCurrency: dto.displayCurrency || booking.displayCurrency,
displayTotalMinor: dto.displayTotalMinor !== undefined ? dto.displayTotalMinor : booking.displayTotalMinor,
},
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true,
seats: { include: { seat: true } },
},
});
}
async delete(id: string) {
const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } });
if (!booking) throw new NotFoundException('Booking not found');
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } });
await this.prisma.booking.delete({ where: { id } });
return { deleted: true, bookingRef: booking.bookingRef };
}
async checkBookingUsage(id: string) {
const booking = await this.prisma.booking.findUnique({ where: { id } });
if (!booking) throw new NotFoundException('Booking not found');
const [ticketCount, paymentIntentCount, modificationsCount, cancellationCount] = await Promise.all([
this.prisma.ticket.count({ where: { bookingId: id } }),
this.prisma.paymentIntent.count({ where: { bookingId: id } }),
this.prisma.bookingModification.count({ where: { bookingId: id } }),
this.prisma.bookingCancellation.count({ where: { bookingId: id } }),
]);
const usage = [];
if (ticketCount > 0) usage.push('Ticket(s)');
if (paymentIntentCount > 0) usage.push('Payment record(s)');
if (modificationsCount > 0) usage.push('Modification history');
if (cancellationCount > 0) usage.push('Cancellation record(s)');
return {
isInUse: usage.length > 0,
affectedModules: usage,
};
}
@Cron(CronExpression.EVERY_MINUTE)
async expirePendingBookings() {
const cutoff = new Date(Date.now() - 20 * 60 * 1000);
const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } });
for (const b of expired) {
await this.seatsService.releaseSeats(b.seats.map((s) => s.seatId));
await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } });
}
}
private selectBestFareRule(
candidates: any[],
scheduleId: string,
segmentRoute?: string,
fullRoute?: string,
nationality?: string,
): any | null {
const priorities = [
{ tripId: scheduleId, route: segmentRoute, nationality },
{ tripId: scheduleId, route: segmentRoute, nationality: null },
{ tripId: scheduleId, route: fullRoute, nationality },
{ tripId: scheduleId, route: fullRoute, nationality: null },
{ tripId: scheduleId, route: null, nationality },
{ tripId: scheduleId, route: null, nationality: null },
{ tripId: null, route: segmentRoute, nationality },
{ tripId: null, route: segmentRoute, nationality: null },
{ tripId: null, route: fullRoute, nationality },
{ tripId: null, route: fullRoute, nationality: null },
{ tripId: null, route: null, nationality },
{ tripId: null, route: null, nationality: null },
];
for (const priority of priorities) {
const match = candidates.find(
(c) =>
c.tripId === priority.tripId &&
c.route === priority.route &&
c.nationality === priority.nationality,
);
if (match) return match;
}
return null;
}
}

Some files were not shown because too many files have changed in this diff Show More