diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..242c1b08a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +**/node_modules +**/dist +**/.turbo +**/.git +**/.github +**/.vscode +**/.idea +**/.env +**/.env.* +!**/.env.example +**/coverage +**/*.tsbuildinfo +**/*.log +.DS_Store diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..dbf44509b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,92 @@ +name: Deploy Stacks + +on: + push: + branches: + - main + - dev + - staging + paths: + - "apps/edr-freight-api/**" + - "apps/edr-freight-web/**" + - "apps/edr-passenger-api/**" + - "apps/edr-passenger-web/**" + - "packages/**" + - "infrastructure/docker/Dockerfile.web" + - "infrastructure/nginx/**" + - "docker-compose.yaml" + - "pnpm-lock.yaml" + - "scripts/deploy/**" + - ".github/workflows/deploy.yml" + workflow_dispatch: + +concurrency: + group: deploy-${{ github.ref_name }} + cancel-in-progress: true + +jobs: + deploy: + name: Deploy ${{ matrix.service }} + runs-on: self-hosted + strategy: + fail-fast: false + matrix: + include: + - project: edr-freight + build_env_file: freight-web.build.env + service: freight-api + # - project: edr-freight + # build_env_file: freight-web.build.env + # service: freight-portal + # - project: edr-freight + # build_env_file: freight-web.build.env + # service: freight-backoffice + - project: edr-passenger + build_env_file: passenger-web.build.env + service: passenger-api + - project: edr-passenger + build_env_file: passenger-web.build.env + service: passenger-portal + - project: edr-passenger + build_env_file: passenger-web.build.env + service: passenger-backoffice + env: + PROJECT: ${{ matrix.project }} + BRANCH: ${{ github.ref_name }} + DEPLOY_USER: tria + BUILD_ENV_FILE: ${{ matrix.build_env_file }} + DOCKER_BUILDKIT: "1" + COMPOSE_DOCKER_CLI_BUILD: "1" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Sync environment from server + run: | + chmod +x scripts/deploy/*.sh + ./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}" + + - name: Set compose project name + run: | + set -euo pipefail + branch_slug=$(echo "${BRANCH}" | tr "[:upper:]" "[:lower:]" | sed -E "s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//") + echo "COMPOSE_PROJECT_NAME=${PROJECT}-${branch_slug}" >> "${GITHUB_ENV}" + + - name: Configure npm auth for Docker builds + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: ./scripts/deploy/create-npmrc.sh + + - name: Build ${{ matrix.service }} + run: | + set -euo pipefail + docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}" + + - name: Deploy ${{ matrix.service }} + run: | + set -euo pipefail + docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" + + - name: Remove npm credentials from workspace + if: always() + run: rm -f .npmrc .npmrc_temp diff --git a/.gitignore b/.gitignore index 13865633d..9f6cafc6d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,10 @@ coverage/ .DS_Store .idea/ .vscode/ +.npmrc +branch_structure.json +temp_auto_push.bat +temp_interactive_push.bat # emacs cache files *~ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 000000000..00ca421d1 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,187 @@ +# Deployment Runbook + +This document explains how deployments work for the EDR platform using Docker, GitHub Actions, and self-hosted runners. + +## Overview + +- Monorepo contains 6 deployable services: + - `freight-api` + - `freight-portal` + - `freight-backoffice` + - `passenger-api` + - `passenger-portal` + - `passenger-backoffice` +- Deployments run through one workflow: `.github/workflows/deploy.yml` +- Each service is built/deployed independently in parallel (matrix jobs). +- Docker Compose project names are branch-aware to avoid environment collisions on the same host. + +## Prerequisites + +- Docker Engine with Compose plugin on the self-hosted runner. +- GitHub self-hosted runner registered for this repository. +- Repository secret configured: + - `NPM_TOKEN` (for private `@tria-plc/*` package install during Docker build) +- Server-side env files created for each branch/environment. + +## Server Environment Files + +`sync-env-from-server.sh` reads env files from: + +`/home//environment/edr///` + +Where: + +- `` defaults to `tria` (overridable by `DEPLOY_USER`) +- `` is derived from Git branch (lowercase, non-alphanumeric replaced with `-`) +- `` is `edr-freight` or `edr-passenger` + +### Required files per project + +For `edr-freight`: + +- `freight-api.env` +- `freight-portal.env` +- `freight-backoffice.env` +- optional: `freight-web.build.env` + +For `edr-passenger`: + +- `passenger-api.env` +- `passenger-portal.env` +- `passenger-backoffice.env` +- optional: `passenger-web.build.env` + +### Required env key + +Each service env file must contain: + +- `PORT=` + +The sync script validates this and fails if missing. + +### Build env files (optional) + +Used for build-time variables (example: Vite API URLs), with `export` syntax: + +```bash +export FREIGHT_VITE_API_URL=https://freight-api.example.com/api +export PASSENGER_VITE_API_URL=https://passenger-api.example.com +``` + +These are injected into `GITHUB_ENV` during workflow execution. + +## Docker Compose Port Mapping + +`docker-compose.yaml` uses per-service env variables for host/container port mappings: + +- `FREIGHT_API_PORT` +- `PASSENGER_API_PORT` +- `FREIGHT_PORTAL_PORT` +- `FREIGHT_BACKOFFICE_PORT` +- `PASSENGER_PORTAL_PORT` +- `PASSENGER_BACKOFFICE_PORT` + +`scripts/deploy/sync-env-from-server.sh` extracts `PORT` from each synced `.env` and exports the corresponding `*_PORT` variable to `GITHUB_ENV`. + +## GitHub Actions Deployment Flow + +Workflow file: `.github/workflows/deploy.yml` + +### 1) `prepare` job + +- Checks out repository once. +- Creates workspace artifact (`workspace.tgz`) and uploads it. + +### 2) `deploy` matrix job (parallel) + +For each service: + +- Downloads and extracts workspace artifact. +- Syncs that service env file from server path. +- Computes branch slug and sets: + - `COMPOSE_PROJECT_NAME=-` +- Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`. +- Runs: + - `docker compose --project-name "$COMPOSE_PROJECT_NAME" build ` + - `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d ` +- Cleans `.npmrc`/`.npmrc_temp`. + +## Branch/Environment Isolation + +Compose project name is generated as: + +`-` + +Examples: + +- `edr-freight-main` +- `edr-freight-staging` +- `edr-passenger-dev` + +This prevents container/network/volume name collisions between branches. + +## Local Manual Deployment (Optional) + +From repo root: + +```bash +DOCKER_BUILDKIT=1 docker compose build +docker compose up -d +``` + +If private packages are required locally, create `.npmrc`: + +```bash +cat < .npmrc +@tria-plc:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken= +always-auth=true +EOF +``` + +## Passenger API Startup Behavior + +Passenger container entrypoint runs on startup: + +1. `npm run prisma:generate` +2. `npm run prisma:migrate` (deploy mode) +3. `npm run prisma:seed` +4. starts API process + +## Troubleshooting + +### Missing env file + +Error: + +- `Missing env file: ...` + +Fix: + +- Create the required file in the server env directory for that project/branch slug. + +### Missing PORT in env file + +Error: + +- `Missing required PORT in env file: ...` + +Fix: + +- Add `PORT=` to that service env file. + +### Private package install fails + +Check: + +- `NPM_TOKEN` exists in repo secrets. +- Workflow created `.npmrc` successfully. + +### Prisma seed/migrate failures (passenger) + +Check: + +- `DATABASE_URL` in `passenger-api.env` +- DB reachability from runner host/container network +- migration history consistency + diff --git a/README.md b/README.md index c974be546..8aba7ec6d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,98 @@ +# EDR Platform - Ethio-Djibouti Railway Passenger API + +Enterprise-grade NestJS REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with TypeScript, PostgreSQL, and Prisma ORM. + +## ๐Ÿš€ Features + +### ๐Ÿ†• NEW: Age-Based Pricing, Verifayda 2.0 & Multi-Currency + +#### Age-Based Pricing +- **ADULT** (โ‰ฅ5 years): Pay 100% of base fare +- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100% +- Automatic age calculation from date of birth +- Example: 2 adults + 3 children = 4ร— base fare (first child free) + +#### Verifayda 2.0 Integration +- Real-time Ethiopian national ID verification +- Retrieves passenger data from government database +- National IDs NOT stored (policy compliant) +- Non-Ethiopians use passport (no verification required) +- Booking fails if verification unsuccessful + +#### Multi-Currency Support +- **Transaction Currency**: ETB (Ethiopian Birr) +- **Display Currencies**: ETB, DJF (Djiboutian Franc), USD (US Dollar) +- Real-time exchange rate conversion +- Prices shown in user's preferred currency +- Exchange rates: ETBโ†’DJF=3.25, ETBโ†’USD=0.018 + +### Core Modules +- **Authentication & Authorization** - Dual authentication system: + - **Passenger Auth**: JWT-based auth with OTP verification, password reset, account lockout + - **Corporate IAM**: Integration with @tria-plc corporate identity system for back-office operations (agents, supervisors, admins) + - Role-based access control (RBAC) with granular permissions +- **Age-Based Pricing** - Smart passenger categorization: + - **ADULT** (โ‰ฅ5 years): Full fare + - **CHILD** (<5 years): First child free, subsequent children full fare + - Automatic age calculation from date of birth +- **Verifayda 2.0 Integration** - Ethiopian national ID verification: + - Real-time verification via government API + - Retrieves passenger data (name, DOB, nationality) + - National IDs NOT stored (policy compliant) + - Non-Ethiopians use passport (no verification) +- **Multi-Currency Support** - Display prices in multiple currencies: + - **ETB** (Ethiopian Birr) - Transaction currency + - **DJF** (Djiboutian Franc) - Display option + - **USD** (US Dollar) - Display option + - Real-time exchange rate conversion +- **Booking Management** - Complete booking lifecycle: + - **Guest Booking**: Book without login, optional account creation + - **Saved Passengers**: Store passenger details for quick rebooking + - Modification, cancellation, refunds, and fare breakdown + - Multi-segment journey support +- **Payment Integration** - Multi-provider support (Telebirr, CBE Birr, eBirr, Card, Wallet) with webhook handling +- **Seat Management** - Real-time seat inventory: + - Seat holds with 5-minute expiry + - Seat releases and blocking with coach/class management + - Segment-based seat availability (partial journey bookings) + - Auto-assign seats with contiguous algorithm + - CSV import/export for seat configurations +- **Ticketing** - QR code and barcode generation, PDF tickets, gate validation with audit logs +- **Agent Operations** - Counter booking, shift management, commission tracking, and reconciliation +- **Passenger Services** - Profile management, traveler profiles, saved routes, and preferences +- **Loyalty Program** - Points accumulation, tier management (Bronze/Silver/Gold/Platinum), and rewards +- **Wallet System** - Balance management, top-up, transaction ledger +- **Live Tracking** - Real-time trip status, location updates, delay notifications, crowd signals +- **Notifications** - Multi-channel (Email, SMS, Push) with templating engine +- **Support System** - FAQ management, live chat conversations +- **Reports & Analytics** - Revenue reports, occupancy analytics, agent sales tracking +- **Route Management** - Route configuration, stops, fare rules, baggage allowance + +### Technical Features +- **Security** - Password hashing (bcrypt), JWT tokens, rate limiting, audit logging +- **Validation** - Request validation with class-validator, DTO transformation +- **Documentation** - Auto-generated Swagger/OpenAPI docs at `/api-docs` +- **Error Handling** - Global exception filters with standardized error responses +- **Database** - PostgreSQL with Prisma ORM, migrations, and comprehensive seeding +- **Scheduling** - Cron jobs for automated tasks (seat release, report generation) +- **Event System** - Event-driven architecture with @nestjs/event-emitter + +## ๐Ÿ“‹ Prerequisites + +- **Node.js** >= 20.x +- **pnpm** >= 9.x (`npm install -g pnpm`) +- **PostgreSQL** >= 15.x +- **Git** + +## ๐Ÿ› ๏ธ Installation & Setup + +### 1. Clone Repository +```bash +git clone +cd edr-platform +``` + +### 2. Install Dependencies # EDR Platform Monorepo for the **Ethio-Djibouti Railway** digital platform. Hosts two product lines โ€” **Freight Management** and **Passenger Management** โ€” each with a NestJS API plus React portal and back-office web apps, sharing TypeScript types, NestJS utilities, and a React component library. @@ -185,6 +280,676 @@ Authentication is provided by an external `@edr/iamui-common` / `@tria-plc/iamap pnpm install ``` +<<<<<<< HEAD +### 3. Environment Configuration +```bash +# Copy environment template +cp apps/edr-passenger-api/.env.example apps/edr-passenger-api/.env + +# Edit .env file with your configuration +``` + +#### Required Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `NODE_ENV` | Environment mode | `development` | +| `PORT` | HTTP server port | `4000` | +| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/edr_passenger` | +| `JWT_SECRET` | JWT signing secret (change in production) | `your-secret-key` | +| `JWT_EXPIRES_IN` | JWT token expiry | `7d` | +| `PORTAL_URL` | Web app CORS origin | `http://localhost:3000` | +| `BACK_OFFICE_URL` | Admin portal CORS origin | `http://localhost:3001` | +| `SENDGRID_API_KEY` | SendGrid API key (optional) | `SG.xxx` | +| `SENDGRID_FROM_EMAIL` | Email sender address | `noreply@edr-platform.com` | + +#### Verifayda 2.0 Configuration (Ethiopian National ID Verification) + +| Variable | Description | Example | +|----------|-------------|---------| +| `VERIFAYDA_ENABLED` | Enable Verifayda integration | `true` or `false` | +| `VERIFAYDA_API_URL` | Verifayda API endpoint | `https://api.verifayda.gov.et/v2` | +| `VERIFAYDA_API_KEY` | API key for Verifayda service | `your-verifayda-api-key` | + +**Note:** When `VERIFAYDA_ENABLED=false`, verification is skipped (development mode only). + +#### Corporate IAM Configuration (Back-office Authentication) + +| Variable | Description | Example | +|----------|-------------|---------| +| `IAM_ENABLED` | Enable corporate IAM integration | `true` or `false` | +| `IAM_API_URL` | Corporate IAM API endpoint | `https://iam.tria-plc.com/api` | +| `IAM_API_KEY` | API key for IAM service | `your-iam-api-key` | + +**Note:** When `IAM_ENABLED=false`, IAM-protected routes allow access without validation (development mode only). + +#### Optional: Payment Provider Configuration +```bash +# Telebirr Configuration +TELEBIRR_BASE_URL=https://api.telebirr.com +TELEBIRR_MERCHANT_CODE=your-merchant-code +TELEBIRR_APP_SECRET=your-app-secret +# ... see .env.example for complete list +``` + +### 4. Database Setup + +#### Start PostgreSQL +```bash +# Using Docker (recommended) +docker run --name edr-postgres \ + -e POSTGRES_USER=edr \ + -e POSTGRES_PASSWORD=edr_secret \ + -e POSTGRES_DB=edr_passenger \ + -p 5432:5432 \ + -d postgres:15 + +# Or use your local PostgreSQL installation +``` + +#### Generate Prisma Client +```bash +pnpm --filter @edr/passenger-api run prisma:generate +``` + +#### Run Migrations +```bash +pnpm --filter @edr/passenger-api run prisma:migrate:dev +``` + +#### Seed Database +```bash +pnpm --filter @edr/passenger-api run prisma:seed +``` + +**Seed Data Includes:** +- 21 Stations (Complete Ethiopian-Djibouti Railway with country codes) +- 1 Route with 21 stops and fare rules +- 2 Train services with 4 trips +- 360 seats across 12 coaches (Economy Regular, Economy Bed, VIP Bed classes) +- 3 User accounts (Admin, Passenger, Agent) +- Fare rules for ADULT and CHILD passenger categories +- Currency exchange rates (ETB, DJF, USD) +- Baggage allowance rules +- Notification templates +- Promotions and FAQ content +- Menu items and station crowd signals +- Fraud detection rules +- Saved passenger profiles for testing + +### 5. Start Development Server +```bash +pnpm --filter @edr/passenger-api run dev +``` + +**API Server:** http://localhost:4000 +**Swagger Docs:** http://localhost:4000/api-docs + +## ๐Ÿ”‘ Default Credentials + +After seeding, use these credentials to test the API: + +| Role | Email | Password | Description | +|------|-------|----------|-------------| +| **Admin** | `admin@edr-platform.com` | `admin123` | Full system access, reports, agent management | +| **Passenger** | `kelemu@email.com` | `password123` | Regular user with loyalty (Silver) and wallet | +| **Agent** | `agent@edr-platform.com` | `agent123` | Counter booking agent with commission tracking | + +## ๐Ÿ“š API Documentation + +### Swagger UI +Interactive API documentation available at: **http://localhost:4000/api-docs** + +### Authentication Methods + +The API uses two authentication schemes: + +#### 1. JWT Authentication (Passenger-facing) +- **Used for**: Passenger bookings, profile management, wallet, loyalty +- **Header**: `Authorization: Bearer ` +- **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 ` +- **Obtain token**: From corporate IAM system (https://iam.tria-plc.com) +- **Swagger Security**: `IAM-auth` +- **Roles**: AGENT, SUPERVISOR, ADMIN, STAFF + +### API Endpoints Overview + +| Module | Base Path | Auth Type | Description | +|--------|-----------|-----------|-------------| +| **Auth** | `/auth` | Public/JWT | Register, login, OTP verification, password reset | +| **Passengers** | `/passengers` | Public/JWT | Verifayda verification, international registration, profiles | +| **Search** | `/search` | Public | Trip search, availability, fare quotes | +| **Stations** | `/stations` | Public/JWT | Station directory and information | +| **Seats** | `/seats` | JWT/IAM | Seat maps, holds, releases, blocking | +| **Bookings** | `/bookings` | Public/JWT | Guest booking, create, modify, cancel bookings | +| **Payments** | `/payments` | JWT/Public | Payment initiation, webhooks, refunds | +| **Tickets** | `/tickets` | JWT/IAM | Ticket generation, QR/barcode, validation | +| **Notifications** | `/notifications` | JWT | In-app notifications, preferences | +| **Loyalty** | `/loyalty` | JWT | Points, tiers, rewards redemption | +| **Wallet** | `/wallet` | JWT | Balance, top-up, transaction history | +| **Promotions** | `/promos` | Public/JWT | Active promotions, promo code validation | +| **Live Tracking** | `/live` | Public/JWT | Real-time trip status, crowd signals | +| **Support** | `/support` | Public/JWT | FAQ, chat conversations | +| **Dashboard** | `/dashboard` | JWT | Home screen aggregated data | +| **Routes** | `/routes` | JWT/IAM | Reusable route templates with ordered stops | +| **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates | +| **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations | +| **Seat Classes** | `/seat-classes` | Public/JWT/IAM | Seat class management and configuration | +| **Segment Seats** | `/segments/seats` | Public/JWT | Segment-based seat availability and booking | +| **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation | +| **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking | +| **Reports** | `/reports` | IAM | Revenue, occupancy, agent sales analytics | + +### Example API Calls + +#### 1. Register Passenger +```bash +POST /auth/register +Content-Type: application/json + +{ + "email": "user@example.com", + "phone": "+251911234567", + "fullName": "John Doe", + "password": "SecurePass123" +} +``` + +#### 2. Login (Passenger) +```bash +POST /auth/login +Content-Type: application/json + +{ + "email": "user@example.com", + "password": "SecurePass123" +} + +# Response includes JWT token +{ + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { "id": "uuid", "role": "PASSENGER" } +} +``` + +#### 3. Verify Ethiopian National ID (Verifayda) +```bash +POST /passengers/verify-fayda +Content-Type: application/json + +{ + "nationalId": "ET123456789" +} + +# Response with verified passenger data +{ + "verified": true, + "passengerData": { + "fullName": "Abebe Kebede", + "dateOfBirth": "1985-03-15T00:00:00.000Z", + "gender": "Male", + "nationality": "Ethiopian" + } +} +``` + +#### 4. Universal Passenger Registration (NEW) +```bash +# Guest Ethiopian with Fayda verification +POST /passengers/register +Content-Type: application/json + +{ + "passengerName": "Abebe Kebede", + "dateOfBirth": "1985-03-15", + "nationalId": "ET123456789", + "phone": "+251911234567", + "deviceId": "device-uuid-123" +} + +# Logged-in user with JWT token +POST /passengers/register +Authorization: Bearer +Content-Type: application/json + +{ + "passengerName": "Abebe Kebede", + "dateOfBirth": "1985-03-15", + "nationalId": "ET123456789", + "phone": "+251911234567" +} + +# International passenger (passport) +POST /passengers/register +Content-Type: application/json + +{ + "passengerName": "John Smith", + "dateOfBirth": "1990-07-20", + "passportNumber": "P1234567", + "passportCountry": "Kenya", + "nationality": "Kenyan", + "phone": "+254712345678", + "email": "john@example.com", + "deviceId": "device-uuid-123" +} +``` + +#### 5. Get User Profile (NEW) +```bash +GET /auth/profile +Authorization: Bearer + +# Response includes user, passenger, loyalty, and wallet details +{ + "id": "uuid", + "email": "user@example.com", + "phone": "+251911234567", + "fullName": "John Doe", + "role": "PASSENGER", + "nationality": "Ethiopian", + "faydaVerified": true, + "faydaVerifiedAt": "2024-01-15T10:30:00.000Z", + "passenger": { + "id": "uuid", + "loyalty": { + "tier": "SILVER", + "pointsBalance": 1500, + "lifetimePoints": 3000 + }, + "wallet": { + "balanceMinor": 50000, + "currency": "ETB" + } + } +} +``` + +#### 6. Search Trips +```bash +POST /search +Content-Type: application/json + +{ + "originStationId": "uuid", + "destinationStationId": "uuid", + "date": "2026-06-15", + "adultCount": 2, + "childCount": 1 +} +``` + +#### 7. Get Fare Quote +```bash +POST /search/fare-quote +Content-Type: application/json + +{ + "tripId": "uuid", + "serviceClass": "ECONOMY_REGULAR", + "adultCount": 2, + "childCount": 1, + "displayCurrency": "USD" +} + +# Response includes age-based pricing breakdown +{ + "baseFareMinor": 35000, + "adultCount": 2, + "adultFareMinor": 70000, + "childCount": 1, + "freeChildrenCount": 1, + "paidChildrenCount": 0, + "childFareMinor": 0, + "totalMinor": 73500, + "currency": "ETB", + "displayCurrency": "USD", + "displayTotalMinor": 1323 +} +``` + +#### 8. Guest Booking (No Login Required) +```bash +POST /bookings/guest +Content-Type: application/json + +{ + "tripId": "uuid", + "holdId": "uuid", + "serviceClass": "ECONOMY_REGULAR", + "displayCurrency": "ETB", + "passengers": [ + { + "seatId": "uuid", + "passengerName": "Abebe Kebede", + "dateOfBirth": "1985-03-15", + "idDocumentType": "NATIONAL_ID", + "idDocumentNumber": "ET123456789" + } + ], + "createAccount": false, + "savePassengerDetails": true, + "deviceId": "device-uuid" +} +``` + +#### 9. Agent Booking (IAM Auth) +```bash +POST /agents/bookings +Authorization: Bearer +Content-Type: application/json + +{ + "tripId": "uuid", + "seats": [...], + "paymentMethod": "CASH", + "cashReceived": 50000 +} +``` + +## ๐Ÿ—๏ธ Project Structure + +``` +apps/edr-passenger-api/ +โ”œโ”€โ”€ prisma/ +โ”‚ โ”œโ”€โ”€ schema.prisma # Database schema (40+ models) +โ”‚ โ”œโ”€โ”€ seed.ts # Comprehensive seed script +โ”‚ โ””โ”€โ”€ migrations/ # Database migrations +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ common/ # Shared utilities +โ”‚ โ”‚ โ”œโ”€โ”€ filters/ # Exception filters +โ”‚ โ”‚ โ”œโ”€โ”€ interceptors/ # Response interceptors +โ”‚ โ”‚ โ”œโ”€โ”€ pipes/ # Validation pipes +โ”‚ โ”‚ โ”œโ”€โ”€ i18n/ # Internationalization +โ”‚ โ”‚ โ”œโ”€โ”€ jwt.guard.ts # JWT authentication guard (passengers) +โ”‚ โ”‚ โ”œโ”€โ”€ jwt.strategy.ts # Passport JWT strategy +โ”‚ โ”‚ โ”œโ”€โ”€ iam-adapter.ts # Corporate IAM guard (back-office) +โ”‚ โ”‚ โ”œโ”€โ”€ iam.module.ts # IAM module +โ”‚ โ”‚ โ”œโ”€โ”€ roles.guard.ts # RBAC authorization guard +โ”‚ โ”‚ โ”œโ”€โ”€ roles.decorator.ts # Roles decorator +โ”‚ โ”‚ โ”œโ”€โ”€ prisma.service.ts # Prisma client service +โ”‚ โ”‚ โ””โ”€โ”€ prisma.module.ts # Prisma module +โ”‚ โ”œโ”€โ”€ config/ # Configuration files +โ”‚ โ”‚ โ”œโ”€โ”€ app.config.ts # App configuration +โ”‚ โ”‚ โ”œโ”€โ”€ database.config.ts # Database configuration +โ”‚ โ”‚ โ””โ”€โ”€ telebirr.config.ts # Payment provider config +โ”‚ โ”œโ”€โ”€ modules/ # Feature modules +โ”‚ โ”‚ โ”œโ”€โ”€ auth/ # Authentication & authorization (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ agents/ # Agent operations (IAM-protected) +โ”‚ โ”‚ โ”œโ”€โ”€ bookings/ # Booking management (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ currency/ # Currency conversion service +โ”‚ โ”‚ โ”œโ”€โ”€ dashboard/ # Dashboard aggregations (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ fleet/ # Train fleet management (JWT/IAM) +โ”‚ โ”‚ โ”œโ”€โ”€ fraud/ # Fraud detection (IAM-protected) +โ”‚ โ”‚ โ”œโ”€โ”€ live/ # Live tracking (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ loyalty/ # Loyalty program (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ notifications/ # Notification system (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ passengers/ # Passenger management (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ payments/ # Payment processing (JWT/Webhooks) +โ”‚ โ”‚ โ”œโ”€โ”€ promos/ # Promotions (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ reports/ # Reports & analytics (IAM-protected) +โ”‚ โ”‚ โ”œโ”€โ”€ schedules/ # Trip schedules (JWT/IAM) +โ”‚ โ”‚ โ”œโ”€โ”€ search/ # Trip search (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ seats/ # Seat management (JWT/IAM) +โ”‚ โ”‚ โ”œโ”€โ”€ segments/ # Journey segments (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ stations/ # Station management (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ support/ # Customer support (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ tickets/ # Ticketing (JWT/IAM) +โ”‚ โ”‚ โ”œโ”€โ”€ verifayda/ # Verifayda 2.0 integration +โ”‚ โ”‚ โ””โ”€โ”€ wallet/ # Wallet system (JWT) +โ”‚ โ”œโ”€โ”€ app.module.ts # Root application module +โ”‚ โ””โ”€โ”€ main.ts # Application entry point +โ”œโ”€โ”€ test/ # E2E tests +โ”œโ”€โ”€ .env.example # Environment template +โ”œโ”€โ”€ Dockerfile # Docker configuration +โ”œโ”€โ”€ nest-cli.json # NestJS CLI configuration +โ”œโ”€โ”€ package.json # Dependencies & scripts +โ”œโ”€โ”€ tsconfig.json # TypeScript configuration +โ””โ”€โ”€ tsconfig.build.json # Build configuration +``` + +## ๐Ÿ—„๏ธ Database Schema + +### Key Models (40+ total) + +**Core Entities:** +- `User`, `Session`, `Passenger`, `Agent` +- `Station`, `Route`, `RouteStop`, `RouteFareRule` +- `TrainService`, `Trip`, `TripStopTime`, `Coach`, `Seat` +- `Booking`, `BookingSeat`, `Ticket` +- `PaymentIntent`, `PaymentRefund`, `PaymentWebhookEvent` + +**Enhanced Features:** +- `OtpCode`, `PasswordResetToken` (Auth) +- `AgentBooking`, `AgentShift`, `AgentCommission` (Agents) +- `BookingModification`, `BookingCancellation` (Booking lifecycle) +- `GateValidationLog` (Ticket validation) +- `BaggageAllowance`, `BaggageBooking` (Baggage) +- `LoyaltyAccount`, `LoyaltyLedgerEntry`, `LoyaltyReward` +- `WalletAccount`, `WalletLedgerEntry` +- `Notification`, `NotificationTemplate` +- `AuditLog`, `OperationalReport` +- `SeatBlock`, `SeatHold` +- `CurrencyExchangeRate` (Multi-currency) +- `VerifaydaVerification` (National ID verification) +- `SavedPassengerProfile` (Guest booking) +- `SeatClass` (Seat class configuration) +- `JourneySegment` (Multi-segment journeys) + +## ๐Ÿ”ง Available Scripts + +```bash +# Development +pnpm --filter @edr/passenger-api run dev # Start with hot-reload + +# Build +pnpm --filter @edr/passenger-api run build # Compile TypeScript + +# Production +pnpm --filter @edr/passenger-api run start # Run compiled code + +# Testing +pnpm --filter @edr/passenger-api run test # Unit tests +pnpm --filter @edr/passenger-api run test:e2e # E2E tests + +# Code Quality +pnpm --filter @edr/passenger-api run lint # ESLint +pnpm --filter @edr/passenger-api run type-check # TypeScript check + +# Database +pnpm --filter @edr/passenger-api run prisma:generate # Generate Prisma client +pnpm --filter @edr/passenger-api run prisma:migrate:dev # Run migrations (local dev) +pnpm --filter @edr/passenger-api run prisma:seed # Seed database +``` + +## ๐Ÿณ Docker Deployment + +All six apps build from Dockerfiles: each API has its own (`apps/edr-freight-api/Dockerfile`, `apps/edr-passenger-api/Dockerfile`); Vite frontends share `infrastructure/docker/Dockerfile.web` and are served with **nginx**. APIs run on **Node 22**. + +**Prerequisites** + +- Docker with BuildKit enabled +- A local [`.npmrc`](.gitignore) with GitHub Packages auth for `@tria-plc/*` (required for **freight** API and web images) +- External Postgres for each API (compose does **not** include databases) +- Copy `apps/edr-freight-api/.env.example` โ†’ `.env` and `apps/edr-passenger-api/.env.example` โ†’ `.env` with real connection strings + +### Build and run (all apps) + +```bash +# From monorepo root +DOCKER_BUILDKIT=1 pnpm docker:build +pnpm docker:up +``` + +Or without pnpm scripts: + +```bash +DOCKER_BUILDKIT=1 docker compose build +docker compose up -d +``` + +| Service | URL (default) | +|---------|----------------| +| Freight API | http://localhost:3001 | +| Passenger API | http://localhost:4000 | +| Freight portal | http://localhost:5173 | +| Freight backoffice | http://localhost:5183 | +| Passenger portal | http://localhost:5174 | +| Passenger backoffice | http://localhost:5184 | + +### Build a single service + +```bash +docker compose build freight-api +docker compose build passenger-portal +``` + +Freight images mount `.npmrc` as a BuildKit secret during `pnpm install`. Passenger web images do not require private packages. + +### `VITE_API_URL` (frontends) + +API URLs are **baked in at image build time** (`import.meta.env.VITE_API_URL`). Defaults in [`docker-compose.yaml`](docker-compose.yaml) use `http://localhost:3001/api` (freight) and `http://localhost:4000` (passenger) for local smoke tests. Override build args for production, e.g.: + +```bash +docker compose build freight-portal \ + --build-arg VITE_API_URL=https://freight-api.example.com/api +``` + +### Migrations + +- **Freight API:** TypeORM migrations are not run on container startup โ€” apply them separately before deploy. +- **Passenger API:** On each container start, the entrypoint runs `npm run prisma:migrate` and `npm run prisma:seed` (same `package.json` scripts as `pnpm run`) before starting the server. Ensure `DATABASE_URL` in `.env` points at a reachable Postgres instance. + +For local development, use `pnpm --filter @edr/passenger-api run prisma:migrate:dev` instead of `prisma:migrate`. + +### GitHub Actions (self-hosted runner) + +Two workflows deploy independently on push to `main`, `develop`, or `staging`: + +| Workflow | Services | Server env root | +|----------|----------|-----------------| +| [`.github/workflows/deploy-freight.yml`](.github/workflows/deploy-freight.yml) | freight-api, freight-portal, freight-backoffice | `/home/user/environmen/edr-freight//` | +| [`.github/workflows/deploy-passenger.yml`](.github/workflows/deploy-passenger.yml) | passenger-api, passenger-portal, passenger-backoffice | `/home/user/environmen/edr-passenger//` | + +**On the runner**, place env files before the first deploy (example for branch `main`): + +```text +/home/user/environmen/edr-freight/main/ + freight-api.env + freight-portal.env # optional runtime env for Vite/nginx + freight-backoffice.env + freight-web.build.env # exports FREIGHT_VITE_API_URL=... + +/home/user/environmen/edr-passenger/main/ + passenger-api.env + passenger-portal.env + passenger-backoffice.env + passenger-web.build.env # exports PASSENGER_VITE_API_URL=... +``` + +Example `freight-web.build.env`: + +```bash +export FREIGHT_VITE_API_URL=https://freight-api.example.com/api +``` + +The workflow copies `*.env` into each app directory, creates `.npmrc` from the `NPM_TOKEN` repository secret, then runs `docker compose build` and `docker compose up -d` for that stack. + +## ๐Ÿ”’ Security Best Practices + +1. **Environment Variables** - Never commit `.env` files. Use secrets management in production. +2. **JWT Secret** - Use strong, randomly generated secrets (min 32 characters). +3. **Password Hashing** - Bcrypt with salt rounds (default: 10). +4. **Rate Limiting** - Implement rate limiting for auth endpoints. +5. **CORS** - Configure allowed origins in production. +6. **HTTPS** - Always use HTTPS in production. +7. **Database** - Use connection pooling and prepared statements (Prisma handles this). +8. **Audit Logging** - All sensitive operations are logged in `AuditLog` table. +9. **Dual Authentication** - Passenger routes use JWT, back-office routes use corporate IAM. +10. **IAM Integration** - Corporate IAM validates tokens against centralized identity service. +11. **Role-Based Access** - Granular permissions enforced via IAM roles (AGENT, SUPERVISOR, ADMIN). +12. **Token Validation** - IAM tokens validated in real-time with 5-second timeout. + +## ๐Ÿ“Š Monitoring & Logging + +- **Application Logs** - NestJS built-in logger +- **Database Queries** - Prisma query logging (enable in development) +- **Audit Trail** - All user actions logged in `AuditLog` table +- **Error Tracking** - Global exception filters with detailed error responses + +## ๐Ÿงช Testing + +```bash +# Unit tests +pnpm --filter @edr/passenger-api run test + +# E2E tests +pnpm --filter @edr/passenger-api run test:e2e + +# Test coverage +pnpm --filter @edr/passenger-api run test:cov +``` + +## ๐Ÿš€ Production Deployment + +### Pre-deployment Checklist +- [ ] Update environment variables (JWT_SECRET, DATABASE_URL, etc.) +- [ ] Configure IAM integration (IAM_ENABLED=true, IAM_API_URL, IAM_API_KEY) +- [ ] Configure Verifayda integration (VERIFAYDA_ENABLED=true, VERIFAYDA_API_KEY) +- [ ] Set up currency exchange rate sync (external API) +- [ ] Set NODE_ENV=production +- [ ] Configure CORS origins (PORTAL_URL, BACK_OFFICE_URL) +- [ ] Set up SSL/TLS certificates +- [ ] Configure database connection pooling +- [ ] Set up monitoring and logging +- [ ] Configure backup strategy +- [ ] Test payment provider integrations +- [ ] Verify IAM token validation endpoint +- [ ] Test Verifayda verification with real national IDs +- [ ] Verify currency conversion accuracy +- [ ] Test age-based pricing calculations +- [ ] Review security settings and audit logs +- [ ] Test both JWT and IAM authentication flows + +### Deployment Steps +```bash +# 1. Build application +pnpm --filter @edr/passenger-api run build + +# 2. Run migrations +pnpm --filter @edr/passenger-api run prisma:migrate + +# 3. Start production server +NODE_ENV=production pnpm --filter @edr/passenger-api run start:prod +``` + +## ๐Ÿค Contributing + +1. Fork the repository +2. Create feature branch (`git checkout -b feature/amazing-feature`) +3. Commit changes (`git commit -m 'Add amazing feature'`) +4. Push to branch (`git push origin feature/amazing-feature`) +5. Open Pull Request + +## ๐Ÿ“ License + +This project is proprietary and confidential. + +## ๐Ÿ“ง Support + +For technical support or questions: +- Email: support@edr-platform.com +- Documentation: http://localhost:4000/api-docs + +--- + +**Built with โค๏ธ for Ethio-Djibouti Railway** +======= ### Start local databases ```bash @@ -257,3 +1022,4 @@ pnpm dev:passenger # passenger API + portal + backoffice - **One DB per domain** โ€” no cross-database joins. See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions. +>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467 diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 90aa517ff..73e122a1d 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -1,14 +1,24 @@ -# App -NODE_ENV=development +# Copy to .env for local/docker compose (not committed). PORT=3001 - -# Database DB_HOST=localhost DB_PORT=5433 -DB_NAME=edr_freight DB_USER=postgres DB_PASSWORD= +DB_NAME=edr_freight +# Telebirr payment gateway (freight merchant credentials) +TELEBIRR_BASE_URL= +TELEBIRR_WEB_BASE_URL= +TELEBIRR_FABRIC_APP_ID= +TELEBIRR_APP_SECRET= +TELEBIRR_MERCHANT_APP_ID= +TELEBIRR_MERCHANT_CODE= +TELEBIRR_NOTIFY_URL=https://freight-api.edr.et/payments/webhooks/telebirr +TELEBIRR_RETURN_URL= +TELEBIRR_TIMEOUT_EXPRESS=15m +TELEBIRR_PRIVATE_KEY= +TELEBIRR_PUBLIC_KEY= +TELEBIRR_INSECURE_TLS=false # JWT (used by @tria-plc/api-common SharedAuthModule) JWT_SECRET= JWT_ACCESS_TOKEN_SECRET= diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index 1f4ac2e60..b0850737b 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -1,26 +1,37 @@ -FROM node:20-alpine AS base -RUN corepack enable && corepack prepare pnpm@9.12.0 --activate +# syntax=docker/dockerfile:1 +# Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile . + +FROM node:24.15.0-alpine AS base +RUN apk add --no-cache libc6-compat +RUN corepack enable WORKDIR /app -FROM base AS deps -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ -COPY apps/edr-freight-api/package.json ./apps/edr-freight-api/ -COPY packages ./packages -RUN pnpm install --frozen-lockfile --filter @edr/freight-api... +FROM base AS pruner +COPY . . +RUN pnpm dlx turbo prune "@edr/freight-api" --docker -FROM deps AS build -COPY apps/edr-freight-api ./apps/edr-freight-api -RUN pnpm --filter @edr/freight-api build +FROM base AS installer +COPY --from=pruner /app/out/json/ . +COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ + pnpm install --frozen-lockfile -FROM node:20-alpine AS runtime -RUN corepack enable && corepack prepare pnpm@9.12.0 --activate -WORKDIR /app/apps/edr-freight-api +FROM base AS builder +COPY --from=installer /app/ . +COPY --from=pruner /app/out/full/ . +RUN pnpm turbo build --filter="@edr/freight-api..." + +FROM base AS deployer +COPY --from=builder /app/ . +RUN pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy + +FROM node:24.15.0-alpine AS runner +RUN apk add --no-cache libc6-compat ENV NODE_ENV=production - -COPY --from=deps /app/node_modules ./../../node_modules -COPY --from=deps /app/apps/edr-freight-api/node_modules ./node_modules -COPY --from=build /app/apps/edr-freight-api/dist ./dist -COPY --from=build /app/apps/edr-freight-api/package.json ./package.json - +WORKDIR /app +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 --ingroup nodejs nestjs +COPY --from=deployer --chown=nestjs:nodejs /deploy . +USER nestjs EXPOSE 3001 CMD ["node", "dist/main.js"] diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index eec917c7e..cd7b0fde7 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -17,11 +17,13 @@ }, "dependencies": { "@edr/api-common": "workspace:*", + "@edr/payment-providers": "workspace:*", "@edr/types": "workspace:*", "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.0", + "@nestjs/event-emitter": "^2.0.4", "@nestjs/mapped-types": "^2.1.1", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", @@ -63,7 +65,7 @@ "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typeorm": "^1.0.0", + "typeorm": "^0.3.30", "typescript": "^5.5.4" }, "jest": { diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 4c57af97a..76909771d 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -8,12 +8,13 @@ import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth. import appConfig from "./config/app.config"; import databaseConfig from "./config/database.config"; +import telebirrConfig from "./config/telebirr.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; import { FilesModule } from "./modules/files/files.module"; import { ConsignmentsModule } from "./modules/consignments/consignments.module"; -//import { TrainsModule } from "./modules/trains/trains.module"; +// import { TrainsModule } from "./modules/trains/trains.module"; import { LocomotivesModule } from "./modules/locomotives/locomotives.module"; import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module"; import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; @@ -47,13 +48,15 @@ import { TrainsModule } from "./modules/trains/trains.module"; import { WagonsModule } from './modules/wagons/wagons.module'; import { ContainersModule } from './modules/container-management/containers.module'; import { CargoesModule } from './modules/cargoes/cargoes.module'; +import { RoutesModule } from './modules/routes/routes.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfig, databaseConfig], + load: [appConfig, databaseConfig, telebirrConfig], }), + // EventEmitterModule.forRoot(), TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): TypeOrmModuleOptions => @@ -98,6 +101,7 @@ import { CargoesModule } from './modules/cargoes/cargoes.module'; WagonsModule, ContainersModule, CargoesModule, + RoutesModule, ], providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder], }) diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index bbac17e7a..0e7375b19 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -17,7 +17,6 @@ import { PositionType, Position, Project, - UnitConfiguration, GlobalUnitConfiguration, Unit, EmployeeSignature, @@ -64,7 +63,6 @@ const iamEntities = [ PositionType, Position, Project, - UnitConfiguration, GlobalUnitConfiguration, Unit, EmployeeSignature, @@ -98,10 +96,8 @@ const iamMigrationsGlob = join( ); const freightMigrationsGlob = join(__dirname, "../migrations/*.js"); -export default registerAs( - "database", - (): TypeOrmModuleOptions => { - return { +export default registerAs("database", (): TypeOrmModuleOptions => { + return { type: "postgres", host: process.env.DB_HOST ?? "localhost", port: parseInt(process.env.DB_PORT ?? "5433", 10), @@ -124,5 +120,4 @@ export default registerAs( synchronize: false, logging: process.env.NODE_ENV === "development", }; - }, -); +}); diff --git a/apps/edr-freight-api/src/config/dmoney.config.ts b/apps/edr-freight-api/src/config/dmoney.config.ts new file mode 100644 index 000000000..7922b4aae --- /dev/null +++ b/apps/edr-freight-api/src/config/dmoney.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("dmoney", () => ({ + baseUrl: process.env.DMONEY_BASE_URL ?? "", + appId: process.env.DMONEY_APP_ID ?? "", + appSecret: process.env.DMONEY_APP_SECRET ?? "", + publicKey: process.env.DMONEY_PUBLIC_KEY ?? "", + privateKey: process.env.DMONEY_PRIVATE_KEY ?? "", + notifyUrl: process.env.DMONEY_NOTIFY_URL ?? "" +})); diff --git a/apps/edr-freight-api/src/config/telebirr.config.ts b/apps/edr-freight-api/src/config/telebirr.config.ts new file mode 100644 index 000000000..8e5d1712a --- /dev/null +++ b/apps/edr-freight-api/src/config/telebirr.config.ts @@ -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", +})); diff --git a/apps/edr-freight-api/src/data-source.ts b/apps/edr-freight-api/src/data-source.ts index b35d79932..a29fb861e 100644 --- a/apps/edr-freight-api/src/data-source.ts +++ b/apps/edr-freight-api/src/data-source.ts @@ -1,14 +1,15 @@ // apps/edr-freight-api/src/data-source.ts +import 'dotenv/config'; import { DataSource } from 'typeorm'; //import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed export const AppDataSource = new DataSource({ type: 'postgres', - host: 'localhost', - port: 5432, - username: 'postgres', - password: '', // Laragon default: empty - database: 'edr_freight', + host: process.env.DB_HOST ?? 'localhost', + port: Number(process.env.DB_PORT ?? 5432), + username: process.env.DB_USER ?? 'postgres', + password: process.env.DB_PASSWORD ?? '', + database: process.env.DB_NAME ?? 'edr_freight', schema: 'freight', // default schema for entities without an explicit schema entities: [__dirname + '/**/*.entity{.ts,.js}'], migrations: [__dirname + '/migrations/*{.ts,.js}'], @@ -17,4 +18,4 @@ export const AppDataSource = new DataSource({ }); // Optional: call ensurePostgresSchemas before initializing -// But you can also run it separately. \ No newline at end of file +// But you can also run it separately. diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index f6f32be91..a76378b0c 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -18,6 +18,7 @@ async function bootstrap() { // freight portal (5173), passenger portal (5174), backoffices (5183/5184) // and any other dev port can call the API with cookies + Authorization. // For production, restrict `origin` to known FQDNs. + app.enableCors({ origin: true, // reflect request origin credentials: true, @@ -52,9 +53,12 @@ async function bootstrap() { SwaggerModule.setup("api/docs", app, document); const port = parseInt(process.env.PORT ?? "3001", 10); - await app.listen(port); + // await app.listen(port, "0.0.0.0"); + await app.listen( + + port) // eslint-disable-next-line no-console - console.log(`[freight-api] listening on http://localhost:${port}`); + console.log(`[freight-api] listening on port ${port}`); } bootstrap(); diff --git a/apps/edr-freight-api/src/migrations/1750100000000-AddRoutesAndExtendLocomotives.ts b/apps/edr-freight-api/src/migrations/1750100000000-AddRoutesAndExtendLocomotives.ts new file mode 100644 index 000000000..421f66ef9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750100000000-AddRoutesAndExtendLocomotives.ts @@ -0,0 +1,87 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRoutesAndExtendLocomotives1750100000000 implements MigrationInterface { + name = 'AddRoutesAndExtendLocomotives1750100000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.locomotives + ADD COLUMN IF NOT EXISTS locomotive_type VARCHAR(20) NOT NULL DEFAULT 'DIESEL', + ADD COLUMN IF NOT EXISTS max_train_length_meters NUMERIC(10,3) NOT NULL DEFAULT 760, + ADD COLUMN IF NOT EXISTS power_kw NUMERIC(10,3) NULL, + ADD COLUMN IF NOT EXISTS traction_force_kn NUMERIC(10,3) NULL, + ADD COLUMN IF NOT EXISTS max_speed_kmh NUMERIC(10,3) NULL; + `); + + await queryRunner.query(` + UPDATE freight.locomotives + SET status = 'OUT_OF_SERVICE' + WHERE status = 'INACTIVE'; + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.routes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(120) NOT NULL UNIQUE, + origin_yard_id UUID NOT NULL REFERENCES freight.yards(id), + destination_yard_id UUID NOT NULL REFERENCES freight.yards(id), + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.route_milestones ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + route_id UUID NOT NULL REFERENCES freight.routes(id) ON DELETE CASCADE, + yard_id UUID NOT NULL REFERENCES freight.yards(id), + sequence_no INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_route_milestones_route_sequence UNIQUE (route_id, sequence_no) + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_routes_origin_yard_id + ON freight.routes(origin_yard_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_routes_destination_yard_id + ON freight.routes(destination_yard_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_routes_is_active + ON freight.routes(is_active); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_route_milestones_route_id + ON freight.route_milestones(route_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_route_milestones_yard_id + ON freight.route_milestones(yard_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.route_milestones;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.routes;`); + await queryRunner.query(` + ALTER TABLE freight.locomotives + DROP COLUMN IF EXISTS max_speed_kmh, + DROP COLUMN IF EXISTS traction_force_kn, + DROP COLUMN IF EXISTS power_kw, + DROP COLUMN IF EXISTS max_train_length_meters, + DROP COLUMN IF EXISTS locomotive_type; + `); + await queryRunner.query(` + UPDATE freight.locomotives + SET status = 'INACTIVE' + WHERE status = 'OUT_OF_SERVICE'; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750300000000-AddRouteToTrainSchedules.ts b/apps/edr-freight-api/src/migrations/1750300000000-AddRouteToTrainSchedules.ts new file mode 100644 index 000000000..027ebfe98 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750300000000-AddRouteToTrainSchedules.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRouteToTrainSchedules1750300000000 implements MigrationInterface { + name = 'AddRouteToTrainSchedules1750300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS route_id UUID NULL; + `); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_train_schedules_route' + ) THEN + ALTER TABLE freight.train_schedules + ADD CONSTRAINT fk_train_schedules_route + FOREIGN KEY (route_id) REFERENCES freight.routes(id); + END IF; + END $$; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_schedules_route_id + ON freight.train_schedules(route_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_route_id;`); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP CONSTRAINT IF EXISTS fk_train_schedules_route; + `); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS route_id; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts index ce15125c2..332916727 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts @@ -25,10 +25,10 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{ key: 'approved_contract', statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'], }, - { key: 'payment', statuses: ['FULLY_EXECUTED', 'PAID'] }, + { key: 'payment', statuses: ['FULLY_EXECUTED'] }, { key: 'operations', - statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED'], + statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'], }, { key: 'completed', statuses: ['COMPLETED'] }, { key: 'closed', statuses: ['REJECTED', 'CANCELLED'] }, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts index c0a865949..c591a1db4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts @@ -34,8 +34,8 @@ export function computeNextStep( }; case 'APPROVED': return { - action: 'GENERATE_CONTRACT', - description: 'Generate the contract document', + action: 'CUSTOMER_SIGN', + description: 'Contract generated; customer must sign', }; case 'CONTRACT_READY': return { @@ -49,8 +49,8 @@ export function computeNextStep( }; case 'FULLY_EXECUTED': return { - action: 'PAY', - description: 'Complete in-app payment', + action: 'AWAIT_PAYMENT', + description: 'Awaiting customer payment', }; case 'PAID': return { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts index ecc410320..07ef6a183 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts @@ -4,56 +4,44 @@ import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; import { PaymentService } from '../payment/payment.service'; - +import { PaymentStatus } from '../payment/entities/payment.entity'; export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { } +const NON_TERMINAL_STATUSES: PaymentStatus[] = [ + "action-required", + "processing", + "success", +]; + @Injectable() export class BookingPaymentService { - constructor(private readonly bookingsRepository: BookingsRepository, private readonly paymentService: PaymentService) { } + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly paymentService: PaymentService, + ) { } - async pay( - bookingId: string, - ): Promise<{ redirectUrl: string }> { + async pay(bookingId: string): Promise<{ redirectUrl: string }> { const booking = await this.requireBooking(bookingId); - assertBookingStatus(booking, ['FULLY_EXECUTED']); + assertBookingStatus(booking, ['FULLY_EXECUTED', '']); - // const receipt = this.buildMockReceipt(booking); - - // const updated = await this.bookingsRepository.update(bookingId, { - // status: 'PAID', - // paymentStatus: 'PAID', - // } as never); - const resp = await this.paymentService.pay(booking.totalAmount, "ETB", "telebirr", "payment for booking", 'booking', (_) => { - return new Promise((resp, _) => { - resp({ - id: booking.id, - type: "booking" - }) - }); - }) - - return { - redirectUrl: resp.clientAction.type == "REDIRECT" ? `http://localhost:3001/api/payments/telebirr/${booking.id}` : "" + const existing = await this.paymentService.findBookingById(bookingId); + if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) { + if (existing.clientAction) { + const action = existing.clientAction as { type?: string; url?: string }; + if (action.type === "REDIRECT" && action.url) { + return { redirectUrl: action.url }; + } + } } + const resp = await this.paymentService.initBookingTelebirr(bookingId, "web"); + + return { + redirectUrl: + resp.redirectUrl ?? "", + }; } - // private buildMockReceipt(booking: Booking): InAppPaymentReceipt { - // const timestamp = Date.now(); - // const isEtb = booking.paymentCurrency === 'ETB'; - // const prefix = isEtb ? 'TB' : 'CARD'; - // const provider = isEtb ? 'TELEBIRR' : 'CARD'; - - // return { - // success: true, - // provider, - // providerRef: `${prefix}-${booking.reference}-${timestamp}`, - // amount: booking.totalAmount, - // currency: booking.paymentCurrency, - // paidAt: new Date().toISOString(), - // }; - // } - private async requireBooking(id: string): Promise { const booking = await this.bookingsRepository.findById(id); if (!booking) throw new NotFoundException(`Booking ${id} not found`); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 56ab6394b..0fdfc5084 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -185,6 +185,11 @@ export class BookingTransitionService { await this.bookingsRepository.update(bookingId, updates as never); } + if (allDone) { + const generated = await this.contractService.generateContract(bookingId); + return this.bookingsService.findById(generated.id); + } + return this.bookingsService.findById(bookingId); } diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts new file mode 100644 index 000000000..1469630ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts @@ -0,0 +1,59 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity'; + +export class CreateLocomotiveDto { + @ApiProperty({ example: 'LOCO-001' }) + @IsString() + @MaxLength(32) + code!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @ApiProperty({ enum: LOCOMOTIVE_TYPES }) + @IsIn([...LOCOMOTIVE_TYPES]) + locomotiveType!: string; + + @ApiProperty({ enum: LOCOMOTIVE_STATUSES }) + @IsIn([...LOCOMOTIVE_STATUSES]) + status!: string; + + @ApiProperty({ example: 3500 }) + @Transform(({ value }) => Number(value)) + @IsNumber() + @Min(0) + maxPullWeightTons!: number; + + @ApiProperty({ example: 760 }) + @Transform(({ value }) => Number(value)) + @IsNumber() + @Min(0) + maxTrainLengthMeters!: number; + + @ApiPropertyOptional({ example: 4200 }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsNumber() + @Min(0) + powerKw?: number; + + @ApiPropertyOptional({ example: 300 }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsNumber() + @Min(0) + tractionForceKn?: number; + + @ApiPropertyOptional({ example: 120 }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsNumber() + @Min(0) + maxSpeedKmh?: number; +} diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts index 3ee26beb9..1ea5ef29d 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts @@ -1,11 +1,16 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsIn, IsOptional } from 'class-validator'; -import { LOCOMOTIVE_STATUSES } from '../entities/locomotive.entity'; +import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity'; export class FilterLocomotivesDto { @ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES }) @IsOptional() @IsIn([...LOCOMOTIVE_STATUSES]) status?: string; + + @ApiPropertyOptional({ enum: LOCOMOTIVE_TYPES }) + @IsOptional() + @IsIn([...LOCOMOTIVE_TYPES]) + locomotiveType?: string; } diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/update-locomotive.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/update-locomotive.dto.ts new file mode 100644 index 000000000..0f5cd2761 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/dto/update-locomotive.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateLocomotiveDto } from './create-locomotive.dto'; + +export class UpdateLocomotiveDto extends PartialType(CreateLocomotiveDto) {} diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts index 1676674b1..2c5aa463a 100644 --- a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -7,10 +7,13 @@ export const LOCOMOTIVE_STATUSES = [ 'AVAILABLE', 'ASSIGNED', 'MAINTENANCE', - 'INACTIVE', + 'OUT_OF_SERVICE', ] as const; +export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const; + export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number]; +export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number]; @Entity({ schema: 'freight', name: 'locomotives' }) @Index(['code']) @@ -22,14 +25,26 @@ export class Locomotive extends BaseEntity { @Column({ name: 'name', type: 'varchar', length: 100, nullable: true }) name?: string | null; + @Column({ name: 'locomotive_type', type: 'varchar', length: 20, default: 'DIESEL' }) + locomotiveType!: LocomotiveType; + @Column({ name: 'max_pull_weight_tons', type: 'numeric', precision: 10, scale: 3 }) maxPullWeightTons!: number; + @Column({ name: 'max_train_length_meters', type: 'numeric', precision: 10, scale: 3, default: 760 }) + maxTrainLengthMeters!: number; + @Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' }) status!: LocomotiveStatus; - @Column({ name: 'available_from', type: 'timestamptz', nullable: true }) - availableFrom?: Date | null; + @Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true }) + powerKw?: number | null; + + @Column({ name: 'traction_force_kn', type: 'numeric', precision: 10, scale: 3, nullable: true }) + tractionForceKn?: number | null; + + @Column({ name: 'max_speed_kmh', type: 'numeric', precision: 10, scale: 3, nullable: true }) + maxSpeedKmh?: number | null; @OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive) trainSets?: TrainSet[]; diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts index 9e64c1e2b..f7ccdde1d 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts @@ -1,7 +1,9 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateLocomotiveDto } from './dto/create-locomotive.dto'; import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; +import { UpdateLocomotiveDto } from './dto/update-locomotive.dto'; import { LocomotivesService } from './locomotives.service'; @ApiTags('locomotives') @@ -15,4 +17,28 @@ export class LocomotivesController { findAll(@Query() filter: FilterLocomotivesDto) { return this.locomotivesService.findAll(filter); } + + @Get(':id') + @ApiOperation({ summary: 'Get a locomotive by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.locomotivesService.findById(id); + } + + @Post() + @ApiOperation({ summary: 'Create a locomotive' }) + create(@Body() dto: CreateLocomotiveDto) { + return this.locomotivesService.create(dto); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a locomotive' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) { + return this.locomotivesService.update(id, dto); + } + + @Post(':id/decommission') + @ApiOperation({ summary: 'Decommission a locomotive' }) + decommission(@Param('id', ParseUUIDPipe) id: string) { + return this.locomotivesService.decommission(id); + } } diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index 946a77d48..ac030d5d8 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -1,7 +1,9 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { CreateLocomotiveDto } from './dto/create-locomotive.dto'; import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; -import { Locomotive, type LocomotiveStatus } from './entities/locomotive.entity'; +import { UpdateLocomotiveDto } from './dto/update-locomotive.dto'; +import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity'; import { LocomotivesRepository } from './locomotives.repository'; @Injectable() @@ -10,13 +12,36 @@ export class LocomotivesService { findAll(filter: FilterLocomotivesDto): Promise { return this.locomotivesRepository.findAll({ - where: filter.status - ? { status: filter.status as LocomotiveStatus } - : undefined, + where: { + ...(filter.status ? { status: filter.status as LocomotiveStatus } : {}), + ...(filter.locomotiveType + ? { locomotiveType: filter.locomotiveType as LocomotiveType } + : {}), + }, order: { code: 'ASC' }, }); } + async create(dto: CreateLocomotiveDto): Promise { + const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } }); + + if (existing) { + throw new ConflictException(`Locomotive code ${dto.code} already exists`); + } + + return this.locomotivesRepository.create({ + code: dto.code, + name: dto.name?.trim() || null, + locomotiveType: dto.locomotiveType as LocomotiveType, + status: dto.status as LocomotiveStatus, + maxPullWeightTons: dto.maxPullWeightTons, + maxTrainLengthMeters: dto.maxTrainLengthMeters, + powerKw: dto.powerKw ?? null, + tractionForceKn: dto.tractionForceKn ?? null, + maxSpeedKmh: dto.maxSpeedKmh ?? null, + }); + } + async findById(id: string): Promise { const locomotive = await this.locomotivesRepository.findById(id); @@ -26,4 +51,48 @@ export class LocomotivesService { return locomotive; } + + async update(id: string, dto: UpdateLocomotiveDto): Promise { + const locomotive = await this.findById(id); + + if (dto.code && dto.code !== locomotive.code) { + const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } }); + if (existing && existing.id !== id) { + throw new ConflictException(`Locomotive code ${dto.code} already exists`); + } + } + + const updated = await this.locomotivesRepository.update(id, { + ...dto, + locomotiveType: + dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType, + status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus, + name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null, + powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null, + tractionForceKn: + dto.tractionForceKn === undefined ? locomotive.tractionForceKn : dto.tractionForceKn ?? null, + maxSpeedKmh: + dto.maxSpeedKmh === undefined ? locomotive.maxSpeedKmh : dto.maxSpeedKmh ?? null, + }); + + if (!updated) { + throw new NotFoundException(`Locomotive ${id} not found`); + } + + return updated; + } + + async decommission(id: string): Promise { + await this.findById(id); + + const updated = await this.locomotivesRepository.update(id, { + status: 'OUT_OF_SERVICE', + }); + + if (!updated) { + throw new NotFoundException(`Locomotive ${id} not found`); + } + + return updated; + } } diff --git a/apps/edr-freight-api/src/modules/payment/dto/initiate-booking-payment.dto.ts b/apps/edr-freight-api/src/modules/payment/dto/initiate-booking-payment.dto.ts deleted file mode 100644 index 3ff5f1798..000000000 --- a/apps/edr-freight-api/src/modules/payment/dto/initiate-booking-payment.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IsString } from "class-validator"; - -export class InitiateBookingPayment { - @IsString() - bookingId!: string; -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index cb03ee25d..83b4d00dd 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -4,7 +4,7 @@ import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } type PaymentType = "booking" type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" type Currency = "ETB" | "USD" -type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" +export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" @Entity({ schema: 'freight', name: 'payments' }) export class PaymentEntity extends BaseEntity { diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 296c60520..4799a4c37 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -1,53 +1,31 @@ -import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common"; +import { Controller, Get, NotFoundException, Param, Post, Res } from "@nestjs/common"; import { PaymentService } from "./payment.service"; import { Public } from "@edr/api-common"; -// import { randomUUID } from "crypto"; import { Response } from "express" @Public() @Controller("payments") export class PaymentController { - constructor(private readonly paymentService: PaymentService,) { } + constructor(private readonly paymentService: PaymentService,) { } + @Post("/initiate") + initiate() { + return this.paymentService.initBookingTelebirr("123", "web") + } - // @Get("/receipts/:orderId/html") - // async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) { - // const filled = await this.paymentService.genReceiptHtml(orderId); - // return res.send(filled) - // } + @Post("/bookings/check-payment/:orderId") + checkPayment(@Param("orderId") orderId: string) { + return this.paymentService.checkStatusAndUpdate(orderId) + } - // @Post("/initiate/booking") - // async initiatePayment() { - - // //Only for testing.. - // const description = "Booking for contact" - // const price = 2000 - // const data = await this.paymentService.pay(price, "ETB", "telebirr", description, "booking", (_) => { - // return new Promise((resp, _) => { - // resp({ - // id: randomUUID(), - // type: "booking" - // }) - // }); - // }) - - // return data - // } - - @Post("/bookings/check-payment/:orderId") - checkPayment(@Param("orderId", ParseUUIDPipe) orderId: string) { - return this.paymentService.checkStatusAndUpdate(orderId) + @Get("/bookings/telebirr/redirect/:orderId") + async pay(@Param("orderId") orderId: string, @Res() res: Response) { + const payment = await this.paymentService.getActivePaymentByOrderIdAndMethod(orderId, "telebirr") + if (!payment) { + throw new NotFoundException('payment not found') } - - @Get("/telebirr/:refId") - async pay(@Param("refId", ParseUUIDPipe) refId: string, @Res() res: Response) { - const payment = await this.paymentService.getActivePaymentByRefIdAndMethod(refId, "telebirr") - if (!payment) { - throw new NotFoundException('payment not found') - } - - return res.send(` + return res.send(` @@ -62,6 +40,5 @@ export class PaymentController { `); - } - + } } diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index ff6a50943..ac38503b9 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -1,5 +1,4 @@ import { Module } from "@nestjs/common"; -import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy"; import { PaymentService } from "./payment.service"; import { HttpModule } from "@nestjs/axios"; import { PaymentController } from "./payment.controller"; @@ -7,10 +6,11 @@ import { ConfigModule } from "@nestjs/config"; import { PaymentRepository } from "./payment.repository"; import { WebhookController } from "./webhooks/webhook.controller"; import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service"; +import { TelebirrProvider } from "@edr/payment-providers"; @Module({ imports: [HttpModule, ConfigModule], - providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService], + providers: [PaymentRepository, PaymentService, TelebirrWebhookService, TelebirrProvider], controllers: [PaymentController, WebhookController], exports: [PaymentService] }) diff --git a/apps/edr-freight-api/src/modules/payment/payment.repository.ts b/apps/edr-freight-api/src/modules/payment/payment.repository.ts index b9e37a55b..3a713c357 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.repository.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.repository.ts @@ -14,6 +14,13 @@ export class PaymentRepository { return qr.manager.save(payment) } + async create(data: Pick): Promise { + const payment = this.paymentRepo.create(data) + return this.paymentRepo.save(payment) + } + + + findOneBy(options: FindOptionsWhere | FindOptionsWhere[]): Promise { return this.paymentRepo.findOneBy(options); } @@ -36,4 +43,20 @@ export class PaymentRepository { + + + getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]) { + return this.paymentRepo + .createQueryBuilder('payment') + .where('payment.method = :method', { method }) + .andWhere('payment.merchantOrderId = :orderId', { orderId }) + .andWhere('payment.status IN (:...statuses)', { + statuses: ['action-required'], + }) + .andWhere('payment.expiresAt > :now', { now: new Date() }) + .getOne(); + } + + + } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 997a0e18f..ccc0e8833 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -1,111 +1,90 @@ -import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common"; -import { DataSource, QueryRunner } from "typeorm"; +import { + BadRequestException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from "@nestjs/common"; +import { DataSource } from "typeorm"; import { PaymentEntity } from "./entities/payment.entity"; -import { PaymentStrategy } from "./strategies/payment.strategy"; -import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy"; import { PaymentRepository } from "./payment.repository"; -import { ClientAction, PaymentPlatform } from "./strategies/payments.types"; -import * as crypto from 'crypto'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as Handlebars from 'handlebars'; +import * as fs from "fs"; +import * as path from "path"; +import * as Handlebars from "handlebars"; import { ConfigService } from "@nestjs/config"; import { Booking } from "../bookings/entities/booking.entity"; +import { + ClientAction, + createMerchantOrderId, + ProviderPaymentStatus, + TelebirrProvider, +} from "@edr/payment-providers"; +import { ProviderInitiationInput } from "@edr/types" +import { InitiateResponseDto, PaymentPlatformDto } from "./payments.dto"; -type PaymentMethod = PaymentEntity["method"] -type CurrencyType = PaymentEntity["currency"] +const DEFAULT_CURRENCY = "ETB"; @Injectable() export class PaymentService { - private strategies: Map; - constructor( private readonly configService: ConfigService, private readonly datasource: DataSource, private readonly paymentRepo: PaymentRepository, - private readonly telebirrPaymentStategy: PaymentTelebirrStrategy) { - this.strategies = new Map([ - ["telebirr", this.telebirrPaymentStategy as PaymentStrategy] - ]) - } + private readonly telebirrProvider: TelebirrProvider, + ) { } - async pay(amount: number, currency: CurrencyType, method: PaymentMethod, reason: string, type: PaymentEntity["type"], cb: (qr: QueryRunner) => Promise<{ id: string, type: PaymentEntity["type"] }>, payform: PaymentPlatform = "web"): Promise<{ - refId: string, - clientAction: ClientAction, - status: PaymentEntity["status"], - paidAt?: string, - failureCode?: string, - failureMessage?: string, - }> { + async initBookingTelebirr( + bookingId: string, + platform: PaymentPlatformDto, + ): Promise<{ redirectUrl: string }> { + // const booking = await this.datasource.getRepository(Booking).findOneBy({ id: bookingId }); + // if (!booking) throw new NotFoundException("Booking not found"); + // const booking = new Booking() + // booking.totalAmount = 20 + // booking.id = randomUUID + const amount = 20 + const merchantOrderId = createMerchantOrderId(); + const redirectBase = this.configService.get("TELEBIRR_SUCCESS_BOOKING_REDIRECT_BASE_URL"); + const redirectUrl = `${redirectBase}/${merchantOrderId}`; + const amountMinor = Math.round(Number(amount) * 100); - const strategy = this.strategies.get(method) - if (!strategy) { - throw new NotFoundException("strategy not found") - } - - const orderId = `${Date.now()}${crypto.randomBytes(4).toString('hex')}` //todo: make it dynamic - let redirectUrl: string; - switch (type) { - case "booking": - const url = this.configService.get("TELEBIRR_SUCCESS_REDIRECT_BASE_URL") - redirectUrl = `${url}/check-status/${orderId}` - break; - } - - const paymentResp = await strategy.pay({ + const input: ProviderInitiationInput = { + merchantOrderId, + orderRef: bookingId, + amountMinor, + currency: DEFAULT_CURRENCY, + platform: platform || "web", redirectUrl, - amountMinor: amount, - currency: currency, - merchantOrderId: orderId, - platform: payform, + }; + + const result = await this.telebirrProvider.initiate(input); + + const payment = await this.paymentRepo.create({ + amount: amount, + currency: DEFAULT_CURRENCY, + method: "telebirr", + refId: bookingId, + type: "booking", + merchantOrderId, + rawInitiation: result.rawInitiation, + clientAction: result.clientAction as Record, + expiresAt: result.expiresAt, + reason: `Payment for booking`, }); - const queryRunner = this.datasource.createQueryRunner() - await queryRunner.connect() - await queryRunner.startTransaction() - - console.log(paymentResp.expiresAt) - try { - const resp = await cb(queryRunner) - const payment = await this.paymentRepo.createTr(queryRunner, { - amount, - currency, - method, - refId: resp.id, - type: resp.type, - merchantOrderId: orderId, - rawInitiation: paymentResp.rawInitiation, - clientAction: paymentResp.clientAction, - expiresAt: paymentResp.expiresAt, - reason - - }) - await queryRunner.commitTransaction() - return { - refId: payment.refId, - clientAction: paymentResp.clientAction, - status: payment.status, - paidAt: payment.paidAt?.toISOString(), - failureCode: payment.failerCode ?? undefined, - failureMessage: payment.failureMessage ?? undefined, - } - } catch (err) { - await queryRunner.rollbackTransaction() - throw new Error("payment failed") - } finally { - await queryRunner.release() + return { + redirectUrl: `${this.configService.get("TELEBIRR_REDIRECT_BASE_URL")}/${payment.merchantOrderId}` } - } - async getActivePaymentByRefIdAndMethod(refId: string, method: PaymentEntity["method"]): Promise { - return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method) + async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise { + return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method) } + async genReceiptHtml(orderId: string) { const payment = await this.paymentRepo.findOneBy({ merchantOrderId: orderId, @@ -141,13 +120,9 @@ export class PaymentService { if (!resp) { throw new NotFoundException("order id not found") } - const result = await this.telebirrPaymentStategy.queryStatus(resp.merchantOrderId) - const bizContent = result.rawResponse.biz_content as { - order_status: string; - }; + const result = await this.telebirrProvider.queryStatus(resp.merchantOrderId) - const ordersStatus = bizContent.order_status - if (ordersStatus == "PAY_SUCCESS") { + if (result.status === ProviderPaymentStatus.SUCCEEDED) { await this.datasource.transaction(async (mg) => { await mg.update(Booking, { id: resp.refId }, { status: "PAID" }) await mg.update(PaymentEntity, { id: resp.id }, { status: "success" }) @@ -158,5 +133,28 @@ export class PaymentService { } } -} + findBookingById(id: string) { + return this.paymentRepo.findOneBy({ refId: id, type: "booking" }) + } + formatIntentResponse(intent: PaymentEntity): InitiateResponseDto { + const clientAction = + intent.clientAction && typeof intent.clientAction === "object" + ? (intent.clientAction as unknown as ClientAction) + : undefined; + const statusMap: Record = { + "action-required": ProviderPaymentStatus.REQUIRES_ACTION, + "processing": ProviderPaymentStatus.PROCESSING, + "success": ProviderPaymentStatus.SUCCEEDED, + "failed": ProviderPaymentStatus.FAILED, + "canceled": ProviderPaymentStatus.CANCELLED, + "refunded": ProviderPaymentStatus.CANCELLED, + }; + return { + intentId: intent.id, + status: statusMap[intent.status] ?? ProviderPaymentStatus.PROCESSING, + clientAction, + merchantOrderId: intent.merchantOrderId ?? undefined, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts new file mode 100644 index 000000000..a3e3d256e --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -0,0 +1,62 @@ +import { ProviderPaymentStatus } from "@edr/types"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsOptional, IsString } from "class-validator"; + +export type PaymentPlatformDto = "web" | "mobile"; + +export class InitiatePaymentDto { + @ApiProperty({ example: "booking-uuid" }) + @IsString() + bookingId!: string; + + @ApiProperty({ enum: ["TELEBIRR"], example: "TELEBIRR" }) + @IsIn(["TELEBIRR"]) + method!: "TELEBIRR"; + + @ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" }) + @IsOptional() + @IsIn(["web", "mobile"]) + platform?: PaymentPlatformDto; +} + +export class ClientActionDto { + @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] }) + type!: "REDIRECT" | "LAUNCH_APP"; + + @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) + url?: string; + + @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) + appId?: string; + + @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) + receiveCode?: string; + + @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) + shortCode?: string; +} + +export class InitiateResponseDto { + @ApiProperty() + intentId!: string; + + @ApiProperty({ enum: ProviderPaymentStatus }) + status!: ProviderPaymentStatus; + + @ApiPropertyOptional({ type: ClientActionDto }) + clientAction?: ClientActionDto; + + @ApiPropertyOptional() + merchantOrderId?: string; +} + +export class IntentStatusDto extends InitiateResponseDto { + @ApiPropertyOptional() + paidAt?: string; + + @ApiPropertyOptional() + failureCode?: string; + + @ApiPropertyOptional() + failureMessage?: string; +} diff --git a/apps/edr-freight-api/src/modules/payment/strategies/payment.strategy.ts b/apps/edr-freight-api/src/modules/payment/strategies/payment.strategy.ts deleted file mode 100644 index b1daa2770..000000000 --- a/apps/edr-freight-api/src/modules/payment/strategies/payment.strategy.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { ProviderInitiationInput, ProviderInitiationResult } from "./payments.types"; - - -@Injectable() -export abstract class PaymentStrategy { - abstract pay(data: ProviderInitiationInput): Promise -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/strategies/payment.telebirr.strategy.ts b/apps/edr-freight-api/src/modules/payment/strategies/payment.telebirr.strategy.ts deleted file mode 100644 index de1768bc6..000000000 --- a/apps/edr-freight-api/src/modules/payment/strategies/payment.telebirr.strategy.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { PaymentStrategy } from "./payment.strategy"; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as https from 'node:https'; -import { PaymentEntity } from "../entities/payment.entity"; -import { ProviderInitiationInput, ProviderInitiationResult, ProviderStatus } from "./payments.types"; -import { CreateOrderRequest, CreateOrderResponse, FabricTokenResponse, QueryOrderResponse } from "./telebirr/telebirr.types"; -import { createNonceStr, createTimestamp, signRequestObject, verifyRequestObject } from "./telebirr/telebirr.crypto"; - - - -// type PaymentCurrency = PaymentEntity["currency"] -type PaymentIntentStatus = PaymentEntity["status"] - -const TELEBIRR_HTTP_TIMEOUT_MS = 10_000; - -@Injectable() -export class PaymentTelebirrStrategy implements PaymentStrategy { - async pay(data: ProviderInitiationInput): Promise { - // const refId = randomUUID() - // const orderId = createMerchantOrderId() - const resp = await this.initiate(data) - return resp; - } - - // readonly method = PaymentMethodType.TELEBIRR; - private readonly logger = new Logger(PaymentTelebirrStrategy.name); - private readonly httpsAgent: https.Agent; - - constructor( - private readonly config: ConfigService, - private readonly http: HttpService, - ) { - const insecure = this.config.get('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 { - const fabricToken = await this.applyFabricToken(); - const requestBody = this.buildCreateOrderRequest(input); - const response = await this.requestCreateOrder(fabricToken, requestBody); - - const prepayId = response.biz_content?.prepay_id; - if (!prepayId) { - throw new Error( - `Telebirr createOrder returned no prepay_id: ${JSON.stringify(response)}`, - ); - } - - const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express); - const platform = input.platform ?? 'web'; - const clientAction = - platform === 'mobile' - ? { - type: 'LAUNCH_APP' as const, - prepayId, - receiveCode: response.biz_content?.receiveCode, - shortCode: this.merchantCode, - } - : { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) }; - - return { - providerOrderId: prepayId, - clientAction, - expiresAt, - rawInitiation: { - request: this.sanitize(requestBody), - response, - }, - }; - } - - async queryStatus(merchantOrderId: string): Promise { - const fabricToken = await this.applyFabricToken(); - const requestBody = this.buildQueryOrderRequest(merchantOrderId); - const response = await this.postJson( - `${this.baseUrl}/payment/v1/merchant/queryOrder`, - requestBody, - { - 'Content-Type': 'application/json', - 'X-APP-Key': this.fabricAppId, - Authorization: fabricToken, - }, - ); - - const tradeStatus = response.biz_content?.trade_status; - const providerTxnId = - response.biz_content?.trans_id ?? response.biz_content?.payment_order_id; - const mapped = this.mapTradeStatus(tradeStatus); - - return { - status: mapped, - providerTxnId, - failureCode: - mapped === "failed" && tradeStatus ? tradeStatus : undefined, - rawResponse: response as Record, - }; - } - - mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus { - switch (tradeStatus) { - case 'PAY_SUCCESS': - return "success"; - case 'PAY_FAILED': - case 'ORDER_CLOSED': - return "failed"; - case 'WAIT_PAY': - return "action-required"; - case 'PAYING': - return "processing"; - default: - return "processing"; - } - } - - mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus { - switch (tradeStatus) { - case 'Completed': - return "success"; - case 'Failure': - case 'Expired': - return "failed"; - case 'Paying': - case 'Pending': - return "processing"; - default: - return "processing"; - } - } - - verifyWebhookSignature(payload: Record): 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 { - console.log(this.baseUrl, "base url") - const response = await this.postJson( - `${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 { - return this.postJson( - `${this.baseUrl}/payment/v1/inapp/createOrder`, - body, - { - 'Content-Type': 'application/json', - 'X-APP-Key': this.fabricAppId, - Authorization: fabricToken, - }, - ); - } - - private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest { - // const totalAmount = String(input.amountMinor / 100); - const totalAmount = String(input.amountMinor) - const req = { - timestamp: createTimestamp(), - nonce_str: createNonceStr(), - method: 'payment.preorder' as const, - version: '1.0' as const, - biz_content: { - notify_url: this.notifyUrl, - appid: this.merchantAppId, - redirect_url: input.redirectUrl, - merch_code: this.merchantCode, - merch_order_id: input.merchantOrderId, - trade_type: 'Checkout' as const, - title: `EDR Booking`, - total_amount: totalAmount, - trans_currency: input.currency, - timeout_express: this.timeoutExpress, - }, - }; - const sign = signRequestObject(req as unknown as Record, this.privateKey); - return { ...req, sign, sign_type: 'SHA256WithRSA' }; - } - - private buildQueryOrderRequest(merchantOrderId: string): Record { - 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, this.privateKey); - return { ...req, sign, sign_type: 'SHA256WithRSA' }; - } - - private buildCheckoutUrl(prepayId: string): string { - const map: Record = { - 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( - url: string, - body: unknown, - headers: Record, - ): Promise { - const config: AxiosRequestConfig = { - headers, - timeout: TELEBIRR_HTTP_TIMEOUT_MS, - httpsAgent: this.httpsAgent, - }; - const started = Date.now(); - try { - const res = await firstValueFrom(this.http.post(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 { - const { sign: _sign, ...rest } = body; - return rest; - } - - private get baseUrl(): string { return this.config.get('telebirr.baseUrl') ?? ''; } - private get webBaseUrl(): string { return this.config.get('telebirr.webBaseUrl') ?? ''; } - private get fabricAppId(): string { return this.config.get('telebirr.fabricAppId') ?? ''; } - private get appSecret(): string { return this.config.get('telebirr.appSecret') ?? ''; } - private get merchantAppId(): string { return this.config.get('telebirr.merchantAppId') ?? ''; } - private get merchantCode(): string { return this.config.get('telebirr.merchantCode') ?? ''; } - private get notifyUrl(): string { return this.config.get('telebirr.notifyUrl') ?? ''; } - private get timeoutExpress(): string { return this.config.get('telebirr.timeoutExpress') ?? '15m'; } - private get privateKey(): string { return this.config.get('telebirr.privateKey') ?? ''; } - private get publicKey(): string { - return this.config.get('telebirr.publicKey') ?? ''; - } - -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/strategies/payments.types.ts b/apps/edr-freight-api/src/modules/payment/strategies/payments.types.ts deleted file mode 100644 index 75a1c8bdc..000000000 --- a/apps/edr-freight-api/src/modules/payment/strategies/payments.types.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { PaymentEntity } from "../entities/payment.entity"; - -type PaymentIntentStatus = PaymentEntity["status"] -type PaymentMethodType = PaymentEntity["method"] - -export type PaymentPlatform = 'web' | 'mobile'; - -export type ClientAction = - | { type: 'REDIRECT'; url: string } - | { type: 'LAUNCH_APP'; prepayId: string; receiveCode?: string; shortCode: string }; - -export interface ProviderInitiationInput { - redirectUrl: string; - merchantOrderId: string; - // bookingRef: string; - amountMinor: number; - currency: string; - platform?: PaymentPlatform; -} - -export interface ProviderInitiationResult { - providerOrderId: string; - clientAction: ClientAction; - expiresAt: Date; - rawInitiation: Record; -} - -export interface ProviderStatus { - status: PaymentIntentStatus; - providerTxnId?: string; - failureCode?: string; - failureMessage?: string; - rawResponse: Record; -} - -export interface PaymentProvider { - readonly method: PaymentMethodType; - initiate(input: ProviderInitiationInput): Promise; - queryStatus(merchantOrderId: string): Promise; -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/strategies/telebirr/telebirr.crypto.ts b/apps/edr-freight-api/src/modules/payment/strategies/telebirr/telebirr.crypto.ts deleted file mode 100644 index 20319818d..000000000 --- a/apps/edr-freight-api/src/modules/payment/strategies/telebirr/telebirr.crypto.ts +++ /dev/null @@ -1,98 +0,0 @@ -import * as crypto from 'crypto'; - -const EXCLUDE_FIELDS = new Set([ - 'sign', - 'sign_type', - 'header', - 'refund_info', - 'openType', - 'raw_request', - 'biz_content', -]); - -const NONCE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - -export function buildCanonicalString(requestObject: Record): string { - const fieldMap: Record = {}; - - 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)) { - if (EXCLUDE_FIELDS.has(key)) continue; - fieldMap[key] = (biz as Record)[key]; - } - } - - return Object.keys(fieldMap) - .sort() - .map((k) => `${k}=${fieldMap[k]}`) - .join('&'); -} - -export function signRequestObject( - requestObject: Record, - privateKey: string, -): string { - return signString(buildCanonicalString(requestObject), privateKey); -} - -export function verifyRequestObject( - requestObject: Record, - 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')}`; -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/strategies/telebirr/telebirr.types.ts b/apps/edr-freight-api/src/modules/payment/strategies/telebirr/telebirr.types.ts deleted file mode 100644 index 6cc29e9f4..000000000 --- a/apps/edr-freight-api/src/modules/payment/strategies/telebirr/telebirr.types.ts +++ /dev/null @@ -1,69 +0,0 @@ -export interface FabricTokenResponse { - token: string; - expires_in?: number | string; -} - -export interface CreateOrderBizContent { - notify_url: string; - appid: string; - merch_code: string; - merch_order_id: string; - trade_type: 'Checkout' | 'InApp' | 'MiniApp'; - title: string; - total_amount: string; - trans_currency: string; - timeout_express: string; -} - -export interface CreateOrderRequest { - timestamp: string; - nonce_str: string; - method: 'payment.preorder'; - version: '1.0'; - biz_content: CreateOrderBizContent; - sign: string; - sign_type: 'SHA256WithRSA'; -} - -export interface CreateOrderResponse { - code?: string; - msg?: string; - biz_content?: { - prepay_id?: string; - receiveCode?: string; - [key: string]: unknown; - }; - [key: string]: unknown; -} - -export type TelebirrTradeStatus = - | 'PAY_SUCCESS' - | 'PAY_FAILED' - | 'WAIT_PAY' - | 'ORDER_CLOSED' - | 'PAYING' - | 'ACCEPTED' - | 'REFUNDING' - | 'REFUND_SUCCESS' - | 'REFUND_FAILED'; - -export interface QueryOrderResponse { - result?: 'SUCCESS' | 'FAIL'; - code?: string; - msg?: string; - nonce_str?: string; - sign?: string; - sign_type?: string; - biz_content?: { - merch_order_id?: string; - order_status?: string; - trade_status?: TelebirrTradeStatus | string; - payment_order_id?: string; - trans_id?: string; - trans_time?: string; - trans_currency?: string; - total_amount?: string; - [key: string]: unknown; - }; - [key: string]: unknown; -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts b/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts index 3e450c2a0..cf89a2d60 100644 --- a/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts +++ b/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts @@ -1,82 +1,53 @@ -import { Injectable, } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import * as crypto from "crypto" +import { Injectable, Logger } from '@nestjs/common'; import { TelebirrDto } from '../dto/telebirr.dto'; import { PaymentRepository } from '../../payment.repository'; import { DataSource } from 'typeorm'; -import { Booking } from 'src/modules/bookings/entities/booking.entity'; +import { Booking } from '../../../bookings/entities/booking.entity'; +import { TelebirrProvider, ProviderPaymentStatus } from '@edr/payment-providers'; + @Injectable() export class TelebirrWebhookService { - // private readonly logger = new Logger(TelebirrWebhookService.name); + private readonly logger = new Logger(TelebirrWebhookService.name); constructor( private readonly datasource: DataSource, - private readonly config: ConfigService, private readonly paymentRepo: PaymentRepository, - + private readonly telebirrProvider: TelebirrProvider, ) { } verifyTelebirrNotification(payload: TelebirrDto) { - // 1. Extract the signature provided by Telebirr - const { sign, ...bizContent } = payload; - - if (!sign) { - throw new Error("Missing 'sign' field from Telebirr payload"); - } - - // 2. Sort the remaining keys alphabetically to rebuild the raw string - const sortedKeys = Object.keys(bizContent).sort(); - const signString = sortedKeys - .map(key => `${key}=${typeof bizContent[key] === 'object' ? JSON.stringify(bizContent[key]) : bizContent[key]}`) - .join('&'); - - // 3. Convert Telebirr's public key into an object specifying RSA-PSS padding - const publicKey = { - key: this.config.get("telebirr.publicKey") ?? "", - padding: crypto.constants.RSA_PKCS1_PSS_PADDING, - saltLength: 32 // Telebirr standard salt length - }; - - // 4. Verify the signature against the sorted string - const isVerified = crypto.verify( - "sha256", - Buffer.from(signString), - publicKey, - Buffer.from(sign, 'base64') - ); - - return isVerified; + return this.telebirrProvider.verifyWebhookSignature(payload as unknown as Record); } async handle(payload: TelebirrDto): Promise { const payment = await this.paymentRepo.findOneBy({ merchantOrderId: payload.merch_order_id }) if (!payment) { - throw new Error("payment not found") + this.logger.warn(`Webhook received for unknown merchantOrderId: ${payload.merch_order_id}`); + return; } - switch (payload.trade_status) { - case "SUCCEEDED": - await this.paymentRepo.update({ id: payment.id }, { status: "success", paidAt: new Date() }) - switch (payment.type) { - case "booking": - await this.datasource.manager.update(Booking, { id: payment.refId }, { paymentStatus: "PAID", }) - // await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID", }) - break; + + const mapped = this.telebirrProvider.mapWebhookTradeStatus(payload.trade_status); + + switch (mapped) { + case ProviderPaymentStatus.SUCCEEDED: + await this.paymentRepo.update( + { id: payment.id }, + { status: "success", paidAt: new Date() }, + ); + if (payment.type === "booking") { + await this.datasource.manager.update( + Booking, + { id: payment.refId }, + { paymentStatus: "PAID" }, + ); } break; - case "FAILED": - await this.paymentRepo.update({ id: payment.id }, { status: "failed" }) + case ProviderPaymentStatus.FAILED: + await this.paymentRepo.update({ id: payment.id }, { status: "failed" }); break; - case "CANCELLED": - await this.paymentRepo.update({ id: payment.id }, { status: "canceled" }) + case ProviderPaymentStatus.PROCESSING: + await this.paymentRepo.update({ id: payment.id }, { status: "processing" }); break; - case "PROCESSING": - await this.paymentRepo.update({ id: payment.id }, { status: "processing" }) - break; - case "REFUNDED": - await this.paymentRepo.update({ id: payment.id }, { status: "refunded" }) - break; - } } - } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/webhooks/webhook.controller.ts b/apps/edr-freight-api/src/modules/payment/webhooks/webhook.controller.ts index d74eb82a0..16473e614 100644 --- a/apps/edr-freight-api/src/modules/payment/webhooks/webhook.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/webhooks/webhook.controller.ts @@ -22,14 +22,12 @@ export class WebhookController { ); try { - // const verified = this.telebirr.verifyTelebirrNotification(payload) - // if (!verified) { - // throw new Error("not valid") - // } - // const merchantOrderId = payload.merch_order_id; + const verified = this.telebirr.verifyTelebirrNotification(payload) + if (!verified) { + throw new Error("Telebirr webhook signature verification failed") + } await this.telebirr.handle(payload); - } catch (err) { const message = err instanceof Error ? err.message : String(err); this.logger.error(`Telebirr webhook handler threw: ${message}`); diff --git a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts new file mode 100644 index 000000000..45e737607 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator'; + +export class CreateRouteMilestoneDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + yardId!: string; +} + +export class CreateRouteDto { + @ApiProperty() + @IsString() + @MaxLength(120) + name!: string; + + @ApiProperty({ type: [CreateRouteMilestoneDto] }) + @IsArray() + @ArrayMinSize(2) + @ValidateNested({ each: true }) + @Type(() => CreateRouteMilestoneDto) + milestones!: CreateRouteMilestoneDto[]; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts new file mode 100644 index 000000000..020a34cdf --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts @@ -0,0 +1,16 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsOptional, IsString } from 'class-validator'; + +export class FilterRoutesDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional() + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/routes/dto/update-route.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/update-route.dto.ts new file mode 100644 index 000000000..ccda6bd61 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/dto/update-route.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateRouteDto } from './create-route.dto'; + +export class UpdateRouteDto extends PartialType(CreateRouteDto) {} diff --git a/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts new file mode 100644 index 000000000..63e37b8ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { Route } from './route.entity'; + +@Entity({ schema: 'freight', name: 'route_milestones' }) +@Index(['routeId', 'sequenceNo'], { unique: true }) +export class RouteMilestone extends BaseEntity { + @Column({ name: 'route_id', type: 'uuid' }) + routeId!: string; + + @ManyToOne(() => Route, (route) => route.milestones, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'route_id' }) + route?: Route; + + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'yard_id' }) + yard?: Yard; + + @Column({ name: 'sequence_no', type: 'int' }) + sequenceNo!: number; +} diff --git a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts new file mode 100644 index 000000000..8c6e4785e --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts @@ -0,0 +1,33 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { RouteMilestone } from './route-milestone.entity'; + +@Entity({ schema: 'freight', name: 'routes' }) +@Index(['name']) +@Index(['isActive']) +export class Route extends BaseEntity { + @Column({ name: 'name', type: 'varchar', length: 120, unique: true }) + name!: string; + + @Column({ name: 'origin_yard_id', type: 'uuid' }) + originYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'origin_yard_id' }) + originYard?: Yard; + + @Column({ name: 'destination_yard_id', type: 'uuid' }) + destinationYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'destination_yard_id' }) + destinationYard?: Yard; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false }) + milestones?: RouteMilestone[]; +} diff --git a/apps/edr-freight-api/src/modules/routes/route-milestones.repository.ts b/apps/edr-freight-api/src/modules/routes/route-milestones.repository.ts new file mode 100644 index 000000000..a0e97cd23 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/route-milestones.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { RouteMilestone } from './entities/route-milestone.entity'; + +@Injectable() +export class RouteMilestonesRepository extends BaseRepository { + constructor(@InjectRepository(RouteMilestone) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/routes/routes.controller.ts b/apps/edr-freight-api/src/modules/routes/routes.controller.ts new file mode 100644 index 000000000..4af088727 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateRouteDto } from './dto/create-route.dto'; +import { FilterRoutesDto } from './dto/filter-routes.dto'; +import { UpdateRouteDto } from './dto/update-route.dto'; +import { RoutesService } from './routes.service'; + +@ApiTags('routes') +@ApiBearerAuth() +@Controller('routes') +export class RoutesController { + constructor(private readonly routesService: RoutesService) {} + + @Get() + @ApiOperation({ summary: 'List routes' }) + findAll(@Query() filter: FilterRoutesDto) { + return this.routesService.findAll(filter); + } + + @Get(':id') + @ApiOperation({ summary: 'Get route by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.routesService.findById(id); + } + + @Post() + @ApiOperation({ summary: 'Create route' }) + create(@Body() dto: CreateRouteDto) { + return this.routesService.create(dto); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update route' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) { + return this.routesService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Deactivate route' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.routesService.deactivate(id); + } +} diff --git a/apps/edr-freight-api/src/modules/routes/routes.module.ts b/apps/edr-freight-api/src/modules/routes/routes.module.ts new file mode 100644 index 000000000..c7033f25b --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { Yard } from '../rule-engine/entities/yard.entity'; +import { RouteMilestone } from './entities/route-milestone.entity'; +import { Route } from './entities/route.entity'; +import { RouteMilestonesRepository } from './route-milestones.repository'; +import { RoutesController } from './routes.controller'; +import { RoutesRepository } from './routes.repository'; +import { RoutesService } from './routes.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Route, RouteMilestone, Yard])], + controllers: [RoutesController], + providers: [RoutesRepository, RouteMilestonesRepository, RoutesService], + exports: [RoutesRepository, RouteMilestonesRepository, RoutesService], +}) +export class RoutesModule {} diff --git a/apps/edr-freight-api/src/modules/routes/routes.repository.ts b/apps/edr-freight-api/src/modules/routes/routes.repository.ts new file mode 100644 index 000000000..df6df41d0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Route } from './entities/route.entity'; + +@Injectable() +export class RoutesRepository extends BaseRepository { + constructor(@InjectRepository(Route) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts new file mode 100644 index 000000000..4c8e62498 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -0,0 +1,171 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, ILike } from 'typeorm'; + +import { Yard } from '../rule-engine/entities/yard.entity'; +import { CreateRouteDto } from './dto/create-route.dto'; +import { FilterRoutesDto } from './dto/filter-routes.dto'; +import { UpdateRouteDto } from './dto/update-route.dto'; +import { RouteMilestone } from './entities/route-milestone.entity'; +import { Route } from './entities/route.entity'; +import { RoutesRepository } from './routes.repository'; + +@Injectable() +export class RoutesService { + constructor( + private readonly dataSource: DataSource, + private readonly routesRepository: RoutesRepository, + ) {} + + findAll(filter: FilterRoutesDto): Promise { + return this.routesRepository.findAll({ + where: { + ...(filter.search ? { name: ILike(`%${filter.search.trim()}%`) } : {}), + ...(filter.isActive !== undefined ? { isActive: filter.isActive } : {}), + }, + relations: { + originYard: true, + destinationYard: true, + milestones: { yard: true }, + }, + order: { + name: 'ASC', + milestones: { sequenceNo: 'ASC' }, + }, + }); + } + + async findById(id: string): Promise { + const route = await this.dataSource.getRepository(Route).findOne({ + where: { id }, + relations: { + originYard: true, + destinationYard: true, + milestones: { yard: true }, + }, + order: { milestones: { sequenceNo: 'ASC' } }, + }); + + if (!route) { + throw new NotFoundException(`Route ${id} not found`); + } + + return route; + } + + async create(dto: CreateRouteDto): Promise { + await this.validateRouteName(dto.name); + const validated = await this.validateMilestones(dto.milestones); + + const route = await this.dataSource.transaction(async (manager) => { + const savedRoute = await manager.getRepository(Route).save( + manager.getRepository(Route).create({ + name: dto.name.trim(), + originYardId: validated.originYardId, + destinationYardId: validated.destinationYardId, + isActive: dto.isActive ?? true, + }), + ); + + await manager.getRepository(RouteMilestone).save( + validated.milestones.map((milestone) => + manager.getRepository(RouteMilestone).create({ + routeId: savedRoute.id, + yardId: milestone.yardId, + sequenceNo: milestone.sequenceNo, + }), + ), + ); + + return savedRoute; + }); + + return this.findById(route.id); + } + + async update(id: string, dto: UpdateRouteDto): Promise { + const existing = await this.findById(id); + + if (dto.name && dto.name.trim() !== existing.name) { + await this.validateRouteName(dto.name, id); + } + + const milestoneInput = dto.milestones + ? await this.validateMilestones(dto.milestones) + : null; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(Route).update(id, { + name: dto.name?.trim() ?? existing.name, + originYardId: milestoneInput?.originYardId ?? existing.originYardId, + destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId, + isActive: dto.isActive ?? existing.isActive, + }); + + if (milestoneInput) { + await manager.getRepository(RouteMilestone).delete({ routeId: id }); + await manager.getRepository(RouteMilestone).save( + milestoneInput.milestones.map((milestone) => + manager.getRepository(RouteMilestone).create({ + routeId: id, + yardId: milestone.yardId, + sequenceNo: milestone.sequenceNo, + }), + ), + ); + } + }); + + return this.findById(id); + } + + async deactivate(id: string): Promise { + await this.findById(id); + const updated = await this.routesRepository.update(id, { isActive: false }); + + if (!updated) { + throw new NotFoundException(`Route ${id} not found`); + } + + return this.findById(id); + } + + private async validateRouteName(name: string, routeId?: string) { + const trimmedName = name.trim(); + const [existing] = await this.routesRepository.findAll({ where: { name: trimmedName } }); + + if (existing && existing.id !== routeId) { + throw new ConflictException(`Route name ${trimmedName} already exists`); + } + } + + private async validateMilestones(milestones: Array<{ yardId: string }>) { + if (milestones.length < 2) { + throw new BadRequestException('A route requires at least two yards'); + } + + const normalized = milestones.map((milestone, index) => ({ + yardId: milestone.yardId, + sequenceNo: index + 1, + })); + + const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))]; + const yards = await this.dataSource.getRepository(Yard).find({ where: uniqueYardIds.map((id) => ({ id })) }); + const yardIds = new Set(yards.map((yard) => yard.id)); + + for (const milestone of normalized) { + if (!yardIds.has(milestone.yardId)) { + throw new BadRequestException(`Yard ${milestone.yardId} does not exist`); + } + } + + if (normalized[0].yardId === normalized[normalized.length - 1].yardId) { + throw new BadRequestException('Origin and destination yards must be different'); + } + + return { + originYardId: normalized[0].yardId, + destinationYardId: normalized[normalized.length - 1].yardId, + milestones: normalized, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 965723f6c..4edd09f09 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; import { Yard } from '../../rule-engine/entities/yard.entity'; +import { Route } from '../../routes/entities/route.entity'; import { TrainSet } from '../../train-sets/entities/train-set.entity'; import { TrainScheduleBooking } from './train-schedule-booking.entity'; @@ -26,6 +27,13 @@ export class TrainSchedule extends BaseEntity { @JoinColumn({ name: 'train_set_id' }) trainSet?: TrainSet; + @Column({ name: 'route_id', type: 'uuid', nullable: true }) + routeId?: string | null; + + @ManyToOne(() => Route) + @JoinColumn({ name: 'route_id' }) + route?: Route | null; + @Column({ name: 'origin_station_id', type: 'uuid' }) originStationId!: string; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 173b0a6b3..1b4fa29d8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -1,9 +1,15 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsUUID } from 'class-validator'; +import { IsDateString, IsUUID } from 'class-validator'; -import { PreviewContainerTrainScheduleDto } from './preview-container-train-schedule.dto'; +export class CreateContainerTrainScheduleDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + routeId!: string; + + @ApiProperty({ example: '2026-06-20T08:00:00.000Z' }) + @IsDateString() + scheduleDate!: string; -export class CreateContainerTrainScheduleDto extends PreviewContainerTrainScheduleDto { @ApiProperty({ format: 'uuid' }) @IsUUID() locomotiveId!: string; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 227b329d0..8f2b77bb4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -17,6 +17,7 @@ const locomotive = { id: 'loc-1', code: 'LOC-001', maxPullWeightTons: 3500, + maxTrainLengthMeters: 760, status: 'AVAILABLE', }; @@ -202,47 +203,12 @@ describe('TrainSchedulingService', () => { }); it('creates a schedule transactionally when validation passes', async () => { - const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')]; - const validation = { - valid: true, - violations: [], - bookings, - wagonType: nw5, - summary: { - totalBookings: 1, - totalWeightTons: 140, - wagonType: 'NW5', - wagonsNeeded: 2, - totalLengthMeters: 28, - }, - wagonPlan: [ - { - sequenceNo: 1, - capacityTons: 70, - lengthMeters: 14, - assignedWeightTons: 70, - allocations: [ - { - bookingId: 'b1', - bookingReference: 'BKG-CONT-001', - allocatedWeightTons: 70, - }, - ], - }, - { - sequenceNo: 2, - capacityTons: 70, - lengthMeters: 14, - assignedWeightTons: 70, - allocations: [ - { - bookingId: 'b1', - bookingReference: 'BKG-CONT-001', - allocatedWeightTons: 70, - }, - ], - }, - ], + const route = { + id: 'route-1', + name: 'Djibouti to Addis', + originYardId: 'yard-origin', + destinationYardId: 'yard-destination', + isActive: true, }; const lockedLocomotiveRepo = { @@ -253,23 +219,6 @@ describe('TrainSchedulingService', () => { create: jest.fn().mockImplementation((value) => value), save: jest.fn().mockResolvedValue({ id: 'schedule-1' }), }; - const trainScheduleBookingRepo = { - count: jest.fn().mockResolvedValue(0), - create: jest.fn().mockImplementation((value) => value), - save: jest.fn().mockResolvedValue(undefined), - }; - const trainSetWagonRepo = { - create: jest.fn().mockImplementation((value) => value), - save: jest.fn().mockResolvedValue(undefined), - find: jest.fn().mockResolvedValue([ - { id: 'wagon-1', sequenceNo: 1 }, - { id: 'wagon-2', sequenceNo: 2 }, - ]), - }; - const wagonAllocRepo = { - create: jest.fn().mockImplementation((value) => value), - save: jest.fn().mockResolvedValue(undefined), - }; const trainSetRepo = { create: jest.fn().mockImplementation((value) => value), save: jest.fn().mockResolvedValue({ id: 'train-set-1' }), @@ -281,12 +230,6 @@ describe('TrainSchedulingService', () => { return lockedLocomotiveRepo; case 'TrainSchedule': return trainScheduleRepo; - case 'TrainScheduleBooking': - return trainScheduleBookingRepo; - case 'TrainSetWagon': - return trainSetWagonRepo; - case 'WagonBookingAllocation': - return wagonAllocRepo; case 'TrainSet': return trainSetRepo; default: @@ -295,70 +238,60 @@ describe('TrainSchedulingService', () => { }), }; - jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never); jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); + dataSource.getRepository.mockImplementation((entity: { name?: string }) => { + if (entity?.name === 'Route') { + return { findOne: jest.fn().mockResolvedValue(route) }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }); jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never); dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => callback(manager), ); const result = await service.createContainerTrainSchedule({ - bookingIds: ['b1'], + routeId: 'route-1', scheduleDate: '2026-06-20T08:00:00.000Z', - originStationId: 'yard-origin', - destinationStationId: 'yard-destination', locomotiveId: 'loc-1', }); expect(trainSetRepo.save).toHaveBeenCalled(); expect(trainScheduleRepo.save).toHaveBeenCalled(); - expect(trainSetWagonRepo.save).toHaveBeenCalled(); - expect(wagonAllocRepo.save).toHaveBeenCalled(); expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' }); expect(result).toEqual({ id: 'schedule-1' }); }); it('rejects create when the locked locomotive is no longer available', async () => { - const validation = { - valid: true, - violations: [], - bookings: [makeBooking('b1', 'BKG-CONT-001', 70, 1, '40FT')], - wagonType: nw5, - summary: { - totalBookings: 1, - totalWeightTons: 70, - wagonType: 'NW5', - wagonsNeeded: 1, - totalLengthMeters: 14, - }, - wagonPlan: [ - { - sequenceNo: 1, - capacityTons: 70, - lengthMeters: 14, - assignedWeightTons: 70, - allocations: [], - }, - ], - }; const manager = { getRepository: jest.fn(() => ({ findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }), })), }; - jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never); jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); + dataSource.getRepository.mockImplementation((entity: { name?: string }) => { + if (entity?.name === 'Route') { + return { + findOne: jest.fn().mockResolvedValue({ + id: 'route-1', + name: 'Djibouti to Addis', + originYardId: 'yard-origin', + destinationYardId: 'yard-destination', + isActive: true, + }), + }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }); dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => callback(manager), ); await expect( service.createContainerTrainSchedule({ - bookingIds: ['b1'], + routeId: 'route-1', scheduleDate: '2026-06-20T08:00:00.000Z', - originStationId: 'yard-origin', - destinationStationId: 'yard-destination', locomotiveId: 'loc-1', }), ).rejects.toBeInstanceOf(ConflictException); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index e6b921656..64147940d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -15,9 +15,9 @@ import { import { LocomotivesRepository } from "../locomotives/locomotives.repository"; import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity"; import { TrainSet } from "../train-sets/entities/train-set.entity"; +import { Route } from "../routes/entities/route.entity"; import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity"; import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; -import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity"; import { WagonType } from "../wagon-types/entities/wagon-type.entity"; import { WagonTypesRepository } from "../wagon-types/wagon-types.repository"; import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto"; @@ -179,18 +179,12 @@ export class TrainSchedulingService { } async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) { - const validation = await this.validateContainerBookingsForScheduling(dto); - - if (!validation.valid) { - throw new BadRequestException({ - message: "train_schedule_invalid", - violations: validation.violations, - }); - } + const route = await this.getActiveRoute(dto.routeId); const locomotive = await this.selectOrValidateLocomotive( dto.locomotiveId, - validation.summary.totalWeightTons, + 0, + 0, ); const createdSchedule = await this.dataSource.transaction( @@ -211,90 +205,24 @@ export class TrainSchedulingService { ); } - if ( - Number(lockedLocomotive.maxPullWeightTons) < - validation.summary.totalWeightTons - ) { - throw new BadRequestException( - `Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`, - ); - } - - const existingScheduleCount = await manager - .getRepository(TrainScheduleBooking) - .count({ - where: { - bookingId: In(validation.bookings.map((booking) => booking.id)), - }, - }); - - if (existingScheduleCount > 0) { - throw new BadRequestException( - "One or more bookings are already scheduled", - ); - } - - const trainSet = await this.buildTrainSet( + const trainSet = await this.buildEmptyTrainSet( manager, lockedLocomotive, - validation.wagonType, - validation.summary.totalWeightTons, - validation.summary.totalLengthMeters, - validation.wagonPlan, ); const schedule = manager.getRepository(TrainSchedule).create({ trainSetId: trainSet.id, - originStationId: dto.originStationId, - destinationStationId: dto.destinationStationId, + routeId: route.id, + originStationId: route.originYardId, + destinationStationId: route.destinationYardId, scheduledDepartureDate: new Date(dto.scheduleDate), - status: "SCHEDULED", + status: "DRAFT", }); const savedSchedule = await manager .getRepository(TrainSchedule) .save(schedule); - const scheduleBookings = validation.bookings.map((booking) => - manager.getRepository(TrainScheduleBooking).create({ - trainScheduleId: savedSchedule.id, - bookingId: booking.id, - }), - ); - await manager - .getRepository(TrainScheduleBooking) - .save(scheduleBookings); - - const savedWagons = await manager.getRepository(TrainSetWagon).find({ - where: { trainSetId: trainSet.id }, - order: { sequenceNo: "ASC" }, - }); - - const wagonBySequence = new Map( - savedWagons.map((wagon) => [wagon.sequenceNo, wagon]), - ); - const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => { - const wagon = wagonBySequence.get(wagonPlan.sequenceNo); - - if (!wagon) { - throw new BadRequestException( - `Missing wagon sequence ${wagonPlan.sequenceNo}`, - ); - } - - return wagonPlan.allocations.map((allocation) => - manager.getRepository(WagonBookingAllocation).create({ - trainSetWagonId: wagon.id, - bookingId: allocation.bookingId, - allocatedWeightTons: allocation.allocatedWeightTons, - }), - ); - }); - - await manager - .getRepository(WagonBookingAllocation) - .save(allocationRows); - await locomotiveRepository.update(lockedLocomotive.id, { status: "ASSIGNED", }); @@ -461,10 +389,14 @@ export class TrainSchedulingService { where: { status: "AVAILABLE" }, }); const canPull = capableLocomotives.some( - (locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons, + (locomotive) => + Number(locomotive.maxPullWeightTons) >= totalWeightTons && + Number(locomotive.maxTrainLengthMeters) >= totalLengthMeters, ); if (!canPull) { - violations.push("No available locomotive can pull the total weight"); + violations.push( + 'No available locomotive can support the total train weight and length', + ); } } @@ -513,6 +445,7 @@ export class TrainSchedulingService { async selectOrValidateLocomotive( locomotiveId: string, totalWeightTons: number, + totalLengthMeters: number, ) { const locomotive = await this.locomotivesRepository.findById(locomotiveId); @@ -532,6 +465,12 @@ export class TrainSchedulingService { ); } + if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) { + throw new BadRequestException( + `Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`, + ); + } + return locomotive; } @@ -568,6 +507,21 @@ export class TrainSchedulingService { return savedTrainSet; } + async buildEmptyTrainSet( + manager: EntityManager, + locomotive: Locomotive, + ) { + const trainSet = manager.getRepository(TrainSet).create({ + locomotiveId: locomotive.id, + totalWeightTons: 0, + totalLengthMeters: 0, + wagonCount: 0, + status: 'DRAFT', + }); + + return manager.getRepository(TrainSet).save(trainSet); + } + allocateBookingsToWagons( bookings: Booking[], baseWagonPlan: WagonPlanRecord[], @@ -627,6 +581,7 @@ export class TrainSchedulingService { const schedules = await this.dataSource.getRepository(TrainSchedule).find({ relations: { trainSet: { locomotive: true }, + route: true, originStation: true, destinationStation: true, scheduleBookings: true, @@ -637,6 +592,7 @@ export class TrainSchedulingService { return schedules.map((schedule) => ({ id: schedule.id, scheduleDate: schedule.scheduledDepartureDate, + routeName: schedule.route?.name ?? null, origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: @@ -668,6 +624,7 @@ export class TrainSchedulingService { .findOne({ where: { id }, relations: { + route: true, trainSet: { locomotive: true, wagons: { wagonType: true, allocations: { booking: true } }, @@ -687,6 +644,12 @@ export class TrainSchedulingService { return { id: schedule.id, status: schedule.status, + route: schedule.route + ? { + id: schedule.route.id, + name: schedule.route.name, + } + : null, scheduledDepartureDate: schedule.scheduledDepartureDate, scheduledArrivalDate: schedule.scheduledArrivalDate, originStation: schedule.originStation, @@ -711,6 +674,9 @@ export class TrainSchedulingService { maxPullWeightTons: this.roundTons( Number(schedule.trainSet.locomotive.maxPullWeightTons), ), + maxTrainLengthMeters: this.roundTons( + Number(schedule.trainSet.locomotive.maxTrainLengthMeters), + ), } : null, wagons: [...(schedule.trainSet.wagons ?? [])] @@ -806,6 +772,22 @@ export class TrainSchedulingService { }); } + private async getActiveRoute(routeId: string) { + const route = await this.dataSource.getRepository(Route).findOne({ + where: { id: routeId }, + }); + + if (!route) { + throw new NotFoundException(`Route ${routeId} not found`); + } + + if (!route.isActive) { + throw new BadRequestException(`Route ${route.name} is inactive`); + } + + return route; + } + private toUtcDateKey(value: Date | string) { const date = value instanceof Date ? value : new Date(value); return date.toISOString().slice(0, 10); diff --git a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts index 686336b6d..6de5debda 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts @@ -14,6 +14,9 @@ import { const toNumber = ({ value }: { value: unknown }) => value === '' || value == null ? value : Number(value); +const toOptionalNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? undefined : Number(value); + const toBoolean = ({ value }: { value: unknown }) => { if (typeof value === 'boolean') return value; if (value === 'true') return true; @@ -22,8 +25,12 @@ const toBoolean = ({ value }: { value: unknown }) => { }; const toStringArray = ({ value }: { value: unknown }) => { - if (Array.isArray(value)) return value; + if (Array.isArray(value)) { + return value.map((entry) => String(entry).trim()).filter(Boolean); + } + if (typeof value !== 'string') return []; + return value .split(',') .map((entry) => entry.trim()) @@ -31,36 +38,40 @@ const toStringArray = ({ value }: { value: unknown }) => { }; export class CreateWagonTypeDto { - @ApiProperty({ maxLength: 32, example: 'FLAT' }) + @ApiProperty({ maxLength: 32, example: 'NW5' }) @IsString() @MaxLength(32) code!: string; - @ApiProperty({ maxLength: 100, example: 'Flat wagon' }) + @ApiProperty({ maxLength: 100, example: 'Flat wagon container' }) @IsString() @MaxLength(100) name!: string; - @ApiProperty({ example: 60 }) + @ApiProperty({ description: 'Maximum payload capacity in metric tons', example: 70 }) @Transform(toNumber) @IsNumber() - @Min(0) + @Min(0.001) capacityTons!: number; - @ApiProperty({ example: 14.2 }) + @ApiProperty({ description: 'Wagon length in meters', example: 14 }) @Transform(toNumber) @IsNumber() - @Min(0) + @Min(0.001) lengthMeters!: number; - @ApiPropertyOptional({ example: 45 }) + @ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 53 }) @IsOptional() - @Transform(toNumber) + @Transform(toOptionalNumber) @IsInt() @Min(1) maxWagonsPerTrain?: number; - @ApiPropertyOptional({ type: [String], example: ['container', 'break-bulk'] }) + @ApiPropertyOptional({ + description: 'Supported load types, e.g. CONTAINER,BULK', + type: [String], + default: [], + }) @IsOptional() @Transform(toStringArray) @IsArray() diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts index 76b52a0aa..8f6417220 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts @@ -11,48 +11,64 @@ import { Post, Query, } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards'; + import { CreateWagonTypeDto } from './dto/create-wagon-type.dto'; import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto'; import { WagonTypesService } from './wagon-types.service'; -import { WagonType } from './entities/wagon-type.entity'; -@ApiTags('Wagon Types') +@ApiTags('wagon-types') @Controller('wagon-types') +@ApiBearerAuth() export class WagonTypesController { constructor(private readonly wagonTypesService: WagonTypesService) {} - @Post() - @ApiOperation({ summary: 'Create a wagon type' }) - async create(@Body() dto: CreateWagonTypeDto): Promise { - return this.wagonTypesService.create(dto); - } - @Get() - @ApiOperation({ summary: 'Get wagon types' }) - async findAll(@Query() query: Record): Promise { - return this.wagonTypesService.findAll(query); + @RuleEngineView('wagon-types') + @ApiOperation({ summary: 'List wagon types' }) + findAll(@Query() query: Record) { + return this.wagonTypesService.findAll({ + isActive: + query.isActive === 'all' + ? undefined + : query.isActive !== undefined + ? query.isActive === 'true' + : true, + page: query.page ? parseInt(query.page, 10) : undefined, + pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }); } @Get(':id') + @RuleEngineView('wagon-types') @ApiOperation({ summary: 'Get a wagon type by ID' }) - async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + findOne(@Param('id', ParseUUIDPipe) id: string) { return this.wagonTypesService.findById(id); } + @Post() + @RuleEngineManage('wagon-types') + @ApiOperation({ summary: 'Create a wagon type' }) + create(@Body() dto: CreateWagonTypeDto) { + return this.wagonTypesService.create(dto); + } + @Patch(':id') + @RuleEngineManage('wagon-types') @ApiOperation({ summary: 'Update a wagon type' }) - async update( - @Param('id', ParseUUIDPipe) id: string, - @Body() dto: UpdateWagonTypeDto, - ): Promise { + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto) { return this.wagonTypesService.update(id, dto); } @Delete(':id') + @RuleEngineManage('wagon-types') @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Deactivate a wagon type' }) - async remove(@Param('id', ParseUUIDPipe) id: string): Promise { + @ApiOperation({ summary: 'Soft-delete a wagon type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { return this.wagonTypesService.remove(id); } } diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts index 001ff0212..ce7166e67 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts @@ -13,4 +13,8 @@ export class WagonTypesRepository extends BaseRepository { ) { super(repository); } + + findByCode(code: string): Promise { + return this.repository.findOne({ where: { code } }); + } } diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts index 6f9e5830e..ec69bb76a 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts @@ -6,44 +6,47 @@ import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto'; import { WagonType } from './entities/wagon-type.entity'; import { WagonTypesRepository } from './wagon-types.repository'; +type WagonTypeListFilter = { + isActive?: boolean; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: string; +}; + @Injectable() export class WagonTypesService { constructor(private readonly wagonTypesRepository: WagonTypesRepository) {} - async create(dto: CreateWagonTypeDto): Promise { - const code = dto.code.trim().toUpperCase(); - const existing = await this.wagonTypesRepository.findAll({ where: { code } }); - if (existing.length > 0) { - throw new ConflictException(`Wagon type code "${code}" already exists`); - } - - return this.wagonTypesRepository.create({ - ...dto, - code, - name: dto.name.trim(), - supportedLoadTypes: dto.supportedLoadTypes ?? [], - isActive: dto.isActive ?? true, - }); - } - - async findAll(query: Record = {}): Promise { - const isActive = - query.isActive === 'all' - ? undefined - : query.isActive === undefined - ? true - : query.isActive === 'true'; + async findAll(filter: WagonTypeListFilter = {}): Promise<{ + data: WagonType[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; + }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 500; const sortBy = ['code', 'name', 'capacityTons', 'lengthMeters', 'isActive'].includes( - query.sortBy ?? '', + filter.sortBy ?? '', ) - ? (query.sortBy as keyof WagonType) + ? (filter.sortBy as keyof WagonType) : 'code'; - const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; - return this.wagonTypesRepository.findAll({ - where: isActive === undefined ? {} : { isActive }, + const [data, total] = await this.wagonTypesRepository.findAndCount({ + where: filter.isActive === undefined ? {} : { isActive: filter.isActive }, order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: (page - 1) * pageSize, + take: pageSize, }); + + return { + data, + meta: { + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + }; } async findById(id: string): Promise { @@ -57,22 +60,39 @@ export class WagonTypesService { } async findByCode(code: string): Promise { - const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } }); - + const wagonType = await this.wagonTypesRepository.findByCode(code); if (!wagonType) { throw new NotFoundException(`Wagon type ${code} not found`); } - return wagonType; } + async create(dto: CreateWagonTypeDto): Promise { + const code = dto.code.trim().toUpperCase(); + const existing = await this.wagonTypesRepository.findByCode(code); + + if (existing) { + throw new ConflictException(`Wagon type code "${code}" already exists`); + } + + return this.wagonTypesRepository.create({ + code, + name: dto.name.trim(), + capacityTons: dto.capacityTons, + lengthMeters: dto.lengthMeters, + maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null, + supportedLoadTypes: dto.supportedLoadTypes ?? [], + isActive: dto.isActive ?? true, + }); + } + async update(id: string, dto: UpdateWagonTypeDto): Promise { const wagonType = await this.findById(id); const nextCode = dto.code?.trim().toUpperCase(); if (nextCode && nextCode !== wagonType.code) { - const existing = await this.wagonTypesRepository.findAll({ where: { code: nextCode } }); - if (existing.length > 0) { + const existing = await this.wagonTypesRepository.findByCode(nextCode); + if (existing) { throw new ConflictException(`Wagon type code "${nextCode}" already exists`); } } @@ -81,6 +101,9 @@ export class WagonTypesService { ...dto, ...(nextCode ? { code: nextCode } : {}), ...(dto.name ? { name: dto.name.trim() } : {}), + maxWagonsPerTrain: + dto.maxWagonsPerTrain === undefined ? undefined : dto.maxWagonsPerTrain ?? null, + supportedLoadTypes: dto.supportedLoadTypes ?? undefined, }); if (!updated) { @@ -92,6 +115,6 @@ export class WagonTypesService { async remove(id: string): Promise { await this.findById(id); - await this.wagonTypesRepository.update(id, { isActive: false }); + await this.wagonTypesRepository.softDelete(id); } } diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index c5dfad32c..edf19adbb 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -178,13 +178,17 @@ export class DemoBookingsSeeder { { code: "LOC-001", name: "Demo Locomotive 1", + locomotiveType: 'ELECTRIC', maxPullWeightTons: 3500, + maxTrainLengthMeters: 760, status: "AVAILABLE", }, { code: "LOC-002", name: "Demo Locomotive 2", + locomotiveType: 'DIESEL', maxPullWeightTons: 2500, + maxTrainLengthMeters: 760, status: "AVAILABLE", }, ], diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index dba80d96b..e82032cff 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -10,6 +10,7 @@ export type FreightPermissionSeed = { export const RULE_ENGINE_RESOURCE_SLUGS = [ 'cargo-types', 'container-types', + 'wagon-types', 'service-types', 'yards', 'shipping-lines', @@ -56,6 +57,7 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ const RULE_ENGINE_PERMISSION_IDS: Record = { 'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' }, 'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' }, + 'wagon-types': { view: 'b2000001-0001-4000-8000-000000000015', manage: 'b2000001-0001-4000-8000-000000000016' }, 'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' }, yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' }, 'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' }, diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 671436a73..fb8fbf2e6 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -353,6 +353,8 @@ export class PricingDataSeeder { ): Promise { const effectiveFrom = new Date("2026-01-01"); const now = new Date(); + // await rRepo.createQueryBuilder().delete().execute(); + const rateData = [ { rateType: "CONTAINER_IMPORT", diff --git a/apps/edr-freight-web/Dockerfile.backoffice b/apps/edr-freight-web/Dockerfile.backoffice deleted file mode 100644 index 62bec7399..000000000 --- a/apps/edr-freight-web/Dockerfile.backoffice +++ /dev/null @@ -1,18 +0,0 @@ -FROM node:20-alpine AS base -RUN corepack enable && corepack prepare pnpm@9.12.0 --activate -WORKDIR /app - -FROM base AS deps -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ -COPY apps/edr-freight-web/backoffice/package.json ./apps/edr-freight-web/backoffice/ -COPY packages ./packages -RUN pnpm install --frozen-lockfile --filter @edr/freight-backoffice... - -FROM deps AS build -COPY apps/edr-freight-web/backoffice ./apps/edr-freight-web/backoffice -RUN pnpm --filter @edr/freight-backoffice build - -FROM nginx:1.27-alpine AS runtime -COPY --from=build /app/apps/edr-freight-web/backoffice/dist /usr/share/nginx/html -EXPOSE 5183 -CMD ["nginx", "-g", "daemon off;"] diff --git a/apps/edr-freight-web/Dockerfile.portal b/apps/edr-freight-web/Dockerfile.portal deleted file mode 100644 index 32b71f672..000000000 --- a/apps/edr-freight-web/Dockerfile.portal +++ /dev/null @@ -1,18 +0,0 @@ -FROM node:20-alpine AS base -RUN corepack enable && corepack prepare pnpm@9.12.0 --activate -WORKDIR /app - -FROM base AS deps -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ -COPY apps/edr-freight-web/portal/package.json ./apps/edr-freight-web/portal/ -COPY packages ./packages -RUN pnpm install --frozen-lockfile --filter @edr/freight-portal... - -FROM deps AS build -COPY apps/edr-freight-web/portal ./apps/edr-freight-web/portal -RUN pnpm --filter @edr/freight-portal build - -FROM nginx:1.27-alpine AS runtime -COPY --from=build /app/apps/edr-freight-web/portal/dist /usr/share/nginx/html -EXPOSE 5173 -CMD ["nginx", "-g", "daemon off;"] diff --git a/apps/edr-freight-web/backoffice/index.css b/apps/edr-freight-web/backoffice/index.css index d232351ed..8d40838ea 100644 --- a/apps/edr-freight-web/backoffice/index.css +++ b/apps/edr-freight-web/backoffice/index.css @@ -1,6 +1,15 @@ @import "tailwindcss"; @import "@edr/ui-common/theme.css" layer(theme); +:root { + --freight-brand: #15803d; + --freight-brand-dark: #166534; + --freight-brand-light: #22c55e; + --freight-brand-muted: #f0fdf4; + --freight-brand-border: #bbf7d0; + --freight-brand-ring: rgb(21 128 61 / 0.2); +} + html, body, #root { diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 6bf5df01c..13e522996 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -14,6 +14,9 @@ "dependencies": { "@edr/types": "workspace:*", "@edr/ui-common": "workspace:*", + "@mantine/core": "^9.3.0", + "@mantine/hooks": "^9.3.0", + "@tabler/icons-react": "^3.44.0", "@hello-pangea/dnd": "^18.0.1", "@tanstack/react-query": "^5.100.11", "@tria-plc/iamui-common": "1.1.2", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index d02e4ff0d..a1807599b 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -34,16 +34,18 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; -import TrainsPage from "./pages/trains/TrainsPage"; -import { +import TrainsPage from "./pages/trains/TrainsPage"; +import { CargoesCrudPage, ContainersCrudPage, + LocomotivesCrudPage, TrainMasterDataPage, WagonTypesCrudPage, WagonsCrudPage, } from "./pages/fleet/FleetCrudPages"; -import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; -import TrainDetailPage from "./pages/trains/TrainDetailPage"; +import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; +import TrainDetailPage from "./pages/trains/TrainDetailPage"; +import RoutesPage from "./pages/fleet/RoutesPage"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { @@ -60,17 +62,32 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/booking-requests", icon: , }, + ...demoItems, + ], + }, + { + title: "Operations", + items: [ { - label: "Train scheduling", + label: "Train Schedules", href: "/dashboard/operations/train-scheduling", icon: , }, - ...demoItems, ], }, { title: "Fleet Management", items: [ + { + label: "Routes", + href: "/dashboard/routes", + icon: , + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + }, { label: "Trains", href: "/dashboard/trains", @@ -225,17 +242,19 @@ const App = () => { } /> } /> - } - /> - } /> + } + /> + } /> } /> } /> + } /> + } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx index 293c17e2a..25b3816b7 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { Check, ShieldCheck } from "lucide-react"; +import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core"; import { BookingConfirmDialog } from "./BookingConfirmDialog"; import { useAuth } from "@/auth/useAuth"; @@ -11,9 +12,7 @@ import { } from "@/features/bookings/booking-actions.config"; import type { useBookingMutations } from "@/hooks/bookings/useBookings"; import type { BookingApprovalStep, BookingDetail } from "@/types/booking"; -import { bookingGlass, bookingSurface } from "./booking-ui.styles"; -import { Badge, Button } from "@edr/ui-common"; -import { cn } from "@/lib/utils"; +import { SectionCard } from "./detail/SectionCard"; type Mutations = ReturnType; @@ -26,23 +25,16 @@ interface ApprovalStepsCardProps { export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) { const { user } = useAuth(); const [confirmOpen, setConfirmOpen] = useState(false); - const [pendingStep, setPendingStep] = useState( - null, - ); + const [pendingStep, setPendingStep] = useState(null); const steps = useMemo( - () => - [...(booking.approvalSteps ?? [])].sort( - (a, b) => a.stepOrder - b.stepOrder, - ), + () => [...(booking.approvalSteps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder), [booking.approvalSteps], ); const nextPending = getNextPendingApprovalStep(steps); const summary = formatApprovalProgress(booking.status, steps); - const pendingAction = pendingStep - ? buildApproveActionForStep(pendingStep) - : null; + const pendingAction = pendingStep ? buildApproveActionForStep(pendingStep) : null; const openApprove = (step: BookingApprovalStep) => { setPendingStep(step); @@ -62,54 +54,60 @@ export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps ); }; + const subtitle = + summary.detail || + (nextPending + ? `Next: ${nextPending.requiredRole} ยท step ${nextPending.stepOrder}` + : steps.length + ? "All steps complete" + : "Accept submission to begin"); + return ( <> -
-
-
- -
-
-

