mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #24 from Tria-plc/alpha
Merge request from alpha to dev
This commit is contained in:
691
README.md
691
README.md
@@ -1,141 +1,620 @@
|
||||
# EDR Passenger API
|
||||
# EDR Platform - Ethio-Djibouti Railway Passenger API
|
||||
|
||||
NestJS REST API for the Ethio-Djibouti Railway passenger platform. Handles booking lifecycle, seat inventory, payments (Telebirr, CBE Birr, eBirr, Card, Wallet), loyalty, live tracking, notifications, and support.
|
||||
Enterprise-grade NestJS REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with TypeScript, PostgreSQL, and Prisma ORM.
|
||||
|
||||
## Tech Stack
|
||||
## 🚀 Features
|
||||
|
||||
- **Runtime**: Node.js 20, TypeScript
|
||||
- **Framework**: NestJS 11
|
||||
- **Database**: PostgreSQL via Prisma ORM
|
||||
- **Auth**: JWT (Passport)
|
||||
- **Docs**: Swagger / OpenAPI (`/api-docs`)
|
||||
- **Package manager**: pnpm 9
|
||||
### 🆕 NEW: Age-Based Pricing, Verifayda 2.0 & Multi-Currency
|
||||
|
||||
## Prerequisites
|
||||
#### 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)
|
||||
|
||||
- Node.js >= 20
|
||||
- pnpm >= 9 (`npm i -g pnpm`)
|
||||
- PostgreSQL 15+
|
||||
#### 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
|
||||
|
||||
## Quick Start
|
||||
#### 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 with modification, cancellation, refunds, and fare breakdown
|
||||
- **Payment Integration** - Multi-provider support (Telebirr, CBE Birr, eBirr, Card, Wallet) with webhook handling
|
||||
- **Seat Management** - Real-time seat inventory, holds, releases, and blocking with coach/class management
|
||||
- **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
|
||||
# 1. Install dependencies (from monorepo root)
|
||||
pnpm install
|
||||
git clone <repository-url>
|
||||
cd edr-platform
|
||||
```
|
||||
|
||||
# 2. Copy and fill environment variables
|
||||
### 2. Install Dependencies
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 3. Environment Configuration
|
||||
```bash
|
||||
# Copy environment template
|
||||
cp apps/edr-passenger-api/.env.example apps/edr-passenger-api/.env
|
||||
|
||||
# 3. Generate Prisma client
|
||||
# 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` |
|
||||
| `FRONTEND_URL` | Web app CORS origin | `http://localhost:3000` |
|
||||
| `PORTAL_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
|
||||
```
|
||||
|
||||
# 4. Run database migrations
|
||||
#### Run Migrations
|
||||
```bash
|
||||
pnpm --filter @edr/passenger-api run prisma:migrate
|
||||
```
|
||||
|
||||
# 5. Seed the database
|
||||
#### Seed Database
|
||||
```bash
|
||||
pnpm --filter @edr/passenger-api run prisma:seed
|
||||
```
|
||||
|
||||
# 6. Start in development mode
|
||||
**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, Bed, VIP 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
|
||||
|
||||
### 5. Start Development Server
|
||||
```bash
|
||||
pnpm --filter @edr/passenger-api run dev
|
||||
```
|
||||
|
||||
API runs at **http://localhost:4000**
|
||||
Swagger UI at **http://localhost:4000/api-docs**
|
||||
**API Server:** http://localhost:4000
|
||||
**Swagger Docs:** http://localhost:4000/api-docs
|
||||
|
||||
## Environment Variables
|
||||
## 🔑 Default Credentials
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --------------------- | ------------------------------------ | -------------------------------- |
|
||||
| `PORT` | HTTP port | `4000` |
|
||||
| `DATABASE_URL` | PostgreSQL connection string | — |
|
||||
| `JWT_SECRET` | JWT signing secret | — |
|
||||
| `JWT_EXPIRES_IN` | JWT expiry | `7d` |
|
||||
| `FRONTEND_URL` | Allowed CORS origin (web app) | `http://localhost:3000` |
|
||||
| `PORTAL_URL` | Allowed CORS origin (portal) | `http://localhost:3001` |
|
||||
| `SENDGRID_API_KEY` | SendGrid key for email notifications | _(optional — logs if absent)_ |
|
||||
| `SENDGRID_FROM_EMAIL` | Sender email address | `noreply@edr-platform.com` |
|
||||
After seeding, use these credentials to test the API:
|
||||
|
||||
## API Modules
|
||||
| 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 |
|
||||
|
||||
| Tag | Base path | Description |
|
||||
| -------------- | ----------------- | ---------------------------------------- |
|
||||
| Auth | `/auth` | Register, login, JWT |
|
||||
| Stations | `/stations` | Station directory |
|
||||
| Fleet | `/fleet` | Train services, coaches, seat batches |
|
||||
| Schedule | `/schedule` | Trips, fare rules, status updates |
|
||||
| Search | `/search` | Trip search, fare quotes |
|
||||
| Seats | `/seats` | Seat maps, holds, releases |
|
||||
| Booking | `/bookings` | Create, retrieve, cancel bookings |
|
||||
| Payment | `/payments` | Initiate payment, refunds, methods |
|
||||
| Tickets | `/tickets` | QR ticket generation and validation |
|
||||
| Passenger | `/passengers` | Profiles, traveler profiles, saved routes|
|
||||
| Notifications | `/notifications` | In-app notifications |
|
||||
| Loyalty | `/loyalty` | Points, tiers, rewards |
|
||||
| Wallet | `/wallet` | Balance, top-up, ledger |
|
||||
| Promotions | `/promos` | Active promos, promo code validation |
|
||||
| Live Tracking | `/live` | Real-time trip status, crowd signals |
|
||||
| Support | `/support` | FAQ, chat conversations |
|
||||
| Dashboard | `/dashboard` | Home screen aggregate |
|
||||
## 📚 API Documentation
|
||||
|
||||
## Seed Credentials
|
||||
### Swagger UI
|
||||
Interactive API documentation available at: **http://localhost:4000/api-docs**
|
||||
|
||||
After running `prisma:seed`:
|
||||
### Authentication Methods
|
||||
|
||||
| Role | Email | Password |
|
||||
| --------- | ------------------------ | ------------- |
|
||||
| Passenger | `kelemu@email.com` | `password123` |
|
||||
| Admin | `admin@edr-platform.com` | `admin123` |
|
||||
The API uses two authentication schemes:
|
||||
|
||||
## Docker
|
||||
#### 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 |
|
||||
| **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 |
|
||||
| **Stations** | `/stations` | JWT | Station directory and information |
|
||||
| **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations |
|
||||
| **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates |
|
||||
| **Search** | `/search` | JWT | Trip search, availability, fare quotes |
|
||||
| **Seats** | `/seats` | JWT/IAM | Seat maps, holds, releases, blocking |
|
||||
| **Bookings** | `/bookings` | JWT | Create, modify, cancel bookings |
|
||||
| **Payments** | `/payments` | JWT/Public | Payment initiation, webhooks, refunds |
|
||||
| **Tickets** | `/tickets` | JWT/IAM | Ticket generation, QR/barcode, validation |
|
||||
| **Passengers** | `/passengers` | JWT | Profile management, traveler profiles |
|
||||
| **Notifications** | `/notifications` | JWT | In-app notifications, preferences |
|
||||
| **Loyalty** | `/loyalty` | JWT | Points, tiers, rewards redemption |
|
||||
| **Wallet** | `/wallet` | JWT | Balance, top-up, transaction history |
|
||||
| **Promotions** | `/promos` | JWT | Active promotions, promo code validation |
|
||||
| **Live Tracking** | `/live` | JWT | Real-time trip status, crowd signals |
|
||||
| **Support** | `/support` | JWT | FAQ, chat conversations |
|
||||
| **Dashboard** | `/dashboard` | JWT | Home screen aggregated data |
|
||||
|
||||
### 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" }
|
||||
}
|
||||
```
|
||||
|
||||
#### 2b. Agent Booking (IAM Auth)
|
||||
```bash
|
||||
POST /agents/bookings
|
||||
Authorization: Bearer <iam-token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"tripId": "uuid",
|
||||
"seats": [...],
|
||||
"paymentMethod": "CASH",
|
||||
"cashReceived": 50000
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Search Trips
|
||||
```bash
|
||||
GET /search/trips?originStationId={id}&destinationStationId={id}&date=2026-06-15&adultCount=2&childCount=1
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
#### 4. Get Fare Quote
|
||||
```bash
|
||||
POST /search/fare-quote
|
||||
Authorization: Bearer {token}
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. Create Booking
|
||||
```bash
|
||||
POST /bookings
|
||||
Authorization: Bearer {token}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"tripId": "uuid",
|
||||
"holdId": "uuid",
|
||||
"serviceClass": "ECONOMY_REGULAR",
|
||||
"displayCurrency": "DJF",
|
||||
"passengers": [
|
||||
{
|
||||
"seatId": "uuid",
|
||||
"passengerName": "Abebe Kebede",
|
||||
"dateOfBirth": "1985-03-15",
|
||||
"idDocumentType": "NATIONAL_ID",
|
||||
"idDocumentNumber": "ET123456789"
|
||||
},
|
||||
{
|
||||
"seatId": "uuid",
|
||||
"passengerName": "Sara Abebe",
|
||||
"dateOfBirth": "2023-01-10",
|
||||
"idDocumentType": "NATIONAL_ID",
|
||||
"idDocumentNumber": "ET987654321"
|
||||
},
|
||||
{
|
||||
"seatId": "uuid",
|
||||
"passengerName": "John Smith",
|
||||
"dateOfBirth": "1990-07-20",
|
||||
"idDocumentType": "PASSPORT",
|
||||
"passportNumber": "P1234567",
|
||||
"passportCountry": "Kenya"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 🏗️ 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)
|
||||
|
||||
## 🔧 Available Scripts
|
||||
|
||||
```bash
|
||||
# Build image (run from monorepo root)
|
||||
# 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 # Run migrations
|
||||
pnpm --filter @edr/passenger-api run prisma:seed # Seed database
|
||||
```
|
||||
|
||||
## 🐳 Docker Deployment
|
||||
|
||||
### Build Image
|
||||
```bash
|
||||
# From monorepo root
|
||||
docker build -f apps/edr-passenger-api/Dockerfile -t edr-passenger-api .
|
||||
|
||||
# Run
|
||||
docker run -p 4000:4000 --env-file apps/edr-passenger-api/.env edr-passenger-api
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Description |
|
||||
| ---------------------- | ------------------------------ |
|
||||
| `pnpm dev` | Start with hot-reload |
|
||||
| `pnpm build` | Compile to `dist/` |
|
||||
| `pnpm start` | Run compiled output |
|
||||
| `pnpm test` | Run unit tests |
|
||||
| `pnpm lint` | ESLint |
|
||||
| `pnpm type-check` | TypeScript type check |
|
||||
| `pnpm prisma:generate` | Regenerate Prisma client |
|
||||
| `pnpm prisma:migrate` | Run pending migrations |
|
||||
| `pnpm prisma:seed` | Seed the database |
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Run Container
|
||||
```bash
|
||||
docker run -d \
|
||||
--name edr-api \
|
||||
-p 4000:4000 \
|
||||
--env-file apps/edr-passenger-api/.env \
|
||||
edr-passenger-api
|
||||
```
|
||||
src/
|
||||
├── common/ # PrismaService, JwtGuard, JwtStrategy, filters, interceptors
|
||||
├── config/ # app.config.ts, database.config.ts
|
||||
└── modules/
|
||||
├── auth/
|
||||
├── stations/
|
||||
├── fleet/
|
||||
├── schedules/
|
||||
├── search/
|
||||
├── seats/
|
||||
├── bookings/
|
||||
├── payments/
|
||||
├── tickets/
|
||||
├── passengers/
|
||||
├── notifications/
|
||||
├── loyalty/
|
||||
├── wallet/
|
||||
├── promos/
|
||||
├── live/
|
||||
├── support/
|
||||
└── dashboard/
|
||||
prisma/
|
||||
├── schema.prisma
|
||||
├── seed.ts
|
||||
└── migrations/
|
||||
|
||||
### Docker Compose (Recommended)
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: edr
|
||||
POSTGRES_PASSWORD: edr_secret
|
||||
POSTGRES_DB: edr_passenger
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/edr-passenger-api/Dockerfile
|
||||
ports:
|
||||
- "4000:4000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://edr:edr_secret@postgres:5432/edr_passenger
|
||||
JWT_SECRET: your-secret-key
|
||||
PORT: 4000
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
## 🔒 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 (FRONTEND_URL, PORTAL_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**
|
||||
|
||||
@@ -3,7 +3,7 @@ NODE_ENV=development
|
||||
PORT=4000
|
||||
|
||||
# Database (Prisma)
|
||||
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger
|
||||
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger?schema=edr_passenger
|
||||
|
||||
# CORS
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
@@ -17,7 +17,72 @@ JWT_EXPIRES_IN=7d
|
||||
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_API_URL=https://api.telebirr.com
|
||||
TELEBIRR_APP_ID=
|
||||
TELEBIRR_APP_KEY=
|
||||
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=
|
||||
|
||||
# Payment Configuration
|
||||
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET
|
||||
|
||||
# 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 Configuration (Ethiopian National ID Verification)
|
||||
VERIFAYDA_ENABLED=false
|
||||
VERIFAYDA_API_URL=https://api.verifayda.gov.et/v2
|
||||
VERIFAYDA_API_KEY=
|
||||
|
||||
GITHUB_PACKAGE_TOKEN=
|
||||
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/
|
||||
199
apps/edr-passenger-api/REFACTORING_SUMMARY.md
Normal file
199
apps/edr-passenger-api/REFACTORING_SUMMARY.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# Train Reservation System Refactoring - Complete
|
||||
|
||||
## ✅ Refactoring Summary
|
||||
|
||||
Successfully refactored the train reservation system from an incorrect tight-coupling model to a flexible, realistic railway architecture.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Architecture Changes
|
||||
|
||||
### Before (Incorrect)
|
||||
```
|
||||
TrainService → Trip → Coach → Seat
|
||||
```
|
||||
- Coaches were permanently bound to specific trips
|
||||
- No reusability of physical coaches
|
||||
- Inflexible train composition
|
||||
|
||||
### After (Correct)
|
||||
```
|
||||
Train → TrainSchedule ↔ CoachAssignment ↔ Coach → Seat
|
||||
```
|
||||
- **Train**: Logical service entity (e.g., "Express 301")
|
||||
- **TrainSchedule**: Specific journey with date/time
|
||||
- **Coach**: Physical reusable railway carriage
|
||||
- **CoachAssignment**: Join table linking schedules to coaches
|
||||
- **Seat**: Belongs strictly to physical coach
|
||||
|
||||
---
|
||||
|
||||
## 📋 Files Modified
|
||||
|
||||
### Schema & Database
|
||||
- ✅ `prisma/schema.prisma` - Complete entity redesign
|
||||
- ✅ `prisma/seed.ts` - Rewritten for new architecture
|
||||
|
||||
### DTOs
|
||||
- ✅ `fleet/fleet.dto.ts` - New Train/Coach/Assignment DTOs
|
||||
- ✅ `schedules/schedules.dto.ts` - TrainSchedule DTOs
|
||||
- ✅ `bookings/bookings.dto.ts` - scheduleId instead of tripId
|
||||
|
||||
### Services
|
||||
- ✅ `fleet/fleet.service.ts` - Physical coach management
|
||||
- ✅ `fleet/fleet.controller.ts` - New endpoints
|
||||
- ✅ `schedules/schedules.service.ts` - TrainSchedule operations
|
||||
- ✅ `schedules/schedules.controller.ts` - Updated routes
|
||||
- ✅ `bookings/bookings.service.ts` - scheduleId references
|
||||
- ✅ `seats/seats.service.ts` - CoachAssignment queries
|
||||
- ✅ `search/search.service.ts` - TrainSchedule search
|
||||
- ✅ `segments/segments.service.ts` - scheduleId throughout
|
||||
- ✅ `segments/enhanced-seats.service.ts` - Fixed references
|
||||
- ✅ `passengers/passengers.service.ts` - schedule.train
|
||||
- ✅ `live/live.service.ts` - TrainSchedule live tracking
|
||||
- ✅ `live/live.controller.ts` - scheduleId routes
|
||||
- ✅ `dashboard/dashboard.service.ts` - schedule references
|
||||
- ✅ `reports/reports.service.ts` - Occupancy with assignments
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Database Schema Changes
|
||||
|
||||
### New Models
|
||||
```prisma
|
||||
model Train {
|
||||
id String @id @default(uuid())
|
||||
number String @unique
|
||||
name String
|
||||
schedules TrainSchedule[]
|
||||
}
|
||||
|
||||
model TrainSchedule {
|
||||
id String @id @default(uuid())
|
||||
trainId String
|
||||
departureAt DateTime
|
||||
train Train @relation(...)
|
||||
coachAssignments CoachAssignment[]
|
||||
}
|
||||
|
||||
model Coach {
|
||||
id String @id @default(uuid())
|
||||
coachNumber String @unique // Physical identifier
|
||||
label String
|
||||
seatClassId String
|
||||
mode String // 'seat', 'bed', 'convertible'
|
||||
totalUnits Int
|
||||
seats Seat[]
|
||||
assignments CoachAssignment[]
|
||||
}
|
||||
|
||||
model CoachAssignment {
|
||||
id String @id @default(uuid())
|
||||
scheduleId String
|
||||
coachId String
|
||||
positionNumber Int
|
||||
schedule TrainSchedule @relation(...)
|
||||
coach Coach @relation(...)
|
||||
}
|
||||
```
|
||||
|
||||
### Renamed Models
|
||||
- `TrainService` → `Train`
|
||||
- `Trip` → `TrainSchedule`
|
||||
- `TripStopTime.tripId` → `scheduleId`
|
||||
- `TripLiveStatus.tripId` → `scheduleId`
|
||||
- `Booking.tripId` → `scheduleId`
|
||||
- `SeatHold.tripId` → `scheduleId`
|
||||
- `MenuItem.tripId` → `scheduleId`
|
||||
- `JourneySegment.tripId` → `scheduleId`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Key Benefits
|
||||
|
||||
1. **Reusability**: Physical coaches can be assigned to different schedules
|
||||
2. **Flexibility**: Train composition can change per schedule
|
||||
3. **Realistic**: Matches real-world railway operations
|
||||
4. **Maintainability**: Clear separation of logical vs physical entities
|
||||
5. **Scalability**: Easy to add/remove coaches from schedules
|
||||
|
||||
---
|
||||
|
||||
## 🚂 Example Usage
|
||||
|
||||
### Creating a Physical Coach
|
||||
```typescript
|
||||
const coach = await prisma.coach.create({
|
||||
data: {
|
||||
coachNumber: 'C-A1',
|
||||
label: 'A',
|
||||
seatClassId: economyClassId,
|
||||
mode: 'seat',
|
||||
totalUnits: 60,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Assigning Coach to Schedule
|
||||
```typescript
|
||||
await prisma.coachAssignment.create({
|
||||
data: {
|
||||
scheduleId: schedule1.id,
|
||||
coachId: coach.id,
|
||||
positionNumber: 1,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Querying Schedule with Coaches
|
||||
```typescript
|
||||
const schedule = await prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
include: {
|
||||
train: true,
|
||||
coachAssignments: {
|
||||
include: {
|
||||
coach: {
|
||||
include: { seats: true, seatClass: true },
|
||||
},
|
||||
},
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Seed Data
|
||||
|
||||
- **2 Trains**: Express 301, Express 302
|
||||
- **6 Physical Coaches**: C-A1, C-B1, C-C1, C-A2, C-B2, C-C2
|
||||
- **4 Train Schedules**: With flexible coach assignments
|
||||
- **3 Seat Classes**: Economy Regular, Economy Bed, VIP Bed
|
||||
- **Users**: Admin, Passenger (with wallet/loyalty), Agent
|
||||
|
||||
---
|
||||
|
||||
## ✨ Migration Status
|
||||
|
||||
✅ Schema pushed to database successfully
|
||||
✅ Seed data populated
|
||||
✅ All services updated
|
||||
✅ All controllers updated
|
||||
✅ All DTOs updated
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- Coaches are now reusable physical entities
|
||||
- Same coach can serve different schedules at different times
|
||||
- Seats belong to coaches, not schedules
|
||||
- CoachAssignment provides the many-to-many relationship
|
||||
- All references to `tripId` changed to `scheduleId`
|
||||
- All references to `service` changed to `train`
|
||||
|
||||
---
|
||||
|
||||
**Refactoring completed successfully! 🎉**
|
||||
@@ -13,12 +13,15 @@
|
||||
"type-check": "tsc --noEmit",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "ts-node prisma/seed.ts"
|
||||
"prisma:seed": "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": {
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.19",
|
||||
@@ -28,8 +31,8 @@
|
||||
"@nestjs/platform-express": "^11.1.19",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/swagger": "^7.4.0",
|
||||
"@prisma/client": "^5.8.0",
|
||||
"@sendgrid/mail": "^8.1.0",
|
||||
"axios": "^1.7.7",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
@@ -47,6 +50,7 @@
|
||||
"@nestjs/cli": "^11.0.21",
|
||||
"@nestjs/schematics": "^11.1.0",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/node": "^20.10.6",
|
||||
@@ -54,7 +58,7 @@
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"jest": "^29.7.0",
|
||||
"prisma": "^5.8.0",
|
||||
"prisma": "^6.19.3",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -1,690 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserRole" AS ENUM ('PASSENGER', 'ADMIN', 'STAFF');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TripStatus" AS ENUM ('SCHEDULED', 'BOARDING', 'EN_ROUTE', 'ARRIVED', 'CANCELLED', 'DELAYED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SeatKind" AS ENUM ('STANDARD', 'PREMIUM', 'ACCESSIBLE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SeatStatus" AS ENUM ('AVAILABLE', 'HELD', 'BOOKED', 'BLOCKED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ServiceClass" AS ENUM ('ECONOMY', 'BUSINESS', 'FIRST');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PaymentIntentStatus" AS ENUM ('REQUIRES_ACTION', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WalletLedgerType" AS ENUM ('CREDIT', 'DEBIT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotificationCategory" AS ENUM ('BOOKING', 'PAYMENT', 'DISRUPTION', 'PROMOTION', 'SYSTEM');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StopStatus" AS ENUM ('COMPLETED', 'APPROACHING', 'CURRENT', 'UPCOMING');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SupportConversationStatus" AS ENUM ('OPEN', 'RESOLVED', 'CLOSED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SupportSender" AS ENUM ('USER', 'BOT', 'AGENT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LoyaltyTier" AS ENUM ('BRONZE', 'SILVER', 'GOLD', 'PLATINUM');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LoyaltyLedgerReason" AS ENUM ('TRIP_COMPLETED', 'REWARD_REDEEMED', 'PROMO_BONUS', 'MANUAL_ADJUSTMENT', 'EXPIRY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "FoodOrderStatus" AS ENUM ('PENDING', 'PREPARING', 'READY', 'DELIVERED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DevicePlatform" AS ENUM ('IOS', 'ANDROID', 'WEB');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"phone" TEXT NOT NULL,
|
||||
"fullName" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"role" "UserRole" NOT NULL DEFAULT 'PASSENGER',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Passenger" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Passenger_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TravelerProfile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"passengerId" TEXT NOT NULL,
|
||||
"fullName" TEXT NOT NULL,
|
||||
"relationship" TEXT NOT NULL,
|
||||
"dateOfBirth" TIMESTAMP(3),
|
||||
"nationalId" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TravelerProfile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Station" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"city" TEXT NOT NULL,
|
||||
"timezone" TEXT NOT NULL DEFAULT 'Africa/Addis_Ababa',
|
||||
"lat" DECIMAL(9,6) NOT NULL,
|
||||
"lng" DECIMAL(9,6) NOT NULL,
|
||||
|
||||
CONSTRAINT "Station_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TrainService" (
|
||||
"id" TEXT NOT NULL,
|
||||
"number" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"operatorId" TEXT NOT NULL DEFAULT 'op_edr',
|
||||
|
||||
CONSTRAINT "TrainService_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Trip" (
|
||||
"id" TEXT NOT NULL,
|
||||
"serviceId" TEXT NOT NULL,
|
||||
"originStationId" TEXT NOT NULL,
|
||||
"destinationStationId" TEXT NOT NULL,
|
||||
"departureAt" TIMESTAMP(3) NOT NULL,
|
||||
"arrivalAt" TIMESTAMP(3) NOT NULL,
|
||||
"durationMinutes" INTEGER NOT NULL,
|
||||
"status" "TripStatus" NOT NULL DEFAULT 'SCHEDULED',
|
||||
"stopsCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"onTimePercent" INTEGER NOT NULL DEFAULT 100,
|
||||
"carbonRating" TEXT NOT NULL DEFAULT 'A',
|
||||
|
||||
CONSTRAINT "Trip_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TripStopTime" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tripId" TEXT NOT NULL,
|
||||
"stationId" TEXT NOT NULL,
|
||||
"sequence" INTEGER NOT NULL,
|
||||
"plannedArrivalAt" TIMESTAMP(3),
|
||||
"plannedDepartureAt" TIMESTAMP(3),
|
||||
"actualArrivalAt" TIMESTAMP(3),
|
||||
"status" "StopStatus" NOT NULL DEFAULT 'UPCOMING',
|
||||
|
||||
CONSTRAINT "TripStopTime_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TripLiveStatus" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tripId" TEXT NOT NULL,
|
||||
"state" TEXT NOT NULL,
|
||||
"currentLocationLabel" TEXT,
|
||||
"progressPercent" INTEGER NOT NULL DEFAULT 0,
|
||||
"delayMinutes" INTEGER NOT NULL DEFAULT 0,
|
||||
"currentSpeedKph" INTEGER,
|
||||
"platformLabel" TEXT,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TripLiveStatus_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Coach" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tripId" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"serviceClass" "ServiceClass" NOT NULL,
|
||||
|
||||
CONSTRAINT "Coach_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Seat" (
|
||||
"id" TEXT NOT NULL,
|
||||
"coachId" TEXT NOT NULL,
|
||||
"row" INTEGER NOT NULL,
|
||||
"col" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"kind" "SeatKind" NOT NULL DEFAULT 'STANDARD',
|
||||
"status" "SeatStatus" NOT NULL DEFAULT 'AVAILABLE',
|
||||
"heldUntil" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "Seat_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SeatHold" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tripId" TEXT NOT NULL,
|
||||
"seatIds" TEXT[],
|
||||
"fareQuoteId" TEXT,
|
||||
"passengerId" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SeatHold_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FareRule" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tripId" TEXT,
|
||||
"route" TEXT,
|
||||
"serviceClass" "ServiceClass" NOT NULL,
|
||||
"baseFareMinor" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"refundable" BOOLEAN NOT NULL DEFAULT true,
|
||||
"validFrom" TIMESTAMP(3) NOT NULL,
|
||||
"validUntil" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "FareRule_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Booking" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingRef" TEXT NOT NULL,
|
||||
"passengerId" TEXT NOT NULL,
|
||||
"tripId" TEXT NOT NULL,
|
||||
"status" "BookingStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"totalMinor" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Booking_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BookingSeat" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"seatId" TEXT NOT NULL,
|
||||
"passengerName" TEXT NOT NULL,
|
||||
"idDocumentType" TEXT,
|
||||
"idDocumentNumber" TEXT,
|
||||
|
||||
CONSTRAINT "BookingSeat_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PaymentMethod" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"type" "PaymentMethodType" NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"maskedHint" TEXT,
|
||||
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PaymentMethod_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PaymentIntent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"amountMinor" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"method" "PaymentMethodType" NOT NULL,
|
||||
"status" "PaymentIntentStatus" NOT NULL DEFAULT 'REQUIRES_ACTION',
|
||||
"providerRef" TEXT,
|
||||
"clientAction" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PaymentIntent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Ticket" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"bookingRef" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'CONFIRMED',
|
||||
"qrPayload" TEXT NOT NULL,
|
||||
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"validatedAt" TIMESTAMP(3),
|
||||
"validatorId" TEXT,
|
||||
|
||||
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LoyaltyAccount" (
|
||||
"id" TEXT NOT NULL,
|
||||
"passengerId" TEXT NOT NULL,
|
||||
"pointsBalance" INTEGER NOT NULL DEFAULT 0,
|
||||
"tier" "LoyaltyTier" NOT NULL DEFAULT 'BRONZE',
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LoyaltyAccount_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LoyaltyLedgerEntry" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"delta" INTEGER NOT NULL,
|
||||
"reason" "LoyaltyLedgerReason" NOT NULL,
|
||||
"bookingId" TEXT,
|
||||
"balanceAfter" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "LoyaltyLedgerEntry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LoyaltyReward" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"costPoints" INTEGER NOT NULL,
|
||||
"available" BOOLEAN NOT NULL DEFAULT true,
|
||||
"description" TEXT,
|
||||
|
||||
CONSTRAINT "LoyaltyReward_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WalletAccount" (
|
||||
"id" TEXT NOT NULL,
|
||||
"passengerId" TEXT NOT NULL,
|
||||
"balanceMinor" INTEGER NOT NULL DEFAULT 0,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WalletAccount_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WalletLedgerEntry" (
|
||||
"id" TEXT NOT NULL,
|
||||
"walletId" TEXT NOT NULL,
|
||||
"type" "WalletLedgerType" NOT NULL,
|
||||
"amountMinor" INTEGER NOT NULL,
|
||||
"balanceAfterMinor" INTEGER NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"relatedBookingId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "WalletLedgerEntry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Notification" (
|
||||
"id" TEXT NOT NULL,
|
||||
"passengerId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"category" "NotificationCategory" NOT NULL,
|
||||
"read" BOOLEAN NOT NULL DEFAULT false,
|
||||
"deepLink" TEXT,
|
||||
"metadata" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Promotion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"subtitle" TEXT,
|
||||
"code" TEXT NOT NULL,
|
||||
"percentOff" INTEGER,
|
||||
"amountOffMinor" INTEGER,
|
||||
"validUntil" TIMESTAMP(3) NOT NULL,
|
||||
"ctaLabel" TEXT,
|
||||
"deepLink" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Promotion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "StationCrowdSignal" (
|
||||
"id" TEXT NOT NULL,
|
||||
"stationId" TEXT NOT NULL,
|
||||
"level" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"statusLabel" TEXT NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "StationCrowdSignal_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WeatherAlert" (
|
||||
"id" TEXT NOT NULL,
|
||||
"region" TEXT NOT NULL,
|
||||
"severity" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"validUntil" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "WeatherAlert_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MenuCategory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "MenuCategory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MenuItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tripId" TEXT NOT NULL,
|
||||
"categoryId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"priceMinor" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"available" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "MenuItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FoodOrder" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"status" "FoodOrderStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"totalMinor" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "FoodOrder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FoodOrderItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"orderId" TEXT NOT NULL,
|
||||
"menuItemId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"lineTotalMinor" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "FoodOrderItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FaqCategory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"iconKey" TEXT,
|
||||
|
||||
CONSTRAINT "FaqCategory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FaqArticle" (
|
||||
"id" TEXT NOT NULL,
|
||||
"categoryId" TEXT NOT NULL,
|
||||
"question" TEXT NOT NULL,
|
||||
"answerMarkdown" TEXT NOT NULL,
|
||||
"rank" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "FaqArticle_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SupportConversation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"status" "SupportConversationStatus" NOT NULL DEFAULT 'OPEN',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SupportConversation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SupportMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"conversationId" TEXT NOT NULL,
|
||||
"sender" "SupportSender" NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SupportMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserPreferences" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"pushEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"emailEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"smsEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"promosEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"biometricEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"defaultPaymentMethodId" TEXT,
|
||||
"autoDownloadTickets" BOOLEAN NOT NULL DEFAULT true,
|
||||
"dataSharing" BOOLEAN NOT NULL DEFAULT false,
|
||||
"locale" TEXT NOT NULL DEFAULT 'en',
|
||||
"darkMode" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "UserPreferences_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Device" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"platform" "DevicePlatform" NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"pushToken" TEXT,
|
||||
"trusted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Device_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SavedRoute" (
|
||||
"id" TEXT NOT NULL,
|
||||
"passengerId" TEXT NOT NULL,
|
||||
"fromStationId" TEXT NOT NULL,
|
||||
"toStationId" TEXT NOT NULL,
|
||||
"fromName" TEXT NOT NULL,
|
||||
"toName" TEXT NOT NULL,
|
||||
"tripCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SavedRoute_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Passenger_userId_key" ON "Passenger"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Station_code_key" ON "Station"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TrainService_number_key" ON "TrainService"("number");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TripStopTime_tripId_sequence_key" ON "TripStopTime"("tripId", "sequence");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TripLiveStatus_tripId_key" ON "TripLiveStatus"("tripId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Coach_tripId_label_key" ON "Coach"("tripId", "label");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Seat_coachId_row_col_key" ON "Seat"("coachId", "row", "col");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Booking_bookingRef_key" ON "Booking"("bookingRef");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PaymentIntent_bookingId_key" ON "PaymentIntent"("bookingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Ticket_bookingId_key" ON "Ticket"("bookingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LoyaltyAccount_passengerId_key" ON "LoyaltyAccount"("passengerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WalletAccount_passengerId_key" ON "WalletAccount"("passengerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Promotion_code_key" ON "Promotion"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UserPreferences_userId_key" ON "UserPreferences"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Passenger" ADD CONSTRAINT "Passenger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TravelerProfile" ADD CONSTRAINT "TravelerProfile_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Trip" ADD CONSTRAINT "Trip_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "TrainService"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Trip" ADD CONSTRAINT "Trip_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Trip" ADD CONSTRAINT "Trip_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Seat" ADD CONSTRAINT "Seat_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LoyaltyAccount" ADD CONSTRAINT "LoyaltyAccount_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WalletAccount" ADD CONSTRAINT "WalletAccount_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserPreferences" ADD CONSTRAINT "UserPreferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Device" ADD CONSTRAINT "Device_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- CreateTable SeatClass (runs before initial migration)
|
||||
CREATE TABLE IF NOT EXISTS "SeatClass" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"basePrice" INTEGER NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT "SeatClass_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "SeatClass_name_key" ON "SeatClass"("name");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- updatedAt default already set in initial migration, no-op
|
||||
SELECT 1;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export {};
|
||||
//# sourceMappingURL=reset-admin.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"reset-admin.d.ts","sourceRoot":"","sources":["reset-admin.ts"],"names":[],"mappings":""}
|
||||
@@ -1,49 +0,0 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const client_1 = require("@prisma/client");
|
||||
const bcrypt = __importStar(require("bcrypt"));
|
||||
const prisma = new client_1.PrismaClient();
|
||||
async function main() {
|
||||
const passwordHash = await bcrypt.hash('admin123', 10);
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: 'admin@edr-platform.com' },
|
||||
update: { passwordHash, role: 'ADMIN' },
|
||||
create: { fullName: 'EDR Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash, role: 'ADMIN' },
|
||||
});
|
||||
console.log('✅ Admin ready:', user.email);
|
||||
}
|
||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||
//# sourceMappingURL=reset-admin.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"reset-admin.js","sourceRoot":"","sources":["reset-admin.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,2CAA8C;AAC9C,+CAAiC;AAEjC,MAAM,MAAM,GAAG,IAAI,qBAAY,EAAE,CAAC;AAElC,KAAK,UAAU,IAAI;IACjB,MAAM,YAAY,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;IACvD,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC;QACpC,KAAK,EAAE,EAAE,KAAK,EAAE,wBAAwB,EAAE;QAC1C,MAAM,EAAE,EAAE,YAAY,EAAE,IAAI,EAAE,OAAO,EAAE;QACvC,MAAM,EAAE,EAAE,QAAQ,EAAE,WAAW,EAAE,KAAK,EAAE,wBAAwB,EAAE,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,IAAI,EAAE,OAAO,EAAE;KACxH,CAAC,CAAC;IACH,OAAO,CAAC,GAAG,CAAC,gBAAgB,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;AAC5C,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC"}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const passwordHash = await bcrypt.hash('admin123', 10);
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: 'admin@edr-platform.com' },
|
||||
update: { passwordHash, role: 'ADMIN' },
|
||||
create: { fullName: 'EDR Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash, role: 'ADMIN' },
|
||||
});
|
||||
console.log('✅ Admin ready:', user.email);
|
||||
}
|
||||
|
||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||
File diff suppressed because it is too large
Load Diff
2
apps/edr-passenger-api/prisma/seed.d.ts
vendored
2
apps/edr-passenger-api/prisma/seed.d.ts
vendored
@@ -1,2 +0,0 @@
|
||||
export {};
|
||||
//# sourceMappingURL=seed.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"seed.d.ts","sourceRoot":"","sources":["seed.ts"],"names":[],"mappings":""}
|
||||
@@ -1,73 +0,0 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const client_1 = require("@prisma/client");
|
||||
const bcrypt = __importStar(require("bcrypt"));
|
||||
const prisma = new client_1.PrismaClient();
|
||||
async function main() {
|
||||
const addis = await prisma.station.upsert({ where: { code: 'ADD' }, update: {}, create: { code: 'ADD', name: 'Addis Ababa', city: 'Addis Ababa', lat: 9.0054, lng: 38.7636 } });
|
||||
const direDawa = await prisma.station.upsert({ where: { code: 'DDW' }, update: {}, create: { code: 'DDW', name: 'Dire Dawa', city: 'Dire Dawa', lat: 9.5931, lng: 41.8661 } });
|
||||
const djibouti = await prisma.station.upsert({ where: { code: 'DJI' }, update: {}, create: { code: 'DJI', name: 'Djibouti', city: 'Djibouti', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 } });
|
||||
const service = await prisma.trainService.upsert({ where: { number: '301' }, update: {}, create: { number: '301', name: 'Express 301' } });
|
||||
const trip = await prisma.trip.create({
|
||||
data: { serviceId: service.id, originStationId: addis.id, destinationStationId: djibouti.id, departureAt: new Date('2026-05-11T08:30:00Z'), arrivalAt: new Date('2026-05-11T20:00:00Z'), durationMinutes: 690, stopsCount: 1 },
|
||||
});
|
||||
for (const [label, cls] of [['A', 'ECONOMY'], ['B', 'BUSINESS']]) {
|
||||
const coach = await prisma.coach.create({ data: { tripId: trip.id, label, serviceClass: cls } });
|
||||
const seats = [];
|
||||
for (let row = 1; row <= 10; row++) {
|
||||
for (const col of ['A', 'B', 'C', 'D'])
|
||||
seats.push({ coachId: coach.id, row, col, label: `${row}${col}` });
|
||||
}
|
||||
await prisma.seat.createMany({ data: seats });
|
||||
}
|
||||
await prisma.fareRule.create({ data: { tripId: trip.id, serviceClass: 'ECONOMY', baseFareMinor: 45000, validFrom: new Date('2026-01-01') } });
|
||||
const hash = await bcrypt.hash('password123', 10);
|
||||
const user = await prisma.user.upsert({ where: { email: 'kelemu@email.com' }, update: {}, create: { fullName: 'Kelemu Ketsela', email: 'kelemu@email.com', phone: '+251912345678', passwordHash: hash } });
|
||||
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: 2450, tier: 'SILVER' } });
|
||||
await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 125000 } });
|
||||
}
|
||||
await prisma.userPreferences.upsert({ where: { userId: user.id }, update: {}, create: { userId: user.id } });
|
||||
await prisma.user.upsert({ where: { email: 'admin@edr-platform.com' }, update: { passwordHash: await bcrypt.hash('admin123', 10), role: 'ADMIN' }, create: { fullName: 'EDR Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash: await bcrypt.hash('admin123', 10), role: 'ADMIN' } });
|
||||
await prisma.promotion.upsert({ where: { code: 'WEEKEND15' }, update: {}, create: { title: 'Weekend Sale', code: 'WEEKEND15', percentOff: 15, validUntil: new Date('2026-12-31') } });
|
||||
const faqCat = await prisma.faqCategory.create({ data: { title: 'Booking & Tickets', iconKey: 'description_outlined' } });
|
||||
await prisma.faqArticle.create({ data: { categoryId: faqCat.id, question: 'How do I book a train ticket?', answerMarkdown: 'Open Search, pick stations and date, select seats, and proceed to payment.', rank: 1 } });
|
||||
console.log('✅ Seed complete');
|
||||
}
|
||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||
//# sourceMappingURL=seed.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"seed.js","sourceRoot":"","sources":["seed.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,2CAA8C;AAC9C,+CAAiC;AAEjC,MAAM,MAAM,GAAG,IAAI,qBAAY,EAAE,CAAC;AAElC,KAAK,UAAU,IAAI;IACjB,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,aAAa,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC;IAChL,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC;IAC/K,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,iBAAiB,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC;IAE3M,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,aAAa,EAAE,EAAE,CAAC,CAAC;IAE3I,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC;QACpC,IAAI,EAAE,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,EAAE,eAAe,EAAE,KAAK,CAAC,EAAE,EAAE,oBAAoB,EAAE,QAAQ,CAAC,EAAE,EAAE,WAAW,EAAE,IAAI,IAAI,CAAC,sBAAsB,CAAC,EAAE,SAAS,EAAE,IAAI,IAAI,CAAC,sBAAsB,CAAC,EAAE,eAAe,EAAE,GAAG,EAAE,UAAU,EAAE,CAAC,EAAE;KAC/N,CAAC,CAAC;IAEH,KAAK,MAAM,CAAC,KAAK,EAAE,GAAG,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,UAAU,CAAC,CAAU,EAAE,CAAC;QAC1E,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;QACjG,MAAM,KAAK,GAAG,EAAE,CAAC;QACjB,KAAK,IAAI,GAAG,GAAG,CAAC,EAAE,GAAG,IAAI,EAAE,EAAE,GAAG,EAAE,EAAE,CAAC;YACnC,KAAK,MAAM,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC;gBAAE,KAAK,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,GAAG,GAAG,GAAG,EAAE,EAAE,CAAC,CAAC;QAC7G,CAAC;QACD,MAAM,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAChD,CAAC;IAED,MAAM,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,IAAI,CAAC,YAAY,CAAC,EAAE,EAAE,CAAC,CAAC;IAE9I,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,aAAa,EAAE,EAAE,CAAC,CAAC;IAClD,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,kBAAkB,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,gBAAgB,EAAE,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC;IAC3M,IAAI,SAAS,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;IAClF,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,SAAS,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;QACzE,MAAM,MAAM,CAAC,cAAc,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,WAAW,EAAE,SAAS,CAAC,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC;QACjH,MAAM,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,WAAW,EAAE,SAAS,CAAC,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC;IACnG,CAAC;IACD,MAAM,MAAM,CAAC,eAAe,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;IAE7G,MAAM,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,wBAAwB,EAAE,EAAE,MAAM,EAAE,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,WAAW,EAAE,KAAK,EAAE,wBAAwB,EAAE,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC;IAEjT,MAAM,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,cAAc,EAAE,IAAI,EAAE,WAAW,EAAE,UAAU,EAAE,EAAE,EAAE,UAAU,EAAE,IAAI,IAAI,CAAC,YAAY,CAAC,EAAE,EAAE,CAAC,CAAC;IAEtL,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,mBAAmB,EAAE,OAAO,EAAE,sBAAsB,EAAE,EAAE,CAAC,CAAC;IAC1H,MAAM,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,EAAE,UAAU,EAAE,MAAM,CAAC,EAAE,EAAE,QAAQ,EAAE,+BAA+B,EAAE,cAAc,EAAE,4EAA4E,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;IAEtN,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;AACjC,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC"}
|
||||
@@ -1,48 +1,184 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient, SeatKind } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const addis = await prisma.station.upsert({ where: { code: 'ADD' }, update: {}, create: { code: 'ADD', name: 'Addis Ababa', city: 'Addis Ababa', lat: 9.0054, lng: 38.7636 } });
|
||||
const direDawa = await prisma.station.upsert({ where: { code: 'DDW' }, update: {}, create: { code: 'DDW', name: 'Dire Dawa', city: 'Dire Dawa', lat: 9.5931, lng: 41.8661 } });
|
||||
const djibouti = await prisma.station.upsert({ where: { code: 'DJI' }, update: {}, create: { code: 'DJI', name: 'Djibouti', city: 'Djibouti', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 } });
|
||||
console.log('🌱 Starting comprehensive seed...');
|
||||
|
||||
const service = await prisma.trainService.upsert({ where: { number: '301' }, update: {}, create: { number: '301', name: 'Express 301' } });
|
||||
// Stations
|
||||
const addis = await prisma.station.upsert({ where: { code: 'ADD' }, update: {}, create: { code: 'ADD', name: 'Addis Ababa Central', city: 'Addis Ababa', countryCode: 'ET', lat: 9.0054, lng: 38.7636 } });
|
||||
const sebeta = await prisma.station.upsert({ where: { code: 'SBT' }, update: {}, create: { code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9167, lng: 38.6167 } });
|
||||
const adama = await prisma.station.upsert({ where: { code: 'ADM' }, update: {}, create: { code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5400, lng: 39.2675 } });
|
||||
const awash = await prisma.station.upsert({ where: { code: 'AWS' }, update: {}, create: { code: 'AWS', name: 'Awash', city: 'Awash', countryCode: 'ET', lat: 8.9833, lng: 40.1667 } });
|
||||
const direDawa = await prisma.station.upsert({ where: { code: 'DDW' }, update: {}, create: { code: 'DDW', name: 'Dire Dawa', city: 'Dire Dawa', countryCode: 'ET', lat: 9.5931, lng: 41.8661 } });
|
||||
const aysha = await prisma.station.upsert({ where: { code: 'AYS' }, update: {}, create: { code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 11.5500, lng: 42.7167 } });
|
||||
const djibouti = await prisma.station.upsert({ where: { code: 'DJI' }, update: {}, create: { code: 'DJI', name: 'Djibouti', city: 'Djibouti', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 } });
|
||||
|
||||
const trip = await prisma.trip.create({
|
||||
data: { serviceId: service.id, originStationId: addis.id, destinationStationId: djibouti.id, departureAt: new Date('2026-05-11T08:30:00Z'), arrivalAt: new Date('2026-05-11T20:00:00Z'), durationMinutes: 690, stopsCount: 1 },
|
||||
});
|
||||
// Seat Classes
|
||||
const scEconomyRegular = await prisma.seatClass.upsert({ where: { name: 'Economy Regular' }, update: {}, create: { name: 'Economy Regular', description: 'Standard economy seating', basePrice: 45000, isActive: true } });
|
||||
const scEconomyBed = await prisma.seatClass.upsert({ where: { name: 'Economy Bed' }, update: {}, create: { name: 'Economy Bed', description: 'Economy bed lower berth', basePrice: 65000, isActive: true } });
|
||||
const scVipBed = await prisma.seatClass.upsert({ where: { name: 'VIP Bed' }, update: {}, create: { name: 'VIP Bed', description: 'First class VIP bed', basePrice: 95000, isActive: true } });
|
||||
|
||||
for (const [label, cls] of [['A', 'ECONOMY'], ['B', 'BUSINESS']] as const) {
|
||||
const coach = await prisma.coach.create({ data: { tripId: trip.id, label, serviceClass: cls } });
|
||||
const seats = [];
|
||||
for (let row = 1; row <= 10; row++) {
|
||||
for (const col of ['A', 'B', 'C', 'D']) seats.push({ coachId: coach.id, row, col, label: `${row}${col}` });
|
||||
// Trains (logical services)
|
||||
const train301 = await prisma.train.upsert({ where: { number: '301' }, update: {}, create: { number: '301', name: 'Express 301', description: 'Addis-Djibouti Express' } });
|
||||
const train302 = await prisma.train.upsert({ where: { number: '302' }, update: {}, create: { number: '302', name: 'Express 302', description: 'Djibouti-Addis Express' } });
|
||||
|
||||
// Physical Coaches (reusable)
|
||||
const coachA1 = await prisma.coach.upsert({ where: { coachNumber: 'C-A1' }, update: {}, create: { coachNumber: 'C-A1', label: 'A', seatClassId: scEconomyRegular.id, mode: 'seat', totalUnits: 60 } });
|
||||
const coachB1 = await prisma.coach.upsert({ where: { coachNumber: 'C-B1' }, update: {}, create: { coachNumber: 'C-B1', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 } });
|
||||
const coachC1 = await prisma.coach.upsert({ where: { coachNumber: 'C-C1' }, update: {}, create: { coachNumber: 'C-C1', label: 'C', seatClassId: scVipBed.id, mode: 'bed', totalUnits: 20 } });
|
||||
const coachA2 = await prisma.coach.upsert({ where: { coachNumber: 'C-A2' }, update: {}, create: { coachNumber: 'C-A2', label: 'A', seatClassId: scEconomyRegular.id, mode: 'seat', totalUnits: 60 } });
|
||||
const coachB2 = await prisma.coach.upsert({ where: { coachNumber: 'C-B2' }, update: {}, create: { coachNumber: 'C-B2', label: 'B', seatClassId: scEconomyBed.id, mode: 'bed', totalUnits: 40 } });
|
||||
const coachC2 = await prisma.coach.upsert({ where: { coachNumber: 'C-C2' }, update: {}, create: { coachNumber: 'C-C2', label: 'C', seatClassId: scVipBed.id, mode: 'bed', totalUnits: 20 } });
|
||||
|
||||
// Create seats for each physical coach
|
||||
for (const coach of [coachA1, coachB1, coachC1, coachA2, coachB2, coachC2]) {
|
||||
const existingSeats = await prisma.seat.count({ where: { coachId: coach.id } });
|
||||
if (existingSeats === 0) {
|
||||
const seats = [];
|
||||
const rows = Math.ceil(coach.totalUnits / 4);
|
||||
for (let row = 1; row <= rows; row++) {
|
||||
for (const col of ['A', 'B', 'C', 'D']) {
|
||||
if (seats.length >= coach.totalUnits) break;
|
||||
seats.push({ coachId: coach.id, row, col, label: `${row}${col}`, seatNumber: `${coach.label}${row}${col}`, kind: (row === 1 && col === 'A' ? 'ACCESSIBLE' : 'STANDARD') as SeatKind });
|
||||
}
|
||||
}
|
||||
await prisma.seat.createMany({ data: seats });
|
||||
}
|
||||
await prisma.seat.createMany({ data: seats });
|
||||
}
|
||||
|
||||
await prisma.fareRule.create({ data: { tripId: trip.id, serviceClass: 'ECONOMY', baseFareMinor: 45000, validFrom: new Date('2026-01-01') } });
|
||||
// Train Schedules — delete dependents first to avoid FK violations
|
||||
const existingScheduleIds = (await prisma.trainSchedule.findMany({
|
||||
where: { trainId: { in: [train301.id, train302.id] } },
|
||||
select: { id: true },
|
||||
})).map((s) => s.id);
|
||||
if (existingScheduleIds.length > 0) {
|
||||
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 schedule1 = await prisma.trainSchedule.create({
|
||||
data: { trainId: train301.id, originStationId: addis.id, destinationStationId: djibouti.id, departureAt: new Date('2026-06-15T08:00:00Z'), arrivalAt: new Date('2026-06-15T20:00:00Z'), durationMinutes: 720, stopsCount: 6 },
|
||||
});
|
||||
const schedule2 = await prisma.trainSchedule.create({
|
||||
data: { trainId: train302.id, originStationId: djibouti.id, destinationStationId: addis.id, departureAt: new Date('2026-06-16T09:00:00Z'), arrivalAt: new Date('2026-06-16T21:30:00Z'), durationMinutes: 750, stopsCount: 5 },
|
||||
});
|
||||
const schedule3 = await prisma.trainSchedule.create({
|
||||
data: { trainId: train301.id, originStationId: addis.id, destinationStationId: djibouti.id, departureAt: new Date('2026-06-17T07:30:00Z'), arrivalAt: new Date('2026-06-17T19:45:00Z'), durationMinutes: 735, stopsCount: 5 },
|
||||
});
|
||||
const schedule4 = await prisma.trainSchedule.create({
|
||||
data: { trainId: train302.id, originStationId: djibouti.id, destinationStationId: addis.id, departureAt: new Date('2026-06-18T08:30:00Z'), arrivalAt: new Date('2026-06-18T21:00:00Z'), durationMinutes: 750, stopsCount: 5 },
|
||||
});
|
||||
|
||||
// Assign coaches to schedules
|
||||
await prisma.coachAssignment.createMany({
|
||||
data: [
|
||||
{ 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 },
|
||||
{ scheduleId: schedule4.id, coachId: coachA2.id, positionNumber: 1 },
|
||||
{ scheduleId: schedule4.id, coachId: coachB2.id, positionNumber: 2 },
|
||||
{ scheduleId: schedule4.id, coachId: coachC2.id, positionNumber: 3 },
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
// Stop Times
|
||||
await prisma.tripStopTime.createMany({
|
||||
data: [
|
||||
{ scheduleId: schedule1.id, stationId: addis.id, sequence: 1, plannedDepartureAt: new Date('2026-06-15T08:00:00Z'), status: 'UPCOMING' },
|
||||
{ scheduleId: schedule1.id, stationId: adama.id, sequence: 2, plannedArrivalAt: new Date('2026-06-15T09:30:00Z'), plannedDepartureAt: new Date('2026-06-15T09:45:00Z'), status: 'UPCOMING' },
|
||||
{ scheduleId: schedule1.id, stationId: awash.id, sequence: 3, plannedArrivalAt: new Date('2026-06-15T11:30:00Z'), plannedDepartureAt: new Date('2026-06-15T11:45:00Z'), status: 'UPCOMING' },
|
||||
{ scheduleId: schedule1.id, stationId: direDawa.id, sequence: 4, plannedArrivalAt: new Date('2026-06-15T15:00:00Z'), plannedDepartureAt: new Date('2026-06-15T15:20:00Z'), status: 'UPCOMING' },
|
||||
{ scheduleId: schedule1.id, stationId: aysha.id, sequence: 5, plannedArrivalAt: new Date('2026-06-15T18:00:00Z'), plannedDepartureAt: new Date('2026-06-15T18:10:00Z'), status: 'UPCOMING' },
|
||||
{ scheduleId: schedule1.id, stationId: djibouti.id, sequence: 6, plannedArrivalAt: new Date('2026-06-15T20:00:00Z'), status: 'UPCOMING' },
|
||||
],
|
||||
});
|
||||
|
||||
// Fare Rules
|
||||
for (const schedule of [schedule1, schedule2, schedule3, schedule4]) {
|
||||
await prisma.fareRule.createMany({
|
||||
data: [
|
||||
{ tripId: schedule.id, seatClassId: scEconomyRegular.id, baseFareMinor: 45000, validFrom: new Date('2026-01-01'), refundable: true },
|
||||
{ tripId: schedule.id, seatClassId: scEconomyBed.id, baseFareMinor: 65000, validFrom: new Date('2026-01-01'), refundable: true },
|
||||
{ tripId: schedule.id, seatClassId: scVipBed.id, baseFareMinor: 95000, validFrom: new Date('2026-01-01'), refundable: true },
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Users
|
||||
const hash = await bcrypt.hash('password123', 10);
|
||||
const user = await prisma.user.upsert({ where: { email: 'kelemu@email.com' }, update: {}, create: { fullName: 'Kelemu Ketsela', email: 'kelemu@email.com', phone: '+251912345678', passwordHash: hash } });
|
||||
let passenger = await prisma.passenger.findUnique({ where: { userId: user.id } });
|
||||
const adminHash = await bcrypt.hash('admin123', 10);
|
||||
const agentHash = await bcrypt.hash('agent123', 10);
|
||||
|
||||
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' } });
|
||||
const passengerUser = await prisma.user.upsert({ where: { email: 'kelemu@email.com' }, update: {}, create: { fullName: 'Kelemu Ketsela', email: 'kelemu@email.com', phone: '+251912345678', passwordHash: hash, nationality: 'Ethiopian', nationalId: 'ET123456789' } });
|
||||
let passenger = await prisma.passenger.findUnique({ where: { userId: passengerUser.id } });
|
||||
if (!passenger) {
|
||||
passenger = await prisma.passenger.create({ data: { userId: user.id } });
|
||||
passenger = await prisma.passenger.create({ data: { userId: passengerUser.id } });
|
||||
await prisma.loyaltyAccount.create({ data: { passengerId: passenger.id, pointsBalance: 2450, tier: 'SILVER' } });
|
||||
await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 125000 } });
|
||||
}
|
||||
await prisma.userPreferences.upsert({ where: { userId: user.id }, update: {}, create: { userId: user.id } });
|
||||
await prisma.userPreferences.upsert({ where: { userId: passengerUser.id }, update: {}, create: { userId: passengerUser.id, language: 'en' } });
|
||||
|
||||
await prisma.user.upsert({ where: { email: 'admin@edr-platform.com' }, update: { passwordHash: await bcrypt.hash('admin123', 10), role: 'ADMIN' }, create: { fullName: 'EDR Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash: await bcrypt.hash('admin123', 10), role: 'ADMIN' } });
|
||||
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' } });
|
||||
await prisma.agent.upsert({ where: { userId: agentUser.id }, update: {}, create: { userId: agentUser.id, agentCode: 'AG001', stationId: addis.id, commissionRate: 5, active: true } });
|
||||
|
||||
await prisma.promotion.upsert({ where: { code: 'WEEKEND15' }, update: {}, create: { title: 'Weekend Sale', code: 'WEEKEND15', percentOff: 15, validUntil: new Date('2026-12-31') } });
|
||||
// Baggage Allowance
|
||||
await prisma.baggageAllowance.deleteMany({});
|
||||
await prisma.baggageAllowance.createMany({
|
||||
data: [
|
||||
{ seatClassId: scEconomyRegular.id, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 500 },
|
||||
{ seatClassId: scEconomyBed.id, maxWeightKg: 25, maxPiecesCount: 2, excessFeePerKg: 450 },
|
||||
{ seatClassId: scVipBed.id, maxWeightKg: 30, maxPiecesCount: 3, excessFeePerKg: 400 },
|
||||
],
|
||||
});
|
||||
|
||||
const faqCat = await prisma.faqCategory.create({ data: { title: 'Booking & Tickets', iconKey: 'description_outlined' } });
|
||||
await prisma.faqArticle.create({ data: { categoryId: faqCat.id, question: 'How do I book a train ticket?', answerMarkdown: 'Open Search, pick stations and date, select seats, and proceed to payment.', rank: 1 } });
|
||||
// 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: 'PAYMENT_SUCCESS' }, update: {}, create: { code: 'PAYMENT_SUCCESS', channel: 'SMS', bodyTemplate: 'Payment successful for {{bookingRef}}. Amount: {{amount}} ETB', active: true } });
|
||||
|
||||
console.log('✅ Seed complete');
|
||||
// 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 } });
|
||||
|
||||
// FAQ
|
||||
await prisma.faqArticle.deleteMany({});
|
||||
await prisma.faqCategory.deleteMany({});
|
||||
const faqBooking = await prisma.faqCategory.create({ data: { title: 'Booking & Tickets', iconKey: 'confirmation_number' } });
|
||||
await prisma.faqArticle.createMany({ data: [{ categoryId: faqBooking.id, question: 'How do I book a train ticket?', answerMarkdown: 'Open Search, select origin and destination stations, choose date, select seats, and proceed to payment.', rank: 1 }] });
|
||||
|
||||
// Station Crowd Signals
|
||||
await prisma.stationCrowdSignal.deleteMany({});
|
||||
await prisma.stationCrowdSignal.createMany({ data: [{ stationId: addis.id, level: 'MODERATE', label: 'Moderate', statusLabel: 'Normal operations' }, { stationId: djibouti.id, level: 'HIGH', label: 'High', statusLabel: 'Busy terminal' }] });
|
||||
|
||||
// Fraud Detection Rules
|
||||
await prisma.fraudRule.upsert({ where: { type: 'VELOCITY' }, update: {}, create: { type: 'VELOCITY', enabled: true, threshold: 3, config: { windowMinutes: 60, action: 'FLAG' } } });
|
||||
|
||||
// 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() }] });
|
||||
|
||||
console.log('✅ Comprehensive seed complete');
|
||||
console.log('\n📋 Seed Summary:');
|
||||
console.log(' - 2 Trains (Express 301, Express 302)');
|
||||
console.log(' - 6 Physical Coaches (reusable across schedules)');
|
||||
console.log(' - 4 Train Schedules with coach assignments');
|
||||
console.log(' - 3 Seat Classes (Economy Regular, Economy Bed, VIP Bed)');
|
||||
console.log(' - 3 Users: Admin, Passenger (Silver tier + wallet), Agent');
|
||||
console.log('\n🔑 Login Credentials:');
|
||||
console.log(' Admin: admin@edr-platform.com / admin123');
|
||||
console.log(' Passenger: kelemu@email.com / password123');
|
||||
console.log(' Agent: agent@edr-platform.com / agent123');
|
||||
console.log('\n🚂 Architecture: Train → TrainSchedule ↔ CoachAssignment ↔ Coach → Seat');
|
||||
}
|
||||
|
||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
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 { AuthModule } from './modules/auth/auth.module';
|
||||
import { StationsModule } from './modules/stations/stations.module';
|
||||
import { FleetModule } from './modules/fleet/fleet.module';
|
||||
@@ -22,13 +29,23 @@ 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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true, load: [appConfig, dbConfig] }),
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfig, dbConfig, telebirrConfig, cbeConfig, ebirrConfig, cardConfig],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
PrismaModule,
|
||||
I18nModule,
|
||||
IamModule,
|
||||
AuthModule,
|
||||
StationsModule,
|
||||
FleetModule,
|
||||
@@ -46,6 +63,15 @@ import { DashboardModule } from './modules/dashboard/dashboard.module';
|
||||
LiveModule,
|
||||
SupportModule,
|
||||
DashboardModule,
|
||||
SegmentsModule,
|
||||
AgentsModule,
|
||||
ReportsModule,
|
||||
FraudModule,
|
||||
SeatClassesModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(LocaleMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,9 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
if (status >= 500) {
|
||||
this.logger.error(
|
||||
`${request.method} ${request.url} -> ${status}`,
|
||||
(exception as Error)?.stack,
|
||||
exception instanceof Error ? exception.stack : JSON.stringify(exception),
|
||||
);
|
||||
console.error('Full error details:', exception);
|
||||
} else {
|
||||
this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`);
|
||||
}
|
||||
|
||||
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 {}
|
||||
@@ -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(() => {}));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
import { SessionActivityInterceptor } from './interceptors/session-activity.interceptor';
|
||||
|
||||
@Global()
|
||||
@Module({ providers: [PrismaService], exports: [PrismaService] })
|
||||
@Module({ providers: [PrismaService, SessionActivityInterceptor], exports: [PrismaService, SessionActivityInterceptor] })
|
||||
export class PrismaModule {}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 || '',
|
||||
}));
|
||||
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 || '',
|
||||
}));
|
||||
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',
|
||||
}));
|
||||
@@ -17,7 +17,10 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new ResponseTransformInterceptor());
|
||||
app.useGlobalInterceptors(
|
||||
new ResponseTransformInterceptor(),
|
||||
app.get(SessionActivityInterceptor),
|
||||
);
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { Body, Controller, Post, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto, LoginDto } from './auth.dto';
|
||||
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
@@ -9,10 +9,73 @@ export class AuthController {
|
||||
constructor(private service: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register new user' })
|
||||
@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')
|
||||
@ApiOperation({ summary: 'Login and get JWT' })
|
||||
@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); }
|
||||
}
|
||||
|
||||
@@ -1,14 +1,152 @@
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ example: 'Kelemu Ketsela' }) @IsString() fullName: string;
|
||||
@ApiProperty({ example: 'kelemu@email.com' }) @IsEmail() email: string;
|
||||
@ApiProperty({ example: '+251912345678' }) @IsString() phone: string;
|
||||
@ApiProperty({ example: 'password123', minLength: 8 }) @IsString() @MinLength(8) password: string;
|
||||
@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({ example: 'kelemu@email.com' }) @IsEmail() email: string;
|
||||
@ApiProperty({ example: 'password123' }) @IsString() password: string;
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { RegisterDto, LoginDto } from './auth.dto';
|
||||
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -15,28 +16,115 @@ export class AuthService {
|
||||
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 },
|
||||
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 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 },
|
||||
include: { passenger: true, agent: true },
|
||||
});
|
||||
if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) {
|
||||
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');
|
||||
}
|
||||
return this.signToken(user.id, user.email, user.role, user.passenger?.id);
|
||||
|
||||
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);
|
||||
return this.signToken(user.id, user.email, user.role, user.passenger?.id, user.agent?.id);
|
||||
}
|
||||
|
||||
private signToken(userId: string, email: string, role: string, passengerId?: string) {
|
||||
const token = this.jwt.sign({ sub: userId, email, role, passengerId });
|
||||
return { token, user: { id: userId, email, role, passengerId } };
|
||||
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 signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
|
||||
const token = this.jwt.sign({ sub: userId, email, role, passengerId, agentId });
|
||||
return { token, user: { id: userId, email, role, passengerId, agentId } };
|
||||
}
|
||||
|
||||
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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { CreateBookingDto } from './bookings.dto';
|
||||
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@ApiTags('Booking')
|
||||
@@ -10,7 +10,54 @@ import { JwtGuard } from '../../common/jwt.guard';
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
export class BookingsController {
|
||||
constructor(private service: BookingsService) {}
|
||||
@Post() @ApiOperation({ summary: 'Create booking from seat hold' }) create(@Body() dto: CreateBookingDto) { return this.service.create(dto); }
|
||||
@Get(':bookingRef') @ApiOperation({ summary: 'Get booking by reference' }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); }
|
||||
@Delete(':bookingRef')@ApiOperation({ summary: 'Cancel booking' }) cancel(@Param('bookingRef') ref: string) { return this.service.cancel(ref); }
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: 'Create booking with age-based pricing and Verifayda verification',
|
||||
description: `Creates a booking with the following features:
|
||||
- Age-based pricing: CHILD (<5 years) first child free, ADULT (>=5 years) full fare
|
||||
- Ethiopian nationals: Verified via Verifayda 2.0 (national ID NOT stored)
|
||||
- Non-Ethiopians: Passport required, no verification
|
||||
- Multi-currency: Display in ETB, DJF, or USD (transaction always in ETB)
|
||||
- All passengers require dateOfBirth for age calculation`
|
||||
})
|
||||
@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',
|
||||
description: 'Returns booking with passenger categories, Verifayda verification status, and multi-currency amounts'
|
||||
})
|
||||
@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(':bookingRef/modify')
|
||||
@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(':bookingRef')
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum } from 'class-validator';
|
||||
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() fullName: string;
|
||||
@ApiProperty() @IsString() phone: string;
|
||||
@ApiProperty() @IsString() email: string;
|
||||
@ApiProperty() @IsString() seatId: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentType?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
|
||||
@ApiProperty({ example: 'John Doe' }) @IsString() passengerName: string;
|
||||
@ApiProperty({ example: '1990-05-15', description: 'Date of birth for age calculation' }) @IsDateString() dateOfBirth: string;
|
||||
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
||||
@ApiPropertyOptional({ example: 'ET123456789', description: 'For Ethiopian nationals only - used for Verifayda verification' }) @IsOptional() @IsString() idDocumentNumber?: string;
|
||||
@ApiPropertyOptional({ example: 'P1234567', description: 'For non-Ethiopians' }) @IsOptional() @IsString() passportNumber?: string;
|
||||
@ApiPropertyOptional({ example: 'Kenya', description: 'For non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string;
|
||||
}
|
||||
|
||||
export class CreateBookingDto {
|
||||
@ApiProperty() @IsString() passengerId: string;
|
||||
@ApiProperty() @IsString() tripId: 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] }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[];
|
||||
@ApiPropertyOptional({ example: 'ECONOMY', enum: ['ECONOMY', 'BUSINESS', 'FIRST'] }) @IsOptional() @IsString() serviceClass?: string;
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' })
|
||||
@IsString() seatClassId: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
|
||||
@ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string;
|
||||
@ApiPropertyOptional({ example: 'ETB', enum: Currency }) @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;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@ import { Module } from '@nestjs/common';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { SeatsModule } from '../seats/seats.module';
|
||||
import { SearchModule } from '../search/search.module';
|
||||
import { VerifaydaModule } from '../verifayda/verifayda.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
|
||||
@Module({ imports: [SeatsModule, SearchModule], controllers: [BookingsController], providers: [BookingsService], exports: [BookingsService] })
|
||||
@Module({
|
||||
imports: [SeatsModule, VerifaydaModule, CurrencyModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService],
|
||||
exports: [BookingsService]
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
@@ -2,69 +2,196 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CreateBookingDto } from './bookings.dto';
|
||||
import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { SearchService } from '../search/search.service';
|
||||
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;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
constructor(private prisma: PrismaService, private seatsService: SeatsService, private eventEmitter: EventEmitter2, private searchService: SearchService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private seatsService: SeatsService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
private verifaydaService: VerifaydaService,
|
||||
private currencyService: CurrencyService,
|
||||
) {}
|
||||
|
||||
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 trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId }, include: { originStation: true, destinationStation: true } });
|
||||
if (!trip) throw new NotFoundException('Trip not found');
|
||||
const fareQuote = await this.searchService.getFareQuote({ tripId: dto.tripId, serviceClass: dto.serviceClass ?? 'ECONOMY', passengerCount: dto.passengers.length, promoCode: dto.promoCode, loyaltyRedemptionPoints: dto.loyaltyRedemptionPoints });
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
} else if (passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||||
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
|
||||
}
|
||||
|
||||
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData });
|
||||
}
|
||||
|
||||
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId);
|
||||
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, tripId: dto.tripId, status: 'PENDING_PAYMENT', totalMinor: fareQuote.totalMinor, seats: { create: dto.passengers.map((p) => ({ seatId: p.seatId, passengerName: p.fullName, idDocumentType: p.idDocumentType, idDocumentNumber: p.idDocumentNumber })) } },
|
||||
include: { seats: { include: { seat: true } }, trip: { include: { originStation: true, destinationStation: true, service: true } } },
|
||||
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;
|
||||
|
||||
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): Promise<number> {
|
||||
const fareRule = await this.prisma.fareRule.findFirst({ where: { tripId: scheduleId, seatClassId } });
|
||||
return fareRule?.baseFareMinor ?? 35000;
|
||||
}
|
||||
|
||||
async getByRef(bookingRef: string) {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } }, paymentIntent: true, ticket: true } });
|
||||
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,
|
||||
createdAt: booking.createdAt,
|
||||
trip: {
|
||||
number: booking.trip.service.number,
|
||||
origin: { id: booking.trip.originStation.id, name: booking.trip.originStation.name, code: booking.trip.originStation.code, city: booking.trip.originStation.city },
|
||||
destination: { id: booking.trip.destinationStation.id, name: booking.trip.destinationStation.name, code: booking.trip.destinationStation.code, city: booking.trip.destinationStation.city },
|
||||
departureAt: booking.trip.departureAt,
|
||||
arrivalAt: booking.trip.arrivalAt,
|
||||
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,
|
||||
seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.serviceClass },
|
||||
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 cancel(bookingRef: string) {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true } });
|
||||
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('Use refund for confirmed bookings');
|
||||
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));
|
||||
return this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
|
||||
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
|
||||
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
|
||||
}
|
||||
|
||||
@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' } }); }
|
||||
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' } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CurrencyService } from './currency.service';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [CurrencyService],
|
||||
exports: [CurrencyService],
|
||||
})
|
||||
export class CurrencyModule {}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class CurrencyService {
|
||||
private readonly logger = new Logger(CurrencyService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async convertAmount(
|
||||
amountMinor: number,
|
||||
fromCurrency: Currency,
|
||||
toCurrency: Currency,
|
||||
): Promise<number> {
|
||||
if (fromCurrency === toCurrency) {
|
||||
return amountMinor;
|
||||
}
|
||||
|
||||
const rate = await this.getExchangeRate(fromCurrency, toCurrency);
|
||||
return Math.round(amountMinor * rate);
|
||||
}
|
||||
|
||||
async getExchangeRate(
|
||||
fromCurrency: Currency,
|
||||
toCurrency: Currency,
|
||||
): Promise<number> {
|
||||
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
|
||||
where: {
|
||||
fromCurrency,
|
||||
toCurrency,
|
||||
},
|
||||
orderBy: {
|
||||
effectiveDate: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
if (!exchangeRate) {
|
||||
this.logger.warn(
|
||||
`No exchange rate found for ${fromCurrency} to ${toCurrency}, using 1.0`,
|
||||
);
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
return Number(exchangeRate.rate);
|
||||
}
|
||||
|
||||
async syncExchangeRates(): Promise<void> {
|
||||
this.logger.log('Syncing exchange rates from external provider');
|
||||
|
||||
// In production, fetch from external API
|
||||
// For now, using static rates
|
||||
const rates = [
|
||||
{ from: 'ETB', to: 'ETB', rate: 1.0 },
|
||||
{ from: 'ETB', to: 'DJF', rate: 3.25 },
|
||||
{ from: 'ETB', to: 'USD', rate: 0.018 },
|
||||
{ from: 'DJF', to: 'ETB', rate: 0.3077 },
|
||||
{ from: 'USD', to: 'ETB', rate: 55.56 },
|
||||
];
|
||||
|
||||
for (const { from, to, rate } of rates) {
|
||||
await this.prisma.currencyExchangeRate.upsert({
|
||||
where: {
|
||||
fromCurrency_toCurrency_effectiveDate: {
|
||||
fromCurrency: from as Currency,
|
||||
toCurrency: to as Currency,
|
||||
effectiveDate: new Date(),
|
||||
},
|
||||
},
|
||||
update: { rate },
|
||||
create: {
|
||||
fromCurrency: from as Currency,
|
||||
toCurrency: to as Currency,
|
||||
rate,
|
||||
effectiveDate: new Date(),
|
||||
source: 'EXTERNAL_API',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log('Exchange rates synced successfully');
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,12 @@ export class DashboardService {
|
||||
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([
|
||||
this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { user: { select: { fullName: true } }, loyalty: true } }),
|
||||
this.prisma.booking.findFirst({
|
||||
where: { passengerId, status: 'CONFIRMED', trip: { departureAt: { gte: now } } },
|
||||
include: { trip: { include: { originStation: true, destinationStation: true, service: true, liveStatus: true } }, seats: { include: { seat: { include: { coach: true } } }, take: 1 }, ticket: true },
|
||||
where: { passengerId, status: 'CONFIRMED', schedule: { departureAt: { gte: now } } },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, liveStatus: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } }, take: 1 },
|
||||
ticket: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
this.prisma.walletAccount.findUnique({ where: { passengerId } }),
|
||||
@@ -30,10 +34,10 @@ export class DashboardService {
|
||||
user: { firstName, greetingKey },
|
||||
upcomingTicket: upcomingBooking ? {
|
||||
ticketId: upcomingBooking.ticket?.id, bookingRef: upcomingBooking.bookingRef,
|
||||
from: upcomingBooking.trip.originStation.name, to: upcomingBooking.trip.destinationStation.name,
|
||||
trainName: upcomingBooking.trip.service.name, coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label,
|
||||
departureAt: upcomingBooking.trip.departureAt,
|
||||
punctualityLabel: (upcomingBooking.trip.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
|
||||
from: upcomingBooking.schedule.originStation.name, to: upcomingBooking.schedule.destinationStation.name,
|
||||
trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label,
|
||||
departureAt: upcomingBooking.schedule.departureAt,
|
||||
punctualityLabel: (upcomingBooking.schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
|
||||
} : null,
|
||||
wallet: wallet ? { balanceMinor: wallet.balanceMinor, currency: wallet.currency } : null,
|
||||
activePromotionsCount: promos,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiResponse } from '@nestjs/swagger';
|
||||
import { FleetService } from './fleet.service';
|
||||
import { CreateTrainServiceDto, CreateCoachDto, CreateSeatBatchDto } from './fleet.dto';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, CreateSeatBatchDto, ListCoachesDto } from './fleet.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@ApiTags('Fleet')
|
||||
@@ -10,9 +10,92 @@ import { JwtGuard } from '../../common/jwt.guard';
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
export class FleetController {
|
||||
constructor(private service: FleetService) {}
|
||||
@Get('services') @ApiOperation({ summary: 'List train services' }) getServices() { return this.service.getServices(); }
|
||||
@Post('services') @ApiOperation({ summary: 'Create train service' }) createService(@Body() dto: CreateTrainServiceDto) { return this.service.createService(dto); }
|
||||
@Post('coaches') @ApiOperation({ summary: 'Add coach to trip' }) createCoach(@Body() dto: CreateCoachDto) { return this.service.createCoach(dto); }
|
||||
@Post('seats/batch')@ApiOperation({ summary: 'Batch-create seats for coach' }) createSeatBatch(@Body() dto: CreateSeatBatchDto) { return this.service.createSeatBatch(dto); }
|
||||
@Get('analytics') @ApiOperation({ summary: 'Fleet analytics' }) getAnalytics() { return this.service.getAnalytics(); }
|
||||
|
||||
@Get('trains')
|
||||
@ApiOperation({ summary: 'List all trains with their recent schedules' })
|
||||
@ApiResponse({ status: 200, description: 'Array of trains each with up to 5 most recent schedules' })
|
||||
getTrains() { return this.service.getTrains(); }
|
||||
|
||||
@Post('trains')
|
||||
@ApiOperation({ summary: 'Create a train service' })
|
||||
@ApiBody({ type: CreateTrainDto })
|
||||
@ApiResponse({ status: 201, description: 'Train created' })
|
||||
createTrain(@Body() dto: CreateTrainDto) { return this.service.createTrain(dto); }
|
||||
|
||||
@Get('coaches')
|
||||
@ApiOperation({ summary: 'List coaches filtered by status, mode, seat class, or schedule assignment' })
|
||||
@ApiQuery({ name: 'isActive', required: false, type: Boolean, description: 'true = active only, false = inactive only, omit = all' })
|
||||
@ApiQuery({ name: 'mode', required: false, description: 'Filter by mode: seat | bed | convertible' })
|
||||
@ApiQuery({ name: 'seatClassId', required: false, description: 'Filter by SeatClass UUID' })
|
||||
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter to coaches assigned to this TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Coaches with seat class info, assignment count, and seat status summary (total/available/held/booked/blocked)' })
|
||||
listCoaches(
|
||||
@Query('isActive') isActive?: string,
|
||||
@Query('mode') mode?: string,
|
||||
@Query('seatClassId') seatClassId?: string,
|
||||
@Query('scheduleId') scheduleId?: string,
|
||||
) {
|
||||
const dto: ListCoachesDto = {
|
||||
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
|
||||
mode,
|
||||
seatClassId,
|
||||
scheduleId,
|
||||
};
|
||||
return this.service.listCoaches(dto);
|
||||
}
|
||||
|
||||
@Get('coaches/:id')
|
||||
@ApiOperation({ summary: 'Get a single coach with full seat layout and arrangement' })
|
||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: `Coach detail including:
|
||||
- seatClass: seat class info
|
||||
- seatsByRow: seats grouped by row number, each seat includes label, seatNumber, col, kind (STANDARD/PREMIUM/ACCESSIBLE), status (AVAILABLE/HELD/BOOKED/BLOCKED), isWindow, isAisle, bedPosition (bed mode only), premiumFeeMinor
|
||||
- seatStatusSummary: total/available/held/booked/blocked counts
|
||||
- assignments: up to 5 most recent schedule assignments with origin/destination`,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||
getCoach(@Param('id') id: string) { return this.service.getCoach(id); }
|
||||
|
||||
@Post('coaches')
|
||||
@ApiOperation({ summary: 'Register a new physical coach and auto-generate its seats from arrangement config' })
|
||||
@ApiBody({ type: CreateCoachDto })
|
||||
@ApiResponse({ status: 201, description: 'Coach created with seats auto-generated from mode + arrangement + totalUnits' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid arrangement format' })
|
||||
createCoach(@Body() dto: CreateCoachDto) { return this.service.createCoach(dto); }
|
||||
|
||||
@Patch('coaches/:id')
|
||||
@ApiOperation({ summary: 'Update coach properties (label, mode, arrangement, etc.)' })
|
||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||
@ApiBody({ type: UpdateCoachDto })
|
||||
@ApiResponse({ status: 200, description: 'Coach updated' })
|
||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) { return this.service.updateCoach(id, dto); }
|
||||
|
||||
@Post('assignments')
|
||||
@ApiOperation({ summary: 'Assign a physical coach to a train schedule at a given position' })
|
||||
@ApiBody({ type: AssignCoachDto })
|
||||
@ApiResponse({ status: 201, description: 'CoachAssignment created' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule or coach not found' })
|
||||
assignCoach(@Body() dto: AssignCoachDto) { return this.service.assignCoach(dto); }
|
||||
|
||||
@Delete('assignments/:id')
|
||||
@ApiOperation({ summary: 'Remove a coach assignment from a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'CoachAssignment UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Assignment removed' })
|
||||
@ApiResponse({ status: 404, description: 'Assignment not found' })
|
||||
removeAssignment(@Param('id') id: string) { return this.service.removeAssignment(id); }
|
||||
|
||||
@Post('seats/batch')
|
||||
@ApiOperation({ summary: 'Batch-generate seats for a coach (rows × cols)' })
|
||||
@ApiBody({ type: CreateSeatBatchDto })
|
||||
@ApiResponse({ status: 201, description: 'Returns count of seats created' })
|
||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||
createSeatBatch(@Body() dto: CreateSeatBatchDto) { return this.service.createSeatBatch(dto); }
|
||||
|
||||
@Get('analytics')
|
||||
@ApiOperation({ summary: 'Fleet analytics: train count, schedule count, seat occupancy rate' })
|
||||
@ApiResponse({ status: 200, description: 'Returns totalTrains, totalSchedules, totalSeats, bookedSeats, occupancyRate' })
|
||||
getAnalytics() { return this.service.getAnalytics(); }
|
||||
}
|
||||
|
||||
@@ -1,20 +1,50 @@
|
||||
import { IsString, IsEnum, IsInt } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ServiceClass } from '@prisma/client';
|
||||
import { IsString, IsInt, IsOptional, IsArray, IsBoolean } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType, OmitType } from '@nestjs/swagger';
|
||||
|
||||
export class CreateTrainServiceDto {
|
||||
@ApiProperty({ example: '301' }) @IsString() number: string;
|
||||
export class CreateTrainDto {
|
||||
@ApiProperty({ example: '301', description: 'Unique train service number' }) @IsString() number: string;
|
||||
@ApiProperty({ example: 'Express 301' }) @IsString() name: string;
|
||||
@ApiPropertyOptional({ example: 'EDR', description: 'Operator ID (defaults to op_edr)' }) @IsOptional() @IsString() operatorId?: string;
|
||||
@ApiPropertyOptional({ example: 'Ethiopian-Djibouti Railway' }) @IsOptional() @IsString() operatorName?: string;
|
||||
@ApiPropertyOptional({ example: 'Addis-Djibouti Express' }) @IsOptional() @IsString() description?: string;
|
||||
}
|
||||
|
||||
export class CreateCoachDto {
|
||||
@ApiProperty() @IsString() tripId: string;
|
||||
@ApiProperty({ example: 'A' }) @IsString() label: string;
|
||||
@ApiProperty({ enum: ServiceClass }) @IsEnum(ServiceClass) serviceClass: ServiceClass;
|
||||
@ApiProperty({ example: 'C-A1', description: 'Unique physical coach identifier' }) @IsString() coachNumber: string;
|
||||
@ApiProperty({ example: 'A', description: 'Display label shown on tickets' }) @IsString() label: string;
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID this coach belongs to' }) @IsString() seatClassId: string;
|
||||
@ApiPropertyOptional({ example: 'sleeper', description: 'Coach type descriptor' }) @IsOptional() @IsString() coachType?: string;
|
||||
@ApiPropertyOptional({ example: 'seat', description: 'seat | bed | convertible. Determines which arrangement field is used for seat generation.' }) @IsOptional() @IsString() mode?: string;
|
||||
@ApiPropertyOptional({ example: '2+2', description: 'Seat arrangement for seat/convertible mode. Format: groups separated by +, e.g. "2+2" (4 cols: A/B aisle C/D) or "1+2+1". Used to derive columns, window and aisle flags. Required when mode=seat and totalUnits>0.' }) @IsOptional() @IsString() seatArrangement?: string;
|
||||
@ApiPropertyOptional({ example: '2+2', description: 'Bed arrangement for bed mode. First number = tiers per berth: 2 → lower/upper, 3 → lower/middle/upper. E.g. "2+2" = 2-tier berths. Required when mode=bed and totalUnits>0.' }) @IsOptional() @IsString() bedArrangement?: string;
|
||||
@ApiPropertyOptional({ example: 60, description: 'Total seat/bed units. When >0, seats are auto-generated from the arrangement on coach creation.' }) @IsOptional() @IsInt() totalUnits?: number;
|
||||
}
|
||||
|
||||
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['coachNumber'] as const)) {}
|
||||
|
||||
export class AssignCoachDto {
|
||||
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' }) @IsString() scheduleId: string;
|
||||
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string;
|
||||
@ApiProperty({ example: 1, description: 'Position in the train consist (1 = first coach)' }) @IsInt() positionNumber: number;
|
||||
@ApiPropertyOptional({ example: true, description: 'Whether this coach is operational for this schedule' }) @IsOptional() @IsBoolean() isOperational?: boolean;
|
||||
}
|
||||
|
||||
export class CreateSeatBatchDto {
|
||||
@ApiProperty() @IsString() coachId: string;
|
||||
@ApiProperty({ example: 10 }) @IsInt() rows: number;
|
||||
@ApiProperty({ example: ['A', 'B', 'C', 'D'] }) cols: string[];
|
||||
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID to generate seats for' }) @IsString() coachId: string;
|
||||
@ApiProperty({ example: 15, description: 'Number of rows to generate' }) @IsInt() rows: number;
|
||||
@ApiProperty({ example: ['A', 'B', 'C', 'D'], type: [String], description: 'Column labels per row' }) @IsArray() @IsString({ each: true }) cols: string[];
|
||||
}
|
||||
|
||||
export class ListCoachesDto {
|
||||
@ApiPropertyOptional({ example: true, description: 'Filter by active/inactive status. Omit to return all.' })
|
||||
@IsOptional() @IsBoolean() isActive?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: 'seat', description: 'Filter by mode: seat | bed | convertible' })
|
||||
@IsOptional() @IsString() mode?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'Filter by SeatClass UUID' })
|
||||
@IsOptional() @IsString() seatClassId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Filter to coaches assigned to this TrainSchedule UUID' })
|
||||
@IsOptional() @IsString() scheduleId?: string;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,258 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateTrainServiceDto, CreateCoachDto, CreateSeatBatchDto } from './fleet.dto';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, CreateSeatBatchDto, ListCoachesDto } from './fleet.dto';
|
||||
import { SeatKind } from '@prisma/client';
|
||||
|
||||
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
|
||||
function parseArrangement(arrangement: string): number[] {
|
||||
return arrangement.split('+').map((n) => parseInt(n, 10));
|
||||
}
|
||||
|
||||
// Derives column labels from a seat-mode arrangement string.
|
||||
// '2+2' → ['A','B','C','D'] (A/D window, B/C aisle)
|
||||
// '1+2+1' → ['A','B','C','D']
|
||||
function seatCols(arrangement: string): string[] {
|
||||
const groups = parseArrangement(arrangement);
|
||||
const total = groups.reduce((s, n) => s + n, 0);
|
||||
return Array.from({ length: total }, (_, i) => String.fromCharCode(65 + i)); // A, B, C …
|
||||
}
|
||||
|
||||
// Returns true if the column index is a window seat given the arrangement groups.
|
||||
function isWindowCol(colIndex: number, groups: number[]): boolean {
|
||||
const total = groups.reduce((s, n) => s + n, 0);
|
||||
return colIndex === 0 || colIndex === total - 1;
|
||||
}
|
||||
|
||||
// Returns true if the column index is an aisle seat.
|
||||
function isAisleCol(colIndex: number, groups: number[]): boolean {
|
||||
let cursor = 0;
|
||||
for (const g of groups) {
|
||||
cursor += g;
|
||||
const leftAisle = cursor - 1;
|
||||
const rightAisle = cursor;
|
||||
if (colIndex === leftAisle || colIndex === rightAisle) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Bed positions for a given tier count: 2 → lower/upper, 3 → lower/middle/upper
|
||||
const BED_POSITIONS: Record<number, string[]> = {
|
||||
2: ['lower', 'upper'],
|
||||
3: ['lower', 'middle', 'upper'],
|
||||
};
|
||||
|
||||
type SeatRow = {
|
||||
coachId: string;
|
||||
row: number;
|
||||
col: string;
|
||||
label: string;
|
||||
seatNumber: string;
|
||||
kind: SeatKind;
|
||||
isWindow: boolean;
|
||||
isAisle: boolean;
|
||||
bedPosition?: string;
|
||||
};
|
||||
|
||||
function buildSeatSeats(coachId: string, coachLabel: string, arrangement: string, totalUnits: number): SeatRow[] {
|
||||
const cols = seatCols(arrangement);
|
||||
const groups = parseArrangement(arrangement);
|
||||
const seats: SeatRow[] = [];
|
||||
let row = 1;
|
||||
while (seats.length < totalUnits) {
|
||||
for (let ci = 0; ci < cols.length && seats.length < totalUnits; ci++) {
|
||||
const col = cols[ci];
|
||||
seats.push({
|
||||
coachId, row, col,
|
||||
label: `${row}${col}`,
|
||||
seatNumber: `${coachLabel}${row}${col}`,
|
||||
kind: SeatKind.STANDARD,
|
||||
isWindow: isWindowCol(ci, groups),
|
||||
isAisle: isAisleCol(ci, groups),
|
||||
});
|
||||
}
|
||||
row++;
|
||||
}
|
||||
return seats;
|
||||
}
|
||||
|
||||
function buildBedSeats(coachId: string, coachLabel: string, arrangement: string, totalUnits: number): SeatRow[] {
|
||||
// arrangement for beds describes tiers per berth, e.g. '2+2' = 2 lower+upper on each side
|
||||
// Each compartment number is the row; each tier is the col (L=lower, M=middle, U=upper)
|
||||
const groups = parseArrangement(arrangement);
|
||||
const tiersPerSide = groups[0]; // e.g. 2 → lower+upper
|
||||
const positions = BED_POSITIONS[tiersPerSide] ?? ['lower', 'upper'];
|
||||
const tierCols = positions.map((_, i) => String.fromCharCode(65 + i)); // A=lower, B=upper, C=middle
|
||||
const seats: SeatRow[] = [];
|
||||
let compartment = 1;
|
||||
while (seats.length < totalUnits) {
|
||||
for (let ti = 0; ti < tierCols.length && seats.length < totalUnits; ti++) {
|
||||
const col = tierCols[ti];
|
||||
seats.push({
|
||||
coachId, row: compartment, col,
|
||||
label: `${compartment}${col}`,
|
||||
seatNumber: `${coachLabel}${compartment}${col}`,
|
||||
kind: SeatKind.STANDARD,
|
||||
isWindow: false,
|
||||
isAisle: false,
|
||||
bedPosition: positions[ti],
|
||||
});
|
||||
}
|
||||
compartment++;
|
||||
}
|
||||
return seats;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FleetService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
getServices() { return this.prisma.trainService.findMany({ include: { trips: { take: 5, orderBy: { departureAt: 'desc' } } } }); }
|
||||
createService(dto: CreateTrainServiceDto) { return this.prisma.trainService.create({ data: dto }); }
|
||||
createCoach(dto: CreateCoachDto) { return this.prisma.coach.create({ data: dto }); }
|
||||
|
||||
getTrains() {
|
||||
return this.prisma.train.findMany({ include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } } });
|
||||
}
|
||||
|
||||
createTrain(dto: CreateTrainDto) { return this.prisma.train.create({ data: dto }); }
|
||||
|
||||
async getCoach(id: string) {
|
||||
const coach = await this.prisma.coach.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
seatClass: true,
|
||||
seats: {
|
||||
orderBy: [{ row: 'asc' }, { col: 'asc' }],
|
||||
},
|
||||
assignments: {
|
||||
include: { schedule: { include: { originStation: true, destinationStation: true } } },
|
||||
orderBy: { schedule: { departureAt: 'desc' } },
|
||||
take: 5,
|
||||
},
|
||||
_count: { select: { seats: true, assignments: true } },
|
||||
},
|
||||
});
|
||||
if (!coach) throw new NotFoundException('Coach not found');
|
||||
|
||||
// Group seats by row to reflect the physical arrangement layout
|
||||
const rowMap = new Map<number, typeof coach.seats>();
|
||||
for (const seat of coach.seats) {
|
||||
if (!rowMap.has(seat.row)) rowMap.set(seat.row, []);
|
||||
rowMap.get(seat.row)!.push(seat);
|
||||
}
|
||||
|
||||
const seatsByRow = Array.from(rowMap.entries()).map(([row, seats]) => ({ row, seats }));
|
||||
|
||||
const seatStatusSummary = {
|
||||
total: coach.seats.length,
|
||||
available: coach.seats.filter(s => s.status === 'AVAILABLE').length,
|
||||
held: coach.seats.filter(s => s.status === 'HELD').length,
|
||||
booked: coach.seats.filter(s => s.status === 'BOOKED').length,
|
||||
blocked: coach.seats.filter(s => s.status === 'BLOCKED').length,
|
||||
};
|
||||
|
||||
const { seats, ...coachData } = coach;
|
||||
return { ...coachData, seatsByRow, seatStatusSummary };
|
||||
}
|
||||
|
||||
async listCoaches(dto: ListCoachesDto) {
|
||||
const where: any = {};
|
||||
if (dto.isActive !== undefined) where.isActive = dto.isActive;
|
||||
if (dto.mode) where.mode = dto.mode;
|
||||
if (dto.seatClassId) where.seatClassId = dto.seatClassId;
|
||||
if (dto.scheduleId) where.assignments = { some: { scheduleId: dto.scheduleId } };
|
||||
|
||||
const coaches = await this.prisma.coach.findMany({
|
||||
where,
|
||||
include: {
|
||||
seatClass: true,
|
||||
seats: { select: { status: true } },
|
||||
_count: { select: { seats: true, assignments: true } },
|
||||
},
|
||||
orderBy: [{ isActive: 'desc' }, { label: 'asc' }],
|
||||
});
|
||||
|
||||
return coaches.map(({ seats, ...coach }) => ({
|
||||
...coach,
|
||||
seatStatusSummary: {
|
||||
total: seats.length,
|
||||
available: seats.filter(s => s.status === 'AVAILABLE').length,
|
||||
held: seats.filter(s => s.status === 'HELD').length,
|
||||
booked: seats.filter(s => s.status === 'BOOKED').length,
|
||||
blocked: seats.filter(s => s.status === 'BLOCKED').length,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async createCoach(dto: CreateCoachDto) {
|
||||
const mode = dto.mode ?? 'seat';
|
||||
const totalUnits = dto.totalUnits ?? 0;
|
||||
|
||||
const isBed = mode === 'bed';
|
||||
const arrangement = isBed
|
||||
? (dto.bedArrangement ?? dto.seatArrangement ?? '2+2')
|
||||
: (dto.seatArrangement ?? '2+2');
|
||||
|
||||
if (totalUnits > 0) {
|
||||
const groups = parseArrangement(arrangement);
|
||||
if (groups.some(isNaN)) {
|
||||
throw new BadRequestException(`Invalid arrangement format "${arrangement}". Use e.g. "2+2" or "2+2+2"`);
|
||||
}
|
||||
}
|
||||
|
||||
const coach = await this.prisma.coach.create({ data: dto });
|
||||
|
||||
if (totalUnits > 0) {
|
||||
const seats = isBed
|
||||
? buildBedSeats(coach.id, coach.label, arrangement, totalUnits)
|
||||
: buildSeatSeats(coach.id, coach.label, arrangement, totalUnits);
|
||||
await this.prisma.seat.createMany({ data: seats, skipDuplicates: true });
|
||||
}
|
||||
|
||||
return this.prisma.coach.findUnique({
|
||||
where: { id: coach.id },
|
||||
include: { seatClass: true, _count: { select: { seats: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async updateCoach(id: string, dto: UpdateCoachDto) {
|
||||
const coach = await this.prisma.coach.findUnique({ where: { id } });
|
||||
if (!coach) throw new NotFoundException('Coach not found');
|
||||
return this.prisma.coach.update({ where: { id }, data: dto });
|
||||
}
|
||||
|
||||
async assignCoach(dto: AssignCoachDto) {
|
||||
const [schedule, coach] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId } }),
|
||||
this.prisma.coach.findUnique({ where: { id: dto.coachId } }),
|
||||
]);
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
if (!coach) throw new NotFoundException('Coach not found');
|
||||
return this.prisma.coachAssignment.create({ data: dto });
|
||||
}
|
||||
|
||||
async removeAssignment(id: string) {
|
||||
const assignment = await this.prisma.coachAssignment.findUnique({ where: { id } });
|
||||
if (!assignment) throw new NotFoundException('Assignment not found');
|
||||
return this.prisma.coachAssignment.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async createSeatBatch(dto: CreateSeatBatchDto) {
|
||||
const coach = await this.prisma.coach.findUnique({ where: { id: dto.coachId } });
|
||||
if (!coach) throw new NotFoundException('Coach not found');
|
||||
const seats = [];
|
||||
for (let row = 1; row <= dto.rows; row++) for (const col of dto.cols) seats.push({ coachId: dto.coachId, row, col, label: `${row}${col}` });
|
||||
for (let row = 1; row <= dto.rows; row++) {
|
||||
for (const col of dto.cols) {
|
||||
seats.push({ coachId: dto.coachId, row, col, label: `${row}${col}`, seatNumber: `${coach.label}${row}${col}` });
|
||||
}
|
||||
}
|
||||
await this.prisma.seat.createMany({ data: seats, skipDuplicates: true });
|
||||
return { created: seats.length };
|
||||
}
|
||||
|
||||
async getAnalytics() {
|
||||
const [totalServices, totalTrips, totalSeats, bookedSeats] = await Promise.all([
|
||||
this.prisma.trainService.count(), this.prisma.trip.count(),
|
||||
this.prisma.seat.count(), this.prisma.seat.count({ where: { status: 'BOOKED' } }),
|
||||
const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([
|
||||
this.prisma.train.count(),
|
||||
this.prisma.trainSchedule.count(),
|
||||
this.prisma.seat.count(),
|
||||
this.prisma.seat.count({ where: { status: 'BOOKED' } }),
|
||||
]);
|
||||
return { totalServices, totalTrips, totalSeats, bookedSeats, occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0 };
|
||||
return { totalTrains, totalSchedules, totalSeats, bookedSeats, occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0 };
|
||||
}
|
||||
}
|
||||
|
||||
74
apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts
Normal file
74
apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Controller, Get, Post, Body, Query, UseGuards, Logger } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { FraudService, FraudRuleConfig } from './fraud.service';
|
||||
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
||||
import { UserRole } from '@prisma/client';
|
||||
|
||||
@ApiTags('Fraud Detection')
|
||||
@Controller('fraud')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
export class FraudController {
|
||||
private readonly logger = new Logger(FraudController.name);
|
||||
|
||||
constructor(private fraudService: FraudService) {}
|
||||
|
||||
/**
|
||||
* Get fraud alerts
|
||||
*/
|
||||
@Get('alerts')
|
||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
||||
@ApiOperation({ summary: 'Get fraud alerts' })
|
||||
async getAlerts(
|
||||
@Query('userId') userId?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('offset') offset?: string,
|
||||
) {
|
||||
const alerts = await this.fraudService.getAlerts(userId, parseInt(limit || '100'), parseInt(offset || '0'));
|
||||
return { data: alerts, total: alerts.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fraud rules
|
||||
*/
|
||||
@Get('rules')
|
||||
@IamRoles('ADMIN')
|
||||
@ApiOperation({ summary: 'Get fraud detection rules' })
|
||||
async getRules() {
|
||||
const rules = await this.fraudService.getRules();
|
||||
return { data: rules };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update fraud rule
|
||||
*/
|
||||
@Post('rules')
|
||||
@IamRoles('ADMIN')
|
||||
@ApiOperation({ summary: 'Create or update fraud rule' })
|
||||
async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) {
|
||||
const rule = await this.fraudService.upsertRule(body.type, body.config);
|
||||
return { data: rule, message: 'Rule updated successfully' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Block user temporarily
|
||||
*/
|
||||
@Post('actions/block')
|
||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
||||
@ApiOperation({ summary: 'Block user temporarily' })
|
||||
async blockUser(@Body() body: { userId: string; durationMinutes: number }) {
|
||||
await this.fraudService.blockUserTemporarily(body.userId, body.durationMinutes);
|
||||
return { message: `User blocked for ${body.durationMinutes} minutes` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock user
|
||||
*/
|
||||
@Post('actions/unblock')
|
||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
||||
@ApiOperation({ summary: 'Unblock user' })
|
||||
async unblockUser(@Body() body: { userId: string }) {
|
||||
await this.fraudService.unblockUser(body.userId);
|
||||
return { message: 'User unblocked' };
|
||||
}
|
||||
}
|
||||
12
apps/edr-passenger-api/src/modules/fraud/fraud.module.ts
Normal file
12
apps/edr-passenger-api/src/modules/fraud/fraud.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { FraudService } from './fraud.service';
|
||||
import { FraudController } from './fraud.controller';
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule],
|
||||
providers: [FraudService],
|
||||
controllers: [FraudController],
|
||||
exports: [FraudService],
|
||||
})
|
||||
export class FraudModule {}
|
||||
252
apps/edr-passenger-api/src/modules/fraud/fraud.service.ts
Normal file
252
apps/edr-passenger-api/src/modules/fraud/fraud.service.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
|
||||
export interface FraudRuleConfig {
|
||||
type: 'VELOCITY' | 'HIGH_VALUE' | 'FAILED_PAYMENTS' | 'MULTIPLE_METHODS';
|
||||
enabled: boolean;
|
||||
threshold: number;
|
||||
timeWindowMinutes?: number;
|
||||
blockDurationMinutes?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FraudService {
|
||||
private readonly logger = new Logger(FraudService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Evaluate fraud rules and create alerts if triggered
|
||||
*/
|
||||
async evaluateRules(
|
||||
userId: string,
|
||||
eventType: 'booking.created' | 'payment.failed' | 'auth.login.failed',
|
||||
context: Record<string, unknown>,
|
||||
): Promise<{ triggered: boolean; rules: string[] }> {
|
||||
const triggeredRules: string[] = [];
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
|
||||
if (!user) return { triggered: false, rules: [] };
|
||||
|
||||
// Check velocity rule (multiple bookings in short time)
|
||||
if (eventType === 'booking.created') {
|
||||
const velocityTriggered = await this.checkVelocityRule(userId);
|
||||
if (velocityTriggered) {
|
||||
triggeredRules.push('VELOCITY');
|
||||
}
|
||||
|
||||
// Check high-value booking
|
||||
const amount = (context.amountMinor as number) || 0;
|
||||
const highValueTriggered = await this.checkHighValueRule(amount);
|
||||
if (highValueTriggered) {
|
||||
triggeredRules.push('HIGH_VALUE');
|
||||
}
|
||||
}
|
||||
|
||||
// Check repeated failed payments
|
||||
if (eventType === 'payment.failed') {
|
||||
const failedPaymentTriggered = await this.checkFailedPaymentRule(userId);
|
||||
if (failedPaymentTriggered) {
|
||||
triggeredRules.push('FAILED_PAYMENTS');
|
||||
}
|
||||
}
|
||||
|
||||
// Create alert if rules triggered
|
||||
if (triggeredRules.length > 0) {
|
||||
await this.createFraudAlert(userId, eventType, triggeredRules, context);
|
||||
return { triggered: true, rules: triggeredRules };
|
||||
}
|
||||
|
||||
return { triggered: false, rules: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check velocity rule: X bookings in Y minutes
|
||||
*/
|
||||
private async checkVelocityRule(userId: string): Promise<boolean> {
|
||||
const rule = await this.prisma.fraudRule.findFirst({
|
||||
where: { type: 'VELOCITY', enabled: true },
|
||||
});
|
||||
|
||||
if (!rule) return false;
|
||||
|
||||
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 30;
|
||||
const threshold = rule.threshold;
|
||||
|
||||
const bookingCount = await this.prisma.booking.count({
|
||||
where: {
|
||||
passengerId: userId,
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return bookingCount > threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check high-value booking rule
|
||||
*/
|
||||
private async checkHighValueRule(amountMinor: number): Promise<boolean> {
|
||||
const rule = await this.prisma.fraudRule.findFirst({
|
||||
where: { type: 'HIGH_VALUE', enabled: true },
|
||||
});
|
||||
|
||||
if (!rule) return false;
|
||||
|
||||
// threshold is in ETB (convert minor units to ETB)
|
||||
const amountEtb = amountMinor / 100;
|
||||
return amountEtb > rule.threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check failed payment rule: X failed attempts in Y minutes
|
||||
*/
|
||||
private async checkFailedPaymentRule(userId: string): Promise<boolean> {
|
||||
const rule = await this.prisma.fraudRule.findFirst({
|
||||
where: { type: 'FAILED_PAYMENTS', enabled: true },
|
||||
});
|
||||
|
||||
if (!rule) return false;
|
||||
|
||||
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 60;
|
||||
const threshold = rule.threshold;
|
||||
|
||||
const failedCount = await this.prisma.paymentIntent.count({
|
||||
where: {
|
||||
booking: { passengerId: userId },
|
||||
status: 'FAILED',
|
||||
updatedAt: {
|
||||
gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return failedCount > threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fraud alert
|
||||
*/
|
||||
private async createFraudAlert(
|
||||
userId: string,
|
||||
eventType: string,
|
||||
triggeredRules: string[],
|
||||
context: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const alert = await this.prisma.fraudAlert.create({
|
||||
data: {
|
||||
userId,
|
||||
eventType,
|
||||
triggeredRules,
|
||||
context: context as any,
|
||||
severity: triggeredRules.length > 1 ? 'HIGH' : 'MEDIUM',
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.warn(`Fraud alert created: ${alert.id} for user ${userId} - rules: ${triggeredRules.join(', ')}`);
|
||||
|
||||
// Trigger blocking if needed
|
||||
if (triggeredRules.includes('HIGH_VALUE') || triggeredRules.length > 1) {
|
||||
await this.blockUserTemporarily(userId, 30); // Block for 30 minutes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block user temporarily
|
||||
*/
|
||||
async blockUserTemporarily(userId: string, durationMinutes: number): Promise<void> {
|
||||
const blockedUntil = new Date(Date.now() + durationMinutes * 60 * 1000);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { blockedUntil },
|
||||
});
|
||||
this.logger.warn(`User ${userId} blocked until ${blockedUntil.toISOString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock user
|
||||
*/
|
||||
async unblockUser(userId: string): Promise<void> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { blockedUntil: null },
|
||||
});
|
||||
this.logger.log(`User ${userId} unblocked`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all fraud alerts
|
||||
*/
|
||||
async getAlerts(userId?: string, limit = 100, offset = 0) {
|
||||
return this.prisma.fraudAlert.findMany({
|
||||
where: userId ? { userId } : {},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a fraud rule
|
||||
*/
|
||||
async upsertRule(
|
||||
type: string,
|
||||
config: FraudRuleConfig,
|
||||
) {
|
||||
return this.prisma.fraudRule.upsert({
|
||||
where: { type: type as any },
|
||||
update: {
|
||||
enabled: config.enabled,
|
||||
threshold: config.threshold,
|
||||
config: config as any,
|
||||
},
|
||||
create: {
|
||||
type: type as any,
|
||||
enabled: config.enabled,
|
||||
threshold: config.threshold,
|
||||
config: config as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all fraud rules
|
||||
*/
|
||||
async getRules() {
|
||||
return this.prisma.fraudRule.findMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listener for booking created
|
||||
*/
|
||||
@OnEvent('booking.created')
|
||||
async onBookingCreated(payload: { booking: any }) {
|
||||
await this.evaluateRules(payload.booking.passengerId, 'booking.created', {
|
||||
bookingId: payload.booking.id,
|
||||
amountMinor: payload.booking.totalMinor,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listener for payment failed
|
||||
*/
|
||||
@OnEvent('payment.failed')
|
||||
async onPaymentFailed(payload: { intentId: string; userId: string }) {
|
||||
await this.evaluateRules(payload.userId, 'payment.failed', {
|
||||
intentId: payload.intentId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listener for auth login failed
|
||||
*/
|
||||
@OnEvent('auth.login.failed')
|
||||
async onLoginFailed(payload: { userId: string; email: string }) {
|
||||
await this.evaluateRules(payload.userId, 'auth.login.failed', {
|
||||
email: payload.email,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import { JwtGuard } from '../../common/jwt.guard';
|
||||
@Controller('live')
|
||||
export class LiveController {
|
||||
constructor(private service: LiveService) {}
|
||||
@Get('trips/:tripId') @ApiOperation({ summary: 'Get live status for a trip' }) getTripLiveStatus(@Param('tripId') id: string) { return this.service.getTripLiveStatus(id); }
|
||||
@Get('trips/:tripId/stops') @ApiOperation({ summary: 'Get stop timeline for a trip' }) getStopTimeline(@Param('tripId') id: string) { return this.service.getStopTimeline(id); }
|
||||
@Patch('trips/:tripId/status')@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update live trip status (staff/system)' }) updateLiveStatus(@Param('tripId') id: string, @Body() dto: UpdateLiveStatusDto) { return this.service.updateLiveStatus(id, dto); }
|
||||
@Get('schedules/:scheduleId') @ApiOperation({ summary: 'Get live status for a schedule' }) getTripLiveStatus(@Param('scheduleId') id: string) { return this.service.getTripLiveStatus(id); }
|
||||
@Get('schedules/:scheduleId/stops') @ApiOperation({ summary: 'Get stop timeline for a schedule' }) getStopTimeline(@Param('scheduleId') id: string) { return this.service.getStopTimeline(id); }
|
||||
@Patch('schedules/:scheduleId/status') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update live schedule status (staff/system)' }) updateLiveStatus(@Param('scheduleId') id: string, @Body() dto: UpdateLiveStatusDto) { return this.service.updateLiveStatus(id, dto); }
|
||||
@Get('crowd-signals') @ApiOperation({ summary: 'Get station crowd signals' }) getCrowdSignals() { return this.service.getStationCrowdSignals(); }
|
||||
@Get('weather-alerts') @ApiOperation({ summary: 'Get active weather alerts' }) getWeatherAlerts() { return this.service.getWeatherAlerts(); }
|
||||
}
|
||||
|
||||
@@ -5,29 +5,31 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
export class LiveService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getTripLiveStatus(tripId: string) {
|
||||
const trip = await this.prisma.trip.findUnique({
|
||||
where: { id: tripId },
|
||||
include: { service: true, originStation: true, destinationStation: true, liveStatus: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
|
||||
async getTripLiveStatus(scheduleId: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
include: { train: true, originStation: true, destinationStation: true, liveStatus: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
if (!trip) throw new NotFoundException('Trip not found');
|
||||
const live = trip.liveStatus;
|
||||
const nextStop = trip.stopTimes.find((s) => s.status === 'UPCOMING' || s.status === 'APPROACHING');
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
const live = schedule.liveStatus;
|
||||
const nextStop = schedule.stopTimes.find((s) => s.status === 'UPCOMING' || s.status === 'APPROACHING');
|
||||
return {
|
||||
tripId: trip.id, trainName: trip.service.name,
|
||||
fromStationName: trip.originStation.name, toStationName: trip.destinationStation.name,
|
||||
state: live?.state ?? trip.status, currentLocationLabel: live?.currentLocationLabel,
|
||||
scheduleId: schedule.id, trainName: schedule.train.name,
|
||||
fromStationName: schedule.originStation.name, toStationName: schedule.destinationStation.name,
|
||||
state: live?.state ?? schedule.status, currentLocationLabel: live?.currentLocationLabel,
|
||||
progressPercent: live?.progressPercent ?? 0, delayMinutes: live?.delayMinutes ?? 0,
|
||||
currentSpeedKph: live?.currentSpeedKph, platformLabel: live?.platformLabel,
|
||||
nextStopStationName: nextStop?.station.name, updatedAt: live?.updatedAt ?? trip.departureAt,
|
||||
nextStopStationName: nextStop?.station.name, updatedAt: live?.updatedAt ?? schedule.departureAt,
|
||||
};
|
||||
}
|
||||
|
||||
updateLiveStatus(tripId: string, data: any) {
|
||||
return this.prisma.tripLiveStatus.upsert({ where: { tripId }, update: data, create: { tripId, state: data.state ?? 'SCHEDULED', ...data } });
|
||||
updateLiveStatus(scheduleId: string, data: any) {
|
||||
return this.prisma.tripLiveStatus.upsert({ where: { scheduleId }, update: data, create: { scheduleId, state: data.state ?? 'SCHEDULED', ...data } });
|
||||
}
|
||||
|
||||
getStopTimeline(tripId: string) { return this.prisma.tripStopTime.findMany({ where: { tripId }, include: { station: true }, orderBy: { sequence: 'asc' } }); }
|
||||
getStopTimeline(scheduleId: string) {
|
||||
return this.prisma.tripStopTime.findMany({ where: { scheduleId }, include: { station: true }, orderBy: { sequence: 'asc' } });
|
||||
}
|
||||
|
||||
getStationCrowdSignals() { return this.prisma.stationCrowdSignal.findMany({ include: { station: true } }); }
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as sgMail from '@sendgrid/mail';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
export interface NotificationChannel {
|
||||
send(recipient: string, subject: string, body: string, context?: Record<string, unknown>): Promise<boolean>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmailAdapter implements NotificationChannel {
|
||||
private readonly logger = new Logger(EmailAdapter.name);
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
const apiKey = this.config.get<string>('SENDGRID_API_KEY');
|
||||
if (apiKey) {
|
||||
sgMail.setApiKey(apiKey);
|
||||
this.logger.log('SendGrid Email adapter initialized');
|
||||
} else {
|
||||
this.logger.warn('SENDGRID_API_KEY not configured - emails will be logged only');
|
||||
}
|
||||
}
|
||||
|
||||
async send(
|
||||
recipient: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
context?: Record<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
const apiKey = this.config.get<string>('SENDGRID_API_KEY');
|
||||
const fromEmail = this.config.get<string>('SENDGRID_FROM_EMAIL') || 'noreply@edr-platform.com';
|
||||
|
||||
if (!apiKey) {
|
||||
this.logger.log(`[EMAIL MOCK] To: ${recipient} | Subject: ${subject} | Body: ${body.substring(0, 100)}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const msg: sgMail.MailDataRequired = {
|
||||
to: recipient,
|
||||
from: fromEmail,
|
||||
subject,
|
||||
text: body,
|
||||
html: this.formatHtml(body, context),
|
||||
};
|
||||
|
||||
await sgMail.send(msg);
|
||||
this.logger.log(`Email sent successfully to ${recipient}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Failed to send email to ${recipient}: ${message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private formatHtml(body: string, context?: Record<string, unknown>): string {
|
||||
const contextHtml = context
|
||||
? `<div style="margin-top: 20px; padding: 10px; background: #f5f5f5; border-radius: 4px;">
|
||||
<small>${JSON.stringify(context, null, 2)}</small>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #0066cc; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; background: white; }
|
||||
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h2>Ethio-Djibouti Railway</h2>
|
||||
</div>
|
||||
<div class="content">
|
||||
${body.replace(/\n/g, '<br>')}
|
||||
${contextHtml}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2024 Ethio-Djibouti Railway. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsAdapter implements NotificationChannel {
|
||||
private readonly logger = new Logger(SmsAdapter.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {
|
||||
const provider = this.config.get<string>('SMS_PROVIDER');
|
||||
this.logger.log(`SMS adapter initialized with provider: ${provider || 'MOCK'}`);
|
||||
}
|
||||
|
||||
async send(
|
||||
recipient: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
_context?: Record<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
const provider = this.config.get<string>('SMS_PROVIDER');
|
||||
const apiKey = this.config.get<string>('SMS_API_KEY');
|
||||
|
||||
if (!provider || !apiKey) {
|
||||
this.logger.log(`[SMS MOCK] To: ${recipient} | Message: ${body.substring(0, 100)}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (provider.toLowerCase()) {
|
||||
case 'twilio':
|
||||
return await this.sendViaTwilio(recipient, body);
|
||||
case 'africastalking':
|
||||
return await this.sendViaAfricasTalking(recipient, body);
|
||||
default:
|
||||
this.logger.warn(`Unknown SMS provider: ${provider}`);
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Failed to send SMS to ${recipient}: ${message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendViaTwilio(to: string, body: string): Promise<boolean> {
|
||||
const accountSid = this.config.get<string>('TWILIO_ACCOUNT_SID');
|
||||
const authToken = this.config.get<string>('TWILIO_AUTH_TOKEN');
|
||||
const fromNumber = this.config.get<string>('TWILIO_FROM_NUMBER');
|
||||
|
||||
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`;
|
||||
const auth = Buffer.from(`${accountSid}:${authToken}`).toString('base64');
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.http.post(
|
||||
url,
|
||||
new URLSearchParams({
|
||||
To: to,
|
||||
From: fromNumber || '',
|
||||
Body: body,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': `Basic ${auth}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return response.status === 201;
|
||||
}
|
||||
|
||||
private async sendViaAfricasTalking(to: string, body: string): Promise<boolean> {
|
||||
const apiKey = this.config.get<string>('SMS_API_KEY');
|
||||
const username = this.config.get<string>('AFRICASTALKING_USERNAME');
|
||||
const from = this.config.get<string>('AFRICASTALKING_FROM');
|
||||
|
||||
const url = 'https://api.africastalking.com/version1/messaging';
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.http.post(
|
||||
url,
|
||||
new URLSearchParams({
|
||||
username: username || '',
|
||||
to,
|
||||
message: body,
|
||||
from: from || '',
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'apiKey': apiKey || '',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return response.status === 201;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PushAdapter implements NotificationChannel {
|
||||
private readonly logger = new Logger(PushAdapter.name);
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.logger.log('Push notification adapter initialized');
|
||||
}
|
||||
|
||||
async send(
|
||||
recipient: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
context?: Record<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
// Push notifications would typically use FCM/APNS
|
||||
// For now, just log
|
||||
this.logger.log(`[PUSH MOCK] To: ${recipient} | Title: ${subject} | Body: ${body.substring(0, 100)}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Param, Patch, Post, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
||||
import { TestNotificationDto } from './notifications.dto';
|
||||
|
||||
@ApiTags('Notifications')
|
||||
@Controller('notifications')
|
||||
@@ -12,13 +14,32 @@ export class NotificationsController {
|
||||
|
||||
@Get(':passengerId')
|
||||
@ApiOperation({ summary: 'Get notifications for passenger' })
|
||||
getForPassenger(@Param('passengerId') id: string) { return this.service.getForPassenger(id); }
|
||||
getForPassenger(@Param('passengerId') id: string) {
|
||||
return this.service.getForPassenger(id);
|
||||
}
|
||||
|
||||
@Patch(':id/read')
|
||||
@ApiOperation({ summary: 'Mark notification as read' })
|
||||
markRead(@Param('id') id: string) { return this.service.markRead(id); }
|
||||
markRead(@Param('id') id: string) {
|
||||
return this.service.markRead(id);
|
||||
}
|
||||
|
||||
@Patch(':passengerId/read-all')
|
||||
@ApiOperation({ summary: 'Mark all notifications as read' })
|
||||
markAllRead(@Param('passengerId') id: string) { return this.service.markAllRead(id); }
|
||||
markAllRead(@Param('passengerId') id: string) {
|
||||
return this.service.markAllRead(id);
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
@UseGuards(IamGuard)
|
||||
@IamRoles('ADMIN', 'STAFF')
|
||||
@ApiOperation({ summary: 'Test notification delivery (Admin only)' })
|
||||
async testNotification(@Body() dto: TestNotificationDto) {
|
||||
return this.service.send(
|
||||
dto.templateKey,
|
||||
dto.recipient,
|
||||
dto.context,
|
||||
dto.channels as any,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsEnum, IsOptional } from 'class-validator';
|
||||
import { IsString, IsEnum, IsOptional, IsArray } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export enum NotificationCategoryEnum {
|
||||
@@ -17,3 +17,21 @@ export class SendNotificationDto {
|
||||
@ApiPropertyOptional({ example: 'edr://tickets/tkt_01' }) @IsOptional() @IsString() deepLink?: string;
|
||||
@ApiPropertyOptional() @IsOptional() metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class TestNotificationDto {
|
||||
@ApiProperty({ example: 'booking.created' })
|
||||
@IsString()
|
||||
templateKey: string;
|
||||
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsString()
|
||||
recipient: string;
|
||||
|
||||
@ApiProperty({ example: { bookingRef: 'EDR123456', passengerName: 'John Doe' } })
|
||||
context: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ example: ['EMAIL', 'SMS', 'IN_APP'] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
channels?: string[];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters';
|
||||
|
||||
@Module({ controllers: [NotificationsController], providers: [NotificationsService], exports: [NotificationsService] })
|
||||
@Module({
|
||||
imports: [HttpModule.register({ timeout: 10_000 })],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService, EmailAdapter, SmsAdapter, PushAdapter],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
|
||||
@@ -1,45 +1,263 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto';
|
||||
import * as sgMail from '@sendgrid/mail';
|
||||
import { EmailAdapter, SmsAdapter, PushAdapter, NotificationChannel } from './notification.adapters';
|
||||
|
||||
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
constructor(private prisma: PrismaService) {
|
||||
if (process.env.SENDGRID_API_KEY) sgMail.setApiKey(process.env.SENDGRID_API_KEY);
|
||||
private readonly logger = new Logger(NotificationsService.name);
|
||||
private readonly channels: Map<NotificationChannelType, NotificationChannel>;
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private emailAdapter: EmailAdapter,
|
||||
private smsAdapter: SmsAdapter,
|
||||
private pushAdapter: PushAdapter,
|
||||
) {
|
||||
this.channels = new Map<NotificationChannelType, NotificationChannel>([
|
||||
['EMAIL', this.emailAdapter as NotificationChannel],
|
||||
['SMS', this.smsAdapter as NotificationChannel],
|
||||
['PUSH', this.pushAdapter as NotificationChannel],
|
||||
]);
|
||||
}
|
||||
|
||||
private sanitize(value: string): string {
|
||||
return value.replace(/[\r\n]/g, ' ').replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c] ?? c));
|
||||
/**
|
||||
* Send notification using template key and context
|
||||
* @param templateKey - Template code from NotificationTemplate table
|
||||
* @param recipient - User/Passenger ID or email/phone
|
||||
* @param context - Variables to interpolate in template
|
||||
* @param channels - Optional array of channels to use (defaults to user preferences)
|
||||
*/
|
||||
async send(
|
||||
templateKey: string,
|
||||
recipient: string,
|
||||
context: Record<string, unknown>,
|
||||
channels?: NotificationChannelType[],
|
||||
): Promise<{ sent: boolean; channels: string[] }> {
|
||||
const template = await this.prisma.notificationTemplate.findUnique({
|
||||
where: { code: templateKey },
|
||||
});
|
||||
|
||||
if (!template || !template.active) {
|
||||
this.logger.warn(`Template ${templateKey} not found or inactive`);
|
||||
return { sent: false, channels: [] };
|
||||
}
|
||||
|
||||
const { subject, body } = this.interpolate(template, context);
|
||||
const targetChannels = channels || await this.getUserPreferredChannels(recipient);
|
||||
const sentChannels: string[] = [];
|
||||
|
||||
// Always create in-app notification
|
||||
if (targetChannels.includes('IN_APP')) {
|
||||
await this.createInAppNotification(recipient, subject, body, context);
|
||||
sentChannels.push('IN_APP');
|
||||
}
|
||||
|
||||
// Send via other channels
|
||||
for (const channelType of targetChannels) {
|
||||
if (channelType === 'IN_APP') continue;
|
||||
|
||||
const adapter = this.channels.get(channelType);
|
||||
if (!adapter) {
|
||||
this.logger.warn(`No adapter for channel: ${channelType}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const recipientAddress = await this.getRecipientAddress(recipient, channelType);
|
||||
if (!recipientAddress) {
|
||||
this.logger.warn(`No ${channelType} address for recipient: ${recipient}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const success = await adapter.send(recipientAddress, subject, body, context);
|
||||
if (success) {
|
||||
sentChannels.push(channelType);
|
||||
}
|
||||
}
|
||||
|
||||
return { sent: sentChannels.length > 0, channels: sentChannels };
|
||||
}
|
||||
|
||||
async send(dto: SendNotificationDto) {
|
||||
const notification = await this.prisma.notification.create({ data: { passengerId: dto.passengerId, title: dto.title, body: dto.body, category: dto.category as any, deepLink: dto.deepLink, metadata: dto.metadata } });
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { id: dto.passengerId }, include: { user: true } });
|
||||
if (passenger?.user) await this.sendEmail(passenger.user.email, this.sanitize(dto.title), this.sanitize(dto.body));
|
||||
/**
|
||||
* Legacy method for backward compatibility
|
||||
*/
|
||||
async sendDirect(dto: SendNotificationDto) {
|
||||
const notification = await this.prisma.notification.create({
|
||||
data: {
|
||||
passengerId: dto.passengerId,
|
||||
title: dto.title,
|
||||
body: dto.body,
|
||||
category: dto.category as any,
|
||||
deepLink: dto.deepLink,
|
||||
metadata: dto.metadata,
|
||||
},
|
||||
});
|
||||
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { id: dto.passengerId },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (passenger?.user) {
|
||||
await this.emailAdapter.send(
|
||||
passenger.user.email,
|
||||
this.sanitize(dto.title),
|
||||
this.sanitize(dto.body),
|
||||
);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
getForPassenger(passengerId: string) { return this.prisma.notification.findMany({ where: { passengerId }, orderBy: { createdAt: 'desc' }, take: 50 }); }
|
||||
private async createInAppNotification(
|
||||
recipient: string,
|
||||
title: string,
|
||||
body: string,
|
||||
context: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
// Try to find passenger by ID or email
|
||||
let passengerId = recipient;
|
||||
|
||||
markRead(id: string) { return this.prisma.notification.update({ where: { id }, data: { read: true } }); }
|
||||
if (!recipient.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: recipient }, { phone: recipient }],
|
||||
},
|
||||
include: { passenger: true },
|
||||
});
|
||||
if (user?.passenger) {
|
||||
passengerId = user.passenger.id;
|
||||
} else {
|
||||
this.logger.warn(`Could not find passenger for recipient: ${recipient}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async markAllRead(passengerId: string) { await this.prisma.notification.updateMany({ where: { passengerId, read: false }, data: { read: true } }); return { updated: true }; }
|
||||
await this.prisma.notification.create({
|
||||
data: {
|
||||
passengerId,
|
||||
title,
|
||||
body,
|
||||
category: (context.category as any) || 'SYSTEM',
|
||||
deepLink: context.deepLink as string,
|
||||
metadata: context as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private interpolate(
|
||||
template: { subject?: string | null; bodyTemplate: string },
|
||||
context: Record<string, unknown>,
|
||||
): { subject: string; body: string } {
|
||||
const subject = template.subject || 'Notification';
|
||||
let body = template.bodyTemplate;
|
||||
|
||||
// Simple template interpolation: {{variable}}
|
||||
for (const [key, value] of Object.entries(context)) {
|
||||
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
|
||||
body = body.replace(regex, String(value));
|
||||
}
|
||||
|
||||
return { subject, body };
|
||||
}
|
||||
|
||||
private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ id: recipient }, { email: recipient }, { phone: recipient }],
|
||||
},
|
||||
include: { preferences: true },
|
||||
});
|
||||
|
||||
if (!user?.preferences) {
|
||||
return ['IN_APP', 'EMAIL'];
|
||||
}
|
||||
|
||||
const channels: NotificationChannelType[] = ['IN_APP'];
|
||||
if (user.preferences.emailEnabled) channels.push('EMAIL');
|
||||
if (user.preferences.smsEnabled) channels.push('SMS');
|
||||
if (user.preferences.pushEnabled) channels.push('PUSH');
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
private async getRecipientAddress(
|
||||
recipient: string,
|
||||
channel: NotificationChannelType,
|
||||
): Promise<string | null> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ id: recipient }, { email: recipient }, { phone: recipient }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
switch (channel) {
|
||||
case 'EMAIL':
|
||||
return user.email;
|
||||
case 'SMS':
|
||||
return user.phone;
|
||||
case 'PUSH':
|
||||
// Would need to fetch device push token
|
||||
return user.id;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(value: string): string {
|
||||
return value
|
||||
.replace(/[\r\n]/g, ' ')
|
||||
.replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c] ?? c));
|
||||
}
|
||||
|
||||
getForPassenger(passengerId: string) {
|
||||
return this.prisma.notification.findMany({
|
||||
where: { passengerId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
}
|
||||
|
||||
markRead(id: string) {
|
||||
return this.prisma.notification.update({ where: { id }, data: { read: true } });
|
||||
}
|
||||
|
||||
async markAllRead(passengerId: string) {
|
||||
await this.prisma.notification.updateMany({
|
||||
where: { passengerId, read: false },
|
||||
data: { read: true },
|
||||
});
|
||||
return { updated: true };
|
||||
}
|
||||
|
||||
@OnEvent('booking.created')
|
||||
async onBookingCreated(payload: any) {
|
||||
await this.send({ passengerId: payload.booking.passengerId, title: 'Booking Created', body: `Booking ${payload.booking.bookingRef} created. Complete payment within 15 minutes.`, category: NotificationCategoryEnum.BOOKING, deepLink: `edr://bookings/${payload.booking.bookingRef}`, metadata: { bookingRef: payload.booking.bookingRef } });
|
||||
await this.send(
|
||||
'booking.created',
|
||||
payload.booking.passengerId,
|
||||
{
|
||||
bookingRef: payload.booking.bookingRef,
|
||||
category: 'BOOKING',
|
||||
deepLink: `edr://bookings/${payload.booking.bookingRef}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@OnEvent('payment.succeeded')
|
||||
async onPaymentSucceeded(payload: any) {
|
||||
await this.send({ passengerId: payload.booking.passengerId, title: 'Payment Successful', body: `Your ticket for ${payload.booking.bookingRef} is confirmed. Have a great journey!`, category: NotificationCategoryEnum.PAYMENT, deepLink: `edr://tickets/${payload.booking.bookingRef}`, metadata: { bookingRef: payload.booking.bookingRef } });
|
||||
}
|
||||
|
||||
private async sendEmail(to: string, subject: string, text: string) {
|
||||
if (!process.env.SENDGRID_API_KEY) { console.log(`[EMAIL] To: ${to} | Subject: ${subject}`); return; }
|
||||
try { await sgMail.send({ to, from: process.env.SENDGRID_FROM_EMAIL || 'noreply@edr-platform.com', subject, text }); }
|
||||
catch (e) { console.error('[EMAIL] Send error:', String(e instanceof Error ? e.message : e).replace(/[\r\n<>&"']/g, ' ')); }
|
||||
await this.send(
|
||||
'payment.succeeded',
|
||||
payload.booking.passengerId,
|
||||
{
|
||||
bookingRef: payload.booking.bookingRef,
|
||||
category: 'PAYMENT',
|
||||
deepLink: `edr://tickets/${payload.booking.bookingRef}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export class PassengersService {
|
||||
where: { id: passengerId },
|
||||
include: {
|
||||
user: { select: { fullName: true, email: true, phone: true } },
|
||||
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } } } },
|
||||
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } } } },
|
||||
loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true,
|
||||
},
|
||||
});
|
||||
@@ -25,12 +25,12 @@ export class PassengersService {
|
||||
bookings: p.bookings.map((b) => ({
|
||||
id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt,
|
||||
trip: {
|
||||
number: b.trip.service.number,
|
||||
origin: { id: b.trip.originStation.id, name: b.trip.originStation.name, code: b.trip.originStation.code, city: b.trip.originStation.city },
|
||||
destination: { id: b.trip.destinationStation.id, name: b.trip.destinationStation.name, code: b.trip.destinationStation.code, city: b.trip.destinationStation.city },
|
||||
departureAt: b.trip.departureAt,
|
||||
number: b.schedule.train.number,
|
||||
origin: { id: b.schedule.originStation.id, name: b.schedule.originStation.name, code: b.schedule.originStation.code, city: b.schedule.originStation.city },
|
||||
destination: { id: b.schedule.destinationStation.id, name: b.schedule.destinationStation.name, code: b.schedule.destinationStation.code, city: b.schedule.destinationStation.city },
|
||||
departureAt: b.schedule.departureAt,
|
||||
},
|
||||
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.serviceClass } })),
|
||||
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass?.name ?? 'N/A' } })),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { JwtGuard } from '../../common/jwt.guard';
|
||||
export class PaymentsController {
|
||||
constructor(private service: PaymentsService) {}
|
||||
@Post('initiate') @ApiOperation({ summary: 'Initiate payment for a booking' }) initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); }
|
||||
@Get('intents/:bookingId') @ApiOperation({ summary: 'Get payment intent status for a booking' }) getIntent(@Param('bookingId') bookingId: string) { return this.service.getIntentByBookingId(bookingId); }
|
||||
@Post('refund') @ApiOperation({ summary: 'Refund a confirmed booking' }) refund(@Body() dto: RefundDto) { return this.service.refund(dto); }
|
||||
@Post('methods') @ApiOperation({ summary: 'Add a payment method' }) addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); }
|
||||
@Get('methods/:userId') @ApiOperation({ summary: 'Get payment methods for user' }) getMethods(@Param('userId') userId: string) { return this.service.getPaymentMethods(userId); }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IsString, IsEnum, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { PaymentIntentStatus } from '@prisma/client';
|
||||
|
||||
export enum PaymentMethodTypeEnum { TELEBIRR = 'TELEBIRR', CBE_BIRR = 'CBE_BIRR', EBIRR = 'EBIRR', CARD = 'CARD', WALLET = 'WALLET' }
|
||||
|
||||
@@ -20,3 +21,25 @@ export class AddPaymentMethodDto {
|
||||
@ApiProperty() @IsString() displayName: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() maskedHint?: string;
|
||||
}
|
||||
|
||||
export class ClientActionDto {
|
||||
@ApiProperty({ enum: ['REDIRECT'] }) type: 'REDIRECT';
|
||||
@ApiProperty() url: string;
|
||||
}
|
||||
|
||||
export class InitiateResponseDto {
|
||||
@ApiProperty() intentId: string;
|
||||
@ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus;
|
||||
@ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto;
|
||||
@ApiPropertyOptional() merchantOrderId?: string;
|
||||
}
|
||||
|
||||
export class IntentStatusDto {
|
||||
@ApiProperty() intentId: string;
|
||||
@ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus;
|
||||
@ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto;
|
||||
@ApiPropertyOptional() merchantOrderId?: string;
|
||||
@ApiPropertyOptional() paidAt?: string;
|
||||
@ApiPropertyOptional() failureCode?: string;
|
||||
@ApiPropertyOptional() failureMessage?: string;
|
||||
}
|
||||
|
||||
161
apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts
Normal file
161
apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from '../../app.module';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
|
||||
describe('Payments E2E', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaService;
|
||||
let authToken: string;
|
||||
let bookingId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
await app.init();
|
||||
|
||||
prisma = app.get<PrismaService>(PrismaService);
|
||||
|
||||
const testUser = await prisma.user.create({
|
||||
data: { email: 'payment-test@example.com', phone: '+251911111112', fullName: 'Payment Test User', passwordHash: '$2b$10$abcdefghijklmnopqrstuvwxyz', role: 'PASSENGER' },
|
||||
});
|
||||
|
||||
const passenger = await prisma.passenger.create({ data: { userId: testUser.id } });
|
||||
|
||||
await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 100000, currency: 'ETB' } });
|
||||
|
||||
authToken = 'mock-jwt-token';
|
||||
|
||||
const station1 = await prisma.station.create({ data: { code: 'TST1', name: 'Test Station 1', city: 'Test City', lat: 9.0, lng: 38.0 } });
|
||||
const station2 = await prisma.station.create({ data: { code: 'TST2', name: 'Test Station 2', city: 'Test City 2', lat: 9.5, lng: 38.5 } });
|
||||
|
||||
const train = await prisma.train.create({ data: { number: 'TEST-001', name: 'Test Train' } });
|
||||
|
||||
const schedule = await prisma.trainSchedule.create({
|
||||
data: { trainId: train.id, originStationId: station1.id, destinationStationId: station2.id, departureAt: new Date(Date.now() + 86400000), arrivalAt: new Date(Date.now() + 90000000), durationMinutes: 60 },
|
||||
});
|
||||
|
||||
const seatClass = await prisma.seatClass.upsert({
|
||||
where: { name: 'Economy Regular' },
|
||||
update: {},
|
||||
create: { name: 'Economy Regular', description: 'Standard economy seating', basePrice: 45000, isActive: true },
|
||||
});
|
||||
|
||||
const coach = await prisma.coach.create({
|
||||
data: { coachNumber: 'TEST-C1', label: 'A', seatClassId: seatClass.id, mode: 'seat', totalUnits: 10 },
|
||||
});
|
||||
|
||||
await prisma.coachAssignment.create({ data: { scheduleId: schedule.id, coachId: coach.id, positionNumber: 1 } });
|
||||
|
||||
const seat = await prisma.seat.create({ data: { coachId: coach.id, row: 1, col: 'A', label: '1A', status: 'AVAILABLE' } });
|
||||
|
||||
const booking = await prisma.booking.create({
|
||||
data: { bookingRef: 'TEST-BOOK-001', passengerId: passenger.id, scheduleId: schedule.id, status: 'PENDING_PAYMENT', totalMinor: 50000, currency: 'ETB' },
|
||||
});
|
||||
|
||||
await prisma.bookingSeat.create({ data: { bookingId: booking.id, seatId: seat.id, passengerName: 'Test Passenger' } });
|
||||
|
||||
bookingId = booking.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$transaction([
|
||||
prisma.bookingSeat.deleteMany(),
|
||||
prisma.paymentIntent.deleteMany(),
|
||||
prisma.booking.deleteMany(),
|
||||
prisma.coachAssignment.deleteMany(),
|
||||
prisma.seat.deleteMany(),
|
||||
prisma.coach.deleteMany(),
|
||||
prisma.trainSchedule.deleteMany(),
|
||||
prisma.train.deleteMany(),
|
||||
prisma.station.deleteMany({ where: { code: { in: ['TST1', 'TST2'] } } }),
|
||||
prisma.walletLedgerEntry.deleteMany(),
|
||||
prisma.walletAccount.deleteMany(),
|
||||
prisma.passenger.deleteMany(),
|
||||
prisma.user.deleteMany({ where: { email: 'payment-test@example.com' } }),
|
||||
]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('POST /payments/initiate', () => {
|
||||
it('should initiate wallet payment successfully', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/payments/initiate')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ bookingId, method: 'WALLET' })
|
||||
.expect(201);
|
||||
expect(response.body.intentId).toBeDefined();
|
||||
expect(response.body.status).toBe('SUCCEEDED');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid payment method', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/payments/initiate')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ bookingId, method: 'INVALID_METHOD' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent booking', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/payments/initiate')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ bookingId: 'non-existent-id', method: 'WALLET' })
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /payments/intents/:bookingId', () => {
|
||||
it('should get payment intent status', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/payments/intents/${bookingId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
expect(response.body.intentId).toBeDefined();
|
||||
expect(response.body.status).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent intent', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/payments/intents/non-existent-booking')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Webhook endpoints', () => {
|
||||
it('should handle Telebirr webhook', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/payments/webhooks/telebirr')
|
||||
.send({ merch_order_id: 'TEST-ORDER-123', payment_order_id: 'PAY-123', trade_status: 'Completed', sign: 'mock-signature' })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should handle CBE Birr webhook', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/payments/webhooks/cbe-birr')
|
||||
.send({ merchantId: 'TEST-MERCHANT', merchantOrderId: 'TEST-ORDER-123', orderId: 'CBE-ORDER-123', status: 'SUCCESS', signature: 'mock-signature' })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should handle eBirr webhook', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/payments/webhooks/ebirr')
|
||||
.send({ merchantCode: 'TEST-MERCHANT', orderNo: 'TEST-ORDER-123', tradeStatus: 'TRADE_SUCCESS', timestamp: Date.now(), sign: 'mock-signature' })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should handle Card webhook', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/payments/webhooks/card')
|
||||
.set('stripe-signature', 'mock-signature')
|
||||
.send({ id: 'evt_123', type: 'payment_intent.succeeded', data: { object: { id: 'pi_123', status: 'succeeded', amount: 50000, currency: 'ETB', metadata: { merchantOrderId: 'TEST-ORDER-123', bookingRef: 'TEST-BOOK-001' } } }, created: Math.floor(Date.now() / 1000) })
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { PaymentsController } from './payments.controller';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { SeatsModule } from '../seats/seats.module';
|
||||
import { TicketsModule } from '../tickets/tickets.module';
|
||||
import { TelebirrProvider } from './providers/telebirr.provider';
|
||||
import { CbeBirrProvider } from './providers/cbe-birr.provider';
|
||||
import { EBirrProvider } from './providers/ebirr.provider';
|
||||
import { CardProvider } from './providers/card.provider';
|
||||
import { WebhooksController } from './webhooks/webhooks.controller';
|
||||
import { TelebirrWebhookService } from './webhooks/telebirr-webhook.service';
|
||||
import { CbeBirrWebhookService } from './webhooks/cbe-birr-webhook.service';
|
||||
import { EBirrWebhookService } from './webhooks/ebirr-webhook.service';
|
||||
import { CardWebhookService } from './webhooks/card-webhook.service';
|
||||
|
||||
@Module({ imports: [SeatsModule, TicketsModule], controllers: [PaymentsController], providers: [PaymentsService] })
|
||||
@Module({
|
||||
imports: [SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 })],
|
||||
controllers: [PaymentsController, WebhooksController],
|
||||
providers: [
|
||||
PaymentsService,
|
||||
TelebirrProvider,
|
||||
CbeBirrProvider,
|
||||
EBirrProvider,
|
||||
CardProvider,
|
||||
TelebirrWebhookService,
|
||||
CbeBirrWebhookService,
|
||||
EBirrWebhookService,
|
||||
CardWebhookService,
|
||||
],
|
||||
})
|
||||
export class PaymentsModule {}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { TicketsService } from '../tickets/tickets.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { TelebirrProvider } from './providers/telebirr.provider';
|
||||
import { CbeBirrProvider } from './providers/cbe-birr.provider';
|
||||
import { EBirrProvider } from './providers/ebirr.provider';
|
||||
import { CardProvider } from './providers/card.provider';
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
|
||||
describe('PaymentsService', () => {
|
||||
let service: PaymentsService;
|
||||
let prisma: PrismaService;
|
||||
let seatsService: SeatsService;
|
||||
let ticketsService: TicketsService;
|
||||
let eventEmitter: EventEmitter2;
|
||||
|
||||
const mockPrisma: Record<string, any> = {
|
||||
booking: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
paymentIntent: {
|
||||
findUnique: jest.fn(),
|
||||
findUniqueOrThrow: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
update: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
walletAccount: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
walletLedgerEntry: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
loyaltyAccount: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
loyaltyLedgerEntry: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn((callback: (tx: any) => any) => callback(mockPrisma)),
|
||||
};
|
||||
|
||||
const mockSeatsService = {
|
||||
confirmSeats: jest.fn(),
|
||||
releaseSeats: jest.fn(),
|
||||
};
|
||||
|
||||
const mockTicketsService = {
|
||||
generate: jest.fn(),
|
||||
};
|
||||
|
||||
const mockEventEmitter = {
|
||||
emit: jest.fn(),
|
||||
};
|
||||
|
||||
const mockTelebirrProvider = {
|
||||
method: PaymentMethodType.TELEBIRR,
|
||||
initiate: jest.fn(),
|
||||
queryStatus: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCbeBirrProvider = {
|
||||
method: PaymentMethodType.CBE_BIRR,
|
||||
initiate: jest.fn(),
|
||||
queryStatus: jest.fn(),
|
||||
};
|
||||
|
||||
const mockEBirrProvider = {
|
||||
method: PaymentMethodType.EBIRR,
|
||||
initiate: jest.fn(),
|
||||
queryStatus: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCardProvider = {
|
||||
method: PaymentMethodType.CARD,
|
||||
initiate: jest.fn(),
|
||||
queryStatus: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PaymentsService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: SeatsService, useValue: mockSeatsService },
|
||||
{ provide: TicketsService, useValue: mockTicketsService },
|
||||
{ provide: EventEmitter2, useValue: mockEventEmitter },
|
||||
{ provide: TelebirrProvider, useValue: mockTelebirrProvider },
|
||||
{ provide: CbeBirrProvider, useValue: mockCbeBirrProvider },
|
||||
{ provide: EBirrProvider, useValue: mockEBirrProvider },
|
||||
{ provide: CardProvider, useValue: mockCardProvider },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<PaymentsService>(PaymentsService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
seatsService = module.get<SeatsService>(SeatsService);
|
||||
ticketsService = module.get<TicketsService>(TicketsService);
|
||||
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initiatePayment', () => {
|
||||
const mockBooking = {
|
||||
id: 'booking-1',
|
||||
bookingRef: 'EDR123456',
|
||||
passengerId: 'passenger-1',
|
||||
totalMinor: 50000,
|
||||
currency: 'ETB',
|
||||
status: 'PENDING_PAYMENT',
|
||||
seats: [{ id: 'seat-1', seatId: 'seat-id-1' }],
|
||||
};
|
||||
|
||||
it('should throw NotFoundException if booking not found', async () => {
|
||||
mockPrisma.booking.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.initiatePayment({
|
||||
bookingId: 'invalid',
|
||||
method: 'TELEBIRR' as any,
|
||||
}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException if booking not payable', async () => {
|
||||
mockPrisma.booking.findUnique.mockResolvedValue({
|
||||
...mockBooking,
|
||||
status: 'CONFIRMED',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.initiatePayment({
|
||||
bookingId: 'booking-1',
|
||||
method: 'TELEBIRR' as any,
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should initiate Telebirr payment successfully', async () => {
|
||||
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
||||
mockTelebirrProvider.initiate.mockResolvedValue({
|
||||
providerOrderId: 'TB-ORDER-123',
|
||||
clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' },
|
||||
expiresAt: new Date(),
|
||||
rawInitiation: {},
|
||||
});
|
||||
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
||||
id: 'intent-1',
|
||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||
merchantOrderId: 'MERCH-123',
|
||||
clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' },
|
||||
});
|
||||
|
||||
const result = await service.initiatePayment({
|
||||
bookingId: 'booking-1',
|
||||
method: 'TELEBIRR' as any,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
|
||||
expect(mockTelebirrProvider.initiate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should initiate CBE Birr payment successfully', async () => {
|
||||
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
||||
mockCbeBirrProvider.initiate.mockResolvedValue({
|
||||
providerOrderId: 'CBE-ORDER-123',
|
||||
clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' },
|
||||
expiresAt: new Date(),
|
||||
rawInitiation: {},
|
||||
});
|
||||
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
||||
id: 'intent-1',
|
||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||
merchantOrderId: 'MERCH-123',
|
||||
clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' },
|
||||
});
|
||||
|
||||
const result = await service.initiatePayment({
|
||||
bookingId: 'booking-1',
|
||||
method: 'CBE_BIRR' as any,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
|
||||
expect(mockCbeBirrProvider.initiate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should initiate wallet payment and debit successfully', async () => {
|
||||
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.walletAccount.findUnique.mockResolvedValue({
|
||||
id: 'wallet-1',
|
||||
passengerId: 'passenger-1',
|
||||
balanceMinor: 100000,
|
||||
});
|
||||
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
||||
id: 'intent-1',
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
});
|
||||
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'intent-1',
|
||||
status: PaymentIntentStatus.SUCCEEDED,
|
||||
bookingId: 'booking-1',
|
||||
});
|
||||
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
|
||||
id: 'loyalty-1',
|
||||
pointsBalance: 100,
|
||||
});
|
||||
|
||||
const result = await service.initiatePayment({
|
||||
bookingId: 'booking-1',
|
||||
method: 'WALLET' as any,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
|
||||
expect(mockSeatsService.confirmSeats).toHaveBeenCalled();
|
||||
expect(mockTicketsService.generate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail wallet payment with insufficient balance', async () => {
|
||||
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.walletAccount.findUnique.mockResolvedValue({
|
||||
id: 'wallet-1',
|
||||
passengerId: 'passenger-1',
|
||||
balanceMinor: 10000, // Less than booking total
|
||||
});
|
||||
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
||||
id: 'intent-1',
|
||||
status: PaymentIntentStatus.FAILED,
|
||||
failureCode: 'INSUFFICIENT_BALANCE',
|
||||
});
|
||||
|
||||
const result = await service.initiatePayment({
|
||||
bookingId: 'booking-1',
|
||||
method: 'WALLET' as any,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(PaymentIntentStatus.FAILED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizePaymentSuccess', () => {
|
||||
it('should finalize payment and issue ticket', async () => {
|
||||
const mockIntent = {
|
||||
id: 'intent-1',
|
||||
bookingId: 'booking-1',
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
};
|
||||
const mockBooking = {
|
||||
id: 'booking-1',
|
||||
passengerId: 'passenger-1',
|
||||
totalMinor: 50000,
|
||||
seats: [{ seatId: 'seat-1' }],
|
||||
};
|
||||
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
|
||||
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
||||
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
|
||||
id: 'loyalty-1',
|
||||
pointsBalance: 100,
|
||||
});
|
||||
|
||||
const result = await service.finalizePaymentSuccess({
|
||||
intentId: 'intent-1',
|
||||
providerTxnId: 'TXN-123',
|
||||
});
|
||||
|
||||
expect(result.alreadyFinalized).toBe(false);
|
||||
expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(['seat-1']);
|
||||
expect(mockTicketsService.generate).toHaveBeenCalledWith('booking-1');
|
||||
expect(mockEventEmitter.emit).toHaveBeenCalledWith('payment.succeeded', {
|
||||
booking: mockBooking,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return alreadyFinalized if payment already succeeded', async () => {
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue({
|
||||
id: 'intent-1',
|
||||
status: PaymentIntentStatus.SUCCEEDED,
|
||||
});
|
||||
|
||||
const result = await service.finalizePaymentSuccess({
|
||||
intentId: 'intent-1',
|
||||
});
|
||||
|
||||
expect(result.alreadyFinalized).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIntentByBookingId', () => {
|
||||
it('should return intent status', async () => {
|
||||
const mockIntent = {
|
||||
id: 'intent-1',
|
||||
bookingId: 'booking-1',
|
||||
status: PaymentIntentStatus.SUCCEEDED,
|
||||
method: PaymentMethodType.TELEBIRR,
|
||||
paidAt: new Date(),
|
||||
merchantOrderId: 'MERCH-123',
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
|
||||
|
||||
const result = await service.getIntentByBookingId('booking-1');
|
||||
|
||||
expect(result.intentId).toBe('intent-1');
|
||||
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if intent not found', async () => {
|
||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getIntentByBookingId('invalid')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,56 +1,268 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { TicketsService } from '../tickets/tickets.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto } from './payments.dto';
|
||||
import { telebirrAdapter, cbeBirrAdapter, eBirrAdapter, cardAdapter } from './payments.adapters';
|
||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto } from './payments.dto';
|
||||
import { PaymentProvider, ProviderStatus } from './payments.types';
|
||||
import { TelebirrProvider } from './providers/telebirr.provider';
|
||||
import { CbeBirrProvider } from './providers/cbe-birr.provider';
|
||||
import { EBirrProvider } from './providers/ebirr.provider';
|
||||
import { CardProvider } from './providers/card.provider';
|
||||
import { createMerchantOrderId } from './providers/telebirr.crypto';
|
||||
|
||||
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
||||
PaymentIntentStatus.REQUIRES_ACTION,
|
||||
PaymentIntentStatus.PROCESSING,
|
||||
PaymentIntentStatus.SUCCEEDED,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
private readonly providers: Map<PaymentMethodType, PaymentProvider>;
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private seatsService: SeatsService,
|
||||
private ticketsService: TicketsService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
private telebirrProvider: TelebirrProvider,
|
||||
private cbeBirrProvider: CbeBirrProvider,
|
||||
private eBirrProvider: EBirrProvider,
|
||||
private cardProvider: CardProvider,
|
||||
) {
|
||||
this.providers = new Map<PaymentMethodType, PaymentProvider>([
|
||||
[PaymentMethodType.TELEBIRR, this.telebirrProvider],
|
||||
[PaymentMethodType.CBE_BIRR, this.cbeBirrProvider],
|
||||
[PaymentMethodType.EBIRR, this.eBirrProvider],
|
||||
[PaymentMethodType.CARD, this.cardProvider],
|
||||
]);
|
||||
}
|
||||
|
||||
async initiatePayment(dto: InitiatePaymentDto) {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } });
|
||||
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: dto.bookingId },
|
||||
include: { seats: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
if (booking.status !== 'PENDING_PAYMENT') throw new BadRequestException('Booking not payable');
|
||||
|
||||
let result;
|
||||
if (dto.method === 'WALLET') {
|
||||
result = await this.prisma.$transaction(async (tx) => {
|
||||
const wallet = await tx.walletAccount.findUnique({ where: { passengerId: booking.passengerId } });
|
||||
if (!wallet || wallet.balanceMinor < booking.totalMinor) return { success: false, providerRef: '' };
|
||||
const newBalance = wallet.balanceMinor - booking.totalMinor;
|
||||
await tx.walletAccount.update({ where: { passengerId: booking.passengerId }, data: { balanceMinor: newBalance } });
|
||||
await tx.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'DEBIT', amountMinor: booking.totalMinor, balanceAfterMinor: newBalance, description: `Train Ticket - ${booking.bookingRef}`, relatedBookingId: booking.id } });
|
||||
return { success: true, providerRef: `WALLET-${Date.now()}` };
|
||||
});
|
||||
} else {
|
||||
const adapters = { TELEBIRR: telebirrAdapter, CBE_BIRR: cbeBirrAdapter, EBIRR: eBirrAdapter, CARD: cardAdapter } as any;
|
||||
result = await adapters[dto.method](booking.totalMinor, booking.bookingRef);
|
||||
if (booking.status !== 'PENDING_PAYMENT') {
|
||||
throw new BadRequestException('Booking not payable');
|
||||
}
|
||||
|
||||
const status = result.success ? 'SUCCEEDED' : 'FAILED';
|
||||
const intent = await this.prisma.paymentIntent.upsert({
|
||||
const existing = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId: dto.bookingId },
|
||||
update: { status, providerRef: result.providerRef, clientAction: result.clientAction as any },
|
||||
create: { bookingId: dto.bookingId, amountMinor: booking.totalMinor, method: dto.method as any, status: status as any, providerRef: result.providerRef, clientAction: result.clientAction as any },
|
||||
});
|
||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||
return this.formatIntentResponse(existing);
|
||||
}
|
||||
|
||||
const method = dto.method as PaymentMethodType;
|
||||
|
||||
if (method === PaymentMethodType.WALLET) {
|
||||
return this.initiateWalletPayment(booking);
|
||||
}
|
||||
|
||||
const provider = this.providers.get(method);
|
||||
if (provider) {
|
||||
return this.initiateProviderPayment(booking, provider);
|
||||
}
|
||||
|
||||
throw new BadRequestException(`Unsupported payment method: ${method}`);
|
||||
}
|
||||
|
||||
private async initiateWalletPayment(
|
||||
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
||||
): Promise<InitiateResponseDto> {
|
||||
const debitResult = await this.prisma.$transaction(async (tx) => {
|
||||
const wallet = await tx.walletAccount.findUnique({
|
||||
where: { passengerId: booking.passengerId },
|
||||
});
|
||||
if (!wallet || wallet.balanceMinor < booking.totalMinor) {
|
||||
return { success: false };
|
||||
}
|
||||
const newBalance = wallet.balanceMinor - booking.totalMinor;
|
||||
await tx.walletAccount.update({
|
||||
where: { passengerId: booking.passengerId },
|
||||
data: { balanceMinor: newBalance },
|
||||
});
|
||||
await tx.walletLedgerEntry.create({
|
||||
data: {
|
||||
walletId: wallet.id,
|
||||
type: 'DEBIT',
|
||||
amountMinor: booking.totalMinor,
|
||||
balanceAfterMinor: newBalance,
|
||||
description: `Train Ticket - ${booking.bookingRef}`,
|
||||
relatedBookingId: booking.id,
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
|
||||
await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: 'CONFIRMED' } });
|
||||
await this.ticketsService.generate(dto.bookingId);
|
||||
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
|
||||
this.eventEmitter.emit('payment.succeeded', { booking });
|
||||
if (!debitResult.success) {
|
||||
const failed = await this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId: booking.id },
|
||||
update: {
|
||||
status: PaymentIntentStatus.FAILED,
|
||||
failureCode: 'INSUFFICIENT_BALANCE',
|
||||
},
|
||||
create: {
|
||||
bookingId: booking.id,
|
||||
amountMinor: booking.totalMinor,
|
||||
method: PaymentMethodType.WALLET,
|
||||
status: PaymentIntentStatus.FAILED,
|
||||
failureCode: 'INSUFFICIENT_BALANCE',
|
||||
},
|
||||
});
|
||||
return this.formatIntentResponse(failed);
|
||||
}
|
||||
|
||||
return { id: intent.id, status: result.success ? 'SUCCESS' : 'FAILED', success: result.success };
|
||||
const intent = await this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId: booking.id },
|
||||
update: { status: PaymentIntentStatus.PROCESSING },
|
||||
create: {
|
||||
bookingId: booking.id,
|
||||
amountMinor: booking.totalMinor,
|
||||
method: PaymentMethodType.WALLET,
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
providerRef: `WALLET-${Date.now()}`,
|
||||
},
|
||||
});
|
||||
await this.finalizePaymentSuccess({ intentId: intent.id });
|
||||
const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||||
where: { id: intent.id },
|
||||
});
|
||||
return this.formatIntentResponse(refreshed);
|
||||
}
|
||||
|
||||
private async initiateProviderPayment(
|
||||
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
||||
provider: PaymentProvider,
|
||||
): Promise<InitiateResponseDto> {
|
||||
const merchantOrderId = createMerchantOrderId();
|
||||
const result = await provider.initiate({
|
||||
merchantOrderId,
|
||||
bookingRef: booking.bookingRef,
|
||||
amountMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
});
|
||||
|
||||
const intent = await this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId: booking.id },
|
||||
update: {
|
||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||
method: provider.method,
|
||||
merchantOrderId,
|
||||
providerOrderId: result.providerOrderId,
|
||||
clientAction: result.clientAction as unknown as Prisma.InputJsonValue,
|
||||
rawInitiation: result.rawInitiation as Prisma.InputJsonValue,
|
||||
expiresAt: result.expiresAt,
|
||||
failureCode: null,
|
||||
failureMessage: null,
|
||||
},
|
||||
create: {
|
||||
bookingId: booking.id,
|
||||
amountMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
method: provider.method,
|
||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||
merchantOrderId,
|
||||
providerOrderId: result.providerOrderId,
|
||||
clientAction: result.clientAction as unknown as Prisma.InputJsonValue,
|
||||
rawInitiation: result.rawInitiation as Prisma.InputJsonValue,
|
||||
expiresAt: result.expiresAt,
|
||||
},
|
||||
});
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private formatIntentResponse(
|
||||
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
|
||||
): InitiateResponseDto {
|
||||
const clientAction =
|
||||
intent.clientAction && typeof intent.clientAction === 'object'
|
||||
? (intent.clientAction as unknown as { type: 'REDIRECT'; url: string })
|
||||
: undefined;
|
||||
return {
|
||||
intentId: intent.id,
|
||||
status: intent.status,
|
||||
clientAction,
|
||||
merchantOrderId: intent.merchantOrderId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId },
|
||||
});
|
||||
if (!intent) throw new NotFoundException('PaymentIntent not found');
|
||||
|
||||
const refreshable =
|
||||
intent.status === PaymentIntentStatus.REQUIRES_ACTION ||
|
||||
intent.status === PaymentIntentStatus.PROCESSING;
|
||||
const stale = intent.updatedAt.getTime() < Date.now() - 5_000;
|
||||
const provider = this.providers.get(intent.method);
|
||||
|
||||
if (refreshable && stale && intent.merchantOrderId && provider) {
|
||||
try {
|
||||
const status = await provider.queryStatus(intent.merchantOrderId);
|
||||
await this.applyProviderStatus(intent.id, status);
|
||||
const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||||
where: { id: intent.id },
|
||||
});
|
||||
return this.formatIntentStatus(refreshed);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`queryStatus failed for intent ${intent.id}: ${message}; returning cached`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.formatIntentStatus(intent);
|
||||
}
|
||||
|
||||
private async applyProviderStatus(
|
||||
intentId: string,
|
||||
status: ProviderStatus,
|
||||
): Promise<void> {
|
||||
if (status.status === PaymentIntentStatus.SUCCEEDED) {
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId,
|
||||
providerTxnId: status.providerTxnId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (status.status === PaymentIntentStatus.FAILED) {
|
||||
await this.markPaymentFailed({
|
||||
intentId,
|
||||
failureCode: status.failureCode,
|
||||
failureMessage: status.failureMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intentId },
|
||||
data: {
|
||||
status: status.status,
|
||||
providerTxnId: status.providerTxnId ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private formatIntentStatus(
|
||||
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
|
||||
): IntentStatusDto {
|
||||
const base = this.formatIntentResponse(intent);
|
||||
return {
|
||||
...base,
|
||||
paidAt: intent.paidAt?.toISOString(),
|
||||
failureCode: intent.failureCode ?? undefined,
|
||||
failureMessage: intent.failureMessage ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async refund(dto: RefundDto) {
|
||||
@@ -69,6 +281,76 @@ export class PaymentsService {
|
||||
|
||||
getPaymentMethods(userId: string) { return this.prisma.paymentMethod.findMany({ where: { userId }, orderBy: { isDefault: 'desc' } }); }
|
||||
|
||||
async finalizePaymentSuccess(input: {
|
||||
intentId: string;
|
||||
providerTxnId?: string;
|
||||
paidAt?: Date;
|
||||
}): Promise<{ alreadyFinalized: boolean }> {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { id: input.intentId },
|
||||
});
|
||||
if (!intent) throw new NotFoundException('PaymentIntent not found');
|
||||
if (intent.status === PaymentIntentStatus.SUCCEEDED) {
|
||||
return { alreadyFinalized: true };
|
||||
}
|
||||
if (intent.status === PaymentIntentStatus.CANCELLED) {
|
||||
throw new BadRequestException('PaymentIntent is cancelled; cannot finalize');
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: intent.bookingId },
|
||||
include: { seats: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
const paidAt = input.paidAt ?? new Date();
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: {
|
||||
status: PaymentIntentStatus.SUCCEEDED,
|
||||
providerTxnId: input.providerTxnId ?? intent.providerTxnId ?? undefined,
|
||||
paidAt,
|
||||
},
|
||||
});
|
||||
await tx.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: { status: 'CONFIRMED' },
|
||||
});
|
||||
});
|
||||
|
||||
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
|
||||
await this.ticketsService.generate(booking.id);
|
||||
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
|
||||
this.eventEmitter.emit('payment.succeeded', { booking });
|
||||
return { alreadyFinalized: false };
|
||||
}
|
||||
|
||||
async markPaymentFailed(input: {
|
||||
intentId: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
}): Promise<void> {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { id: input.intentId },
|
||||
});
|
||||
if (!intent) throw new NotFoundException('PaymentIntent not found');
|
||||
if (
|
||||
intent.status === PaymentIntentStatus.SUCCEEDED ||
|
||||
intent.status === PaymentIntentStatus.CANCELLED
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: {
|
||||
status: PaymentIntentStatus.FAILED,
|
||||
failureCode: input.failureCode,
|
||||
failureMessage: input.failureMessage,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async awardLoyaltyPoints(passengerId: string, amountMinor: number, bookingId: string) {
|
||||
const points = Math.floor(amountMinor / 100);
|
||||
const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId } });
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
|
||||
export interface ClientAction {
|
||||
type: 'REDIRECT';
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ProviderInitiationInput {
|
||||
merchantOrderId: string;
|
||||
bookingRef: string;
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as crypto from 'node:crypto';
|
||||
import {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
} from '../payments.types';
|
||||
|
||||
interface CardInitiateRequest {
|
||||
amount: number;
|
||||
currency: string;
|
||||
description: string;
|
||||
metadata: {
|
||||
merchantOrderId: string;
|
||||
bookingRef: string;
|
||||
};
|
||||
return_url: string;
|
||||
webhook_url: string;
|
||||
}
|
||||
|
||||
interface CardInitiateResponse {
|
||||
id: string;
|
||||
status: string;
|
||||
client_secret: string;
|
||||
checkout_url: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
interface CardQueryResponse {
|
||||
id: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
transaction_id?: string;
|
||||
paid_at?: number;
|
||||
failure_code?: string;
|
||||
failure_message?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CardProvider implements PaymentProvider {
|
||||
readonly method = PaymentMethodType.CARD;
|
||||
private readonly logger = new Logger(CardProvider.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {}
|
||||
|
||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
||||
const amount = input.amountMinor / 100;
|
||||
|
||||
const requestBody: CardInitiateRequest = {
|
||||
amount,
|
||||
currency: input.currency,
|
||||
description: `EDR Train Booking ${input.bookingRef}`,
|
||||
metadata: {
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
bookingRef: input.bookingRef,
|
||||
},
|
||||
return_url: this.returnUrl,
|
||||
webhook_url: this.webhookUrl,
|
||||
};
|
||||
|
||||
const response = await this.postJson<CardInitiateResponse>(
|
||||
`${this.baseUrl}/v1/payment_intents`,
|
||||
requestBody,
|
||||
);
|
||||
|
||||
if (!response.id) {
|
||||
throw new Error(`Card gateway initiate failed: ${JSON.stringify(response)}`);
|
||||
}
|
||||
|
||||
const expiresAt = new Date(response.expires_at * 1000);
|
||||
|
||||
return {
|
||||
providerOrderId: response.id,
|
||||
clientAction: { type: 'REDIRECT', url: response.checkout_url },
|
||||
expiresAt,
|
||||
rawInitiation: {
|
||||
request: requestBody,
|
||||
response,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
||||
// For card payments, we need to find the payment intent by metadata
|
||||
// In a real implementation, we'd store the provider order ID and use it directly
|
||||
const response = await this.getJson<CardQueryResponse>(
|
||||
`${this.baseUrl}/v1/payment_intents/search?metadata[merchantOrderId]=${merchantOrderId}`,
|
||||
);
|
||||
|
||||
const mapped = this.mapStatus(response.status);
|
||||
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: response.transaction_id,
|
||||
failureCode: response.failure_code,
|
||||
failureMessage: response.failure_message,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>, signature: string): boolean {
|
||||
const payloadString = JSON.stringify(payload);
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', this.webhookSecret)
|
||||
.update(payloadString)
|
||||
.digest('hex');
|
||||
|
||||
try {
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
mapWebhookStatus(status: string): PaymentIntentStatus {
|
||||
return this.mapStatus(status);
|
||||
}
|
||||
|
||||
private mapStatus(status: string): PaymentIntentStatus {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'succeeded':
|
||||
case 'paid':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
case 'failed':
|
||||
case 'canceled':
|
||||
case 'expired':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
case 'requires_payment_method':
|
||||
case 'requires_confirmation':
|
||||
case 'requires_action':
|
||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
||||
case 'processing':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
timeout: 10_000,
|
||||
};
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
this.logger.debug(`Card Gateway POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`Card Gateway POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`Card Gateway POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async getJson<T>(url: string): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
timeout: 10_000,
|
||||
};
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.get<T>(url, config));
|
||||
this.logger.debug(`Card Gateway GET ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`Card Gateway GET ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`Card Gateway GET ${url} threw: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>('card.baseUrl') ?? '';
|
||||
}
|
||||
private get apiKey(): string {
|
||||
return this.config.get<string>('card.apiKey') ?? '';
|
||||
}
|
||||
private get webhookSecret(): string {
|
||||
return this.config.get<string>('card.webhookSecret') ?? '';
|
||||
}
|
||||
private get webhookUrl(): string {
|
||||
return this.config.get<string>('card.webhookUrl') ?? '';
|
||||
}
|
||||
private get returnUrl(): string {
|
||||
return this.config.get<string>('card.returnUrl') ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as crypto from 'node:crypto';
|
||||
import {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
} from '../payments.types';
|
||||
|
||||
interface CbeBirrInitiateRequest {
|
||||
merchantId: string;
|
||||
merchantOrderId: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
description: string;
|
||||
returnUrl: string;
|
||||
notifyUrl: string;
|
||||
timestamp: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
interface CbeBirrInitiateResponse {
|
||||
success: boolean;
|
||||
orderId: string;
|
||||
paymentUrl: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
interface CbeBirrQueryResponse {
|
||||
success: boolean;
|
||||
orderId: string;
|
||||
status: string;
|
||||
transactionId?: string;
|
||||
amount?: string;
|
||||
paidAt?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CbeBirrProvider implements PaymentProvider {
|
||||
readonly method = PaymentMethodType.CBE_BIRR;
|
||||
private readonly logger = new Logger(CbeBirrProvider.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {}
|
||||
|
||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
||||
const amount = (input.amountMinor / 100).toFixed(2);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const requestBody: CbeBirrInitiateRequest = {
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
amount,
|
||||
currency: input.currency,
|
||||
description: `EDR Booking ${input.bookingRef}`,
|
||||
returnUrl: this.returnUrl,
|
||||
notifyUrl: this.notifyUrl,
|
||||
timestamp,
|
||||
signature: this.signRequest({
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
amount,
|
||||
timestamp,
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await this.postJson<CbeBirrInitiateResponse>(
|
||||
`${this.baseUrl}/api/v1/payment/initiate`,
|
||||
requestBody,
|
||||
);
|
||||
|
||||
if (!response.success || !response.orderId) {
|
||||
throw new Error(`CBE Birr initiate failed: ${JSON.stringify(response)}`);
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + response.expiresIn * 1000);
|
||||
|
||||
return {
|
||||
providerOrderId: response.orderId,
|
||||
clientAction: { type: 'REDIRECT', url: response.paymentUrl },
|
||||
expiresAt,
|
||||
rawInitiation: {
|
||||
request: this.sanitize(requestBody),
|
||||
response,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const signature = this.signRequest({
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
const response = await this.postJson<CbeBirrQueryResponse>(
|
||||
`${this.baseUrl}/api/v1/payment/query`,
|
||||
{
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId,
|
||||
timestamp,
|
||||
signature,
|
||||
},
|
||||
);
|
||||
|
||||
const mapped = this.mapStatus(response.status);
|
||||
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: response.transactionId,
|
||||
failureCode: mapped === PaymentIntentStatus.FAILED ? response.status : undefined,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||
const { signature, ...data } = payload;
|
||||
if (!signature || typeof signature !== 'string') return false;
|
||||
|
||||
const expectedSignature = this.signRequest(data);
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature),
|
||||
);
|
||||
}
|
||||
|
||||
mapWebhookStatus(status: string): PaymentIntentStatus {
|
||||
return this.mapStatus(status);
|
||||
}
|
||||
|
||||
private mapStatus(status: string): PaymentIntentStatus {
|
||||
switch (status?.toUpperCase()) {
|
||||
case 'SUCCESS':
|
||||
case 'COMPLETED':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
case 'FAILED':
|
||||
case 'REJECTED':
|
||||
case 'EXPIRED':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
case 'PENDING':
|
||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
||||
case 'PROCESSING':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
private signRequest(data: Record<string, unknown>): string {
|
||||
const sortedKeys = Object.keys(data).sort();
|
||||
const signString = sortedKeys
|
||||
.map((key) => `${key}=${data[key]}`)
|
||||
.join('&');
|
||||
|
||||
return crypto
|
||||
.createHmac('sha256', this.secretKey)
|
||||
.update(signString)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Merchant-Id': this.merchantId,
|
||||
},
|
||||
timeout: 10_000,
|
||||
};
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
this.logger.debug(`CBE Birr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`CBE Birr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`CBE Birr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(body: CbeBirrInitiateRequest): Record<string, unknown> {
|
||||
const { signature: _signature, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>('cbe.baseUrl') ?? '';
|
||||
}
|
||||
private get merchantId(): string {
|
||||
return this.config.get<string>('cbe.merchantId') ?? '';
|
||||
}
|
||||
private get secretKey(): string {
|
||||
return this.config.get<string>('cbe.secretKey') ?? '';
|
||||
}
|
||||
private get notifyUrl(): string {
|
||||
return this.config.get<string>('cbe.notifyUrl') ?? '';
|
||||
}
|
||||
private get returnUrl(): string {
|
||||
return this.config.get<string>('cbe.returnUrl') ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as crypto from 'node:crypto';
|
||||
import {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
} from '../payments.types';
|
||||
|
||||
interface EBirrInitiateRequest {
|
||||
merchantCode: string;
|
||||
orderNo: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
notifyUrl: string;
|
||||
returnUrl: string;
|
||||
timestamp: number;
|
||||
sign: string;
|
||||
}
|
||||
|
||||
interface EBirrInitiateResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
data?: {
|
||||
orderNo: string;
|
||||
payUrl: string;
|
||||
expireTime: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface EBirrQueryResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
data?: {
|
||||
orderNo: string;
|
||||
tradeStatus: string;
|
||||
tradeNo?: string;
|
||||
totalAmount?: number;
|
||||
payTime?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EBirrProvider implements PaymentProvider {
|
||||
readonly method = PaymentMethodType.EBIRR;
|
||||
private readonly logger = new Logger(EBirrProvider.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {}
|
||||
|
||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
||||
const amount = input.amountMinor / 100;
|
||||
const timestamp = Date.now();
|
||||
|
||||
const requestBody: EBirrInitiateRequest = {
|
||||
merchantCode: this.merchantCode,
|
||||
orderNo: input.merchantOrderId,
|
||||
amount,
|
||||
currency: input.currency,
|
||||
subject: `EDR Ticket`,
|
||||
body: `Train booking ${input.bookingRef}`,
|
||||
notifyUrl: this.notifyUrl,
|
||||
returnUrl: this.returnUrl,
|
||||
timestamp,
|
||||
sign: this.signRequest({
|
||||
merchantCode: this.merchantCode,
|
||||
orderNo: input.merchantOrderId,
|
||||
amount,
|
||||
timestamp,
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await this.postJson<EBirrInitiateResponse>(
|
||||
`${this.baseUrl}/gateway/api/pay/create`,
|
||||
requestBody,
|
||||
);
|
||||
|
||||
if (response.code !== '0000' || !response.data?.orderNo) {
|
||||
throw new Error(`eBirr initiate failed: ${response.message}`);
|
||||
}
|
||||
|
||||
const expiresAt = new Date(response.data.expireTime);
|
||||
|
||||
return {
|
||||
providerOrderId: response.data.orderNo,
|
||||
clientAction: { type: 'REDIRECT', url: response.data.payUrl },
|
||||
expiresAt,
|
||||
rawInitiation: {
|
||||
request: this.sanitize(requestBody),
|
||||
response,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
||||
const timestamp = Date.now();
|
||||
const requestBody = {
|
||||
merchantCode: this.merchantCode,
|
||||
orderNo: merchantOrderId,
|
||||
timestamp,
|
||||
sign: this.signRequest({
|
||||
merchantCode: this.merchantCode,
|
||||
orderNo: merchantOrderId,
|
||||
timestamp,
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await this.postJson<EBirrQueryResponse>(
|
||||
`${this.baseUrl}/gateway/api/pay/query`,
|
||||
requestBody,
|
||||
);
|
||||
|
||||
if (response.code !== '0000' || !response.data) {
|
||||
throw new Error(`eBirr query failed: ${response.message}`);
|
||||
}
|
||||
|
||||
const mapped = this.mapStatus(response.data.tradeStatus);
|
||||
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: response.data.tradeNo,
|
||||
failureCode: mapped === PaymentIntentStatus.FAILED ? response.data.tradeStatus : undefined,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||
const { sign, ...data } = payload;
|
||||
if (!sign || typeof sign !== 'string') return false;
|
||||
|
||||
const expectedSign = this.signRequest(data);
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(sign),
|
||||
Buffer.from(expectedSign),
|
||||
);
|
||||
}
|
||||
|
||||
mapWebhookStatus(tradeStatus: string): PaymentIntentStatus {
|
||||
return this.mapStatus(tradeStatus);
|
||||
}
|
||||
|
||||
private mapStatus(tradeStatus: string): PaymentIntentStatus {
|
||||
switch (tradeStatus?.toUpperCase()) {
|
||||
case 'TRADE_SUCCESS':
|
||||
case 'SUCCESS':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
case 'TRADE_CLOSED':
|
||||
case 'TRADE_FAILED':
|
||||
case 'FAILED':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
case 'WAIT_BUYER_PAY':
|
||||
case 'PENDING':
|
||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
||||
case 'PROCESSING':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
private signRequest(data: Record<string, unknown>): string {
|
||||
const sortedKeys = Object.keys(data).sort();
|
||||
const signString = sortedKeys
|
||||
.map((key) => `${key}=${data[key]}`)
|
||||
.join('&') + `&key=${this.secretKey}`;
|
||||
|
||||
return crypto
|
||||
.createHash('md5')
|
||||
.update(signString)
|
||||
.digest('hex')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 10_000,
|
||||
};
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
this.logger.debug(`eBirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`eBirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`eBirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(body: EBirrInitiateRequest): Record<string, unknown> {
|
||||
const { sign: _sign, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>('ebirr.baseUrl') ?? '';
|
||||
}
|
||||
private get merchantCode(): string {
|
||||
return this.config.get<string>('ebirr.merchantCode') ?? '';
|
||||
}
|
||||
private get secretKey(): string {
|
||||
return this.config.get<string>('ebirr.secretKey') ?? '';
|
||||
}
|
||||
private get notifyUrl(): string {
|
||||
return this.config.get<string>('ebirr.notifyUrl') ?? '';
|
||||
}
|
||||
private get returnUrl(): string {
|
||||
return this.config.get<string>('ebirr.returnUrl') ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
ClientAction,
|
||||
} from '../payments.types';
|
||||
|
||||
export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS');
|
||||
@@ -0,0 +1,98 @@
|
||||
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')}`;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as https from 'node:https';
|
||||
import {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
} from '../payments.types';
|
||||
import {
|
||||
createNonceStr,
|
||||
createTimestamp,
|
||||
signRequestObject,
|
||||
verifyRequestObject,
|
||||
} from './telebirr.crypto';
|
||||
import {
|
||||
CreateOrderRequest,
|
||||
CreateOrderResponse,
|
||||
FabricTokenResponse,
|
||||
QueryOrderResponse,
|
||||
} from './telebirr.types';
|
||||
|
||||
const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
|
||||
|
||||
@Injectable()
|
||||
export class TelebirrProvider implements PaymentProvider {
|
||||
readonly method = PaymentMethodType.TELEBIRR;
|
||||
private readonly logger = new Logger(TelebirrProvider.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 checkoutUrl = this.buildCheckoutUrl(prepayId);
|
||||
const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express);
|
||||
|
||||
return {
|
||||
providerOrderId: prepayId,
|
||||
clientAction: { type: 'REDIRECT', url: checkoutUrl },
|
||||
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 === PaymentIntentStatus.FAILED && tradeStatus ? tradeStatus : undefined,
|
||||
rawResponse: response as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
|
||||
switch (tradeStatus) {
|
||||
case 'PAY_SUCCESS':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
case 'PAY_FAILED':
|
||||
case 'ORDER_CLOSED':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
case 'WAIT_PAY':
|
||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
||||
case 'PAYING':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
|
||||
switch (tradeStatus) {
|
||||
case 'Completed':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
case 'Failure':
|
||||
case 'Expired':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
case 'Paying':
|
||||
case 'Pending':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.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> {
|
||||
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 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,
|
||||
merch_code: this.merchantCode,
|
||||
merch_order_id: input.merchantOrderId,
|
||||
trade_type: 'Checkout' as const,
|
||||
title: `EDR Booking ${input.bookingRef}`,
|
||||
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') ?? ''; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../common/prisma.service';
|
||||
import { PaymentsService } from '../payments.service';
|
||||
import { CardProvider } from '../providers/card.provider';
|
||||
|
||||
export interface CardWebhookPayload {
|
||||
id: string;
|
||||
type: string;
|
||||
data: {
|
||||
object: {
|
||||
id: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
metadata: {
|
||||
merchantOrderId: string;
|
||||
bookingRef: string;
|
||||
};
|
||||
transaction_id?: string;
|
||||
paid_at?: number;
|
||||
failure_code?: string;
|
||||
failure_message?: string;
|
||||
};
|
||||
};
|
||||
created: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CardWebhookService {
|
||||
private readonly logger = new Logger(CardWebhookService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly provider: CardProvider,
|
||||
private readonly payments: PaymentsService,
|
||||
) {}
|
||||
|
||||
async handle(payload: CardWebhookPayload, signature: string): Promise<void> {
|
||||
const merchantOrderId = payload.data.object.metadata.merchantOrderId;
|
||||
const externalEventId = `${payload.id}_${payload.type}`;
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
signature,
|
||||
);
|
||||
|
||||
const eventRow = await this.persistEvent({
|
||||
externalEventId,
|
||||
merchantOrderId,
|
||||
providerTxnId: payload.data.object.transaction_id,
|
||||
signatureValid,
|
||||
status: payload.data.object.status,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (!eventRow) {
|
||||
this.logger.log(`Card webhook duplicate: ${externalEventId} — short-circuit OK`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signatureValid) {
|
||||
this.logger.warn(`Card webhook signature invalid for merchantOrderId=${merchantOrderId}`);
|
||||
await this.markProcessed(eventRow.id, 'signature-invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { merchantOrderId },
|
||||
});
|
||||
if (!intent) {
|
||||
this.logger.warn(`Card webhook: no PaymentIntent for merchantOrderId=${merchantOrderId}`);
|
||||
await this.markProcessed(eventRow.id, 'intent-not-found');
|
||||
return;
|
||||
}
|
||||
|
||||
const mapped = this.provider.mapWebhookStatus(payload.data.object.status);
|
||||
|
||||
try {
|
||||
if (mapped === PaymentIntentStatus.SUCCEEDED) {
|
||||
await this.payments.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: payload.data.object.transaction_id,
|
||||
paidAt: payload.data.object.paid_at ? new Date(payload.data.object.paid_at * 1000) : undefined,
|
||||
});
|
||||
} else if (mapped === PaymentIntentStatus.FAILED) {
|
||||
await this.payments.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: payload.data.object.failure_code,
|
||||
failureMessage: payload.data.object.failure_message,
|
||||
});
|
||||
} else {
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: {
|
||||
status: mapped,
|
||||
providerTxnId: payload.data.object.transaction_id ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.markProcessed(eventRow.id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Card webhook processing failed for ${merchantOrderId}: ${message}`);
|
||||
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async persistEvent(input: {
|
||||
externalEventId: string;
|
||||
merchantOrderId: string;
|
||||
providerTxnId?: string;
|
||||
signatureValid: boolean;
|
||||
status: string;
|
||||
payload: CardWebhookPayload;
|
||||
}): Promise<{ id: string } | null> {
|
||||
try {
|
||||
return await this.prisma.paymentWebhookEvent.create({
|
||||
data: {
|
||||
provider: PaymentMethodType.CARD,
|
||||
externalEventId: input.externalEventId,
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
providerTxnId: input.providerTxnId,
|
||||
signatureValid: input.signatureValid,
|
||||
status: input.status,
|
||||
payload: input.payload as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
|
||||
await this.prisma.paymentWebhookEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { processedAt: new Date(), processingError },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../common/prisma.service';
|
||||
import { PaymentsService } from '../payments.service';
|
||||
import { CbeBirrProvider } from '../providers/cbe-birr.provider';
|
||||
|
||||
export interface CbeBirrWebhookPayload {
|
||||
merchantId: string;
|
||||
merchantOrderId: string;
|
||||
orderId: string;
|
||||
status: string;
|
||||
transactionId?: string;
|
||||
amount?: string;
|
||||
currency?: string;
|
||||
paidAt?: string;
|
||||
signature: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CbeBirrWebhookService {
|
||||
private readonly logger = new Logger(CbeBirrWebhookService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly provider: CbeBirrProvider,
|
||||
private readonly payments: PaymentsService,
|
||||
) {}
|
||||
|
||||
async handle(payload: CbeBirrWebhookPayload): Promise<void> {
|
||||
const merchantOrderId = payload.merchantOrderId;
|
||||
const externalEventId = `${payload.orderId}_${payload.status}`;
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
|
||||
const eventRow = await this.persistEvent({
|
||||
externalEventId,
|
||||
merchantOrderId,
|
||||
providerTxnId: payload.transactionId ?? payload.orderId,
|
||||
signatureValid,
|
||||
status: payload.status,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (!eventRow) {
|
||||
this.logger.log(`CBE Birr webhook duplicate: ${externalEventId} — short-circuit OK`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signatureValid) {
|
||||
this.logger.warn(`CBE Birr webhook signature invalid for merchantOrderId=${merchantOrderId}`);
|
||||
await this.markProcessed(eventRow.id, 'signature-invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { merchantOrderId },
|
||||
});
|
||||
if (!intent) {
|
||||
this.logger.warn(`CBE Birr webhook: no PaymentIntent for merchantOrderId=${merchantOrderId}`);
|
||||
await this.markProcessed(eventRow.id, 'intent-not-found');
|
||||
return;
|
||||
}
|
||||
|
||||
const mapped = this.provider.mapWebhookStatus(payload.status);
|
||||
|
||||
try {
|
||||
if (mapped === PaymentIntentStatus.SUCCEEDED) {
|
||||
await this.payments.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: payload.transactionId ?? payload.orderId,
|
||||
paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined,
|
||||
});
|
||||
} else if (mapped === PaymentIntentStatus.FAILED) {
|
||||
await this.payments.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: payload.status,
|
||||
});
|
||||
} else {
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: { status: mapped, providerTxnId: payload.transactionId ?? undefined },
|
||||
});
|
||||
}
|
||||
await this.markProcessed(eventRow.id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`CBE Birr webhook processing failed for ${merchantOrderId}: ${message}`);
|
||||
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async persistEvent(input: {
|
||||
externalEventId: string;
|
||||
merchantOrderId: string;
|
||||
providerTxnId?: string;
|
||||
signatureValid: boolean;
|
||||
status: string;
|
||||
payload: CbeBirrWebhookPayload;
|
||||
}): Promise<{ id: string } | null> {
|
||||
try {
|
||||
return await this.prisma.paymentWebhookEvent.create({
|
||||
data: {
|
||||
provider: PaymentMethodType.CBE_BIRR,
|
||||
externalEventId: input.externalEventId,
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
providerTxnId: input.providerTxnId,
|
||||
signatureValid: input.signatureValid,
|
||||
status: input.status,
|
||||
payload: input.payload as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
|
||||
await this.prisma.paymentWebhookEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { processedAt: new Date(), processingError },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../common/prisma.service';
|
||||
import { PaymentsService } from '../payments.service';
|
||||
import { EBirrProvider } from '../providers/ebirr.provider';
|
||||
|
||||
export interface EBirrWebhookPayload {
|
||||
merchantCode: string;
|
||||
orderNo: string;
|
||||
tradeStatus: string;
|
||||
tradeNo?: string;
|
||||
totalAmount?: number;
|
||||
currency?: string;
|
||||
payTime?: number;
|
||||
timestamp: number;
|
||||
sign: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EBirrWebhookService {
|
||||
private readonly logger = new Logger(EBirrWebhookService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly provider: EBirrProvider,
|
||||
private readonly payments: PaymentsService,
|
||||
) {}
|
||||
|
||||
async handle(payload: EBirrWebhookPayload): Promise<void> {
|
||||
const merchantOrderId = payload.orderNo;
|
||||
const externalEventId = `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`;
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
|
||||
const eventRow = await this.persistEvent({
|
||||
externalEventId,
|
||||
merchantOrderId,
|
||||
providerTxnId: payload.tradeNo,
|
||||
signatureValid,
|
||||
status: payload.tradeStatus,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (!eventRow) {
|
||||
this.logger.log(`eBirr webhook duplicate: ${externalEventId} — short-circuit OK`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signatureValid) {
|
||||
this.logger.warn(`eBirr webhook signature invalid for orderNo=${merchantOrderId}`);
|
||||
await this.markProcessed(eventRow.id, 'signature-invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { merchantOrderId },
|
||||
});
|
||||
if (!intent) {
|
||||
this.logger.warn(`eBirr webhook: no PaymentIntent for orderNo=${merchantOrderId}`);
|
||||
await this.markProcessed(eventRow.id, 'intent-not-found');
|
||||
return;
|
||||
}
|
||||
|
||||
const mapped = this.provider.mapWebhookStatus(payload.tradeStatus);
|
||||
|
||||
try {
|
||||
if (mapped === PaymentIntentStatus.SUCCEEDED) {
|
||||
await this.payments.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: payload.tradeNo,
|
||||
paidAt: payload.payTime ? new Date(payload.payTime) : undefined,
|
||||
});
|
||||
} else if (mapped === PaymentIntentStatus.FAILED) {
|
||||
await this.payments.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: payload.tradeStatus,
|
||||
});
|
||||
} else {
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: { status: mapped, providerTxnId: payload.tradeNo ?? undefined },
|
||||
});
|
||||
}
|
||||
await this.markProcessed(eventRow.id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`eBirr webhook processing failed for ${merchantOrderId}: ${message}`);
|
||||
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async persistEvent(input: {
|
||||
externalEventId: string;
|
||||
merchantOrderId: string;
|
||||
providerTxnId?: string;
|
||||
signatureValid: boolean;
|
||||
status: string;
|
||||
payload: EBirrWebhookPayload;
|
||||
}): Promise<{ id: string } | null> {
|
||||
try {
|
||||
return await this.prisma.paymentWebhookEvent.create({
|
||||
data: {
|
||||
provider: PaymentMethodType.EBIRR,
|
||||
externalEventId: input.externalEventId,
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
providerTxnId: input.providerTxnId,
|
||||
signatureValid: input.signatureValid,
|
||||
status: input.status,
|
||||
payload: input.payload as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
|
||||
await this.prisma.paymentWebhookEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { processedAt: new Date(), processingError },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../common/prisma.service';
|
||||
import { PaymentsService } from '../payments.service';
|
||||
import { TelebirrProvider } from '../providers/telebirr.provider';
|
||||
|
||||
export interface TelebirrWebhookPayload {
|
||||
merch_order_id: string;
|
||||
payment_order_id: string;
|
||||
trade_status: string;
|
||||
trans_id?: string;
|
||||
total_amount?: string;
|
||||
trans_currency?: string;
|
||||
notify_time?: string;
|
||||
trans_end_time?: string;
|
||||
sign: string;
|
||||
sign_type?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TelebirrWebhookService {
|
||||
private readonly logger = new Logger(TelebirrWebhookService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly provider: TelebirrProvider,
|
||||
private readonly payments: PaymentsService,
|
||||
) {}
|
||||
|
||||
async handle(payload: TelebirrWebhookPayload): Promise<void> {
|
||||
const merchantOrderId = payload.merch_order_id;
|
||||
const externalEventId = this.buildExternalEventId(payload);
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
|
||||
const eventRow = await this.persistEvent({
|
||||
externalEventId,
|
||||
merchantOrderId,
|
||||
providerTxnId: payload.trans_id ?? payload.payment_order_id,
|
||||
signatureValid,
|
||||
status: payload.trade_status,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (!eventRow) {
|
||||
this.logger.log(
|
||||
`Telebirr webhook duplicate: ${externalEventId} — short-circuit OK`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signatureValid) {
|
||||
this.logger.warn(
|
||||
`Telebirr webhook signature invalid for merch_order_id=${merchantOrderId}`,
|
||||
);
|
||||
await this.markProcessed(eventRow.id, 'signature-invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { merchantOrderId },
|
||||
});
|
||||
if (!intent) {
|
||||
this.logger.warn(
|
||||
`Telebirr webhook: no PaymentIntent for merch_order_id=${merchantOrderId}`,
|
||||
);
|
||||
await this.markProcessed(eventRow.id, 'intent-not-found');
|
||||
return;
|
||||
}
|
||||
|
||||
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
|
||||
|
||||
try {
|
||||
if (mapped === PaymentIntentStatus.SUCCEEDED) {
|
||||
await this.payments.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: payload.trans_id ?? payload.payment_order_id,
|
||||
paidAt: this.parseEpochSeconds(payload.trans_end_time),
|
||||
});
|
||||
} else if (mapped === PaymentIntentStatus.FAILED) {
|
||||
await this.payments.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: payload.trade_status,
|
||||
});
|
||||
} else {
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: { status: mapped, providerTxnId: payload.trans_id ?? undefined },
|
||||
});
|
||||
}
|
||||
await this.markProcessed(eventRow.id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(
|
||||
`Telebirr webhook processing failed for ${merchantOrderId}: ${message}`,
|
||||
);
|
||||
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private buildExternalEventId(payload: TelebirrWebhookPayload): string {
|
||||
return `${payload.payment_order_id}_${payload.trade_status}`;
|
||||
}
|
||||
|
||||
private async persistEvent(input: {
|
||||
externalEventId: string;
|
||||
merchantOrderId: string;
|
||||
providerTxnId?: string;
|
||||
signatureValid: boolean;
|
||||
status: string;
|
||||
payload: TelebirrWebhookPayload;
|
||||
}): Promise<{ id: string } | null> {
|
||||
try {
|
||||
return await this.prisma.paymentWebhookEvent.create({
|
||||
data: {
|
||||
provider: PaymentMethodType.TELEBIRR,
|
||||
externalEventId: input.externalEventId,
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
providerTxnId: input.providerTxnId,
|
||||
signatureValid: input.signatureValid,
|
||||
status: input.status,
|
||||
payload: input.payload as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
|
||||
await this.prisma.paymentWebhookEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { processedAt: new Date(), processingError },
|
||||
});
|
||||
}
|
||||
|
||||
private parseEpochSeconds(raw: string | undefined): Date | undefined {
|
||||
if (!raw) return undefined;
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isNaN(n)) return undefined;
|
||||
return new Date(n * 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Body, Controller, Headers, HttpCode, HttpStatus, Logger, Post } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
TelebirrWebhookPayload,
|
||||
TelebirrWebhookService,
|
||||
} from './telebirr-webhook.service';
|
||||
import {
|
||||
CbeBirrWebhookPayload,
|
||||
CbeBirrWebhookService,
|
||||
} from './cbe-birr-webhook.service';
|
||||
import {
|
||||
EBirrWebhookPayload,
|
||||
EBirrWebhookService,
|
||||
} from './ebirr-webhook.service';
|
||||
import {
|
||||
CardWebhookPayload,
|
||||
CardWebhookService,
|
||||
} from './card-webhook.service';
|
||||
|
||||
@ApiTags('Payment Webhooks')
|
||||
@Controller('payments/webhooks')
|
||||
export class WebhooksController {
|
||||
private readonly logger = new Logger(WebhooksController.name);
|
||||
|
||||
constructor(
|
||||
private readonly telebirr: TelebirrWebhookService,
|
||||
private readonly cbeBirr: CbeBirrWebhookService,
|
||||
private readonly eBirr: EBirrWebhookService,
|
||||
private readonly card: CardWebhookService,
|
||||
) {}
|
||||
|
||||
@Post('telebirr')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Telebirr payment notification callback' })
|
||||
async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) {
|
||||
try {
|
||||
await this.telebirr.handle(payload);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Telebirr webhook handler threw: ${message}`);
|
||||
}
|
||||
return { code: '0', message: 'OK' };
|
||||
}
|
||||
|
||||
@Post('cbe-birr')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'CBE Birr payment notification callback' })
|
||||
async receiveCbeBirr(@Body() payload: CbeBirrWebhookPayload) {
|
||||
try {
|
||||
await this.cbeBirr.handle(payload);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`CBE Birr webhook handler threw: ${message}`);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('ebirr')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'eBirr payment notification callback' })
|
||||
async receiveEBirr(@Body() payload: EBirrWebhookPayload) {
|
||||
try {
|
||||
await this.eBirr.handle(payload);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`eBirr webhook handler threw: ${message}`);
|
||||
}
|
||||
return { code: '0000', message: 'success' };
|
||||
}
|
||||
|
||||
@Post('card')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Card payment notification callback' })
|
||||
async receiveCard(
|
||||
@Body() payload: CardWebhookPayload,
|
||||
@Headers('stripe-signature') signature: string,
|
||||
) {
|
||||
try {
|
||||
await this.card.handle(payload, signature);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Card webhook handler threw: ${message}`);
|
||||
}
|
||||
return { received: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ReportsService } from './reports.service';
|
||||
import { GenerateReportDto } from './reports.dto';
|
||||
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
||||
import { UserRole } from '@prisma/client';
|
||||
|
||||
@ApiTags('Reports')
|
||||
@Controller('reports')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
export class ReportsController {
|
||||
constructor(private service: ReportsService) {}
|
||||
|
||||
@Post('generate')
|
||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
||||
@ApiOperation({ summary: 'Generate operational report' })
|
||||
generateReport(@Body() dto: GenerateReportDto) {
|
||||
return this.service.generateReport(dto);
|
||||
}
|
||||
|
||||
@Get(':reportId')
|
||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
||||
@ApiOperation({ summary: 'Get report by ID' })
|
||||
getReport(@Param('reportId') reportId: string) {
|
||||
return this.service.getReport(reportId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
||||
@ApiOperation({ summary: 'List reports' })
|
||||
listReports(@Query('type') type?: string) {
|
||||
return this.service.listReports(type);
|
||||
}
|
||||
}
|
||||
29
apps/edr-passenger-api/src/modules/reports/reports.dto.ts
Normal file
29
apps/edr-passenger-api/src/modules/reports/reports.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { IsString, IsDateString, IsOptional, IsEnum } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export enum ReportType {
|
||||
REVENUE = 'REVENUE',
|
||||
OCCUPANCY = 'OCCUPANCY',
|
||||
AGENT_SALES = 'AGENT_SALES',
|
||||
CANCELLATIONS = 'CANCELLATIONS',
|
||||
PAYMENT_METHODS = 'PAYMENT_METHODS'
|
||||
}
|
||||
|
||||
export enum ExportFormat {
|
||||
JSON = 'JSON',
|
||||
CSV = 'CSV',
|
||||
PDF = 'PDF'
|
||||
}
|
||||
|
||||
export class GenerateReportDto {
|
||||
@ApiProperty({ enum: ReportType }) @IsEnum(ReportType) reportType: ReportType;
|
||||
@ApiProperty({ example: '2026-01-01' }) @IsDateString() dateFrom: string;
|
||||
@ApiProperty({ example: '2026-01-31' }) @IsDateString() dateTo: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() routeId?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() agentId?: string;
|
||||
}
|
||||
|
||||
export class ExportReportDto {
|
||||
@ApiProperty() @IsString() reportId: string;
|
||||
@ApiProperty({ enum: ExportFormat }) @IsEnum(ExportFormat) format: ExportFormat;
|
||||
}
|
||||
12
apps/edr-passenger-api/src/modules/reports/reports.module.ts
Normal file
12
apps/edr-passenger-api/src/modules/reports/reports.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ReportsController } from './reports.controller';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule],
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService],
|
||||
exports: [ReportsService]
|
||||
})
|
||||
export class ReportsModule {}
|
||||
171
apps/edr-passenger-api/src/modules/reports/reports.service.ts
Normal file
171
apps/edr-passenger-api/src/modules/reports/reports.service.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { GenerateReportDto, ReportType } from './reports.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async generateReport(dto: GenerateReportDto) {
|
||||
const dateFrom = new Date(dto.dateFrom);
|
||||
const dateTo = new Date(dto.dateTo);
|
||||
|
||||
let data: any;
|
||||
switch (dto.reportType) {
|
||||
case ReportType.REVENUE:
|
||||
data = await this.generateRevenueReport(dateFrom, dateTo);
|
||||
break;
|
||||
case ReportType.OCCUPANCY:
|
||||
data = await this.generateOccupancyReport(dateFrom, dateTo);
|
||||
break;
|
||||
case ReportType.AGENT_SALES:
|
||||
data = await this.generateAgentSalesReport(dateFrom, dateTo, dto.agentId);
|
||||
break;
|
||||
case ReportType.CANCELLATIONS:
|
||||
data = await this.generateCancellationsReport(dateFrom, dateTo);
|
||||
break;
|
||||
case ReportType.PAYMENT_METHODS:
|
||||
data = await this.generatePaymentMethodsReport(dateFrom, dateTo);
|
||||
break;
|
||||
default:
|
||||
data = {};
|
||||
}
|
||||
|
||||
const report = await this.prisma.operationalReport.create({
|
||||
data: {
|
||||
reportType: dto.reportType,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
data
|
||||
}
|
||||
});
|
||||
|
||||
return { reportId: report.id, reportType: dto.reportType, data };
|
||||
}
|
||||
|
||||
private async generateRevenueReport(dateFrom: Date, dateTo: Date) {
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
createdAt: { gte: dateFrom, lte: dateTo },
|
||||
status: { in: ['CONFIRMED', 'COMPLETED'] }
|
||||
},
|
||||
include: { paymentIntent: true }
|
||||
});
|
||||
|
||||
const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
|
||||
const byPaymentMethod = bookings.reduce((acc, b) => {
|
||||
const method = b.paymentIntent?.method ?? 'UNKNOWN';
|
||||
acc[method] = (acc[method] || 0) + b.totalMinor;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return {
|
||||
totalBookings: bookings.length,
|
||||
totalRevenueMinor: totalRevenue,
|
||||
totalRevenue: totalRevenue / 100,
|
||||
currency: 'ETB',
|
||||
byPaymentMethod
|
||||
};
|
||||
}
|
||||
|
||||
private async generateOccupancyReport(dateFrom: Date, dateTo: Date) {
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: { departureAt: { gte: dateFrom, lte: dateTo } },
|
||||
include: {
|
||||
coachAssignments: { include: { coach: { include: { seats: true } } } },
|
||||
bookings: { where: { status: { in: ['CONFIRMED', 'COMPLETED'] } }, include: { seats: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const tripData = schedules.map(schedule => {
|
||||
const totalSeats = schedule.coachAssignments.reduce((sum, a) => sum + a.coach.seats.length, 0);
|
||||
const bookedSeats = schedule.bookings.reduce((sum, b) => sum + b.seats.length, 0);
|
||||
const occupancyRate = totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0;
|
||||
return { scheduleId: schedule.id, departureAt: schedule.departureAt, totalSeats, bookedSeats, occupancyRate: +occupancyRate.toFixed(2) };
|
||||
});
|
||||
|
||||
const avgOccupancy = tripData.length > 0 ? tripData.reduce((sum, t) => sum + t.occupancyRate, 0) / tripData.length : 0;
|
||||
return { totalSchedules: schedules.length, averageOccupancyRate: +avgOccupancy.toFixed(2), schedules: tripData };
|
||||
}
|
||||
|
||||
private async generateAgentSalesReport(dateFrom: Date, dateTo: Date, agentId?: string) {
|
||||
const agentBookings = await this.prisma.agentBooking.findMany({
|
||||
where: {
|
||||
createdAt: { gte: dateFrom, lte: dateTo },
|
||||
...(agentId ? { agentId } : {})
|
||||
},
|
||||
include: {
|
||||
agent: { include: { user: true } },
|
||||
booking: true
|
||||
}
|
||||
});
|
||||
|
||||
const byAgent = agentBookings.reduce((acc, ab) => {
|
||||
const agentName = ab.agent.user.fullName;
|
||||
if (!acc[agentName]) {
|
||||
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
|
||||
}
|
||||
acc[agentName].bookings += 1;
|
||||
acc[agentName].revenueMinor += ab.booking.totalMinor;
|
||||
acc[agentName].cashCollected += ab.cashReceived ?? 0;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
return {
|
||||
totalAgentBookings: agentBookings.length,
|
||||
byAgent
|
||||
};
|
||||
}
|
||||
|
||||
private async generateCancellationsReport(dateFrom: Date, dateTo: Date) {
|
||||
const cancellations = await this.prisma.bookingCancellation.findMany({
|
||||
where: { createdAt: { gte: dateFrom, lte: dateTo } },
|
||||
include: { booking: true }
|
||||
});
|
||||
|
||||
const totalRefunded = cancellations.reduce((sum, c) => sum + c.refundAmount, 0);
|
||||
|
||||
return {
|
||||
totalCancellations: cancellations.length,
|
||||
totalRefundedMinor: totalRefunded,
|
||||
totalRefunded: totalRefunded / 100,
|
||||
currency: 'ETB'
|
||||
};
|
||||
}
|
||||
|
||||
private async generatePaymentMethodsReport(dateFrom: Date, dateTo: Date) {
|
||||
const payments = await this.prisma.paymentIntent.findMany({
|
||||
where: {
|
||||
createdAt: { gte: dateFrom, lte: dateTo },
|
||||
status: 'SUCCEEDED'
|
||||
}
|
||||
});
|
||||
|
||||
const byMethod = payments.reduce((acc, p) => {
|
||||
const method = p.method;
|
||||
if (!acc[method]) {
|
||||
acc[method] = { count: 0, totalMinor: 0 };
|
||||
}
|
||||
acc[method].count += 1;
|
||||
acc[method].totalMinor += p.amountMinor;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
return {
|
||||
totalPayments: payments.length,
|
||||
byMethod
|
||||
};
|
||||
}
|
||||
|
||||
async getReport(reportId: string) {
|
||||
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
|
||||
}
|
||||
|
||||
async listReports(reportType?: string) {
|
||||
return this.prisma.operationalReport.findMany({
|
||||
where: reportType ? { reportType } : {},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@ApiTags('Routes')
|
||||
@Controller('routes')
|
||||
export class RoutesController {
|
||||
constructor(private service: RoutesService) {}
|
||||
|
||||
// ── Routes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Create a reusable route with its ordered stops',
|
||||
description: `Define the physical corridor once (e.g. ADD→ADM→AWS→DDW→AYS→DJI).
|
||||
Schedules reference this route via routeId and supply actual planned times per stop.
|
||||
Route stops carry distanceKm for fare-by-distance calculations.`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Route created with stops' })
|
||||
@ApiResponse({ status: 409, description: 'Route code already exists or duplicate sequences' })
|
||||
@ApiResponse({ status: 400, description: 'Fewer than 2 stops or invalid station IDs' })
|
||||
createRoute(@Body() dto: CreateRouteDto) { return this.service.createRoute(dto); }
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all routes' })
|
||||
@ApiQuery({ name: 'activeOnly', required: false, type: Boolean, description: 'Filter to active routes only' })
|
||||
@ApiResponse({ status: 200, description: 'Array of routes with stop count' })
|
||||
listRoutes(@Query('activeOnly') activeOnly?: string) {
|
||||
return this.service.listRoutes(activeOnly === 'true');
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get route with all stops and station details' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Route with enriched stop list (station name, code, city)' })
|
||||
@ApiResponse({ status: 404, description: 'Route not found' })
|
||||
getRoute(@Param('id') id: string) { return this.service.getRoute(id); }
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveUntil)' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Route updated' })
|
||||
@ApiResponse({ status: 404, description: 'Route not found' })
|
||||
updateRoute(@Param('id') id: string, @Body() dto: UpdateRouteDto) { return this.service.updateRoute(id, dto); }
|
||||
|
||||
// ── Route Stops ────────────────────────────────────────────────────────────
|
||||
|
||||
@Get(':id/stops')
|
||||
@ApiOperation({ summary: 'List all stops for a route ordered by sequence' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Ordered stop list with station details' })
|
||||
@ApiResponse({ status: 404, description: 'Route not found' })
|
||||
getStops(@Param('id') id: string) { return this.service.getStops(id); }
|
||||
|
||||
@Post(':id/stops')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Add a stop to an existing route' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Stop added' })
|
||||
@ApiResponse({ status: 409, description: 'Sequence already exists on this route' })
|
||||
@ApiResponse({ status: 404, description: 'Route or station not found' })
|
||||
addStop(@Param('id') id: string, @Body() dto: AddRouteStopDto) { return this.service.addStop(id, dto); }
|
||||
|
||||
@Delete(':id/stops/:sequence')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Remove a stop from a route by sequence number' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiParam({ name: 'sequence', description: 'Stop sequence number to remove' })
|
||||
@ApiResponse({ status: 200, description: 'Stop removed' })
|
||||
@ApiResponse({ status: 400, description: 'Cannot remove — route would have fewer than 2 stops' })
|
||||
@ApiResponse({ status: 404, description: 'Stop not found' })
|
||||
removeStop(@Param('id') id: string, @Param('sequence', ParseIntPipe) sequence: number) {
|
||||
return this.service.removeStop(id, sequence);
|
||||
}
|
||||
|
||||
// ── Schedules for a Route ──────────────────────────────────────────────────
|
||||
|
||||
@Get(':id/schedules')
|
||||
@ApiOperation({ summary: 'List all train schedules that use this route' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Schedules with train and terminal station details' })
|
||||
@ApiResponse({ status: 404, description: 'Route not found' })
|
||||
getSchedules(@Param('id') id: string) { return this.service.getSchedulesForRoute(id); }
|
||||
}
|
||||
44
apps/edr-passenger-api/src/modules/schedules/routes.dto.ts
Normal file
44
apps/edr-passenger-api/src/modules/schedules/routes.dto.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { IsString, IsInt, IsOptional, IsArray, ValidateNested, IsBoolean, IsDateString, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class RouteStopInputDto {
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string;
|
||||
@ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number;
|
||||
@ApiPropertyOptional({ example: 120, description: 'Distance in km from previous stop' }) @IsOptional() @IsInt() distanceKm?: number;
|
||||
}
|
||||
|
||||
export class CreateRouteDto {
|
||||
@ApiProperty({ example: 'ADD-DJI', description: 'Unique route code' }) @IsString() code: string;
|
||||
@ApiProperty({ example: 'Addis Ababa – Djibouti' }) @IsString() name: string;
|
||||
@ApiPropertyOptional({ example: 'Main corridor via Dire Dawa' }) @IsOptional() @IsString() description?: string;
|
||||
@ApiProperty({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsDateString() effectiveFrom: string;
|
||||
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
|
||||
@ApiProperty({
|
||||
type: [RouteStopInputDto],
|
||||
description: 'Ordered stops for this route. Sequence 1 = origin, last sequence = destination.',
|
||||
example: [
|
||||
{ stationId: 'uuid-ADD', sequence: 1 },
|
||||
{ stationId: 'uuid-ADM', sequence: 2, distanceKm: 99 },
|
||||
{ stationId: 'uuid-AWS', sequence: 3, distanceKm: 120 },
|
||||
{ stationId: 'uuid-DDW', sequence: 4, distanceKm: 180 },
|
||||
{ stationId: 'uuid-AYS', sequence: 5, distanceKm: 95 },
|
||||
{ stationId: 'uuid-DJI', sequence: 6, distanceKm: 60 },
|
||||
],
|
||||
})
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto)
|
||||
stops: RouteStopInputDto[];
|
||||
}
|
||||
|
||||
export class AddRouteStopDto {
|
||||
@ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string;
|
||||
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
|
||||
@ApiPropertyOptional({ example: 75 }) @IsOptional() @IsInt() distanceKm?: number;
|
||||
}
|
||||
|
||||
export class UpdateRouteDto {
|
||||
@ApiPropertyOptional({ example: 'Addis Ababa – Djibouti Express' }) @IsOptional() @IsString() name?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
|
||||
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean;
|
||||
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
|
||||
}
|
||||
197
apps/edr-passenger-api/src/modules/schedules/routes.service.ts
Normal file
197
apps/edr-passenger-api/src/modules/schedules/routes.service.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
|
||||
|
||||
@Injectable()
|
||||
export class RoutesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
// ── Route CRUD ─────────────────────────────────────────────────────────────
|
||||
|
||||
async createRoute(dto: CreateRouteDto) {
|
||||
const existing = await this.prisma.route.findUnique({ where: { code: dto.code } });
|
||||
if (existing) throw new ConflictException(`Route code "${dto.code}" already exists`);
|
||||
|
||||
if (dto.stops.length < 2) throw new BadRequestException('A route must have at least 2 stops');
|
||||
|
||||
const seqs = dto.stops.map(s => s.sequence);
|
||||
if (new Set(seqs).size !== seqs.length) throw new ConflictException('Duplicate sequence numbers in stop list');
|
||||
|
||||
const stationIds = [...new Set(dto.stops.map(s => s.stationId))];
|
||||
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
|
||||
if (stations.length !== stationIds.length) throw new BadRequestException('One or more station IDs not found');
|
||||
|
||||
return this.prisma.route.create({
|
||||
data: {
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
effectiveFrom: new Date(dto.effectiveFrom),
|
||||
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null,
|
||||
stops: {
|
||||
create: dto.stops.map(s => ({
|
||||
stationId: s.stationId,
|
||||
sequence: s.sequence,
|
||||
distanceKm: s.distanceKm,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { stops: { include: { route: false }, orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
}
|
||||
|
||||
async listRoutes(activeOnly = false) {
|
||||
return this.prisma.route.findMany({
|
||||
where: activeOnly ? { active: true } : undefined,
|
||||
include: {
|
||||
stops: { orderBy: { sequence: 'asc' } },
|
||||
_count: { select: { stops: true } },
|
||||
},
|
||||
orderBy: { code: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getRoute(id: string) {
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
stops: {
|
||||
orderBy: { sequence: 'asc' },
|
||||
include: {
|
||||
route: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
|
||||
// Enrich stops with station details
|
||||
const stationIds = route.stops.map(s => s.stationId);
|
||||
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
|
||||
const stationMap = Object.fromEntries(stations.map(s => [s.id, s]));
|
||||
|
||||
return {
|
||||
...route,
|
||||
stops: route.stops.map(s => ({ ...s, station: stationMap[s.stationId] })),
|
||||
};
|
||||
}
|
||||
|
||||
async updateRoute(id: string, dto: UpdateRouteDto) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
return this.prisma.route.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
active: dto.active,
|
||||
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined,
|
||||
},
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Route Stops ────────────────────────────────────────────────────────────
|
||||
|
||||
async addStop(routeId: string, dto: AddRouteStopDto) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
|
||||
const station = await this.prisma.station.findUnique({ where: { id: dto.stationId } });
|
||||
if (!station) throw new NotFoundException(`Station ${dto.stationId} not found`);
|
||||
|
||||
const existing = await this.prisma.routeStop.findUnique({
|
||||
where: { routeId_sequence: { routeId, sequence: dto.sequence } },
|
||||
});
|
||||
if (existing) throw new ConflictException(`Sequence ${dto.sequence} already exists on this route`);
|
||||
|
||||
return this.prisma.routeStop.create({
|
||||
data: { routeId, stationId: dto.stationId, sequence: dto.sequence, distanceKm: dto.distanceKm },
|
||||
});
|
||||
}
|
||||
|
||||
async removeStop(routeId: string, sequence: number) {
|
||||
const stop = await this.prisma.routeStop.findUnique({
|
||||
where: { routeId_sequence: { routeId, sequence } },
|
||||
});
|
||||
if (!stop) throw new NotFoundException(`Stop at sequence ${sequence} not found on route`);
|
||||
|
||||
const total = await this.prisma.routeStop.count({ where: { routeId } });
|
||||
if (total <= 2) throw new BadRequestException('A route must retain at least 2 stops');
|
||||
|
||||
await this.prisma.routeStop.delete({ where: { routeId_sequence: { routeId, sequence } } });
|
||||
return { deleted: true, sequence };
|
||||
}
|
||||
|
||||
async getStops(routeId: string) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
|
||||
const stops = await this.prisma.routeStop.findMany({
|
||||
where: { routeId },
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
|
||||
const stationIds = stops.map(s => s.stationId);
|
||||
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
|
||||
const stationMap = Object.fromEntries(stations.map(s => [s.id, s]));
|
||||
|
||||
return stops.map(s => ({ ...s, station: stationMap[s.stationId] }));
|
||||
}
|
||||
|
||||
async getSchedulesForRoute(routeId: string) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
|
||||
return this.prisma.trainSchedule.findMany({
|
||||
where: { routeId },
|
||||
include: { train: true, originStation: true, destinationStation: true },
|
||||
orderBy: { departureAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Used by SchedulesService ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Copies RouteStop definitions into TripStopTime rows for a schedule.
|
||||
* plannedTimes maps sequence → { arrivalAt?, departureAt? } for actual timing.
|
||||
*/
|
||||
async applyRouteToSchedule(
|
||||
routeId: string,
|
||||
scheduleId: string,
|
||||
plannedTimes: Record<number, { plannedArrivalAt?: string; plannedDepartureAt?: string }>,
|
||||
) {
|
||||
const stops = await this.prisma.routeStop.findMany({
|
||||
where: { routeId },
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
if (stops.length === 0) throw new BadRequestException('Route has no stops defined');
|
||||
|
||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId } });
|
||||
|
||||
await this.prisma.tripStopTime.createMany({
|
||||
data: stops.map(s => {
|
||||
const times = plannedTimes[s.sequence] ?? {};
|
||||
return {
|
||||
scheduleId,
|
||||
stationId: s.stationId,
|
||||
sequence: s.sequence,
|
||||
plannedArrivalAt: times.plannedArrivalAt ? new Date(times.plannedArrivalAt) : null,
|
||||
plannedDepartureAt: times.plannedDepartureAt ? new Date(times.plannedDepartureAt) : null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const intermediateCount = Math.max(0, stops.length - 2);
|
||||
await this.prisma.trainSchedule.update({
|
||||
where: { id: scheduleId },
|
||||
data: { stopsCount: intermediateCount },
|
||||
});
|
||||
|
||||
return this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
include: { station: true },
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,100 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { CreateTripDto, CreateFareRuleDto, UpdateTripStatusDto } from './schedules.dto';
|
||||
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { TripStatus } from '@prisma/client';
|
||||
|
||||
@ApiTags('Schedule')
|
||||
@Controller('schedule')
|
||||
@Controller('schedules')
|
||||
export class SchedulesController {
|
||||
constructor(private service: SchedulesService) {}
|
||||
@Post('trips') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create trip' })
|
||||
createTrip(@Body() dto: CreateTripDto) { return this.service.createTrip(dto); }
|
||||
@Get('trips/:id') @ApiOperation({ summary: 'Get trip details' })
|
||||
getTrip(@Param('id') id: string) { return this.service.getTrip(id); }
|
||||
@Patch('trips/:id/status') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update trip status' })
|
||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateTripStatusDto) { return this.service.updateTripStatus(id, dto); }
|
||||
@Post('fares') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create fare rule' })
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Create a train schedule from a route template',
|
||||
description: `Creates a schedule by referencing a Route (routeId).
|
||||
Stops are automatically copied from the route's RouteStop definitions.
|
||||
You supply the actual planned arrival/departure times per stop sequence.
|
||||
Origin and destination are derived from the first and last route stop — no need to specify them manually.`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Schedule created with stops copied from route template' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid times, inactive route, or missing planned times for some stops' })
|
||||
@ApiResponse({ status: 404, description: 'Train or route not found' })
|
||||
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List schedules with optional filters' })
|
||||
@ApiQuery({ name: 'date', required: false, example: '2026-06-15', description: 'Departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' })
|
||||
@ApiQuery({ name: 'routeId', required: false, description: 'Filter by route UUID' })
|
||||
@ApiQuery({ name: 'trainId', required: false, description: 'Filter by train UUID' })
|
||||
@ApiQuery({ name: 'status', required: false, enum: TripStatus, description: 'Filter by schedule status' })
|
||||
@ApiResponse({ status: 200, description: 'Array of schedules ordered by departureAt, each with train, origin/destination, stops, and booking/assignment counts' })
|
||||
listSchedules(
|
||||
@Query('date') date?: string,
|
||||
@Query('routeId') routeId?: string,
|
||||
@Query('trainId') trainId?: string,
|
||||
@Query('status') status?: TripStatus,
|
||||
) {
|
||||
return this.service.listSchedules({ date, routeId, trainId, status });
|
||||
}
|
||||
|
||||
// Static routes before parameterised ones
|
||||
@Post('fares')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
|
||||
@ApiResponse({ status: 201, description: 'Fare rule created' })
|
||||
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
|
||||
@Get('fares/:tripId') @ApiOperation({ summary: 'Get fare for trip and class' })
|
||||
getFare(@Param('tripId') tripId: string, @Query('class') cls: string) { return this.service.getFare(tripId, cls ?? 'ECONOMY'); }
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Full schedule detail including route stops with station info' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
|
||||
|
||||
@Patch(':id/status')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update schedule status (SCHEDULED → BOARDING → EN_ROUTE → ARRIVED)' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Status updated' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) {
|
||||
return this.service.updateScheduleStatus(id, dto);
|
||||
}
|
||||
|
||||
// ── Stop Times ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Get(':id/stops')
|
||||
@ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Ordered stop list with station details and planned/actual times' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
getStops(@Param('id') id: string) { return this.service.getStops(id); }
|
||||
|
||||
@Patch(':id/stops/:sequence')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update planned times or live status of a specific stop' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiParam({ name: 'sequence', description: 'Stop sequence number' })
|
||||
@ApiResponse({ status: 200, description: 'Stop updated' })
|
||||
@ApiResponse({ status: 404, description: 'Stop not found on schedule' })
|
||||
updateStop(
|
||||
@Param('id') id: string,
|
||||
@Param('sequence', ParseIntPipe) sequence: number,
|
||||
@Body() dto: UpdateStopTimeDto,
|
||||
) { return this.service.updateStop(id, sequence, dto); }
|
||||
|
||||
// ── Fares ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Get(':scheduleId/fares')
|
||||
@ApiOperation({ summary: 'Get applicable fare for a schedule and seat class' })
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@ApiQuery({ name: 'class', required: false, description: 'Seat class name: "Economy Regular" | "Economy Bed" | "VIP Bed". Defaults to Economy Regular.' })
|
||||
@ApiResponse({ status: 200, description: 'Fare rule or default fare' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
getFare(@Param('scheduleId') scheduleId: string, @Query('class') cls: string) {
|
||||
return this.service.getFare(scheduleId, cls ?? 'Economy Regular');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,68 @@
|
||||
import { IsString, IsDateString, IsInt, IsOptional, IsEnum } from 'class-validator';
|
||||
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ServiceClass } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import { TripStatus, StopStatus } from '@prisma/client';
|
||||
|
||||
export class CreateTripDto {
|
||||
@ApiProperty() @IsString() serviceId: string;
|
||||
@ApiProperty() @IsString() originStationId: string;
|
||||
@ApiProperty() @IsString() destinationStationId: string;
|
||||
@ApiProperty({ example: '2026-05-11T08:30:00Z' }) @IsDateString() departureAt: string;
|
||||
@ApiProperty({ example: '2026-05-11T20:00:00Z' }) @IsDateString() arrivalAt: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsInt() stopsCount?: number;
|
||||
export class PlannedStopTimeDto {
|
||||
@ApiProperty({ example: 1, description: 'Route stop sequence number this timing applies to' }) @IsInt() @Min(1) sequence: number;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z', description: 'Planned arrival at this stop (omit for first stop)' }) @IsOptional() @IsDateString() plannedArrivalAt?: string;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T09:45:00Z', description: 'Planned departure from this stop (omit for last stop)' }) @IsOptional() @IsDateString() plannedDepartureAt?: string;
|
||||
}
|
||||
|
||||
export class CreateScheduleDto {
|
||||
@ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string;
|
||||
|
||||
@ApiProperty({ example: 'route-uuid', description: 'Route UUID — stops are copied from the route template. Origin and destination are derived from the first and last route stop.' })
|
||||
@IsString() routeId: string;
|
||||
|
||||
@ApiProperty({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsDateString() departureAt: string;
|
||||
@ApiProperty({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsDateString() arrivalAt: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [PlannedStopTimeDto],
|
||||
description: 'Planned arrival/departure times per stop sequence. Must cover all stops defined on the route.',
|
||||
example: [
|
||||
{ sequence: 1, plannedDepartureAt: '2026-06-15T08:00:00Z' },
|
||||
{ sequence: 2, plannedArrivalAt: '2026-06-15T09:30:00Z', plannedDepartureAt: '2026-06-15T09:45:00Z' },
|
||||
{ sequence: 3, plannedArrivalAt: '2026-06-15T11:30:00Z', plannedDepartureAt: '2026-06-15T11:45:00Z' },
|
||||
{ sequence: 4, plannedArrivalAt: '2026-06-15T15:00:00Z', plannedDepartureAt: '2026-06-15T15:20:00Z' },
|
||||
{ sequence: 5, plannedArrivalAt: '2026-06-15T18:00:00Z', plannedDepartureAt: '2026-06-15T18:10:00Z' },
|
||||
{ sequence: 6, plannedArrivalAt: '2026-06-15T20:00:00Z' },
|
||||
],
|
||||
})
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||
plannedTimes: PlannedStopTimeDto[];
|
||||
}
|
||||
|
||||
export class UpdateStopTimeDto {
|
||||
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: string;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T09:45:00Z' }) @IsOptional() @IsDateString() plannedDepartureAt?: string;
|
||||
@ApiPropertyOptional({ enum: StopStatus, example: StopStatus.UPCOMING }) @IsOptional() @IsEnum(StopStatus) status?: StopStatus;
|
||||
}
|
||||
|
||||
export class CreateFareRuleDto {
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() tripId?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() route?: string;
|
||||
@ApiProperty({ enum: ServiceClass }) @IsEnum(ServiceClass) serviceClass: ServiceClass;
|
||||
@ApiProperty({ example: 45000 }) @IsInt() baseFareMinor: number;
|
||||
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Scope fare rule to a specific schedule' }) @IsOptional() @IsString() scheduleId?: string;
|
||||
@ApiPropertyOptional({ example: 'ADD-DJI', description: 'Scope fare rule to a route code (e.g. ADD-DJI)' }) @IsOptional() @IsString() route?: string;
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string;
|
||||
@ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number;
|
||||
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsDateString() validUntil?: string;
|
||||
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
|
||||
}
|
||||
|
||||
export class UpdateTripStatusDto {
|
||||
@ApiProperty({ example: 'EN_ROUTE' }) @IsString() status: string;
|
||||
export class ListSchedulesDto {
|
||||
@ApiPropertyOptional({ example: '2026-06-15', description: 'Filter by departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' })
|
||||
@IsOptional() @IsDateString() date?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'route-uuid', description: 'Filter by route UUID' })
|
||||
@IsOptional() @IsString() routeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'train-uuid', description: 'Filter by train UUID' })
|
||||
@IsOptional() @IsString() trainId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED, description: 'Filter by schedule status' })
|
||||
@IsOptional() @IsEnum(TripStatus) status?: TripStatus;
|
||||
}
|
||||
|
||||
export class UpdateScheduleStatusDto {
|
||||
@ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user