mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
merge conflict
This commit is contained in:
14
.dockerignore
Normal file
14
.dockerignore
Normal 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
92
.github/workflows/deploy.yml
vendored
Normal 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
4
.gitignore
vendored
@@ -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
187
DEPLOYMENT.md
Normal 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
766
README.md
@@ -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: ETB→DJF=3.25, ETB→USD=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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"deleteOutDir": false,
|
||||
"assets": [
|
||||
{
|
||||
"include": "migrations/**/*",
|
||||
@@ -20,4 +20,4 @@
|
||||
],
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
10
apps/edr-freight-api/src/config/dmoney.config.ts
Normal file
10
apps/edr-freight-api/src/config/dmoney.config.ts
Normal 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 ?? ""
|
||||
}));
|
||||
16
apps/edr-freight-api/src/config/telebirr.config.ts
Normal file
16
apps/edr-freight-api/src/config/telebirr.config.ts
Normal 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",
|
||||
}));
|
||||
@@ -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`);
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class InitiateBookingPayment {
|
||||
@IsString()
|
||||
bookingId!: string;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
`);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
62
apps/edr-freight-api/src/modules/payment/payments.dto.ts
Normal file
62
apps/edr-freight-api/src/modules/payment/payments.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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') ?? '';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;"]
|
||||
@@ -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;"]
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}`),
|
||||
};
|
||||
|
||||
@@ -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=
|
||||
|
||||
1
apps/edr-passenger-api/.eslintrc.js
Normal file
1
apps/edr-passenger-api/.eslintrc.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = require('@edr/eslint-config/nestjs');
|
||||
6
apps/edr-passenger-api/.npmrc
Normal file
6
apps/edr-passenger-api/.npmrc
Normal 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/
|
||||
@@ -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"]
|
||||
|
||||
11
apps/edr-passenger-api/docker-entrypoint.sh
Normal file
11
apps/edr-passenger-api/docker-entrypoint.sh
Normal 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 "$@"
|
||||
@@ -3,6 +3,9 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"plugins": ["@nestjs/swagger"],
|
||||
"tsConfigPath": "tsconfig.build.json",
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -0,0 +1,5 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "PaymentMethodType" ADD VALUE 'WAAFI';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "FareRule" ADD COLUMN "nationality" TEXT;
|
||||
@@ -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");
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[faydaSub]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN "faydaSub" TEXT,
|
||||
ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "faydaVerifiedName" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "passenger"."User" ADD COLUMN "faydaSub" TEXT,
|
||||
ADD COLUMN "faydaVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "passenger"."FaydaVerificationSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"state" TEXT NOT NULL,
|
||||
"codeVerifier" TEXT NOT NULL,
|
||||
"purpose" TEXT NOT NULL DEFAULT 'PURCHASE',
|
||||
"saveToAccount" BOOLEAN NOT NULL DEFAULT false,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"errorCode" TEXT,
|
||||
"errorDescription" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"userId" TEXT,
|
||||
"bookingId" TEXT,
|
||||
|
||||
CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "passenger"."FaydaVerificationSession"("state");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FaydaVerificationSession_userId_idx" ON "passenger"."FaydaVerificationSession"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "passenger"."FaydaVerificationSession"("bookingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FaydaVerificationSession_state_idx" ON "passenger"."FaydaVerificationSession"("state");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "passenger"."FaydaVerificationSession"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_faydaSub_key" ON "passenger"."User"("faydaSub");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "passenger"."FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,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");
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "FaydaVerificationSession" ADD COLUMN "authCode" TEXT,
|
||||
ADD COLUMN "platform" TEXT NOT NULL DEFAULT 'WEB';
|
||||
@@ -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;
|
||||
@@ -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)';
|
||||
@@ -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"
|
||||
1299
apps/edr-passenger-api/prisma/schema.prisma
Normal file
1299
apps/edr-passenger-api/prisma/schema.prisma
Normal file
File diff suppressed because it is too large
Load Diff
231
apps/edr-passenger-api/prisma/seed-complete.ts
Normal file
231
apps/edr-passenger-api/prisma/seed-complete.ts
Normal 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();
|
||||
});
|
||||
832
apps/edr-passenger-api/prisma/seed.ts
Normal file
832
apps/edr-passenger-api/prisma/seed.ts
Normal 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();
|
||||
});
|
||||
@@ -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('*');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
9
apps/edr-passenger-api/src/common/i18n/i18n.module.ts
Normal file
9
apps/edr-passenger-api/src/common/i18n/i18n.module.ts
Normal 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 {}
|
||||
49
apps/edr-passenger-api/src/common/i18n/i18n.service.spec.ts
Normal file
49
apps/edr-passenger-api/src/common/i18n/i18n.service.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
63
apps/edr-passenger-api/src/common/i18n/i18n.service.ts
Normal file
63
apps/edr-passenger-api/src/common/i18n/i18n.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
},
|
||||
);
|
||||
16
apps/edr-passenger-api/src/common/i18n/locale.middleware.ts
Normal file
16
apps/edr-passenger-api/src/common/i18n/locale.middleware.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
23
apps/edr-passenger-api/src/common/i18n/translations/am.json
Normal file
23
apps/edr-passenger-api/src/common/i18n/translations/am.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"common": {
|
||||
"welcome": "እንኳን ደህና መጡ",
|
||||
"error": "ስህተት ተከስቷል",
|
||||
"success": "ተሳክቷል"
|
||||
},
|
||||
"booking": {
|
||||
"created": "ቦታ ማስያዝ በተሳካ ሁኔታ ተፈጥሯል",
|
||||
"notFound": "ቦታ ማስያዝ አልተገኘም",
|
||||
"cancelled": "ቦታ ማስያዝ ተሰርዟል",
|
||||
"confirmed": "ቦታ ማስያዝ ተረጋግጧል"
|
||||
},
|
||||
"payment": {
|
||||
"succeeded": "ክፍያ ተሳክቷል",
|
||||
"failed": "ክፍያ አልተሳካም",
|
||||
"pending": "ክፍያ በመጠባበቅ ላይ"
|
||||
},
|
||||
"ticket": {
|
||||
"issued": "ትኬት ተሰጥቷል",
|
||||
"validated": "ትኬት ተረጋግጧል",
|
||||
"alreadyValidated": "ትኬት ቀድሞውኑ ተረጋግጧል"
|
||||
}
|
||||
}
|
||||
23
apps/edr-passenger-api/src/common/i18n/translations/en.json
Normal file
23
apps/edr-passenger-api/src/common/i18n/translations/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
apps/edr-passenger-api/src/common/i18n/translations/fr.json
Normal file
23
apps/edr-passenger-api/src/common/i18n/translations/fr.json
Normal 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é"
|
||||
}
|
||||
}
|
||||
23
apps/edr-passenger-api/src/common/i18n/translations/om.json
Normal file
23
apps/edr-passenger-api/src/common/i18n/translations/om.json
Normal 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"
|
||||
}
|
||||
}
|
||||
264
apps/edr-passenger-api/src/common/iam-adapter.spec.ts
Normal file
264
apps/edr-passenger-api/src/common/iam-adapter.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
144
apps/edr-passenger-api/src/common/iam-adapter.ts
Normal file
144
apps/edr-passenger-api/src/common/iam-adapter.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
};
|
||||
11
apps/edr-passenger-api/src/common/iam.module.ts
Normal file
11
apps/edr-passenger-api/src/common/iam.module.ts
Normal 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 {}
|
||||
@@ -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() })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {}));
|
||||
}
|
||||
}
|
||||
5
apps/edr-passenger-api/src/common/jwt.guard.ts
Normal file
5
apps/edr-passenger-api/src/common/jwt.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtGuard extends AuthGuard('jwt') {}
|
||||
17
apps/edr-passenger-api/src/common/jwt.strategy.ts
Normal file
17
apps/edr-passenger-api/src/common/jwt.strategy.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { createValidationPipe } from "@edr/api-common";
|
||||
7
apps/edr-passenger-api/src/common/prisma.module.ts
Normal file
7
apps/edr-passenger-api/src/common/prisma.module.ts
Normal 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 {}
|
||||
8
apps/edr-passenger-api/src/common/prisma.service.ts
Normal file
8
apps/edr-passenger-api/src/common/prisma.service.ts
Normal 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(); }
|
||||
}
|
||||
5
apps/edr-passenger-api/src/common/roles.decorator.ts
Normal file
5
apps/edr-passenger-api/src/common/roles.decorator.ts
Normal 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);
|
||||
19
apps/edr-passenger-api/src/common/roles.guard.ts
Normal file
19
apps/edr-passenger-api/src/common/roles.guard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}));
|
||||
|
||||
9
apps/edr-passenger-api/src/config/card.config.ts
Normal file
9
apps/edr-passenger-api/src/config/card.config.ts
Normal 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 || '',
|
||||
}));
|
||||
9
apps/edr-passenger-api/src/config/cbe.config.ts
Normal file
9
apps/edr-passenger-api/src/config/cbe.config.ts
Normal 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 || '',
|
||||
}));
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
9
apps/edr-passenger-api/src/config/ebirr.config.ts
Normal file
9
apps/edr-passenger-api/src/config/ebirr.config.ts
Normal 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 || '',
|
||||
}));
|
||||
118
apps/edr-passenger-api/src/config/fayda.config.ts
Normal file
118
apps/edr-passenger-api/src/config/fayda.config.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export interface FaydaJwk {
|
||||
kty: 'RSA';
|
||||
use?: string;
|
||||
kid?: string;
|
||||
alg?: string;
|
||||
n: string;
|
||||
e: string;
|
||||
d: string;
|
||||
p?: string;
|
||||
q?: string;
|
||||
dp?: string;
|
||||
dq?: string;
|
||||
qi?: string;
|
||||
}
|
||||
|
||||
export type FaydaPlatform = 'WEB' | 'MOBILE';
|
||||
|
||||
export interface FaydaConfig {
|
||||
enabled: boolean;
|
||||
clientId: string;
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
userInfoEndpoint: string;
|
||||
redirectUri: string;
|
||||
privateJwk: FaydaJwk;
|
||||
scope: string;
|
||||
acrValues: string;
|
||||
claimsLocales: string;
|
||||
sessionTtlMinutes: number;
|
||||
}
|
||||
|
||||
const REQUIRED_VARS = [
|
||||
'FAYDA_CLIENT_ID',
|
||||
'FAYDA_AUTHORIZATION_ENDPOINT',
|
||||
'FAYDA_TOKEN_ENDPOINT',
|
||||
'FAYDA_USERINFO_ENDPOINT',
|
||||
'FAYDA_PRIVATE_KEY_BASE64',
|
||||
] as const;
|
||||
|
||||
function decodePrivateJwk(base64: string): FaydaJwk {
|
||||
let jwk: unknown;
|
||||
try {
|
||||
const json = Buffer.from(base64, 'base64').toString('utf8');
|
||||
jwk = JSON.parse(json);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`FAYDA_PRIVATE_KEY_BASE64 is not valid Base64-encoded JSON: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
if (!jwk || typeof jwk !== 'object') {
|
||||
throw new Error('FAYDA_PRIVATE_KEY_BASE64 must decode to a JSON object');
|
||||
}
|
||||
const candidate = jwk as Partial<FaydaJwk>;
|
||||
if (candidate.kty !== 'RSA') {
|
||||
throw new Error('FAYDA_PRIVATE_KEY_BASE64 JWK must have kty="RSA"');
|
||||
}
|
||||
if (!candidate.n || !candidate.e || !candidate.d) {
|
||||
throw new Error(
|
||||
'FAYDA_PRIVATE_KEY_BASE64 JWK is missing required RSA private-key fields (n, e, d)',
|
||||
);
|
||||
}
|
||||
return candidate as FaydaJwk;
|
||||
}
|
||||
|
||||
export default registerAs('fayda', (): FaydaConfig => {
|
||||
const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true';
|
||||
const scope = process.env.FAYDA_SCOPE ?? 'openid profile email';
|
||||
const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code';
|
||||
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
|
||||
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
|
||||
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
|
||||
if (!enabled) {
|
||||
return {
|
||||
enabled: false,
|
||||
clientId: process.env.FAYDA_CLIENT_ID ?? '',
|
||||
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT ?? '',
|
||||
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '',
|
||||
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
|
||||
redirectUri,
|
||||
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
||||
scope,
|
||||
acrValues,
|
||||
claimsLocales,
|
||||
sessionTtlMinutes: Number.isNaN(sessionTtl) || sessionTtl <= 0 ? 10 : sessionTtl,
|
||||
};
|
||||
}
|
||||
|
||||
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Fayda integration is enabled (FAYDA_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (!redirectUri) {
|
||||
throw new Error(
|
||||
'Fayda integration is enabled but the redirect URI is missing: set FAYDA_REDIRECT_URI',
|
||||
);
|
||||
}
|
||||
if (Number.isNaN(sessionTtl) || sessionTtl <= 0) {
|
||||
throw new Error('FAYDA_SESSION_TTL_MINUTES must be a positive integer');
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
clientId: process.env.FAYDA_CLIENT_ID!,
|
||||
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT!,
|
||||
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!,
|
||||
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
|
||||
redirectUri,
|
||||
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
|
||||
scope,
|
||||
acrValues,
|
||||
claimsLocales,
|
||||
sessionTtlMinutes: sessionTtl,
|
||||
};
|
||||
});
|
||||
16
apps/edr-passenger-api/src/config/telebirr.config.ts
Normal file
16
apps/edr-passenger-api/src/config/telebirr.config.ts
Normal 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',
|
||||
}));
|
||||
10
apps/edr-passenger-api/src/config/waafi.config.ts
Normal file
10
apps/edr-passenger-api/src/config/waafi.config.ts
Normal 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 ?? '',
|
||||
}));
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
33
apps/edr-passenger-api/src/modules/agents/agents.dto.ts
Normal file
33
apps/edr-passenger-api/src/modules/agents/agents.dto.ts
Normal 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;
|
||||
}
|
||||
12
apps/edr-passenger-api/src/modules/agents/agents.module.ts
Normal file
12
apps/edr-passenger-api/src/modules/agents/agents.module.ts
Normal 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 {}
|
||||
132
apps/edr-passenger-api/src/modules/agents/agents.service.ts
Normal file
132
apps/edr-passenger-api/src/modules/agents/agents.service.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
220
apps/edr-passenger-api/src/modules/auth/auth.controller.ts
Normal file
220
apps/edr-passenger-api/src/modules/auth/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
152
apps/edr-passenger-api/src/modules/auth/auth.dto.ts
Normal file
152
apps/edr-passenger-api/src/modules/auth/auth.dto.ts
Normal 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;
|
||||
}
|
||||
24
apps/edr-passenger-api/src/modules/auth/auth.module.ts
Normal file
24
apps/edr-passenger-api/src/modules/auth/auth.module.ts
Normal 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 {}
|
||||
233
apps/edr-passenger-api/src/modules/auth/auth.service.ts
Normal file
233
apps/edr-passenger-api/src/modules/auth/auth.service.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
42
apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
Normal file
42
apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {}
|
||||
467
apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
Normal file
467
apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
Normal 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
Reference in New Issue
Block a user