- Approval chain -

-

- {summary.detail || - (nextPending - ? `Next: ${nextPending.requiredRole} ยท step ${nextPending.stepOrder}` - : steps.length - ? "All steps complete" - : "Accept submission to begin")} -

-
-
+ + {steps.filter((s) => s.status === "APPROVED").length}/{steps.length} + + } + > + + {subtitle} + -
- {steps.length === 0 ? ( -

- Use{" "} - - Accept for approval - {" "} - in staff actions to instantiate steps. -

- ) : ( -
    - {steps.map((step) => ( - - ))} -
- )} -
-
+ {steps.length === 0 ? ( + + Use Accept for approval in staff actions to instantiate steps. + + ) : ( + + {steps.map((step) => ( + + ))} + + )} + void; }) { const canApprove = canActOnApprovalStep(user, step, steps); - const statusStyles = + const statusColor = step.status === "APPROVED" - ? "border-emerald-500/25 bg-emerald-500/10 text-black" + ? "green" : step.status === "REJECTED" - ? "bg-red-500/10 text-red-800 dark:text-red-300" + ? "red" : isNext - ? "border-emerald-500/25 bg-emerald-500/10 text-black" - : "bg-muted/40 text-muted-foreground"; + ? "green" + : "gray"; return ( -
  • -
    - + {step.stepOrder} - -
    -

    + + + {step.requiredRole} -

    + {step.remarks && ( -

    + {step.remarks} -

    + )} -
    -
    -
    + + + {canApprove && ( )} - + {step.status} -
    -
  • + + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx index e4e1d8db9..49950d97b 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -1,10 +1,6 @@ import { useNavigate } from "react-router-dom"; -import { - ChevronRight, - ExternalLink, - Loader2, - MoreHorizontal, -} from "lucide-react"; +import { ChevronRight, ExternalLink, MoreHorizontal } from "lucide-react"; +import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core"; import { BookingConfirmDialog } from "./BookingConfirmDialog"; import { useBookingActionDialog } from "./useBookingActionDialog"; @@ -16,16 +12,6 @@ import { type BookingActionContext, } from "@/features/bookings/booking-actions.config"; import type { BookingListRow } from "@/types/booking"; -import { cn } from "@/lib/utils"; -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@edr/ui-common"; interface BookingActionsMenuProps { row: BookingListRow; @@ -39,7 +25,6 @@ interface BookingActionsMenuProps { export function BookingActionsMenu({ row, variant = "table", - className, onSuppressRowClick, }: BookingActionsMenuProps) { const navigate = useNavigate(); @@ -57,184 +42,179 @@ export function BookingActionsMenu({ const goToContract = () => navigate(`/dashboard/booking-requests/${row.id}/contract`); - const hasMenu = listRowHasActions(row, user); + const handleAction = (action: (typeof actions)[number]) => { + onSuppressRowClick?.(); + if (isContractNavAction(action.id)) { + goToContract(); + } else { + flow.openAction(action); + } + }; + const hasMenu = listRowHasActions(row, user); const primary = actions.find((a) => a.primary) ?? actions[0]; if (!hasMenu && variant === "table") { return ( - + + + ); + } + + // Toolbar: lay every action out as a button row. + if (variant === "toolbar" && actions.length > 0) { + return ( + <> + + {actions.map((action) => { + const Icon = action.icon; + const destructive = action.variant === "destructive"; + return ( + + ); + })} + + + ); } return ( - <> -
    e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - {variant === "table" && primary && ( - + )} + + + + - - {primary.shortLabel} - - )} - - {variant === "toolbar" && actions.length > 0 ? ( -
    - {actions.map((action) => { - const Icon = action.icon; - return ( - - ); - })} -
    - ) : ( - - - - - - + +
    +
    + + + {row.reference} - - - {actions.map((action) => { - const Icon = action.icon; - return ( - { - event.preventDefault(); - onSuppressRowClick?.(); - if (isContractNavAction(action.id)) { - goToContract(); - } else { - flow.openAction(action); - } - }} - > - - {action.label} - - ); - })} - {actions.length > 0 && } - { - event.preventDefault(); - onSuppressRowClick?.(); - navigate(`/dashboard/booking-requests/${row.id}`); - }} - > - - Open full details - - - - )} -
    + + + {actions.map((action) => { + const Icon = action.icon; + return ( + } + onClick={() => handleAction(action)} + > + {action.label} + + ); + })} + {actions.length > 0 && } + } + onClick={() => { + onSuppressRowClick?.(); + navigate(`/dashboard/booking-requests/${row.id}`); + }} + > + Open full details + + + - { - if (!open) onSuppressRowClick?.(); - flow.setDialogOpen(open); - }} - action={pendingAction} - reference={flow.mergedContext.reference} - inputValue={flow.inputValue} - onInputChange={flow.setInputValue} - selectedFile={flow.selectedFile} - onFileChange={flow.setSelectedFile} - onConfirm={() => { - onSuppressRowClick?.(); - flow.runAction(); - }} - isPending={mutations.isPending || flow.detailLoading} - confirmDisabled={flow.confirmDisabled} - extra={ - flow.detailLoading ? ( -

    - - Loading approval stepsโ€ฆ -

    - ) : pendingAction?.id === "approve" && - !getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? ( -

    - No pending approval step. Refresh the page after staff accept, or - reject the booking. -

    - ) : null - } - /> - + + + ); +} + +function ActionDialog({ + flow, + pendingAction, + onSuppressRowClick, +}: { + flow: ReturnType; + pendingAction: ReturnType["pendingAction"]; + onSuppressRowClick?: () => void; +}) { + return ( + { + if (!open) onSuppressRowClick?.(); + flow.setDialogOpen(open); + }} + action={pendingAction} + reference={flow.mergedContext.reference} + inputValue={flow.inputValue} + onInputChange={flow.setInputValue} + selectedFile={flow.selectedFile} + onFileChange={flow.setSelectedFile} + onConfirm={() => { + onSuppressRowClick?.(); + flow.runAction(); + }} + isPending={flow.mutations.isPending || flow.detailLoading} + confirmDisabled={flow.confirmDisabled} + extra={ + flow.detailLoading ? ( + + Loading approval stepsโ€ฆ + + ) : pendingAction?.id === "approve" && + !getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? ( + + No pending approval step. Refresh the page after staff accept, or reject the + booking. + + ) : null + } + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx index 006c57e11..e451f2d2c 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx @@ -1,12 +1,11 @@ -import { Download, Zap } from "lucide-react"; +import { Download, Zap, FileText, Clock } from "lucide-react"; +import { Stack, Text, Button } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; import { BookingActionsMenu } from "./BookingActionsMenu"; -import { bookingSurface } from "./booking-ui.styles"; +import { SectionCard } from "./detail/SectionCard"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import type { useBookingMutations } from "@/hooks/bookings/useBookings"; -import { Button } from "@edr/ui-common"; -import { cn } from "@/lib/utils"; type Mutations = ReturnType; @@ -16,10 +15,7 @@ interface BookingActionsToolbarProps { } /** Detail-page actions: primary toolbar + downloads. */ -export function BookingActionsToolbar({ - booking, - mutations, -}: BookingActionsToolbarProps) { +export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) { const row = toBookingListRow(booking); const { status } = booking; @@ -33,50 +29,84 @@ export function BookingActionsToolbar({ URL.revokeObjectURL(url); }; - if ( - status === "REJECTED" || - status === "CANCELLED" || - status === "COMPLETED" - ) { + if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") { return null; } if (status === "CHANGES_REQUESTED") { return ( - - {booking.latestChangeRequestNote && ( -

    - {booking.latestChangeRequestNote} -

    - )} -
    + + + + No staff actions until resubmit. + + {booking.latestChangeRequestNote && ( + + {booking.latestChangeRequestNote} + + )} + + ); } if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) { return ( - + + + Monitor until the customer or system advances status. + + + ); + } + + if ( + ["FULLY_EXECUTED", "PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS"].includes( + status, + ) + ) { + return ( + + + + + Payment is completed by the customer. The booking status updates + automatically once payment is confirmed, then moves to Operations. + + {status === "FULLY_EXECUTED" && ( + + )} + + + ); } return ( -
    - - - + + + + + Confirm each step before it is applied. + + + + {status === "CONTRACT_READY" && ( - + - + )} -
    - ); -} - -function PanelShell({ - title, - description, - children, - muted, -}: { - title: string; - description: string; - children: React.ReactNode; - muted?: boolean; -}) { - return ( -
    -
    -
    - -
    -
    -

    {title}

    -

    {description}

    -
    -
    -
    {children}
    -
    + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx index 69d01692b..be051371c 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx @@ -14,7 +14,7 @@ export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCell

    {summary.label} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx index 190fe4475..3eae09b49 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx @@ -1,17 +1,16 @@ -import { Loader2 } from "lucide-react"; +import type { ReactNode } from "react"; +import { + Modal, + Group, + Stack, + Text, + Box, + Button, + Textarea, + FileInput, +} from "@mantine/core"; import type { BookingActionDef } from "@/features/bookings/booking-actions.config"; -import { cn } from "@/lib/utils"; -import { - Button, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - Textarea, -} from "@edr/ui-common"; interface BookingConfirmDialogProps { open: boolean; @@ -25,7 +24,7 @@ interface BookingConfirmDialogProps { onConfirm: () => void; isPending: boolean; confirmDisabled?: boolean; - extra?: React.ReactNode; + extra?: ReactNode; } export function BookingConfirmDialog({ @@ -45,129 +44,119 @@ export function BookingConfirmDialog({ if (!action || !action.confirmTitle) return null; const Icon = action.icon; - const needsTextInput = - action.input === "note" || action.input === "reason"; + const needsTextInput = action.input === "note" || action.input === "reason"; const needsFileInput = action.input === "file"; const inputMissing = - (needsTextInput && !inputValue.trim()) || - (needsFileInput && !selectedFile); + (needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile); const isDestructive = action.variant === "destructive"; - - const preventClickThrough = (event: React.MouseEvent) => { - event.preventDefault(); - }; + const accent = isDestructive ? "red" : "green"; return ( -

    - event.preventDefault()} + onOpenChange(false)} + withCloseButton={false} + centered + radius="md" + size="md" + padding={0} + title={null} + > + {/* Header */} + -
    - -
    -
    - -
    -
    - - {action.confirmTitle} - - {reference && ( -

    - {reference} -

    - )} -
    -
    - - {action.confirmDescription} - -
    -
    - -
    - {needsTextInput && ( -
    - - +
    + + +
    + )} + + setShowModal(false)} title="Create Notification Template"> +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/operational-reports/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/operational-reports/layout.tsx new file mode 100644 index 000000000..86d53715f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/operational-reports/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function Layout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx new file mode 100644 index 000000000..339cd591f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Download } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import { reportsApi } from '@/lib/api'; +import { formatDateTime, formatCurrency } from '@/lib/utils'; + +export default function OperationalreportsPage() { + const [filters, setFilters] = useState({ search: '', reportType: '' }); + + const { data, isLoading } = useQuery({ + queryKey: ['operational-reports', filters], + queryFn: () => reportsApi.getOperationalReports(filters), + }); + + const columns = [ + { key: 'reportType', label: 'Type', render: (report: any) => {report.reportType} }, + { key: 'period', label: 'Period', render: (report: any) => report.period || 'N/A' }, + { key: 'generatedBy', label: 'Generated By', render: (report: any) => report.generatedBy?.fullName || 'System' }, + { key: 'createdAt', label: 'Generated', render: (report: any) => formatDateTime(report.createdAt) }, + ]; + + return ( +
    +
    +
    +

    Operational Reports

    +

    View operational reports and analytics

    +
    + Export +
    + +
    +
    + +
    + + setFilters({ ...filters, search: e.target.value })} /> +
    +
    + + +
    + +
    +
    + + +
    + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/page.tsx b/apps/edr-passenger-web/backoffice/src/app/page.tsx new file mode 100644 index 000000000..1413e02ca --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function Home() { + redirect('/login'); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/layout.tsx new file mode 100644 index 000000000..f72f4df0b --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function PassengersLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx new file mode 100644 index 000000000..2f47cdb7a --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -0,0 +1,342 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Download, Eye, Trash2 } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import Pagination from '@/components/ui/Pagination'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { passengersApi, apiClient } from '@/lib/api'; +import { formatDate, formatDateTime } from '@/lib/utils'; +import { PassengerFilters } from '@/types'; + +export default function PassengersPage() { + const [filters, setFilters] = useState({ + page: 1, + pageSize: 20, + search: '', + }); + const [selectedPassenger, setSelectedPassenger] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null }); + + const queryClient = useQueryClient(); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/passengers/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['passengers'] }); + }, + }); + + const handleDelete = (passenger: any) => { + setDeleteConfirm({ isOpen: true, passenger }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.passenger) { + await deleteMutation.mutateAsync(deleteConfirm.passenger.id); + setDeleteConfirm({ isOpen: false, passenger: null }); + } + }; + + const { data, isLoading, error } = useQuery({ + queryKey: ['passengers', filters], + queryFn: () => passengersApi.getAll(filters), + }); + + if (error) { + console.error('Passengers API Error:', error); + } + + const columns = [ + { + key: 'fullName', + label: 'Name', + sortable: true, + render: (passenger: any) => ( +
    +
    {passenger.fullName}
    +
    {passenger.email}
    +
    + ), + }, + { + key: 'phone', + label: 'Phone', + render: (passenger: any) => passenger.phone, + }, + { + key: 'nationalId', + label: 'National ID', + render: (passenger: any) => passenger.nationalId || 'N/A', + }, + { + key: 'dateOfBirth', + label: 'Date of Birth', + render: (passenger: any) => passenger.dateOfBirth ? formatDate(passenger.dateOfBirth) : 'N/A', + }, + { + key: 'verified', + label: 'Status', + render: (passenger: any) => ( + + {passenger.nationalId ? 'Verified' : 'Unverified'} + + ), + }, + ]; + + const actions = [ + { + label: 'View Details', + onClick: (passenger: any) => setSelectedPassenger(passenger), + variant: 'secondary' as const, + icon: Eye, + }, + { + label: 'Delete', + onClick: handleDelete, + variant: 'danger' as const, + icon: Trash2, + }, + ]; + + return ( +
    +
    +
    +

    Passengers

    +

    Manage passenger profiles and verification

    +
    +
    + Export +
    +
    + +
    + {error && ( +
    + Error loading passengers: {error instanceof Error ? error.message : 'Unknown error'} +
    + )} +
    +
    + setFilters({ ...filters, search: e.target.value, page: 1 })} + /> +
    + +
    + + + + {data?.meta && ( + setFilters({ ...filters, page })} + /> + )} +
    + + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, passenger: null })} + onConfirm={confirmDelete} + title="Delete Passenger" + message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`} + confirmText="Delete" + isDanger={true} + warning="This passenger may have active bookings, loyalty points, and wallet balance. Deleting will impact these systems and records." + /> + + {/* Passenger Details Modal */} + setSelectedPassenger(null)} + title="Passenger Details" + size="xl" + > + {selectedPassenger && ( +
    + {/* Personal Information */} +
    +

    Personal Information

    +
    +
    + +

    {selectedPassenger.fullName}

    +
    +
    + +

    + {selectedPassenger.dateOfBirth ? formatDate(selectedPassenger.dateOfBirth) : 'N/A'} +

    +
    +
    + +

    {selectedPassenger.gender || 'N/A'}

    +
    +
    + +

    {selectedPassenger.nationality || 'N/A'}

    +
    +
    +
    + +
    + + {/* Contact Information */} +
    +

    Contact Information

    +
    +
    + +

    {selectedPassenger.email || 'N/A'}

    +
    +
    + +

    {selectedPassenger.phone || 'N/A'}

    +
    +
    +
    + +
    + + {/* Identification */} +
    +

    Identification

    +
    +
    + +

    {selectedPassenger.nationalId || 'N/A'}

    +
    +
    + +

    {selectedPassenger.passportNumber || 'N/A'}

    +
    +
    + +

    {selectedPassenger.passportCountry || 'N/A'}

    +
    +
    + +
    + + {selectedPassenger.nationalId ? 'Verified' : 'Unverified'} + +
    +
    +
    +
    + +
    + + {/* Account Information */} +
    +

    Account Information

    +
    +
    + +

    {selectedPassenger.id}

    +
    +
    + +

    {selectedPassenger.userId || 'N/A'}

    +
    +
    +
    + + {/* Loyalty & Wallet (if available) */} + {(selectedPassenger.loyalty || selectedPassenger.wallet) && ( + <> +
    +
    + {selectedPassenger.loyalty && ( +
    +

    Loyalty Account

    +
    +
    + +

    {selectedPassenger.loyalty.tier || 'N/A'}

    +
    +
    + +

    {selectedPassenger.loyalty.pointsBalance || 0}

    +
    +
    +
    + )} + {selectedPassenger.wallet && ( +
    +

    Wallet

    +
    +
    + +

    + {(selectedPassenger.wallet.balanceMinor / 100).toFixed(2)} {selectedPassenger.wallet.currency} +

    +
    +
    +
    + )} +
    + + )} + +
    + + {/* Timestamps */} +
    +

    Timestamps

    +
    +
    + +

    {selectedPassenger.createdAt ? formatDateTime(selectedPassenger.createdAt) : 'N/A'}

    +
    +
    + +

    {selectedPassenger.updatedAt ? formatDateTime(selectedPassenger.updatedAt) : 'N/A'}

    +
    +
    +
    + +
    + setSelectedPassenger(null)} + > + Close + +
    +
    + )} +
    +
    + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/layout.tsx new file mode 100644 index 000000000..86d53715f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/payments/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function Layout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx new file mode 100644 index 000000000..69ef22859 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Download } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import { paymentsApi } from '@/lib/api'; +import { formatDateTime, formatCurrency } from '@/lib/utils'; + +export default function PaymentsPage() { + const [filters, setFilters] = useState({ search: '', status: '', method: '' }); + + const { data, isLoading } = useQuery({ + queryKey: ['payments', filters], + queryFn: () => paymentsApi.getAll(filters), + }); + + const columns = [ + { key: 'reference', label: 'Reference', render: (payment: any) => {payment.reference || payment.id?.substring(0, 8)} }, + { key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' }, + { key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) }, + { key: 'method', label: 'Method', render: (payment: any) => {payment.method} }, + { key: 'status', label: 'Status', render: (payment: any) => {payment.status} }, + { key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) }, + ]; + + const actions: any[] = []; + + return ( +
    +
    +
    +

    Payments

    +

    Manage payment transactions and refunds

    +
    + Export +
    + +
    +
    + +
    + + setFilters({ ...filters, search: e.target.value })} /> +
    +
    + + +
    + +
    +
    + + +
    + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/pricing/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/pricing/layout.tsx new file mode 100644 index 000000000..9a5bb12de --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/pricing/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function PricingLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx new file mode 100644 index 000000000..36937946f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Save } from 'lucide-react'; +import Table from '@/components/ui/Table'; +import { routesApi } from '@/lib/api/routes'; +import { formatCurrency } from '@/lib/utils'; + +export default function PricingPage() { + const [selectedRoute, setSelectedRoute] = useState('1'); + + const { data: fareRules } = useQuery({ + queryKey: ['fare-rules', selectedRoute], + queryFn: () => routesApi.getFareRules(selectedRoute), + initialData: [ + { id: '1', routeId: '1', passengerCategory: 'ADULT', serviceClass: 'ECONOMY_REGULAR', baseFare: 35000, currency: 'ETB' }, + { id: '2', routeId: '1', passengerCategory: 'CHILD', serviceClass: 'ECONOMY_REGULAR', baseFare: 0, currency: 'ETB' }, + { id: '3', routeId: '1', passengerCategory: 'ADULT', serviceClass: 'ECONOMY_BED', baseFare: 52500, currency: 'ETB' }, + { id: '4', routeId: '1', passengerCategory: 'ADULT', serviceClass: 'VIP_BED', baseFare: 70000, currency: 'ETB' }, + ], + }); + + return ( +
    +
    +
    +

    Pricing & Fare Rules

    +

    Manage fare rules and pricing for different routes and classes

    +
    + +
    + +
    +
    + + +
    + +
    +

    Fare Rules

    +

    Configure base fares for different passenger categories and service classes

    +
    + + ( + + )}, + { key: 'currency', label: 'Currency' }, + ]} + /> + + +
    +

    Pricing Rules

    +
    +
    +

    Age-Based Pricing

    +
      +
    • โ€ข ADULT (โ‰ฅ5 years): Pay 100% of base fare
    • +
    • โ€ข CHILD (<5 years): First child travels FREE, subsequent children pay 100%
    • +
    +
    +
    +

    Multi-Currency Support

    +
      +
    • โ€ข Transaction Currency: ETB (Ethiopian Birr)
    • +
    • โ€ข Display Currencies: ETB, DJF, USD
    • +
    • โ€ข Exchange rates: ETBโ†’DJF=3.25, ETBโ†’USD=0.018
    • +
    +
    +
    +
    + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/providers.tsx b/apps/edr-passenger-web/backoffice/src/app/providers.tsx new file mode 100644 index 000000000..663a52e05 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/providers.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useState, useEffect } from 'react'; +import { useTheme } from '@/lib/theme-store'; +import { useAuthStore } from '@/lib/auth-store'; + +function ThemeProvider({ children }: { children: React.ReactNode }) { + const { isDark, setTheme } = useTheme(); + + useEffect(() => { + document.documentElement.classList.toggle('dark', isDark); + }, [isDark]); + + return <>{children}; +} + +function AuthProvider({ children }: { children: React.ReactNode }) { + const initialize = useAuthStore((state) => state.initialize); + + useEffect(() => { + initialize(); + }, [initialize]); + + return <>{children}; +} + +export default function Providers({ children }: { children: React.ReactNode }) { + const [queryClient] = useState(() => new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + refetchOnWindowFocus: false, + }, + }, + })); + + return ( + + + {children} + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/layout.tsx new file mode 100644 index 000000000..d51983daf --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function ReportsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx new file mode 100644 index 000000000..7f1f02abe --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useState } from 'react'; +import { Download, Calendar } from 'lucide-react'; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; +import { formatCurrency } from '@/lib/utils'; + +const revenueByRoute = [ + { route: 'Addis - Djibouti', revenue: 125000000 }, + { route: 'Addis - Dire Dawa', revenue: 85000000 }, + { route: 'Dire Dawa - Djibouti', revenue: 45000000 }, +]; + +const bookingsByClass = [ + { name: 'Economy Regular', value: 65, color: '#3b82f6' }, + { name: 'Economy Bed', value: 25, color: '#10b981' }, + { name: 'VIP Bed', value: 10, color: '#f59e0b' }, +]; + +const occupancyData = [ + { month: 'Jan', rate: 72 }, + { month: 'Feb', rate: 78 }, + { month: 'Mar', rate: 85 }, + { month: 'Apr', rate: 82 }, + { month: 'May', rate: 88 }, + { month: 'Jun', rate: 91 }, +]; + +export default function ReportsPage() { + const [dateRange, setDateRange] = useState('last-30-days'); + + return ( +
    +
    +
    +

    Reports & Analytics

    +

    View detailed reports and analytics

    +
    +
    + + +
    +
    + +
    +
    +

    Revenue by Route

    + + + + + + formatCurrency(value, 'ETB')} /> + + + +
    + +
    +

    Bookings by Class

    + + + `${name}: ${value}%`} + outerRadius={100} + fill="#8884d8" + dataKey="value" + > + {bookingsByClass.map((entry, index) => ( + + ))} + + + + +
    + +
    +

    Occupancy Rate Trend

    + + + + + + `${value}%`} /> + + + +
    +
    + +
    +

    Quick Stats

    +
    +
    +

    Total Revenue

    +

    {formatCurrency(255000000, 'ETB')}

    +
    +
    +

    Total Bookings

    +

    1,247

    +
    +
    +

    Avg. Ticket Price

    +

    {formatCurrency(42500, 'ETB')}

    +
    +
    +

    Cancellation Rate

    +

    3.2%

    +
    +
    +
    +
    + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/layout.tsx new file mode 100644 index 000000000..987b54e48 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/routes/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function RoutesLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx new file mode 100644 index 000000000..cc1638a1b --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -0,0 +1,534 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Edit, Trash2, X } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { routesApi } from '@/lib/api/routes'; +import { stationsApi } from '@/lib/api'; + +interface RouteStop { + stationId: string; + sequence: number; + distanceKm?: number; + distanceFromOrigin?: number; +} + +export default function RoutesPage() { + const [showModal, setShowModal] = useState(false); + const [editingRoute, setEditingRoute] = useState(null); + const [stops, setStops] = useState([]); + const [originStationId, setOriginStationId] = useState(''); + const [destinationStationId, setDestinationStationId] = useState(''); + const [destinationDistance, setDestinationDistance] = useState(undefined); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null }>({ isOpen: false, route: null }); + const queryClient = useQueryClient(); + + const { data: routes, isLoading: routesLoading } = useQuery({ + queryKey: ['routes'], + queryFn: async () => { + const result = await routesApi.getAll(); + console.log('Routes query result:', result); + return result; + }, + }); + + const { data: stations } = useQuery({ + queryKey: ['stations'], + queryFn: stationsApi.getAll, + }); + + const createMutation = useMutation({ + mutationFn: routesApi.create, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['routes'] }); + setShowModal(false); + setEditingRoute(null); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => routesApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['routes'] }); + setShowModal(false); + setEditingRoute(null); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: routesApi.delete, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['routes'] }); + }, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + + if (!originStationId || !destinationStationId) { + alert('Please select origin and destination stations'); + return; + } + + if (originStationId === destinationStationId) { + alert('Origin and destination must be different'); + return; + } + + // Sort middle stops by distance from origin + const sortedMiddleStops = [...stops].sort((a, b) => + (a.distanceFromOrigin || 0) - (b.distanceFromOrigin || 0) + ); + + // Calculate distanceKm (distance from previous stop) + const stopsArray = [ + { stationId: originStationId, sequence: 1, distanceKm: 0 }, + ...sortedMiddleStops.map((stop, idx) => { + const prevDistance = idx === 0 ? 0 : (sortedMiddleStops[idx - 1].distanceFromOrigin || 0); + return { + stationId: stop.stationId, + sequence: idx + 2, + distanceKm: (stop.distanceFromOrigin || 0) - prevDistance, + }; + }), + { + stationId: destinationStationId, + sequence: sortedMiddleStops.length + 2, + distanceKm: (destinationDistance || 0) - (sortedMiddleStops.length > 0 ? (sortedMiddleStops[sortedMiddleStops.length - 1].distanceFromOrigin || 0) : 0), + }, + ]; + + const routeData = { + code: formData.get('code') as string, + name: formData.get('name') as string, + description: formData.get('description') as string || undefined, + effectiveFrom: formData.get('effectiveFrom') as string, + effectiveUntil: formData.get('effectiveUntil') as string || undefined, + stops: stopsArray, + }; + + console.log('Submitting route data:', JSON.stringify(routeData, null, 2)); + + if (editingRoute) { + await updateMutation.mutateAsync({ id: editingRoute.id, data: routeData }); + } else { + await createMutation.mutateAsync(routeData); + } + }; + + const addStop = () => { + setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: 0 }]); + }; + + const removeStop = (index: number) => { + setStops(stops.filter((_, i) => i !== index)); + }; + + const updateStop = (index: number, field: keyof RouteStop, value: any) => { + const updated = [...stops]; + updated[index] = { ...updated[index], [field]: value }; + setStops(updated); + }; + + const generateRouteCode = (originId: string, destId: string) => { + if (!originId || !destId) return ''; + const origin = stations?.items?.find((s: any) => s.id === originId); + const dest = stations?.items?.find((s: any) => s.id === destId); + return origin && dest ? `${origin.code}-${dest.code}` : ''; + }; + + const generateRouteName = (originId: string, destId: string) => { + if (!originId || !destId) return ''; + const origin = stations?.items?.find((s: any) => s.id === originId); + const dest = stations?.items?.find((s: any) => s.id === destId); + return origin && dest ? `${origin.name} - ${dest.name}` : ''; + }; + + const handleDelete = (route: any) => { + setDeleteConfirm({ isOpen: true, route }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.route) { + await deleteMutation.mutateAsync(deleteConfirm.route.id); + setDeleteConfirm({ isOpen: false, route: null }); + } + }; + + const routeColumns = [ + { key: 'code', label: 'Route Code', sortable: true }, + { key: 'name', label: 'Route Name', sortable: true }, + { key: 'description', label: 'Description', render: (route: any) => route.description || 'N/A' }, + { + key: 'active', + label: 'Status', + render: (route: any) => ( + + {route.active ? 'Active' : 'Inactive'} + + ), + }, + ]; + + const routeActions = [ + { + label: 'Edit', + onClick: (route: any) => { + setEditingRoute(route); + const routeStops = route.stops || []; + if (routeStops.length >= 2) { + setOriginStationId(routeStops[0].stationId); + setDestinationStationId(routeStops[routeStops.length - 1].stationId); + + // Calculate cumulative distance for destination + let cumulativeDistance = 0; + routeStops.forEach((stop: any, idx: number) => { + if (idx > 0) { + cumulativeDistance += stop.distanceKm || 0; + } + }); + setDestinationDistance(cumulativeDistance); + + // Calculate distance from origin for middle stops + const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => { + let distFromOrigin = 0; + for (let i = 1; i <= idx + 1; i++) { + distFromOrigin += routeStops[i].distanceKm || 0; + } + return { + stationId: stop.stationId, + sequence: stop.sequence, + distanceKm: stop.distanceKm, + distanceFromOrigin: distFromOrigin, + }; + }); + setStops(middleStops); + } + setShowModal(true); + }, + variant: 'secondary' as const, + icon: Edit, + }, + { + label: 'Delete', + onClick: handleDelete, + variant: 'danger' as const, + icon: Trash2, + }, + ]; + + return ( +
    +
    +
    +

    Routes

    +

    Manage railway routes

    +
    + { + setEditingRoute(null); + setOriginStationId(''); + setDestinationStationId(''); + setDestinationDistance(undefined); + setStops([]); + setShowModal(true); + }} + > + Add Route + +
    + + + + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, route: null })} + onConfirm={confirmDelete} + title="Delete Route" + message={`Are you sure you want to delete ${deleteConfirm.route?.name}?`} + confirmText="Delete" + isDanger={true} + warning="This route may be referenced by schedules and bookings. Deleting it may impact these systems." + /> + + {/* Add/Edit Modal */} + { + setShowModal(false); + setEditingRoute(null); + setOriginStationId(''); + setDestinationStationId(''); + setDestinationDistance(undefined); + setStops([]); + }} + title={`${editingRoute ? 'Edit' : 'Add'} Route`} + size="lg" + > +
    + {editingRoute && ( +
    +

    โš  Warning

    +

    Editing this route may impact schedules, trips, and bookings that reference it. Proceed with caution.

    +
    + )} +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + +
    + +