This commit is contained in:
Roba Boru
2026-06-05 22:01:29 +03:00
271 changed files with 25736 additions and 12557 deletions

View File

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

View File

@@ -1,96 +0,0 @@
name: Automatic Deployment
on:
push:
branches:
- dev
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
environment:
name: 🌍 Setup Environment
runs-on: [self-hosted]
outputs:
target: ${{ steps.dev.outputs.target || steps.staging.outputs.target }}
steps:
- name: Verify NPM Token
env:
# We map the secret here to check its existence
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ -z "$NPM_TOKEN" ]; then
echo "::error::The NPM_TOKEN secret is missing or empty. Please add it to your GitHub Secrets."
exit 1
fi
echo "NPM_TOKEN is present, proceeding with build..."
- name: 🛠️ Set Development Environment
id: dev
if: ${{github.ref_name == 'dev'}}
run: |
echo "target=dev" >> $GITHUB_OUTPUT
- name: 🚀 Set Staging Environment
id: staging
if: ${{github.ref_name == 'staging'}}
run: |
echo "target=staging" >> $GITHUB_OUTPUT
build-base-image:
name: 🏗️ Build Base Image
runs-on: [self-hosted, dev]
needs: [environment]
steps:
- name: 🔍 Checkout
uses: actions/checkout@v4
- name: 🐳 Build Docker Image
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
# Create the multi-line file
cat <<EOF > .npmrc_temp
@tria-plc:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
always-auth=true
EOF
# Build using the file
docker build --secret id=npmrc,src=.npmrc_temp -t edr-${{needs.environment.outputs.target}} .
docker build --secret id=npmrc,src=.npmrc_temp --target passenger-migration -t edr-passenger-migration-${{needs.environment.outputs.target}} .
# Shred/Remove the sensitive file
rm .npmrc_temp
deploy-service:
name: ${{ matrix.display_name }}
runs-on: [self-hosted, dev]
needs: [build-base-image, environment]
strategy:
fail-fast: false
matrix:
include:
- service: freight-api
env_file: .env.freight-api
display_name: 🚚 Deploy Freight API Service
- service: passenger-api
env_file: .env.passenger-api
display_name: 🧑‍🦲 Deploy Passenger API Service
steps:
- name: 🔍 Checkout
uses: actions/checkout@v4
- name: 📋 Copy ${{ matrix.service }} Environment
run: cp ~/environment/edr/${{needs.environment.outputs.target}}/${{ matrix.env_file }} .env
- name: 🧪 Run Passenger API migrations
if: ${{ matrix.service == 'passenger-api' }}
run: |
docker run --rm --env-file .env edr-passenger-migration-${{needs.environment.outputs.target}}
- name: 🚀 Start ${{ matrix.service }} Service
run: docker compose --project-name="edr-${{needs.environment.outputs.target}}" up -d --force-recreate ${{ matrix.service }} --build

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

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

5
.gitignore vendored
View File

@@ -22,4 +22,7 @@ coverage/
.DS_Store
.idea/
.vscode/
.npmrc
.npmrc
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat

11
.npmrc Normal file
View File

@@ -0,0 +1,11 @@
# Increase fetch timeouts for network resilience
fetch-timeout=60000
fetch-retry-mintimeout=20000
fetch-retry-maxtimeout=120000
# GitHub Packages configuration for @tria-plc scope
@tria-plc:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_PACKAGE_TOKEN}
# Default registry for other packages
registry=https://registry.npmjs.org/

187
DEPLOYMENT.md Normal file
View File

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

View File

@@ -1,84 +0,0 @@
FROM node:24.15.0 AS base
RUN corepack enable && corepack prepare pnpm@latest-11 --activate
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 apps/edr-passenger-api/package.json ./apps/edr-passenger-api/
COPY apps/edr-freight-web/backoffice/package.json ./apps/edr-freight-web/backoffice/
COPY apps/edr-freight-web/portal/package.json ./apps/edr-freight-web/portal/
COPY apps/edr-passenger-web/backoffice/package.json ./apps/edr-passenger-web/backoffice/
COPY apps/edr-passenger-web/portal/package.json ./apps/edr-passenger-web/portal/
COPY packages/api-common/package.json packages/api-common/
COPY packages/config/eslint-config/package.json packages/config/eslint-config/
COPY packages/config/prettier-config/package.json packages/config/prettier-config/
COPY packages/config/tsconfig/package.json packages/config/tsconfig/
COPY packages/types/package.json packages/types/
COPY packages/ui-common/package.json packages/ui-common/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store\
--mount=type=secret,id=npmrc,target=./.npmrc \
pnpm install --frozen-lockfile
FROM deps AS build
COPY . .
RUN pnpm run build --filter=\!"@edr/passenger-portal"
FROM base AS freight-api
# RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app/apps/edr-freight-api
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
COPY --from=build /app/packages ./../../packages
EXPOSE 3001
CMD ["node", "dist/main.js"]
FROM base AS passenger-api
RUN apt-get update -y && apt-get install -y openssl
# RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app/apps/edr-passenger-api
ENV NODE_ENV=production
# Use build-stage node_modules (not deps): `pnpm run build` runs `prisma generate`, which
# writes the real @prisma/client (enums, types). deps never runs generate, so @IsEnum(ServiceClass)
# and similar would see undefined at runtime if we copied deps only.
COPY --from=build /app/node_modules ./../../node_modules
COPY --from=build /app/apps/edr-passenger-api/node_modules ./node_modules
COPY --from=build /app/apps/edr-passenger-api/dist ./dist
COPY --from=build /app/apps/edr-passenger-api/package.json ./package.json
COPY --from=build /app/packages ./../../packages
EXPOSE 3001
CMD ["node", "dist/main.js"]
FROM build as passenger-migration
WORKDIR /app/apps/edr-passenger-api
CMD pnpm run prisma:migrate && pnpm run prisma:seed
FROM nginx:1.27-alpine AS freight-web-portal
COPY --from=build /app/apps/edr-freight-web/portal/dist /usr/share/nginx/html
EXPOSE 5173
CMD ["nginx", "-g", "daemon off;"]
FROM nginx:1.27-alpine AS freight-web-backoffice
COPY --from=build /app/apps/edr-freight-web/backoffice/dist /usr/share/nginx/html
EXPOSE 5173
CMD ["nginx", "-g", "daemon off;"]

198
README.md
View File

@@ -52,7 +52,7 @@ Enterprise-grade NestJS REST API for the Ethio-Djibouti Railway passenger bookin
- 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 15-minute expiry
- 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
@@ -114,8 +114,8 @@ cp apps/edr-passenger-api/.env.example apps/edr-passenger-api/.env
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/edr_passenger` |
| `JWT_SECRET` | JWT signing secret (change in production) | `your-secret-key` |
| `JWT_EXPIRES_IN` | JWT token expiry | `7d` |
| `FRONTEND_URL` | Web app CORS origin | `http://localhost:3000` |
| `PORTAL_URL` | Admin portal CORS origin | `http://localhost:3001` |
| `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` |
@@ -170,7 +170,7 @@ pnpm --filter @edr/passenger-api run prisma:generate
#### Run Migrations
```bash
pnpm --filter @edr/passenger-api run prisma:migrate
pnpm --filter @edr/passenger-api run prisma:migrate:dev
```
#### Seed Database
@@ -314,9 +314,34 @@ Content-Type: application/json
}
```
#### 4. Register International Passenger
#### 4. Universal Passenger Registration (NEW)
```bash
POST /passengers/register-international
# Guest Ethiopian with Fayda verification
POST /passengers/register
Content-Type: application/json
{
"passengerName": "Abebe Kebede",
"dateOfBirth": "1985-03-15",
"nationalId": "ET123456789",
"phone": "+251911234567",
"deviceId": "device-uuid-123"
}
# Logged-in user with JWT token
POST /passengers/register
Authorization: Bearer <jwt-token>
Content-Type: application/json
{
"passengerName": "Abebe Kebede",
"dateOfBirth": "1985-03-15",
"nationalId": "ET123456789",
"phone": "+251911234567"
}
# International passenger (passport)
POST /passengers/register
Content-Type: application/json
{
@@ -326,11 +351,42 @@ Content-Type: application/json
"passportCountry": "Kenya",
"nationality": "Kenyan",
"phone": "+254712345678",
"email": "john@example.com"
"email": "john@example.com",
"deviceId": "device-uuid-123"
}
```
#### 5. Search Trips
#### 5. Get User Profile (NEW)
```bash
GET /auth/profile
Authorization: Bearer <jwt-token>
# Response includes user, passenger, loyalty, and wallet details
{
"id": "uuid",
"email": "user@example.com",
"phone": "+251911234567",
"fullName": "John Doe",
"role": "PASSENGER",
"nationality": "Ethiopian",
"faydaVerified": true,
"faydaVerifiedAt": "2024-01-15T10:30:00.000Z",
"passenger": {
"id": "uuid",
"loyalty": {
"tier": "SILVER",
"pointsBalance": 1500,
"lifetimePoints": 3000
},
"wallet": {
"balanceMinor": 50000,
"currency": "ETB"
}
}
}
```
#### 6. Search Trips
```bash
POST /search
Content-Type: application/json
@@ -344,7 +400,7 @@ Content-Type: application/json
}
```
#### 6. Get Fare Quote
#### 7. Get Fare Quote
```bash
POST /search/fare-quote
Content-Type: application/json
@@ -373,7 +429,7 @@ Content-Type: application/json
}
```
#### 7. Guest Booking (No Login Required)
#### 8. Guest Booking (No Login Required)
```bash
POST /bookings/guest
Content-Type: application/json
@@ -398,7 +454,7 @@ Content-Type: application/json
}
```
#### 8. Agent Booking (IAM Auth)
#### 9. Agent Booking (IAM Auth)
```bash
POST /agents/bookings
Authorization: Bearer <iam-token>
@@ -523,59 +579,103 @@ pnpm --filter @edr/passenger-api run type-check # TypeScript check
# Database
pnpm --filter @edr/passenger-api run prisma:generate # Generate Prisma client
pnpm --filter @edr/passenger-api run prisma:migrate # Run migrations
pnpm --filter @edr/passenger-api run prisma:migrate:dev # Run migrations (local dev)
pnpm --filter @edr/passenger-api run prisma:seed # Seed database
```
## 🐳 Docker Deployment
### Build Image
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 build -f apps/edr-passenger-api/Dockerfile -t edr-passenger-api .
DOCKER_BUILDKIT=1 pnpm docker:build
pnpm docker:up
```
### Run Container
Or without pnpm scripts:
```bash
docker run -d \
--name edr-api \
-p 4000:4000 \
--env-file apps/edr-passenger-api/.env \
edr-passenger-api
DOCKER_BUILDKIT=1 docker compose build
docker compose up -d
```
### Docker Compose (Recommended)
```yaml
version: '3.8'
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: edr
POSTGRES_PASSWORD: edr_secret
POSTGRES_DB: edr_passenger
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
| 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 |
api:
build:
context: .
dockerfile: apps/edr-passenger-api/Dockerfile
ports:
- "4000:4000"
environment:
DATABASE_URL: postgresql://edr:edr_secret@postgres:5432/edr_passenger
JWT_SECRET: your-secret-key
PORT: 4000
depends_on:
- postgres
### Build a single service
volumes:
postgres_data:
```bash
docker compose build freight-api
docker compose build passenger-portal
```
Freight images mount `.npmrc` as a BuildKit secret during `pnpm install`. Passenger web images do not require private packages.
### `VITE_API_URL` (frontends)
API URLs are **baked in at image build time** (`import.meta.env.VITE_API_URL`). Defaults in [`docker-compose.yaml`](docker-compose.yaml) use `http://localhost:3001/api` (freight) and `http://localhost:4000` (passenger) for local smoke tests. Override build args for production, e.g.:
```bash
docker compose build freight-portal \
--build-arg VITE_API_URL=https://freight-api.example.com/api
```
### Migrations
- **Freight API:** TypeORM migrations are not run on container startup — apply them separately before deploy.
- **Passenger API:** On each container start, the entrypoint runs `npm run prisma:migrate` and `npm run prisma:seed` (same `package.json` scripts as `pnpm run`) before starting the server. Ensure `DATABASE_URL` in `.env` points at a reachable Postgres instance.
For local development, use `pnpm --filter @edr/passenger-api run prisma:migrate:dev` instead of `prisma:migrate`.
### GitHub Actions (self-hosted runner)
Two workflows deploy independently on push to `main`, `develop`, or `staging`:
| Workflow | Services | Server env root |
|----------|----------|-----------------|
| [`.github/workflows/deploy-freight.yml`](.github/workflows/deploy-freight.yml) | freight-api, freight-portal, freight-backoffice | `/home/user/environmen/edr-freight/<branch>/` |
| [`.github/workflows/deploy-passenger.yml`](.github/workflows/deploy-passenger.yml) | passenger-api, passenger-portal, passenger-backoffice | `/home/user/environmen/edr-passenger/<branch>/` |
**On the runner**, place env files before the first deploy (example for branch `main`):
```text
/home/user/environmen/edr-freight/main/
freight-api.env
freight-portal.env # optional runtime env for Vite/nginx
freight-backoffice.env
freight-web.build.env # exports FREIGHT_VITE_API_URL=...
/home/user/environmen/edr-passenger/main/
passenger-api.env
passenger-portal.env
passenger-backoffice.env
passenger-web.build.env # exports PASSENGER_VITE_API_URL=...
```
Example `freight-web.build.env`:
```bash
export FREIGHT_VITE_API_URL=https://freight-api.example.com/api
```
The workflow copies `*.env` into each app directory, creates `.npmrc` from the `NPM_TOKEN` repository secret, then runs `docker compose build` and `docker compose up -d` for that stack.
## 🔒 Security Best Practices
1. **Environment Variables** - Never commit `.env` files. Use secrets management in production.
@@ -619,7 +719,7 @@ pnpm --filter @edr/passenger-api run test:cov
- [ ] Configure Verifayda integration (VERIFAYDA_ENABLED=true, VERIFAYDA_API_KEY)
- [ ] Set up currency exchange rate sync (external API)
- [ ] Set NODE_ENV=production
- [ ] Configure CORS origins (FRONTEND_URL, PORTAL_URL)
- [ ] Configure CORS origins (PORTAL_URL, BACK_OFFICE_URL)
- [ ] Set up SSL/TLS certificates
- [ ] Configure database connection pooling
- [ ] Set up monitoring and logging

View File

@@ -1,17 +1,7 @@
# 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=
# JWT (provided by external auth package — placeholder only)
JWT_SECRET=
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
DB_NAME=edr_freight

View File

@@ -0,0 +1,37 @@
# 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 pruner
COPY . .
RUN pnpm dlx turbo prune "@edr/freight-api" --docker
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 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
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs nestjs
COPY --from=deployer --chown=nestjs:nodejs /deploy .
USER nestjs
EXPOSE 3001
CMD ["node", "dist/main.js"]

View File

@@ -1,13 +1,13 @@
# App
NODE_ENV=development
PORT=4000
PORT=3002
# Database (Prisma)
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger?schema=edr_passenger
# CORS
FRONTEND_URL=http://localhost:3000
PORTAL_URL=http://localhost:3001
FRONTEND_URL=http://localhost:5174
BACK_OFFICE_URL=http://localhost:5184
# JWT
JWT_SECRET=edr-platform-secret-change-in-production
@@ -88,9 +88,19 @@ IAM_ENABLED=false
IAM_API_URL=https://iam.tria-plc.com/api
IAM_API_KEY=
# Verifayda 2.0 Configuration (Ethiopian National ID Verification)
VERIFAYDA_ENABLED=false
VERIFAYDA_API_URL=https://api.verifayda.gov.et/v2
VERIFAYDA_API_KEY=
# --- VeriFayda 2.0 (eSignet) OIDC integration ---
FAYDA_ENABLED=true
FAYDA_CLIENT_ID=
FAYDA_AUTHORIZATION_ENDPOINT=
FAYDA_TOKEN_ENDPOINT=
FAYDA_USERINFO_ENDPOINT=
# Base64 of the RSA private JWK (JSON). Secret — never commit a real value.
FAYDA_PRIVATE_KEY_BASE64=
FAYDA_REDIRECT_URI=
# Optional (defaults shown)
FAYDA_SCOPE=openid profile email
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
FAYDA_CLAIMS_LOCALES=en am
FAYDA_SESSION_TTL_MINUTES=10
GITHUB_PACKAGE_TOKEN=
GITHUB_PACKAGE_TOKEN=

View File

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

View File

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

View File

@@ -13,7 +13,8 @@
"type-check": "tsc --noEmit",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node prisma/seed.ts",
"prisma:seed": "ts-node prisma/seed-complete.ts",
"prisma:seed-full": "ts-node prisma/seed.ts",
"prisma:backfill": "ts-node prisma/backfill-fields.ts",
"prisma:verify": "ts-node prisma/verify-backfill.ts"
},
@@ -31,11 +32,14 @@
"@nestjs/platform-express": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^7.4.0",
"@prisma/client": "^6.19.3",
"@sendgrid/mail": "^8.1.0",
"axios": "^1.7.7",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"express": "^4.18.2",
"jose": "^5.10.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"qrcode": "^1.5.3",
@@ -50,8 +54,8 @@
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19",
"@prisma/client": "^6.19.3",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.6",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.6",
"@types/passport-jwt": "^4.0.1",

View File

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

View File

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

View File

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

View File

@@ -224,6 +224,11 @@ model User {
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
faydaVerified Boolean @default(false)
faydaVerifiedAt DateTime?
faydaSub String? @unique
passenger Passenger?
agent Agent?
sessions Session[]
@@ -232,6 +237,8 @@ model User {
auditLogs AuditLog[]
fraudAlerts FraudAlert[]
faydaVerificationSessions FaydaVerificationSession[]
@@schema("passenger")
}
@@ -331,6 +338,7 @@ model TrainSchedule {
carbonRating String @default("A")
notes String?
train Train @relation(fields: [trainId], references: [id])
route Route? @relation(fields: [routeId], references: [id])
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id])
coachAssignments CoachAssignment[]
@@ -429,6 +437,7 @@ model Seat {
coach Coach @relation(fields: [coachId], references: [id])
bookingSeats BookingSeat[]
blocks SeatBlock[]
ticketSeats TicketSeat[]
@@unique([coachId, row, col])
@@unique([coachId, seatNumber])
@@ -515,6 +524,9 @@ model BookingSeat {
passportCountry String?
verifaydaVerified Boolean @default(false)
verifaydaData Json?
faydaVerifiedAt DateTime?
faydaSub String?
faydaVerifiedName String?
seatLabelSnapshot String?
fareMinor Int?
displayCurrency Currency?
@@ -616,6 +628,20 @@ model Ticket {
validatorId String?
booking Booking @relation(fields: [bookingId], references: [id])
validationLogs GateValidationLog[]
seats TicketSeat[]
@@schema("passenger")
}
model TicketSeat {
id String @id @default(uuid())
ticketId String
seatId String
seatIndex Int @default(0)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
seat Seat @relation(fields: [seatId], references: [id])
@@index([ticketId])
@@index([seatId])
@@schema("passenger")
}
@@ -952,6 +978,7 @@ model Route {
createdAt DateTime @default(now())
stops RouteStop[]
fareRules RouteFareRule[]
schedules TrainSchedule[]
@@schema("passenger")
}
@@ -1256,3 +1283,32 @@ model SavedPassengerProfile {
@@schema("passenger")
}
model FaydaVerificationSession {
id String @id @default(uuid())
state String @unique
codeVerifier String
purpose String @default("PURCHASE")
platform String @default("WEB") // WEB | MOBILE — recorded for audit
saveToAccount Boolean @default(false)
status String @default("PENDING")
errorCode String?
errorDescription String?
authCode String?
createdAt DateTime @default(now())
expiresAt DateTime
completedAt DateTime?
userId String?
bookingId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([bookingId])
@@index([state])
@@index([expiresAt])
@@schema("passenger")
}

View File

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

View File

@@ -1,4 +1,4 @@
import { PrismaClient, SeatKind } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
@@ -136,11 +136,11 @@ async function seedCoachesAndSeats(seatClasses: any[]) {
col,
label: `${row}${col}`,
seatNumber: `${config.label}${row}${col}`,
kind: (row === 1 && col === 'A' ? 'ACCESSIBLE' : 'STANDARD') as SeatKind,
kind: row === 1 && col === 'A' ? 'ACCESSIBLE' : 'STANDARD',
});
}
}
await prisma.seat.createMany({ data: seats });
await prisma.seat.createMany({ data: seats as any });
}
}
@@ -151,19 +151,29 @@ async function seedCoachesAndSeats(seatClasses: any[]) {
// ============================================================================
// SECTION 5: SCHEDULES (15+ SEGMENTS)
// ============================================================================
async function seedSchedules(trains: any[], stations: any[]) {
async function seedSchedules(trains: any[], stations: any[], routes: any[]) {
console.log('📅 Seeding schedules with 15+ segments...');
const [train301, train302, train303] = trains;
const [sebeta, labu, indode, bishoftu, mojo, adama, feto, metahara, mieso, bike, diredawa, arawa, adigala, aysha, dawanle, alisabieh, holhol, nagad] = stations;
const [fullRoute, regionalRoute] = routes;
// Clean up existing schedules
const existingScheduleIds = (await prisma.trainSchedule.findMany({
where: { trainId: { in: [train301.id, train302.id, train303.id] } },
select: { id: true },
})).map((s) => s.id);
})).map((s: { id: string }) => s.id);
if (existingScheduleIds.length > 0) {
// Delete in correct order to avoid foreign key constraints
await prisma.bookingSeat.deleteMany({
where: {
booking: {
scheduleId: { in: existingScheduleIds }
}
}
});
await prisma.booking.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } });
await prisma.fareRule.deleteMany({ where: { tripId: { in: existingScheduleIds } } });
await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } });
await prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: existingScheduleIds } } });
@@ -174,6 +184,7 @@ async function seedSchedules(trains: any[], stations: any[]) {
// Full route: Sebeta to Nagad (18 stations)
{
trainId: train301.id,
routeId: fullRoute.id,
originStationId: sebeta.id,
destinationStationId: nagad.id,
departureAt: new Date('2026-06-15T06:00:00Z'),
@@ -184,6 +195,7 @@ async function seedSchedules(trains: any[], stations: any[]) {
// Return route: Nagad to Sebeta
{
trainId: train302.id,
routeId: fullRoute.id,
originStationId: nagad.id,
destinationStationId: sebeta.id,
departureAt: new Date('2026-06-16T07:00:00Z'),
@@ -194,6 +206,7 @@ async function seedSchedules(trains: any[], stations: any[]) {
// Regional service: Sebeta to Diredawa
{
trainId: train303.id,
routeId: regionalRoute.id,
originStationId: sebeta.id,
destinationStationId: diredawa.id,
departureAt: new Date('2026-06-17T08:00:00Z'),
@@ -204,6 +217,7 @@ async function seedSchedules(trains: any[], stations: any[]) {
// Additional schedules for next day
{
trainId: train301.id,
routeId: fullRoute.id,
originStationId: sebeta.id,
destinationStationId: nagad.id,
departureAt: new Date('2026-06-18T06:30:00Z'),
@@ -213,6 +227,7 @@ async function seedSchedules(trains: any[], stations: any[]) {
},
{
trainId: train302.id,
routeId: fullRoute.id,
originStationId: nagad.id,
destinationStationId: sebeta.id,
departureAt: new Date('2026-06-19T07:15:00Z'),
@@ -484,7 +499,173 @@ async function seedUsers() {
}
// ============================================================================
// SECTION 10: SUPPORTING DATA
// SECTION 10: ROUTES
// ============================================================================
async function seedRoutes(stations: any[], seatClasses: any[]) {
console.log('🛤️ Seeding routes...');
const [sebeta, labu, indode, bishoftu, mojo, adama, feto, metahara, mieso, bike, diredawa, arawa, adigala, aysha, dawanle, alisabieh, holhol, nagad] = stations;
const [scEconomy, scEconomyBed, scVip] = seatClasses;
// Route 1: Full Line (Sebeta to Nagad)
const fullRoute = await prisma.route.upsert({
where: { code: 'SBT-NGD-FULL' },
update: {},
create: {
code: 'SBT-NGD-FULL',
name: 'Sebeta - Nagad Express',
description: 'Complete Ethio-Djibouti Railway route from Sebeta to Nagad',
effectiveFrom: new Date('2026-01-01'),
active: true,
},
});
// Create stops for full route
const fullRouteStops = [
{ routeId: fullRoute.id, stationId: sebeta.id, sequence: 1, distanceKm: 0 },
{ routeId: fullRoute.id, stationId: labu.id, sequence: 2, distanceKm: 15 },
{ routeId: fullRoute.id, stationId: indode.id, sequence: 3, distanceKm: 28 },
{ routeId: fullRoute.id, stationId: bishoftu.id, sequence: 4, distanceKm: 45 },
{ routeId: fullRoute.id, stationId: mojo.id, sequence: 5, distanceKm: 73 },
{ routeId: fullRoute.id, stationId: adama.id, sequence: 6, distanceKm: 99 },
{ routeId: fullRoute.id, stationId: feto.id, sequence: 7, distanceKm: 125 },
{ routeId: fullRoute.id, stationId: metahara.id, sequence: 8, distanceKm: 168 },
{ routeId: fullRoute.id, stationId: mieso.id, sequence: 9, distanceKm: 245 },
{ routeId: fullRoute.id, stationId: bike.id, sequence: 10, distanceKm: 312 },
{ routeId: fullRoute.id, stationId: diredawa.id, sequence: 11, distanceKm: 378 },
{ routeId: fullRoute.id, stationId: arawa.id, sequence: 12, distanceKm: 445 },
{ routeId: fullRoute.id, stationId: adigala.id, sequence: 13, distanceKm: 512 },
{ routeId: fullRoute.id, stationId: aysha.id, sequence: 14, distanceKm: 578 },
{ routeId: fullRoute.id, stationId: dawanle.id, sequence: 15, distanceKm: 625 },
{ routeId: fullRoute.id, stationId: alisabieh.id, sequence: 16, distanceKm: 672 },
{ routeId: fullRoute.id, stationId: holhol.id, sequence: 17, distanceKm: 718 },
{ routeId: fullRoute.id, stationId: nagad.id, sequence: 18, distanceKm: 756 },
];
await prisma.routeStop.createMany({ data: fullRouteStops, skipDuplicates: true });
// Fare rules for full route
const fullRouteFares = [
{ routeId: fullRoute.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT' as const, baseFareMinor: 65000, validFrom: new Date('2026-01-01') },
{ routeId: fullRoute.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD' as const, baseFareMinor: 65000, validFrom: new Date('2026-01-01') },
{ routeId: fullRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'ADULT' as const, baseFareMinor: 91000, validFrom: new Date('2026-01-01') },
{ routeId: fullRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'CHILD' as const, baseFareMinor: 91000, validFrom: new Date('2026-01-01') },
{ routeId: fullRoute.id, seatClassId: scVip.id, passengerCategory: 'ADULT' as const, baseFareMinor: 117000, validFrom: new Date('2026-01-01') },
{ routeId: fullRoute.id, seatClassId: scVip.id, passengerCategory: 'CHILD' as const, baseFareMinor: 117000, validFrom: new Date('2026-01-01') },
];
await prisma.routeFareRule.createMany({ data: fullRouteFares, skipDuplicates: true });
// Route 2: Regional (Sebeta to Diredawa)
const regionalRoute = await prisma.route.upsert({
where: { code: 'SBT-DDW-REG' },
update: {},
create: {
code: 'SBT-DDW-REG',
name: 'Sebeta - Diredawa Regional',
description: 'Regional service from Sebeta to Diredawa',
effectiveFrom: new Date('2026-01-01'),
active: true,
},
});
const regionalStops = [
{ routeId: regionalRoute.id, stationId: sebeta.id, sequence: 1, distanceKm: 0 },
{ routeId: regionalRoute.id, stationId: labu.id, sequence: 2, distanceKm: 15 },
{ routeId: regionalRoute.id, stationId: indode.id, sequence: 3, distanceKm: 28 },
{ routeId: regionalRoute.id, stationId: bishoftu.id, sequence: 4, distanceKm: 45 },
{ routeId: regionalRoute.id, stationId: mojo.id, sequence: 5, distanceKm: 73 },
{ routeId: regionalRoute.id, stationId: adama.id, sequence: 6, distanceKm: 99 },
{ routeId: regionalRoute.id, stationId: feto.id, sequence: 7, distanceKm: 125 },
{ routeId: regionalRoute.id, stationId: metahara.id, sequence: 8, distanceKm: 168 },
{ routeId: regionalRoute.id, stationId: mieso.id, sequence: 9, distanceKm: 245 },
{ routeId: regionalRoute.id, stationId: bike.id, sequence: 10, distanceKm: 312 },
{ routeId: regionalRoute.id, stationId: diredawa.id, sequence: 11, distanceKm: 378 },
];
await prisma.routeStop.createMany({ data: regionalStops, skipDuplicates: true });
const regionalFares = [
{ routeId: regionalRoute.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT' as const, baseFareMinor: 35000, validFrom: new Date('2026-01-01') },
{ routeId: regionalRoute.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD' as const, baseFareMinor: 35000, validFrom: new Date('2026-01-01') },
{ routeId: regionalRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'ADULT' as const, baseFareMinor: 49000, validFrom: new Date('2026-01-01') },
{ routeId: regionalRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'CHILD' as const, baseFareMinor: 49000, validFrom: new Date('2026-01-01') },
{ routeId: regionalRoute.id, seatClassId: scVip.id, passengerCategory: 'ADULT' as const, baseFareMinor: 63000, validFrom: new Date('2026-01-01') },
{ routeId: regionalRoute.id, seatClassId: scVip.id, passengerCategory: 'CHILD' as const, baseFareMinor: 63000, validFrom: new Date('2026-01-01') },
];
await prisma.routeFareRule.createMany({ data: regionalFares, skipDuplicates: true });
// Route 3: Short Distance (Sebeta to Adama)
const shortRoute = await prisma.route.upsert({
where: { code: 'SBT-ADM-SHORT' },
update: {},
create: {
code: 'SBT-ADM-SHORT',
name: 'Sebeta - Adama Commuter',
description: 'Short distance commuter service',
effectiveFrom: new Date('2026-01-01'),
active: true,
},
});
const shortStops = [
{ routeId: shortRoute.id, stationId: sebeta.id, sequence: 1, distanceKm: 0 },
{ routeId: shortRoute.id, stationId: labu.id, sequence: 2, distanceKm: 15 },
{ routeId: shortRoute.id, stationId: indode.id, sequence: 3, distanceKm: 28 },
{ routeId: shortRoute.id, stationId: bishoftu.id, sequence: 4, distanceKm: 45 },
{ routeId: shortRoute.id, stationId: mojo.id, sequence: 5, distanceKm: 73 },
{ routeId: shortRoute.id, stationId: adama.id, sequence: 6, distanceKm: 99 },
];
await prisma.routeStop.createMany({ data: shortStops, skipDuplicates: true });
const shortFares = [
{ routeId: shortRoute.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT' as const, baseFareMinor: 18000, validFrom: new Date('2026-01-01') },
{ routeId: shortRoute.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD' as const, baseFareMinor: 18000, validFrom: new Date('2026-01-01') },
{ routeId: shortRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'ADULT' as const, baseFareMinor: 25200, validFrom: new Date('2026-01-01') },
{ routeId: shortRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'CHILD' as const, baseFareMinor: 25200, validFrom: new Date('2026-01-01') },
{ routeId: shortRoute.id, seatClassId: scVip.id, passengerCategory: 'ADULT' as const, baseFareMinor: 32400, validFrom: new Date('2026-01-01') },
{ routeId: shortRoute.id, seatClassId: scVip.id, passengerCategory: 'CHILD' as const, baseFareMinor: 32400, validFrom: new Date('2026-01-01') },
];
await prisma.routeFareRule.createMany({ data: shortFares, skipDuplicates: true });
// Route 4: Cross-Border (Diredawa to Nagad)
const crossBorderRoute = await prisma.route.upsert({
where: { code: 'DDW-NGD-INTL' },
update: {},
create: {
code: 'DDW-NGD-INTL',
name: 'Diredawa - Nagad International',
description: 'Cross-border service from Ethiopia to Djibouti',
effectiveFrom: new Date('2026-01-01'),
active: true,
},
});
const crossBorderStops = [
{ routeId: crossBorderRoute.id, stationId: diredawa.id, sequence: 1, distanceKm: 0 },
{ routeId: crossBorderRoute.id, stationId: arawa.id, sequence: 2, distanceKm: 67 },
{ routeId: crossBorderRoute.id, stationId: adigala.id, sequence: 3, distanceKm: 134 },
{ routeId: crossBorderRoute.id, stationId: aysha.id, sequence: 4, distanceKm: 200 },
{ routeId: crossBorderRoute.id, stationId: dawanle.id, sequence: 5, distanceKm: 247 },
{ routeId: crossBorderRoute.id, stationId: alisabieh.id, sequence: 6, distanceKm: 294 },
{ routeId: crossBorderRoute.id, stationId: holhol.id, sequence: 7, distanceKm: 340 },
{ routeId: crossBorderRoute.id, stationId: nagad.id, sequence: 8, distanceKm: 378 },
];
await prisma.routeStop.createMany({ data: crossBorderStops, skipDuplicates: true });
const crossBorderFares = [
{ routeId: crossBorderRoute.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT' as const, baseFareMinor: 45000, validFrom: new Date('2026-01-01') },
{ routeId: crossBorderRoute.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD' as const, baseFareMinor: 45000, validFrom: new Date('2026-01-01') },
{ routeId: crossBorderRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'ADULT' as const, baseFareMinor: 63000, validFrom: new Date('2026-01-01') },
{ routeId: crossBorderRoute.id, seatClassId: scEconomyBed.id, passengerCategory: 'CHILD' as const, baseFareMinor: 63000, validFrom: new Date('2026-01-01') },
{ routeId: crossBorderRoute.id, seatClassId: scVip.id, passengerCategory: 'ADULT' as const, baseFareMinor: 81000, validFrom: new Date('2026-01-01') },
{ routeId: crossBorderRoute.id, seatClassId: scVip.id, passengerCategory: 'CHILD' as const, baseFareMinor: 81000, validFrom: new Date('2026-01-01') },
];
await prisma.routeFareRule.createMany({ data: crossBorderFares, skipDuplicates: true });
console.log(` ✅ Created 4 routes with stops and fare rules`);
return [fullRoute, regionalRoute, shortRoute, crossBorderRoute];
}
// ============================================================================
// SECTION 11: SUPPORTING DATA
// ============================================================================
async function seedSupportingData(seatClasses: any[]) {
console.log('📦 Seeding supporting data...');
@@ -529,6 +710,18 @@ async function seedSupportingData(seatClasses: any[]) {
},
});
await prisma.notificationTemplate.upsert({
where: { code: 'booking.created' },
update: {},
create: {
code: 'booking.created',
channel: 'EMAIL',
subject: 'Booking Created',
bodyTemplate: 'Your booking {{bookingRef}} has been created successfully.',
active: true,
},
});
await prisma.notificationTemplate.upsert({
where: { code: 'PAYMENT_SUCCESS' },
update: {},
@@ -592,7 +785,8 @@ async function main() {
const seatClasses = await seedSeatClasses();
const trains = await seedTrains();
const coaches = await seedCoachesAndSeats(seatClasses);
const schedules = await seedSchedules(trains, stations);
const routes = await seedRoutes(stations, seatClasses);
const schedules = await seedSchedules(trains, stations, routes);
await seedCoachAssignments(schedules, coaches);
await seedStopTimes(schedules, stations);
await seedFareRules(schedules, seatClasses);
@@ -606,6 +800,7 @@ async function main() {
console.log(' - 3 Trains (Express 301, Express 302, Local 303)');
console.log(' - 6 Physical Coaches with seats');
console.log(' - 5 Train Schedules covering full and regional routes');
console.log(' - 4 Routes with stops and fare rules');
console.log(' - 15+ Fare Segments with nationality-based pricing');
console.log(' - 4 Users: Admin, Ethiopian Passenger, Djiboutian Passenger, Agent');
console.log(' - Currency rates: ETB, USD, DJF');
@@ -621,10 +816,10 @@ async function main() {
console.log(' - Pay: Multiple payment methods (Telebirr, CBE, Card, Wallet)');
console.log(' - Ticket: QR code generation and validation');
console.log('\n🚂 Sample Routes:');
console.log(' - Full Route: Sebeta → Nagad (18 stations, 16 hours)');
console.log(' - Regional: Sebeta → Diredawa (11 stations, 10 hours)');
console.log(' - Short: Sebeta → Adama (6 stations, 3 hours)');
console.log(' - Cross-border: Diredawa → Nagad (8 stations, 7 hours)');
console.log(' - Full Route: Sebeta → Nagad (18 stations, 756 km)');
console.log(' - Regional: Sebeta → Diredawa (11 stations, 378 km)');
console.log(' - Short: Sebeta → Adama (6 stations, 99 km)');
console.log(' - Cross-border: Diredawa → Nagad (8 stations, 378 km)');
}
main()

View File

@@ -13,6 +13,7 @@ import cbeConfig from './config/cbe.config';
import ebirrConfig from './config/ebirr.config';
import cardConfig from './config/card.config';
import waafiConfig from './config/waafi.config';
import faydaConfig from './config/fayda.config';
import { AuthModule } from './modules/auth/auth.module';
import { StationsModule } from './modules/stations/stations.module';
import { FleetModule } from './modules/fleet/fleet.module';
@@ -36,12 +37,22 @@ import { ReportsModule } from './modules/reports/reports.module';
import { FraudModule } from './modules/fraud/fraud.module';
import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, dbConfig, telebirrConfig, cbeConfig, ebirrConfig, cardConfig, waafiConfig],
load: [
appConfig,
dbConfig,
telebirrConfig,
cbeConfig,
ebirrConfig,
cardConfig,
waafiConfig,
faydaConfig,
],
}),
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
@@ -71,6 +82,7 @@ import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
FraudModule,
SeatClassesModule,
FareEngineModule,
VerifaydaModule,
],
})
export class AppModule implements NestModule {

View File

@@ -4,6 +4,6 @@ export default registerAs('app', () => ({
port: parseInt(process.env.PORT ?? '4000', 10),
jwtSecret: process.env.JWT_SECRET ?? 'dev-secret',
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d',
frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3000',
portalUrl: process.env.PORTAL_URL ?? 'http://localhost:3001',
frontendUrl: process.env.PORTAL_URL ?? 'http://localhost:3000',
portalUrl: process.env.BACK_OFFICE_URL ?? 'http://localhost:3001',
}));

View File

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

View File

@@ -12,8 +12,8 @@ async function bootstrap() {
app.enableCors({
origin: [
process.env.FRONTEND_URL ?? "http://localhost:3000",
process.env.PORTAL_URL ?? "http://localhost:3001",
process.env.PORTAL_URL ?? "http://localhost:5174",
process.env.BACK_OFFICE_URL ?? "http://localhost:5184",
],
});
@@ -22,7 +22,7 @@ async function bootstrap() {
new ResponseTransformInterceptor(),
app.get(SessionActivityInterceptor),
);
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidUnknownValues: false }));
const config = new DocumentBuilder()
.setTitle("EDR Passenger API")
@@ -210,31 +210,33 @@ Payment providers send notifications to:
{ type: "http", scheme: "bearer", bearerFormat: "JWT", in: "header" },
"JWT-auth",
)
.addTag("Agents", "Counter booking and management")
.addTag("Auth", "Registration and login")
.addTag("Booking", "Booking lifecycle")
.addTag("Dashboard", "Home dashboard aggregate")
.addTag("Fare Engine", "Distance-based fare calculator — km × rate × exchange rate, nationality-aware currency")
.addTag("Fleet", "Train services and coaches")
.addTag("Fraud Detection", "Fraud detection and monitoring")
.addTag("Live Tracking", "Real-time trip status and crowd signals")
.addTag("Loyalty", "Points, tiers, and rewards")
.addTag("Notifications", "Push and email notifications")
.addTag("Passenger", "Profiles, traveler profiles, saved routes")
.addTag("Payment", "Payment intents and refunds")
.addTag("Payment Webhooks", "Endpoints for payment provider notifications")
.addTag("Promotions", "Promo codes and campaigns")
.addTag("Reports", "Sales and operational reports")
.addTag("Routes", "Route information and management")
.addTag("Schedule", "Trips and fare rules")
.addTag("Search", "Trip search and fare quotes")
.addTag("Seat Classes", "Economy Regular, Economy Bed, VIP Bed")
.addTag("Seats", "Seat maps and holds")
.addTag("Segment-based Seats", "Seats assigned and released by trip segments")
.addTag("Stations", "Station directory")
.addTag("Support", "FAQ and chat support")
.addTag("Tickets", "QR ticket generation and validation")
.addTag("Wallet", "Wallet balance and ledger")
.addTag("Agents", "Counter booking, shift management, and commission tracking")
.addTag("Auth", "User registration, login, and profile management")
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel")
.addTag("Dashboard", "Aggregated dashboard data for home screen")
.addTag("Fare Engine", "Distance-based fare calculator with multi-currency support")
.addTag("Fayda Verification", "Ethiopian national ID verification via government API")
.addTag("Fleet", "Train services, coaches, and seat configurations")
.addTag("Fraud Detection", "Fraud monitoring, alerts, and user blocking")
.addTag("Live Tracking", "Real-time trip status, delays, and station crowds")
.addTag("Loyalty", "Points accumulation, tiers, and reward redemption")
.addTag("Notifications", "Multi-channel notifications: email, SMS, push")
.addTag("Passengers", "Passenger registration, verification, and profiles")
.addTag("Payment", "Payment processing, intents, and refunds")
.addTag("Payment Webhooks", "Payment provider webhook handlers")
.addTag("Promotions", "Promo codes, campaigns, and discount management")
.addTag("Reports", "Sales reports, occupancy analytics, and metrics")
.addTag("Routes", "Route templates with stops and fare rules")
.addTag("Schedule", "Trip schedules, availability, and status updates")
.addTag("Search", "Trip search, availability checks, and fare quotes")
.addTag("Seat Classes", "Seat class management: Economy, VIP configurations")
.addTag("Seats", "Seat maps, holds, releases, and blocking")
.addTag("Segment-based Seats", "Segment-level seat allocation and availability")
.addTag("Stations", "Station directory and information")
.addTag("Support", "FAQ management and live chat support")
.addTag("Tickets", "QR ticket generation, PDFs, and gate validation")
.addTag("Wallet", "Wallet balance, top-ups, and transaction ledger")
.addTag("Config", "System configuration and settings")
//.addServer('http://localhost:4000', 'Development')
// .addServer("https://api.edr-platform.com", "Production")
.build();
@@ -246,6 +248,8 @@ Payment providers send notifications to:
persistAuthorization: true,
docExpansion: "none",
filter: true,
tagsSorter: "alpha",
operationsSorter: "alpha",
},
});

View File

@@ -1,7 +1,8 @@
import { Body, Controller, Post, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Auth')
@Controller('auth')
@@ -78,4 +79,165 @@ export class AuthController {
@ApiResponse({ status: 404, description: 'User not found' })
@ApiBody({ type: ResetPasswordDto })
resetPassword(@Body() dto: ResetPasswordDto) { return this.service.resetPassword(dto); }
@Post('logout')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Logout current user',
description: `Logout the authenticated user and invalidate their session.
### What happens:
- Invalidates the current session token
- Records logout in audit log
- Frontend should clear stored token and redirect to home
### Authentication:
- **Required**: JWT Bearer Token
- Token will be invalidated after successful logout`
})
@ApiResponse({
status: 200,
description: 'Logout successful',
schema: {
example: {
success: true,
message: 'Logged out successfully'
}
}
})
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' })
logout(@Request() req: any) {
if (!req.user || !req.user.userId) {
throw new UnauthorizedException('User not authenticated');
}
return this.service.logout(req.user.userId);
}
@Get('profile')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Get current user profile',
description: `**Returns complete user profile with all connected data**
---
### Response Includes
#### User Information
- Basic details (id, email, phone, fullName, role)
- Nationality and document information
- Fayda verification status
- Account timestamps (created, last login)
#### Passenger Data (if role=PASSENGER)
- Passenger ID and preferences
- **Loyalty Account**: Tier, points balance, lifetime points
- **Wallet Account**: Balance (minor units), currency
#### Devices
- List of registered devices with platform, name, push token, and last seen time
#### User Preferences
- Language, notification settings, etc.
---
### Use Cases
1. **App Initialization**: Fetch on app load to get user context
2. **Profile Pre-fill**: Use data to auto-fill booking forms
3. **Verification Check**: Check \`faydaVerified\` before registration
4. **Loyalty Display**: Show tier and points in UI
5. **Wallet Balance**: Display available balance
6. **Device Management**: Get list of user's registered devices
---
### Authentication
- **Required**: JWT Bearer Token
- Token must be valid and not expired
- Returns profile for authenticated user only`,
})
@ApiResponse({
status: 200,
description: 'User profile retrieved successfully',
schema: {
example: {
id: 'user-uuid-123',
email: 'kelemu@email.com',
phone: '+251911234567',
fullName: 'Kelemu Abebe',
role: 'PASSENGER',
nationality: 'Ethiopian',
nationalityCode: 'ET',
nationalId: null,
passportNumber: null,
faydaVerified: true,
faydaVerifiedAt: '2024-01-15T10:30:00.000Z',
lastLoginAt: '2024-01-20T14:22:00.000Z',
createdAt: '2023-12-01T08:00:00.000Z',
passenger: {
id: 'passenger-uuid-456',
preferredLanguage: 'am',
loyalty: {
tier: 'SILVER',
pointsBalance: 1500,
lifetimePoints: 3000
},
wallet: {
balanceMinor: 50000,
currency: 'ETB'
}
},
preferences: {
emailNotifications: true,
smsNotifications: true,
language: 'am'
},
devices: [
{
id: 'device-uuid-1',
platform: 'WEB',
name: 'Chrome on Windows',
pushToken: 'token-abc123',
trusted: true,
lastSeenAt: '2024-01-20T14:22:00.000Z'
},
{
id: 'device-uuid-2',
platform: 'IOS',
name: 'iPhone 14',
pushToken: 'token-xyz789',
trusted: false,
lastSeenAt: '2024-01-19T10:15:00.000Z'
}
]
}
}
})
@ApiResponse({
status: 401,
description: 'Unauthorized - Invalid or missing JWT token',
schema: {
example: {
statusCode: 401,
message: 'Unauthorized'
}
}
})
getProfile(@Request() req: any) {
console.log('Profile request - User from JWT:', req.user);
if (!req.user || !req.user.userId) {
throw new UnauthorizedException('User not authenticated');
}
return this.service.getProfile(req.user.userId);
}
}

View File

@@ -31,7 +31,7 @@ export class AuthService {
await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } });
await this.prisma.userPreferences.create({ data: { userId: user.id } });
await this.createAuditLog(user.id, 'USER_REGISTERED', 'User', user.id, null, { email: user.email });
return this.signToken(user.id, user.email, user.role, passenger.id);
return await this.signToken(user.id, user.email, user.role, passenger.id);
}
async login(dto: LoginDto) {
@@ -62,7 +62,21 @@ export class AuthService {
});
await this.createAuditLog(user.id, 'USER_LOGIN', 'User', user.id, null, null);
return this.signToken(user.id, user.email, user.role, user.passenger?.id, user.agent?.id);
// Ensure passenger exists and get its ID
let passengerId = user.passenger?.id;
if (!passengerId) {
// If passenger doesn't exist, create it
const passenger = await this.prisma.passenger.create({
data: { userId: user.id }
});
passengerId = passenger.id;
// Also create loyalty and wallet accounts
await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } });
await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } });
}
return await this.signToken(user.id, user.email, user.role, passengerId, user.agent?.id);
}
async requestOtp(dto: RequestOtpDto) {
@@ -117,9 +131,32 @@ export class AuthService {
return { reset: true };
}
private signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
const token = this.jwt.sign({ sub: userId, email, role, passengerId, agentId });
return { token, user: { id: userId, email, role, passengerId, agentId } };
private async signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
// Get the full user data to include fullName
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, fullName: true, role: true }
});
const payload = { sub: userId, email, role, passengerId, agentId };
console.log('[AUTH] Creating JWT with payload:', payload);
const token = this.jwt.sign(payload);
console.log('[AUTH] JWT created, token length:', token.length);
const response = {
token,
user: {
id: userId,
email,
fullName: user?.fullName || email,
role,
passengerId,
agentId
}
};
console.log('[AUTH] Returning user object with passengerId:', response.user.passengerId);
return response;
}
private async createAuditLog(userId: string, action: string, entityType: string, entityId: string, oldData: any, newData: any) {
@@ -127,4 +164,79 @@ export class AuthService {
data: { userId, action, entityType, entityId, oldData, newData }
});
}
async getProfile(userId: string) {
if (!userId) {
throw new UnauthorizedException('User ID not found in token');
}
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
passenger: {
include: {
loyalty: true,
wallet: true,
},
},
preferences: true,
devices: true,
},
});
if (!user) throw new UnauthorizedException('User not found');
return {
id: user.id,
email: user.email,
phone: user.phone,
fullName: user.fullName,
role: user.role,
nationality: user.nationality,
nationalityCode: user.nationalityCode,
nationalId: user.nationalId,
passportNumber: user.passportNumber,
faydaVerified: user.faydaVerified,
faydaVerifiedAt: user.faydaVerifiedAt,
lastLoginAt: user.lastLoginAt,
createdAt: user.createdAt,
passenger: user.passenger ? {
id: user.passenger.id,
preferredLanguage: user.passenger.preferredLanguage,
loyalty: user.passenger.loyalty ? {
tier: user.passenger.loyalty.tier,
pointsBalance: user.passenger.loyalty.pointsBalance,
lifetimePoints: user.passenger.loyalty.lifetimePoints,
} : null,
wallet: user.passenger.wallet ? {
balanceMinor: user.passenger.wallet.balanceMinor,
currency: user.passenger.wallet.currency,
} : null,
} : null,
preferences: user.preferences,
devices: user.devices.map(device => ({
id: device.id,
platform: device.platform,
name: device.name,
pushToken: device.pushToken,
trusted: device.trusted,
lastSeenAt: device.lastSeenAt,
})),
};
}
async logout(userId: string) {
// Invalidate all active sessions for this user
await this.prisma.session.deleteMany({
where: { userId }
});
// Log the logout action
await this.createAuditLog(userId, 'USER_LOGOUT', 'User', userId, null, null);
return {
success: true,
message: 'Logged out successfully'
};
}
}

View File

@@ -1,10 +1,11 @@
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service';
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { IamGuard } from '../../common/iam-adapter';
@ApiTags('Booking')
@Controller('bookings')
@@ -14,6 +15,86 @@ export class BookingsController {
private guestService: GuestBookingService,
) {}
@Get('my')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Get logged-in user\'s booking history',
description: 'Returns all bookings for the authenticated user with schedule and payment details'
})
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference or station names' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
@ApiResponse({ status: 200, description: 'List of user bookings with schedule and passenger details' })
getMyBookings(
@Req() req: any,
@Query('search') search?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const passengerId = req.user?.passengerId;
if (!passengerId) throw new Error('Passenger ID not found in token');
return this.service.findByPassengerId(passengerId, {
search,
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20
});
}
@Get('by-device')
@ApiOperation({
summary: 'Get bookings by device ID',
description: 'Returns all bookings associated with a device ID (for guest users). Includes saved passenger details and booking history.'
})
@ApiQuery({ name: 'deviceId', required: true, description: 'Device identifier' })
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference or station names' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
@ApiResponse({ status: 200, description: 'List of guest bookings and saved passengers for device' })
@ApiResponse({ status: 400, description: 'Device ID is required' })
getByDevice(
@Query('deviceId') deviceId?: string,
@Query('search') search?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
if (!deviceId) throw new BadRequestException('Device ID is required');
return this.service.findByDeviceId(deviceId, {
search,
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20
});
}
@Get()
@ApiOperation({
summary: 'List all bookings with filters (Admin/Agent)',
description: 'Returns paginated list of bookings with search and status filters'
})
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
findAll(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.findAll({
search,
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20
});
}
@Post('guest')
@ApiOperation({
summary: 'Create guest booking without login (optional account creation)',
@@ -85,6 +166,17 @@ export class BookingsController {
return this.service.getByRef(ref);
}
@Patch(':id')
@ApiOperation({
summary: 'Update booking details',
description: 'Updates booking information for admin/agent operations'
})
@ApiResponse({ status: 200, description: 'Booking updated successfully' })
@ApiResponse({ status: 404, description: 'Booking not found' })
update(@Param('id') id: string, @Body() dto: any) {
return this.service.update(id, dto);
}
@Patch(':bookingRef/modify')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@@ -98,6 +190,28 @@ export class BookingsController {
return this.service.modify(dto);
}
@Delete(':id')
@ApiOperation({
summary: 'Delete booking (admin only)',
description: 'Permanently deletes a booking record'
})
@ApiResponse({ status: 200, description: 'Booking deleted successfully' })
@ApiResponse({ status: 404, description: 'Booking not found' })
delete(@Param('id') id: string) {
return this.service.delete(id);
}
@Get(':id/usage')
@ApiOperation({
summary: 'Check if booking is in use',
description: 'Returns list of modules/data that reference this booking'
})
@ApiResponse({ status: 200, description: 'Usage information retrieved' })
@ApiResponse({ status: 404, description: 'Booking not found' })
checkUsage(@Param('id') id: string) {
return this.service.checkBookingUsage(id);
}
@Delete(':bookingRef')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')

View File

@@ -10,7 +10,7 @@ export class PassengerInputDto {
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
@ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string;
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string;
@ApiPropertyOptional({ example: 'Kenya', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string;
@ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string;
}

View File

@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { BookingsController } from './bookings.controller';
import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service';
@@ -7,7 +8,7 @@ import { VerifaydaModule } from '../verifayda/verifayda.module';
import { CurrencyModule } from '../currency/currency.module';
@Module({
imports: [SeatsModule, VerifaydaModule, CurrencyModule],
imports: [SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
controllers: [BookingsController],
providers: [BookingsService, GuestBookingService],
exports: [BookingsService, GuestBookingService]

View File

@@ -21,6 +21,13 @@ function calculateAge(dateOfBirth: Date): number {
return age;
}
interface BookingFilters {
search?: string;
status?: string;
page?: number;
pageSize?: number;
}
@Injectable()
export class BookingsService {
constructor(
@@ -31,6 +38,213 @@ export class BookingsService {
private currencyService: CurrencyService,
) {}
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = { passengerId };
if (search) {
where.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
];
}
if (status) {
where.status = status;
}
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true,
seats: { include: { seat: true } },
},
}),
this.prisma.booking.count({ where }),
]);
return {
items: items.map(booking => ({
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.totalMinor,
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
arrivalAt: booking.schedule.arrivalAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
})),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async findByDeviceId(deviceId: string, filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
// Find user with this device ID
const device = await this.prisma.device.findUnique({
where: { id: deviceId },
include: { user: { include: { passenger: true } } },
}).catch(() => null);
const searchConditions = search ? [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
] : [];
const where: any = {
OR: [
{ userAgent: deviceId },
...(device?.user?.passenger ? [{ passengerId: device.user.passenger.id }] : []),
],
};
if (search) {
where.AND = [{ OR: searchConditions }];
}
if (status) {
where.status = status;
}
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true,
seats: { include: { seat: true } },
},
}),
this.prisma.booking.count({ where }),
]);
return {
items: items.map(booking => ({
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.totalMinor,
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
arrivalAt: booking.schedule.arrivalAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
})),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async findAll(filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
if (search) {
where.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ contactEmail: { contains: search, mode: 'insensitive' } },
{ contactPhone: { contains: search, mode: 'insensitive' } },
{ passenger: { user: { fullName: { contains: search, mode: 'insensitive' } } } },
];
}
if (status) {
where.status = status;
}
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
passenger: { include: { user: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true,
seats: { include: { seat: true } },
},
}),
this.prisma.booking.count({ where }),
]);
return {
items: items.map(booking => ({
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.totalMinor,
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
createdAt: booking.createdAt,
passenger: booking.passenger?.user,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
})),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async create(dto: CreateBookingDto) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
@@ -81,7 +295,6 @@ export class BookingsService {
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
// Use first passenger's nationality for fare lookup (or allow per-passenger pricing)
const primaryNationality = passengersData[0]?.nationality;
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality);
const adultFareMinor = baseFareMinor * adultCount;
@@ -229,6 +442,60 @@ export class BookingsService {
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
}
async update(id: string, dto: any) {
const booking = await this.prisma.booking.findUnique({ where: { id } });
if (!booking) throw new NotFoundException('Booking not found');
return this.prisma.booking.update({
where: { id },
data: {
status: dto.status || booking.status,
totalMinor: dto.totalMinor !== undefined ? dto.totalMinor : booking.totalMinor,
displayCurrency: dto.displayCurrency || booking.displayCurrency,
displayTotalMinor: dto.displayTotalMinor !== undefined ? dto.displayTotalMinor : booking.displayTotalMinor,
},
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true,
seats: { include: { seat: true } },
},
});
}
async delete(id: string) {
const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } });
if (!booking) throw new NotFoundException('Booking not found');
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } });
await this.prisma.booking.delete({ where: { id } });
return { deleted: true, bookingRef: booking.bookingRef };
}
async checkBookingUsage(id: string) {
const booking = await this.prisma.booking.findUnique({ where: { id } });
if (!booking) throw new NotFoundException('Booking not found');
const [ticketCount, paymentIntentCount, modificationsCount, cancellationCount] = await Promise.all([
this.prisma.ticket.count({ where: { bookingId: id } }),
this.prisma.paymentIntent.count({ where: { bookingId: id } }),
this.prisma.bookingModification.count({ where: { bookingId: id } }),
this.prisma.bookingCancellation.count({ where: { bookingId: id } }),
]);
const usage = [];
if (ticketCount > 0) usage.push('Ticket(s)');
if (paymentIntentCount > 0) usage.push('Payment record(s)');
if (modificationsCount > 0) usage.push('Modification history');
if (cancellationCount > 0) usage.push('Cancellation record(s)');
return {
isInUse: usage.length > 0,
affectedModules: usage,
};
}
@Cron(CronExpression.EVERY_MINUTE)
async expirePendingBookings() {
const cutoff = new Date(Date.now() - 20 * 60 * 1000);

View File

@@ -22,7 +22,7 @@ export class GuestPassengerDto {
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopians' })
@IsOptional() @IsString() passportNumber?: string;
@ApiPropertyOptional({ example: 'Kenya', description: 'Passport issuing country' })
@ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country' })
@IsOptional() @IsString() passportCountry?: string;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda), Djiboutian, Other' })

View File

@@ -71,24 +71,44 @@ export class GuestBookingService {
let verifaydaData: Record<string, any> | undefined;
let nationality = passenger.nationality;
// Verifayda verification for Ethiopian nationals
if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) {
throw new BadRequestException(
`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`
);
// Determine if passenger is Ethiopian
const isEthiopian = passenger.nationality === 'Ethiopian' ||
passenger.nationality === 'ETHIOPIAN' ||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
// Ethiopian with National ID
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
if (passenger.idDocumentNumber) {
// Attempt Fayda verification
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) {
throw new BadRequestException(
`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`
);
}
passengerName = verification.passengerData?.fullName || passengerName;
verifaydaVerified = true;
verifaydaData = verification.passengerData?.profileData;
}
passengerName = verification.passengerData?.fullName || passengerName;
verifaydaVerified = true;
verifaydaData = verification.passengerData?.profileData;
nationality = nationality || 'Ethiopian';
} else if (passenger.idDocumentType === IdDocumentType.PASSPORT) {
nationality = 'Ethiopian';
}
// International passenger with Passport (non-Ethiopian)
else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
// Passport details are required for international passengers
if (!passenger.passportNumber || !passenger.passportCountry) {
throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
}
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
}
// Ethiopian with Passport (manual entry without Fayda)
else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
// Ethiopians can use passport instead of national ID
nationality = 'Ethiopian';
}
// International with National ID (e.g., Djiboutian national ID)
else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
nationality = nationality || 'Other';
}
passengersData.push({
...passenger,
@@ -148,12 +168,19 @@ export class GuestBookingService {
throw new BadRequestException('Email already registered. Please login instead.');
}
let accountPhone = firstPassenger.phone || null;
if (accountPhone) {
const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
}
if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const passwordHash = await bcrypt.hash(dto.password, 10);
const user = await this.prisma.user.create({
data: {
fullName: firstPassenger.passengerName,
email: firstPassenger.email,
phone: firstPassenger.phone || '',
phone: accountPhone,
passwordHash,
nationality: firstPassenger.nationality,
nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
@@ -169,11 +196,31 @@ export class GuestBookingService {
createdAccount = true;
} else {
// Create anonymous guest passenger with minimal data
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
// Check if email exists and use a unique guest email if it does
let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
if (firstPassenger.email) {
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
if (existingUser) {
// Email exists, use guest email instead for anonymous booking
guestEmail = `guest-${uniqueId}@edr-platform.com`;
}
}
// Use a guaranteed-unique guest phone to avoid constraint collisions
let guestPhone = firstPassenger.phone || null;
if (guestPhone) {
const existingPhone = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
if (existingPhone) guestPhone = null;
}
if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
const tempUser = await this.prisma.user.create({
data: {
fullName: firstPassenger.passengerName,
email: firstPassenger.email || `guest-${Date.now()}@edr-platform.com`,
phone: firstPassenger.phone || `+251${Date.now()}`,
email: guestEmail,
phone: guestPhone,
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
role: 'PASSENGER',
},
@@ -203,6 +250,7 @@ export class GuestBookingService {
displayCurrency,
displayTotalMinor,
bookingType: 'ONE_WAY',
userAgent: dto.deviceId,
// contactEmail: firstPassenger.email, // Temporarily disabled until migration
// contactPhone: firstPassenger.phone, // Temporarily disabled until migration
seats: {

View File

@@ -1,12 +1,17 @@
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { FareEngineService } from './fare-engine.service';
import { FareCalculateDto, FareBreakdownDto } from './fare-engine.dto';
import { FaydaConfig } from '../../config/fayda.config';
@ApiTags('Fare Engine')
@Controller('fare-engine')
export class FareEngineController {
constructor(private service: FareEngineService) {}
constructor(
private service: FareEngineService,
private configService: ConfigService,
) {}
@Post('calculate')
@ApiOperation({
@@ -63,3 +68,36 @@ Returns a full breakdown including a human-readable calculation trace.`,
);
}
}
@ApiTags('Config')
@Controller('config')
export class ConfigController {
constructor(private configService: ConfigService) {}
@Get('fayda-status')
@ApiOperation({
summary: 'Check Verifayda 2.0 configuration status',
description: 'Returns whether Verifayda integration is enabled and ready to use'
})
@ApiResponse({
status: 200,
description: 'Verifayda status retrieved successfully',
schema: {
example: {
enabled: true,
mode: 'production',
apiUrl: 'https://api.verifayda.gov.et/v2'
}
}
})
getFaydaStatus() {
const faydaConfig = this.configService.get<FaydaConfig>('fayda');
const verifaydaEnabled = this.configService.get<boolean>('VERIFAYDA_ENABLED', false);
return {
enabled: faydaConfig?.enabled || verifaydaEnabled,
mode: verifaydaEnabled ? 'production' : 'development',
apiUrl: this.configService.get<string>('VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2'),
};
}
}

View File

@@ -1,12 +1,12 @@
import { Module } from '@nestjs/common';
import { FareEngineController } from './fare-engine.controller';
import { FareEngineController, ConfigController } from './fare-engine.controller';
import { FareEngineService } from './fare-engine.service';
import { CurrencyController } from './currency.controller';
import { CurrencyModule } from '../currency/currency.module';
@Module({
imports: [CurrencyModule],
controllers: [FareEngineController, CurrencyController],
controllers: [FareEngineController, CurrencyController, ConfigController],
providers: [FareEngineService],
exports: [FareEngineService],
})

View File

@@ -22,6 +22,14 @@ export class FleetController {
@ApiResponse({ status: 201, description: 'Train created' })
createTrain(@Body() dto: CreateTrainDto) { return this.service.createTrain(dto); }
@Patch('trains/:id')
@ApiOperation({ summary: 'Update a train service' })
@ApiParam({ name: 'id', description: 'Train UUID' })
@ApiBody({ type: CreateTrainDto })
@ApiResponse({ status: 200, description: 'Train updated' })
@ApiResponse({ status: 404, description: 'Train not found' })
updateTrain(@Param('id') id: string, @Body() dto: CreateTrainDto) { return this.service.updateTrain(id, dto); }
@Get('coaches')
@ApiOperation({ summary: 'List coaches filtered by status, mode, seat class, or schedule assignment' })
@ApiQuery({ name: 'isActive', required: false, type: Boolean, description: 'true = active only, false = inactive only, omit = all' })
@@ -73,6 +81,20 @@ export class FleetController {
@ApiResponse({ status: 404, description: 'Coach not found' })
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) { return this.service.updateCoach(id, dto); }
@Delete('trains/:id')
@ApiOperation({ summary: 'Delete a train service' })
@ApiParam({ name: 'id', description: 'Train UUID' })
@ApiResponse({ status: 200, description: 'Train deleted' })
@ApiResponse({ status: 404, description: 'Train not found' })
deleteTrain(@Param('id') id: string) { return this.service.deleteTrain(id); }
@Delete('coaches/:id')
@ApiOperation({ summary: 'Delete a coach' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiResponse({ status: 200, description: 'Coach deleted' })
@ApiResponse({ status: 404, description: 'Coach not found' })
deleteCoach(@Param('id') id: string) { return this.service.deleteCoach(id); }
@Post('assignments')
@ApiOperation({ summary: 'Assign a physical coach to a train schedule at a given position' })
@ApiBody({ type: AssignCoachDto })

View File

@@ -112,6 +112,12 @@ export class FleetService {
createTrain(dto: CreateTrainDto) { return this.prisma.train.create({ data: dto }); }
async updateTrain(id: string, dto: CreateTrainDto) {
const train = await this.prisma.train.findUnique({ where: { id } });
if (!train) throw new NotFoundException('Train not found');
return this.prisma.train.update({ where: { id }, data: dto });
}
async getCoach(id: string) {
const coach = await this.prisma.coach.findUnique({
where: { id },
@@ -217,6 +223,18 @@ export class FleetService {
return this.prisma.coach.update({ where: { id }, data: dto });
}
async deleteTrain(id: string) {
const train = await this.prisma.train.findUnique({ where: { id } });
if (!train) throw new NotFoundException('Train not found');
return this.prisma.train.delete({ where: { id } });
}
async deleteCoach(id: string) {
const coach = await this.prisma.coach.findUnique({ where: { id } });
if (!coach) throw new NotFoundException('Coach not found');
return this.prisma.coach.delete({ where: { id } });
}
async assignCoach(dto: AssignCoachDto) {
const [schedule, coach] = await Promise.all([
this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId } }),

View File

@@ -1,18 +1,81 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException, Patch, Delete } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { PassengersService } from './passengers.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, RegisterInternationalPassengerDto } from './passengers.dto';
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { IamGuard } from '../../common/iam-adapter';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
import { PrismaService } from '../../common/prisma.service';
@ApiTags('Passenger')
@ApiTags('Passengers')
@Controller('passengers')
export class PassengersController {
constructor(
private service: PassengersService,
private verifaydaService: VerifaydaService,
private prisma: PrismaService,
) {}
@Get()
@ApiOperation({
summary: 'List all passengers with filters (Admin/Agent)',
description: 'Returns paginated list of passengers with search filters'
})
@ApiQuery({ name: 'search', required: false, description: 'Search by name, email, or phone' })
@ApiQuery({ name: 'verified', required: false, description: 'Filter by verification status' })
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
findAll(
@Query('search') search?: string,
@Query('verified') verified?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.findAll({
search,
verified: verified ? verified === 'true' : undefined,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20
});
}
@Get('me')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Get current passenger profile',
description: 'Returns complete profile for authenticated passenger including passport details and verification status. Returns null if no passenger profile exists.'
})
@ApiResponse({
status: 200,
description: 'Passenger profile retrieved successfully or null if not found'
})
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing token' })
async getMe(@Request() req: any) {
if (!req.user || !req.user.userId) {
throw new UnauthorizedException('User not authenticated');
}
try {
const user = await this.prisma.user.findUnique({
where: { id: req.user.userId },
include: {
passenger: true,
},
});
if (!user || !user.passenger) {
return null;
}
return this.service.getProfile(user.passenger.id);
} catch (error) {
// If profile lookup fails for any reason, return null to allow app to continue
return null;
}
}
@Get(':id/profile')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@@ -32,14 +95,51 @@ export class PassengersController {
@Post('verify-fayda')
@ApiOperation({
summary: 'Verify Ethiopian national ID via Verifayda 2.0',
description: `Verifies Ethiopian national ID and retrieves passenger data from government database.
description: `**Standalone endpoint for pre-verification of Ethiopian national IDs**
---
### Purpose
Pre-verify national ID to auto-fill passenger registration form before submission.
---
### Flow
1. User enters national ID in form
2. Frontend calls \`POST /passengers/verify-fayda\`
3. API queries Verifayda 2.0 government database
4. Returns verified passenger data (name, DOB, gender)
5. Frontend auto-fills form with verified data
6. User submits form via \`POST /passengers/register\`
---
### Features
- Real-time verification via Verifayda 2.0 API
- Retrieves verified passenger data (name, DOB, gender, nationality)
- National IDs NOT stored (policy compliant)
- Retrieves verified data: name, date of birth, gender, nationality
- **National IDs NOT stored** (policy compliant)
- Only for Ethiopian nationals with national ID
- Non-Ethiopians should use passport (no verification required)
- Returns passenger details for booking form auto-fill`,
- Non-Ethiopians use passport (no verification)
---
### Important Notes
- This is a **read-only** verification endpoint
- Does NOT save passenger data to database
- Use \`POST /passengers/register\` to actually register
- Falls back to manual entry if Verifayda disabled or fails
---
### Authentication
- **Public endpoint** (no authentication required)
- Can be called before login/registration`,
})
@ApiResponse({
status: 200,
@@ -61,21 +161,164 @@ export class PassengersController {
return this.verifaydaService.verifyNationalId(dto.nationalId);
}
@Post('register-international')
@Post('register')
@UseGuards(OptionalJwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Register international passenger with passport details',
description: `Saves international passenger profile for booking.
summary: 'Universal passenger registration endpoint',
description: `**Single endpoint for all passenger registration scenarios**
- For non-Ethiopian passengers (Djiboutian, Kenyan, etc.)
- Collects passport information
- No government verification required
- Profile saved for future bookings
- Can be used by logged-in users or guest users (via deviceId)`,
---
### Automatic Detection
The API automatically detects:
- **Passenger Type**: Ethiopian (nationalId) vs International (passportNumber)
- **Authentication**: Logged-in (JWT token) vs Guest (deviceId)
- **Verification**: Auto-attempts Fayda for Ethiopian nationals
---
### Scenarios Handled
#### 1. Guest Ethiopian Passenger
- Provide: \`nationalId\`, \`deviceId\`
- Behavior: Attempts Fayda verification → Saves to SavedPassengerProfile
- Response: \`verified: true/false\`, \`linked: false\`
#### 2. Guest International Passenger
- Provide: \`passportNumber\`, \`passportCountry\`, \`deviceId\`
- Behavior: No verification → Saves to SavedPassengerProfile
- Response: \`verified: false\`, \`linked: false\`
#### 3. Logged-in Ethiopian Passenger
- Provide: JWT token + \`nationalId\`
- Behavior: Attempts Fayda verification → Updates user profile
- Response: \`verified: true/false\`, \`linked: true\`
#### 4. Logged-in International Passenger
- Provide: JWT token + \`passportNumber\`, \`passportCountry\`
- Behavior: No verification → Updates user profile
- Response: \`verified: false\`, \`linked: true\`
---
### Authentication
- **Optional JWT Bearer Token** (OptionalJwtGuard)
- Token present → Links to user account
- No token → Saves as guest (requires deviceId)
---
### Benefits
- Single endpoint for all scenarios
- Auto-detects passenger type and flow
- Graceful fallback if Fayda fails
- Consistent response structure
---
### Replaces
- Manual verification + save flows`,
})
@ApiResponse({ status: 201, description: 'International passenger profile saved successfully' })
@ApiResponse({ status: 400, description: 'Invalid passport details' })
registerInternational(@Body() dto: RegisterInternationalPassengerDto) {
return this.service.registerInternational(dto);
@ApiResponse({
status: 201,
description: 'Passenger registered successfully',
schema: {
example: {
id: 'uuid-123',
passengerName: 'Abebe Kebede',
dateOfBirth: '1985-03-15T00:00:00.000Z',
nationality: 'Ethiopian',
verified: true,
linked: false,
message: 'Passenger details saved for guest booking'
}
}
})
@ApiResponse({
status: 400,
description: 'Validation error or verification failed',
schema: {
example: {
statusCode: 400,
message: 'Validation failed',
error: 'Bad Request'
}
}
})
@ApiResponse({
status: 401,
description: 'Invalid JWT token (only if token provided but invalid)'
})
registerPassenger(@Body() dto: RegisterPassengerDto, @Request() req: any) {
const userId = req.user?.userId;
return this.service.registerPassenger({ ...dto, userId });
}
@Post('save-details')
@ApiOperation({
summary: 'Bulk save passenger details from booking flow',
description: `**Endpoint for saving multiple passengers in a single booking**
---
### Purpose
Save all passenger details for a multi-passenger booking before proceeding to seat selection. Optimized for batch operations where all passengers are collected upfront.
---
### Use Cases
1. **Multi-passenger bookings** - Save all passengers in a single request
2. **Batch registration** - Admin/Agent registering multiple passengers at once
3. **Data preservation** - Save passenger data before proceeding to seat selection
4. **Guest bookings** - Multiple guests booking together
---
### Differences from /register
| Feature | /register | /save-details |
|---------|-----------|---------------|
| Purpose | Single passenger registration with optional verification | Bulk save multiple passengers |
| Passengers | One at a time | Multiple in array |
| Verification | Auto-attempts for Ethiopian nationals (if enabled) | No automatic verification |
| Use case | Individual registration flow | Booking flow with all passengers |
| Authentication | Optional JWT | Optional JWT |
---
### Response
Returns saved passenger details with generated IDs and confirmation.`,
})
@ApiResponse({
status: 201,
description: 'All passenger details saved successfully',
schema: {
example: {
count: 2,
passengerIds: ['uuid-1', 'uuid-2'],
passengers: [
{
id: 'uuid-1',
passengerName: 'Abebe Kebede',
dateOfBirth: '1985-03-15T00:00:00.000Z',
nationality: 'Ethiopian',
nationalId: 'ET123456789'
},
{
id: 'uuid-2',
passengerName: 'Sara Ketsela',
dateOfBirth: '1990-08-22T00:00:00.000Z',
nationality: 'Ethiopian',
nationalId: 'ET987654321'
}
],
message: 'Passenger details saved successfully'
}
}
})
@ApiResponse({ status: 400, description: 'Validation error - passengers array required' })
savePassengers(@Body() dto: SavePassengersDto) {
return this.service.savePassengers(dto.passengers, dto.userId, dto.deviceId);
}
@Post('traveler-profiles')
@@ -109,4 +352,37 @@ export class PassengersController {
getSavedRoutes(@Param('id') id: string) {
return this.service.getSavedRoutes(id);
}
@Patch(':id')
@ApiOperation({
summary: 'Update passenger details',
description: 'Updates passenger information for admin/agent operations'
})
@ApiResponse({ status: 200, description: 'Passenger updated successfully' })
@ApiResponse({ status: 404, description: 'Passenger not found' })
updatePassenger(@Param('id') id: string, @Body() dto: any) {
return this.service.updatePassenger(id, dto);
}
@Delete(':id')
@ApiOperation({
summary: 'Delete passenger (admin only)',
description: 'Permanently deletes a passenger record and associated data'
})
@ApiResponse({ status: 200, description: 'Passenger deleted successfully' })
@ApiResponse({ status: 404, description: 'Passenger not found' })
deletePassenger(@Param('id') id: string) {
return this.service.deletePassenger(id);
}
@Get(':id/usage')
@ApiOperation({
summary: 'Check if passenger is in use',
description: 'Returns list of modules/data that reference this passenger'
})
@ApiResponse({ status: 200, description: 'Usage information retrieved' })
@ApiResponse({ status: 404, description: 'Passenger not found' })
checkUsage(@Param('id') id: string) {
return this.service.checkPassengerUsage(id);
}
}

View File

@@ -1,4 +1,5 @@
import { IsString, IsOptional, IsDateString, IsEnum } from 'class-validator';
import { IsString, IsOptional, IsDateString, IsEnum, IsBoolean, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateTravelerProfileDto {
@@ -19,7 +20,15 @@ export class CreateSavedRouteDto {
}
export class VerifyFaydaDto {
@ApiProperty({ example: 'ET123456789', description: 'Ethiopian national ID number' })
@ApiProperty({
example: 'ET123456789',
description: `**Ethiopian national ID number**
- Format: Varies by Ethiopian ID system
- Example: ET123456789
- Must be valid Ethiopian national ID
- Used to query Verifayda 2.0 government database`
})
@IsString()
nationalId: string;
}
@@ -66,3 +75,193 @@ export class RegisterInternationalPassengerDto {
@IsString()
deviceId?: string;
}
export class SavePassengerDetailsDto {
@ApiProperty({ example: 'Abebe Kebede', description: 'Full name of passenger' })
@IsString()
name: string;
@ApiProperty({ example: '1985-03-15', description: 'Date of birth in ISO format YYYY-MM-DD' })
@IsDateString()
dateOfBirth: string;
@ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID (for Ethiopian passengers)' })
@IsOptional()
@IsString()
nationalId?: string;
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number (for international passengers)' })
@IsOptional()
@IsString()
passportNumber?: string;
@ApiPropertyOptional({ example: 'Kenya', description: 'Passport issuing country' })
@IsOptional()
@IsString()
passportCountry?: string;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality' })
@IsOptional()
@IsString()
nationality?: string;
@ApiPropertyOptional({ example: '+251911234567', description: 'Phone number' })
@IsOptional()
@IsString()
phone?: string;
@ApiPropertyOptional({ example: 'abebe@example.com', description: 'Email address' })
@IsOptional()
@IsString()
email?: string;
@ApiPropertyOptional({ example: 'Male', description: 'Gender' })
@IsOptional()
@IsString()
gender?: string;
@ApiPropertyOptional({ example: true, description: 'Whether this is the primary passenger' })
@IsOptional()
@IsBoolean()
isPrimaryPassenger?: boolean;
}
export class SavePassengersDto {
@ApiProperty({ type: [SavePassengerDetailsDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => SavePassengerDetailsDto)
passengers: SavePassengerDetailsDto[];
@ApiPropertyOptional({ description: 'User ID if logged in' })
@IsOptional()
@IsString()
userId?: string;
@ApiPropertyOptional({ description: 'Device ID for guest users' })
@IsOptional()
@IsString()
deviceId?: string;
}
export class RegisterPassengerDto {
@ApiProperty({
example: 'Abebe Kebede',
description: 'Full name of passenger (required for all scenarios)'
})
@IsString()
passengerName: string;
@ApiProperty({
example: '1985-03-15',
description: 'Date of birth in ISO format YYYY-MM-DD (required for all scenarios)'
})
@IsDateString()
dateOfBirth: string;
@ApiPropertyOptional({
example: 'ET123456789',
description: `**Ethiopian national ID number**
- Triggers automatic Fayda verification if enabled
- Use for Ethiopian nationals only
- Mutually exclusive with passportNumber
- If Fayda enabled: passenger data auto-filled from government database
- If Fayda disabled: falls back to manual entry`
})
@IsOptional()
@IsString()
nationalId?: string;
@ApiPropertyOptional({
example: 'P1234567',
description: `**Passport number**
- Required for international passengers
- Mutually exclusive with nationalId
- No verification performed (manual entry only)`
})
@IsOptional()
@IsString()
passportNumber?: string;
@ApiPropertyOptional({
example: 'Kenya',
description: 'Passport issuing country (required if passportNumber provided)'
})
@IsOptional()
@IsString()
passportCountry?: string;
@ApiPropertyOptional({
example: 'Ethiopian',
description: `**Nationality**
- Auto-filled if Fayda verification succeeds
- Required for international passengers
- Optional for Ethiopian passengers (defaults to "Ethiopian")`
})
@IsOptional()
@IsString()
nationality?: string;
@ApiPropertyOptional({
example: '+251911234567',
description: 'Phone number in international format (optional but recommended)'
})
@IsOptional()
@IsString()
phone?: string;
@ApiPropertyOptional({
example: 'abebe@example.com',
description: 'Email address (optional but recommended)'
})
@IsOptional()
@IsString()
email?: string;
@ApiPropertyOptional({
description: `**User ID (auto-populated from JWT token)**
- Do NOT send this field in request
- Automatically extracted from JWT token if present
- Used to link passenger to user account`
})
@IsOptional()
@IsString()
userId?: string;
@ApiPropertyOptional({
example: 'device-uuid-123',
description: `**Device ID for guest users**
- **Required if no JWT token provided (guest mode)**
- Generate once and store locally (localStorage/AsyncStorage)
- Used to retrieve saved passenger profiles
- Format: UUID or any unique string`
})
@IsOptional()
@IsString()
deviceId?: string;
@ApiPropertyOptional({
example: 'Male',
description: 'Gender (auto-filled if Fayda verification succeeds)'
})
@IsOptional()
@IsString()
gender?: string;
@ApiPropertyOptional({
example: true,
description: `**Whether to verify with Fayda (auto-determined)**
- Default: Auto-detect (true if nationalId provided)
- Set to false to skip Fayda verification (use manual entry)
- Only applicable for Ethiopian nationals with nationalId`
})
@IsOptional()
@IsBoolean()
verifyWithFayda?: boolean;
}

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { PassengersController } from './passengers.controller';
import { PassengersService } from './passengers.service';
import { VerifaydaModule } from '../verifayda/verifayda.module';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [VerifaydaModule],
imports: [VerifaydaModule, HttpModule, PrismaModule],
controllers: [PassengersController],
providers: [PassengersService]
})

View File

@@ -1,10 +1,95 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterInternationalPassengerDto } from './passengers.dto';
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto';
import { VerifaydaService } from '../verifayda/verifayda.service';
interface PassengerFilters {
search?: string;
verified?: boolean;
page?: number;
pageSize?: number;
}
@Injectable()
export class PassengersService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private verifaydaService: VerifaydaService,
) {}
async findAll(filters: PassengerFilters = {}) {
const { search, verified, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
if (search) {
where.user = {
OR: [
{ fullName: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
{ phone: { contains: search, mode: 'insensitive' } },
],
};
}
if (verified !== undefined) {
where.user = {
...where.user,
nationalId: verified ? { not: null } : null,
};
}
const [items, total] = await Promise.all([
this.prisma.passenger.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
fullName: true,
email: true,
phone: true,
nationalId: true,
nationality: true,
},
},
loyalty: true,
_count: {
select: {
bookings: true,
},
},
},
}),
this.prisma.passenger.count({ where }),
]);
return {
items: items.map(passenger => ({
id: passenger.id,
fullName: passenger.user.fullName,
email: passenger.user.email,
phone: passenger.user.phone,
nationalId: passenger.user.nationalId,
nationality: passenger.user.nationality,
verified: !!passenger.user.nationalId,
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
totalBookings: passenger._count.bookings,
createdAt: passenger.createdAt,
})),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async getProfile(passengerId: string) {
const p = await this.prisma.passenger.findUnique({
@@ -45,29 +130,43 @@ export class PassengersService {
return { totalTrips, totalSpend, loyaltyPoints: loyalty?.pointsBalance ?? 0, co2Saved: totalTrips * 6 };
}
async registerInternational(dto: RegisterInternationalPassengerDto) {
const profile = await this.prisma.savedPassengerProfile.create({
data: {
userId: dto.userId,
deviceId: dto.deviceId,
passengerName: dto.passengerName,
dateOfBirth: new Date(dto.dateOfBirth),
idDocumentType: 'PASSPORT',
passportNumber: dto.passportNumber,
passportCountry: dto.passportCountry,
nationality: dto.nationality,
phone: dto.phone,
email: dto.email,
},
});
async savePassengers(passengers: any[], userId?: string, deviceId?: string) {
if (!passengers || !Array.isArray(passengers)) {
throw new BadRequestException('Passengers array is required');
}
if (passengers.length === 0) {
throw new BadRequestException('At least one passenger is required');
}
const savedProfiles = await Promise.all(
passengers.map((p) =>
this.prisma.savedPassengerProfile.create({
data: {
userId,
deviceId,
passengerName: p.name || p.passengerName,
dateOfBirth: new Date(p.dateOfBirth),
idDocumentType: p.nationalId ? 'NATIONAL_ID' : 'PASSPORT',
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
nationality: p.nationality,
phone: p.phone,
email: p.email,
},
})
)
);
return {
id: profile.id,
passengerName: profile.passengerName,
dateOfBirth: profile.dateOfBirth,
passportNumber: profile.passportNumber,
passportCountry: profile.passportCountry,
nationality: profile.nationality,
message: 'International passenger profile saved successfully',
count: savedProfiles.length,
passengerIds: savedProfiles.map(p => p.id),
passengers: savedProfiles.map(p => ({
id: p.id,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
nationality: p.nationality,
})),
message: 'Passenger details saved successfully',
};
}
@@ -80,4 +179,143 @@ export class PassengersService {
createSavedRoute(dto: CreateSavedRouteDto) { return this.prisma.savedRoute.create({ data: dto }); }
getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); }
}
async updatePassenger(id: string, dto: any) {
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
if (!passenger) throw new NotFoundException('Passenger not found');
return this.prisma.passenger.update({
where: { id },
data: {
user: {
update: {
fullName: dto.fullName || undefined,
email: dto.email || undefined,
phone: dto.phone || undefined,
nationality: dto.nationality || undefined,
},
},
},
include: {
user: { select: { fullName: true, email: true, phone: true, nationality: true } },
loyalty: true,
},
});
}
async registerPassenger(dto: RegisterPassengerDto) {
const isEthiopian = !!dto.nationalId;
const isLoggedIn = !!dto.userId;
let verifiedData: any = null;
// Auto-verify Ethiopian passengers with national ID if Fayda is enabled
if (isEthiopian && dto.verifyWithFayda !== false) {
try {
const verification = await this.verifaydaService.verifyNationalId(dto.nationalId!);
if (verification.verified && verification.passengerData) {
verifiedData = verification.passengerData;
}
} catch (error) {
// If verification fails, continue with manual data
console.warn('Fayda verification failed, using manual data:', error);
}
}
// Use verified data if available, otherwise use provided data
const finalData = {
passengerName: verifiedData?.fullName || dto.passengerName,
dateOfBirth: verifiedData?.dateOfBirth || new Date(dto.dateOfBirth),
nationality: verifiedData?.nationality || dto.nationality || (isEthiopian ? 'Ethiopian' : null),
gender: verifiedData?.gender || dto.gender,
phone: dto.phone,
email: dto.email,
};
// If logged in, update user profile and link passenger
if (isLoggedIn) {
const user = await this.prisma.user.findUnique({
where: { id: dto.userId },
include: { passenger: true },
});
if (!user) {
throw new BadRequestException('User not found');
}
// Update user record if not already verified
if (!user.faydaVerified && verifiedData) {
await this.prisma.user.update({
where: { id: dto.userId },
data: {
fullName: finalData.passengerName,
nationality: finalData.nationality,
nationalId: dto.nationalId,
passportNumber: dto.passportNumber,
faydaVerified: !!verifiedData,
faydaVerifiedAt: verifiedData ? new Date() : null,
},
});
}
return {
id: user.passenger?.id || user.id,
passengerName: finalData.passengerName,
dateOfBirth: finalData.dateOfBirth,
nationality: finalData.nationality,
verified: !!verifiedData,
linked: true,
message: 'Passenger details saved and linked to user account',
};
}
// Guest user - save to SavedPassengerProfile
const profile = await this.prisma.savedPassengerProfile.create({
data: {
deviceId: dto.deviceId,
passengerName: finalData.passengerName,
dateOfBirth: finalData.dateOfBirth,
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
passportNumber: dto.passportNumber,
passportCountry: dto.passportCountry,
nationality: finalData.nationality,
phone: dto.phone,
email: dto.email,
},
});
return {
id: profile.id,
passengerName: finalData.passengerName,
dateOfBirth: finalData.dateOfBirth,
nationality: finalData.nationality,
verified: !!verifiedData,
linked: false,
message: 'Passenger details saved for guest booking',
};
}
async deletePassenger(id: string) {
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
if (!passenger) throw new NotFoundException('Passenger not found');
await this.prisma.passenger.delete({ where: { id } });
return { deleted: true, passengerId: id };
}
async checkPassengerUsage(id: string) {
const [bookingCount, loyaltyAccount, walletAccount] = await Promise.all([
this.prisma.booking.count({ where: { passengerId: id } }),
this.prisma.loyaltyAccount.findUnique({ where: { passengerId: id } }),
this.prisma.walletAccount.findUnique({ where: { passengerId: id } }),
]);
const usage = [];
if (bookingCount > 0) usage.push(`${bookingCount} booking(s)`);
if (loyaltyAccount) usage.push('Loyalty account');
if (walletAccount) usage.push('Wallet account');
return {
isInUse: usage.length > 0,
affectedModules: usage,
};
}
}

View File

@@ -1,7 +1,8 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse } from '@nestjs/swagger';
import { Body, Controller, Get, HttpStatus, Param, Post, Query, Res, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse, ApiProduces } from '@nestjs/swagger';
import { Response } from 'express';
import { PaymentsService } from './payments.service';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto } from './payments.dto';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto, PaymentMethodTypeEnum, PaymentPlatformDto } from './payments.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { RolesGuard } from '../../common/roles.guard';
import { Roles } from '../../common/roles.decorator';
@@ -62,4 +63,113 @@ export class PaymentsController {
@ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false })
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
getMethods(@Query('region') region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); }
@Get('checkout')
@ApiOperation({
summary: 'Browser checkout redirect',
description: 'Initiates payment and returns an HTML page that auto-redirects the browser to the provider checkout URL. Designed to be opened directly in a browser tab.',
})
@ApiQuery({ name: 'bookingId', required: true })
@ApiQuery({ name: 'method', enum: PaymentMethodTypeEnum, required: true })
@ApiQuery({ name: 'platform', enum: ['web', 'mobile'], required: false })
@ApiProduces('text/html')
async checkout(
@Query('bookingId') bookingId: string,
@Query('method') method: PaymentMethodTypeEnum,
@Query('platform') platform: PaymentPlatformDto = 'web',
@Res() res: Response,
) {
if (!bookingId) {
return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing required query parameter: bookingId'));
}
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing or invalid query parameter: method'));
}
try {
const result = await this.service.initiatePayment({ bookingId, method, platform });
const url = result.clientAction?.type === 'REDIRECT' ? result.clientAction.url : undefined;
if (url) {
return res.status(HttpStatus.OK).type('html').send(this.buildRedirectHtml(url));
}
return res.status(HttpStatus.OK).type('html').send(this.buildStatusHtml(result.status, result.intentId));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'An unexpected error occurred';
return res.status(HttpStatus.OK).type('html').send(this.buildErrorHtml(message));
}
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/"/g, '&quot;');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=${escaped}">
<title>Redirecting to payment…</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
@keyframes spin { to { transform: rotate(360deg); } }
p { color: #555; margin: 0 0 16px; }
a { color: #1a73e8; }
</style>
</head>
<body>
<div class="card">
<div class="spinner"></div>
<p>Redirecting to payment provider…</p>
<p><a href="${escaped}">Click here if you are not redirected</a></p>
</div>
<script>window.location.href = "${escaped}";</script>
</body>
</html>`;
}
private buildStatusHtml(status: string, intentId: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment status</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
small { color: #888; }
</style>
</head>
<body>
<div class="card">
<div class="status">${status}</div>
<small>Intent: ${intentId}</small>
</div>
</body>
</html>`;
}
private buildErrorHtml(message: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment error</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
p { color: #555; }
</style>
</head>
<body>
<div class="card">
<div class="error">Payment could not be initiated</div>
<p>${message}</p>
</div>
</body>
</html>`;
}
}

View File

@@ -182,8 +182,6 @@ export class PaymentsService {
return this.formatIntentResponse(intent);
}
private formatIntentResponse(
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
): InitiateResponseDto {
@@ -349,9 +347,31 @@ export class PaymentsService {
});
});
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
await this.ticketsService.generate(booking.id);
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
try {
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
} catch (err) {
this.logger.error(`Error confirming seats: ${err instanceof Error ? err.message : String(err)}`);
}
try {
await this.createJourneySegments(booking);
} catch (err) {
this.logger.error(`Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`);
}
try {
await this.ticketsService.generate(booking.id);
} catch (err) {
this.logger.error(`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
try {
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
} catch (err) {
this.logger.warn(`Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`);
}
this.eventEmitter.emit('payment.succeeded', { booking });
return { alreadyFinalized: false };
}
@@ -390,4 +410,47 @@ export class PaymentsService {
await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any } });
await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: 'TRIP_COMPLETED', bookingId, balanceAfter: newBalance } });
}
private async createJourneySegments(booking: Prisma.BookingGetPayload<{ include: { seats: true } }>) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: booking.scheduleId },
include: { stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
});
if (!schedule) return;
const stopTimes = schedule.stopTimes;
if (stopTimes.length < 2) return;
const originSequence = stopTimes.findIndex(st => st.stationId === schedule.originStationId);
const destSequence = stopTimes.findIndex(st => st.stationId === schedule.destinationStationId);
if (originSequence < 0 || destSequence < 0 || originSequence >= destSequence) return;
const journey = await this.prisma.journey.create({
data: {
passengerId: booking.passengerId,
status: 'CONFIRMED',
totalMinor: booking.totalMinor,
currency: booking.currency,
},
});
const journeySegments = [];
for (const bookingSeat of booking.seats) {
for (let i = originSequence; i < destSequence; i++) {
journeySegments.push({
journeyId: journey.id,
scheduleId: booking.scheduleId,
segmentOrder: i,
seatId: bookingSeat.seatId,
departureStationId: stopTimes[i].stationId,
arrivalStationId: stopTimes[i + 1].stationId,
});
}
}
if (journeySegments.length > 0) {
await this.prisma.journeySegment.createMany({ data: journeySegments });
}
}
}

View File

@@ -190,7 +190,7 @@ export class TelebirrProvider implements PaymentProvider {
merch_code: this.merchantCode,
merch_order_id: input.merchantOrderId,
trade_type: 'Checkout' as const,
title: `EDR Booking ${input.bookingRef}`,
title: `EDR Booking`,
total_amount: totalAmount,
trans_currency: input.currency,
timeout_express: this.timeoutExpress,

View File

@@ -47,6 +47,14 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
@ApiResponse({ status: 404, description: 'Route not found' })
updateRoute(@Param('id') id: string, @Body() dto: UpdateRouteDto) { return this.service.updateRoute(id, dto); }
@Delete(':id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete a route' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Route deleted' })
@ApiResponse({ status: 404, description: 'Route not found' })
deleteRoute(@Param('id') id: string) { return this.service.deleteRoute(id); }
// ── Route Stops ────────────────────────────────────────────────────────────
@Get(':id/stops')

View File

@@ -91,6 +91,13 @@ export class RoutesService {
});
}
async deleteRoute(id: string) {
const route = await this.prisma.route.findUnique({ where: { id } });
if (!route) throw new NotFoundException('Route not found');
await this.prisma.route.delete({ where: { id } });
return { deleted: true, id };
}
// ── Route Stops ────────────────────────────────────────────────────────────
async addStop(routeId: string, dto: AddRouteStopDto) {

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { SchedulesService } from './schedules.service';
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
@@ -54,6 +54,16 @@ Origin and destination are derived from the first and last route stop — no nee
@ApiResponse({ status: 404, description: 'Schedule not found' })
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
@Patch(':id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiResponse({ status: 200, description: 'Schedule updated' })
@ApiResponse({ status: 404, description: 'Schedule not found' })
updateSchedule(@Param('id') id: string, @Body() dto: CreateScheduleDto) {
return this.service.updateSchedule(id, dto);
}
@Patch(':id/status')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update schedule status (SCHEDULED → BOARDING → EN_ROUTE → ARRIVED)' })
@@ -64,6 +74,16 @@ Origin and destination are derived from the first and last route stop — no nee
return this.service.updateScheduleStatus(id, dto);
}
@Delete(':id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiResponse({ status: 200, description: 'Schedule deleted' })
@ApiResponse({ status: 404, description: 'Schedule not found' })
deleteSchedule(@Param('id') id: string) {
return this.service.deleteSchedule(id);
}
// ── Stop Times ─────────────────────────────────────────────────────────────
@Get(':id/stops')
@@ -130,4 +150,43 @@ Origin and destination are derived from the first and last route stop — no nee
syncFares(@Param('id') id: string) {
return this.service.syncFaresFromEngine(id);
}
// ── Coach Assignments ──────────────────────────────────────────────────────
@Post(':id/coaches')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Assign coaches to a schedule',
description: 'Assigns selected coaches to a schedule with their position numbers. Replaces any existing coach assignments.'
})
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiResponse({ status: 201, description: 'Coaches assigned successfully' })
@ApiResponse({ status: 404, description: 'Schedule or coach not found' })
assignCoaches(
@Param('id') id: string,
@Body() dto: { coaches: Array<{ coachId: string; positionNumber: number }> },
) {
return this.service.assignCoaches(id, dto.coaches);
}
@Get(':id/coaches')
@ApiOperation({ summary: 'Get assigned coaches for a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiResponse({ status: 200, description: 'List of assigned coaches with seat details' })
getAssignedCoaches(@Param('id') id: string) {
return this.service.getAssignedCoaches(id);
}
@Delete(':id/coaches/:coachId')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Remove a coach assignment from a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiParam({ name: 'coachId', description: 'Coach UUID' })
@ApiResponse({ status: 200, description: 'Coach assignment removed' })
removeCoachAssignment(
@Param('id') id: string,
@Param('coachId') coachId: string,
) {
return this.service.removeCoachAssignment(id, coachId);
}
}

View File

@@ -30,9 +30,14 @@ export class SchedulesService {
where,
include: {
train: true,
route: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: true },
orderBy: { positionNumber: 'asc' },
},
_count: { select: { coachAssignments: true, bookings: true } },
},
orderBy: { departureAt: 'asc' },
@@ -53,8 +58,38 @@ export class SchedulesService {
if (!route.active) throw new BadRequestException('Route is not active');
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
// Auto-generate plannedTimes if not provided or empty
let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
// First stop - use departure time
stopTime = dep;
} else if (index === route.stops.length - 1) {
// Last stop - use arrival time
stopTime = arr;
} else {
// Intermediate stops - calculate based on distance proportion
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
};
});
}
// Validate all route stop sequences are covered by plannedTimes
const providedSeqs = new Set(dto.plannedTimes.map(t => t.sequence));
const providedSeqs = new Set(plannedTimes.map(t => t.sequence));
const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq));
if (missingSeqs.length > 0) {
throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`);
@@ -80,7 +115,7 @@ export class SchedulesService {
// Copy route stops into TripStopTime with the provided planned times
const plannedTimesMap = Object.fromEntries(
dto.plannedTimes.map(t => [t.sequence, t]),
plannedTimes.map(t => [t.sequence, t]),
);
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
@@ -161,10 +196,97 @@ export class SchedulesService {
return statusMap;
}
async updateSchedule(id: string, dto: CreateScheduleDto) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
if (!schedule) throw new NotFoundException('Schedule not found');
const dep = new Date(dto.departureAt);
const arr = new Date(dto.arrivalAt);
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
// Validate route exists and has stops
const route = await this.prisma.route.findUnique({
where: { id: dto.routeId },
include: { stops: { orderBy: { sequence: 'asc' } } },
});
if (!route) throw new NotFoundException('Route not found');
if (!route.active) throw new BadRequestException('Route is not active');
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
// Derive origin and destination from first and last route stop
const firstStop = route.stops[0];
const lastStop = route.stops[route.stops.length - 1];
await this.prisma.trainSchedule.update({
where: { id },
data: {
trainId: dto.trainId,
routeId: dto.routeId,
originStationId: firstStop.stationId,
destinationStationId: lastStop.stationId,
departureAt: dep,
arrivalAt: arr,
durationMinutes: Math.round((arr.getTime() - dep.getTime()) / 60_000),
stopsCount: Math.max(0, route.stops.length - 2),
},
});
// Delete existing stop times and recreate
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
// Auto-generate plannedTimes if not provided
let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
stopTime = arr;
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
};
});
}
const plannedTimesMap = Object.fromEntries(
plannedTimes.map(t => [t.sequence, t]),
);
await this.routesService.applyRouteToSchedule(dto.routeId, id, plannedTimesMap);
return this.getSchedule(id);
}
updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
return this.prisma.trainSchedule.update({ where: { id }, data: { status: dto.status } });
}
async deleteSchedule(id: string) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
if (!schedule) throw new NotFoundException('Schedule not found');
// Delete related records first (in dependency order)
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } });
await this.prisma.booking.deleteMany({ where: { scheduleId: id } });
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
return this.prisma.trainSchedule.delete({ where: { id } });
}
// ── Stop Times (per-schedule overrides) ───────────────────────────────────
getStops(scheduleId: string) {
@@ -254,4 +376,63 @@ export class SchedulesService {
return { synced, errors };
}
// ── Coach Assignments ──────────────────────────────────────────────────────
async assignCoaches(
scheduleId: string,
coaches: Array<{ coachId: string; positionNumber: number }>,
) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
if (!schedule) throw new NotFoundException('Schedule not found');
// Validate all coaches exist
const coachIds = coaches.map(c => c.coachId);
const existingCoaches = await this.prisma.coach.findMany({
where: { id: { in: coachIds } },
});
if (existingCoaches.length !== coachIds.length) {
throw new NotFoundException('One or more coaches not found');
}
// Remove existing assignments
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
// Create new assignments
await this.prisma.coachAssignment.createMany({
data: coaches.map(c => ({
scheduleId,
coachId: c.coachId,
positionNumber: c.positionNumber,
isOperational: true,
})),
});
return { message: 'Coaches assigned successfully', count: coaches.length };
}
async getAssignedCoaches(scheduleId: string) {
return this.prisma.coachAssignment.findMany({
where: { scheduleId },
include: {
coach: {
include: {
seatClass: true,
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
},
},
},
orderBy: { positionNumber: 'asc' },
});
}
async removeCoachAssignment(scheduleId: string, coachId: string) {
const assignment = await this.prisma.coachAssignment.findFirst({
where: { scheduleId, coachId },
});
if (!assignment) throw new NotFoundException('Coach assignment not found');
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
return { message: 'Coach assignment removed' };
}
}

View File

@@ -73,10 +73,13 @@ export class SearchService {
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
// Fetch fares for all seat classes from fare engine in one call
const faresByClass = await this.fareEngine
.calculateAllForSchedule(schedule.id, dto.nationality)
.catch(() => []);
// Fetch fares for all seat classes - need to pass the SEARCH origin/destination, not schedule terminals
const faresByClass = await this.calculateFaresForSegment(
schedule,
dto.originStationId,
dto.destinationStationId,
dto.nationality,
);
results.push({
scheduleId: schedule.id,
@@ -240,6 +243,113 @@ export class SearchService {
};
}
/**
* Calculate fares for a specific segment of a schedule
*/
private async calculateFaresForSegment(
schedule: any,
originStationId: string,
destinationStationId: string,
nationality?: string,
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
// Get seat classes that are actually assigned to this schedule via coaches
const assignedSeatClassIds: string[] = Array.from(
new Set(
schedule.coachAssignments.map((a: any) => a.coach.seatClass.id as string)
)
);
// Get only the seat classes that are assigned to this schedule
const seatClasses = await this.prisma.seatClass.findMany({
where: {
isActive: true,
id: { in: assignedSeatClassIds }
},
orderBy: { basePrice: 'asc' },
});
// If no coaches assigned, return empty array
if (seatClasses.length === 0) {
console.log(`No seat classes assigned to schedule ${schedule.id}`);
return [];
}
// If schedule has a route, use route-based calculation
if (schedule.routeId) {
const results = await Promise.all(
seatClasses.map(async (sc) => {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId,
destinationStationId,
seatClassId: sc.id,
nationality,
});
return {
seatClassName: fare.seatClassName,
baseFareMinor: fare.baseFarePerPassengerMinor,
};
} catch (error) {
console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message);
return null;
}
}),
);
const validResults = results.filter((r): r is { seatClassName: string; baseFareMinor: number } => r !== null);
if (validResults.length > 0) {
return validResults;
}
}
// Fallback: Try to get fares from FareRule table
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } });
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
if (originStation && destStation) {
const segmentRoute = `${originStation.code}-${destStation.code}`;
const now = new Date();
const fareRules = await this.prisma.fareRule.findMany({
where: {
route: segmentRoute,
seatClassId: { in: assignedSeatClassIds },
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
include: { seatClass: true },
});
if (fareRules.length > 0) {
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
return fareRules.map(rule => ({
seatClassName: rule.seatClass.name,
baseFareMinor: rule.baseFareMinor,
}));
}
}
// Last resort: Return default fares only for assigned seat classes
console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`);
return seatClasses.map(sc => ({
seatClassName: sc.name,
baseFareMinor: this.getDefaultFareForClass(sc.name),
}));
}
private getDefaultFareForClass(className: string): number {
const defaults: Record<string, number> = {
'Economy Regular': 35000,
'Economy Bed': 49000,
'VIP Bed': 63000,
};
return defaults[className] ?? 35000;
}
private defaultFare(seatClassName: string): number {
const fares: Record<string, number> = {
'Economy Regular': 45000,
@@ -249,6 +359,47 @@ export class SearchService {
return fares[seatClassName] ?? 45000;
}
/**
* Fallback method to get fares from FareRule table when fare engine fails
*/
private async getFallbackFares(
scheduleId: string,
originCode: string,
destCode: string,
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
const segmentRoute = `${originCode}-${destCode}`;
const now = new Date();
// Try to find fare rules for this segment
const fareRules = await this.prisma.fareRule.findMany({
where: {
route: segmentRoute,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
include: { seatClass: true },
});
if (fareRules.length > 0) {
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
return fareRules.map(rule => ({
seatClassName: rule.seatClass.name,
baseFareMinor: rule.baseFareMinor,
}));
}
// If no segment-specific rules, return default fares
console.log(`No fare rules found for ${segmentRoute}, using defaults`);
return [
{ seatClassName: 'Economy Regular', baseFareMinor: 35000 },
{ seatClassName: 'Economy Bed', baseFareMinor: 49000 },
{ seatClassName: 'VIP Bed', baseFareMinor: 63000 },
];
}
/**
* Select the best matching fare rule based on specificity:
* 1. schedule+segment+nationality

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger';
import { SeatClassesService } from './seat-classes.service';
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
@@ -37,4 +37,12 @@ export class SeatClassesController {
@ApiResponse({ status: 200, description: 'Seat class updated' })
@ApiResponse({ status: 404, description: 'Seat class not found' })
updateSeatClass(@Param('id') id: string, @Body() dto: UpdateSeatClassDto) { return this.service.updateSeatClass(id, dto); }
@Delete(':id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete a seat class' })
@ApiParam({ name: 'id', description: 'Seat class UUID' })
@ApiResponse({ status: 200, description: 'Seat class deleted' })
@ApiResponse({ status: 404, description: 'Seat class not found' })
deleteSeatClass(@Param('id') id: string) { return this.service.deleteSeatClass(id); }
}

View File

@@ -37,4 +37,10 @@ export class SeatClassesService {
if (!sc) throw new NotFoundException('SeatClass not found');
return this.prisma.seatClass.update({ where: { id }, data: dto, include: this.coachInclude });
}
async deleteSeatClass(id: string) {
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
if (!sc) throw new NotFoundException('SeatClass not found');
return this.prisma.seatClass.delete({ where: { id } });
}
}

View File

@@ -55,16 +55,16 @@ This makes it clear which segment of the route each seat is held for, enabling s
getHold(@Param('holdId') holdId: string) { return this.service.getHold(holdId); }
@Post('hold')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Hold seats for 15 minutes before booking',
summary: 'Hold seats for 15 minutes before booking (Public - Guest booking supported)',
description: `Temporarily reserves seats for a passenger to complete booking.
**Features:**
- 15-minute hold duration
- Auto-release after expiry
- Prevents double booking
- Required before creating booking`
- Required before creating booking
- **Public endpoint** - No authentication required (supports guest booking)`
})
@ApiResponse({ status: 201, description: 'Seats held successfully with holdId' })
@ApiResponse({ status: 409, description: 'One or more seats unavailable' })

View File

@@ -104,7 +104,7 @@ export class SeatsService {
if (new Set(seatIds).size !== seatIds.length)
throw new BadRequestException('Duplicate seatId in passengers list — each seat can only be assigned to one passenger');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
const hold = await this.prisma.$transaction(async (tx) => {
// ── 1. Validate seats exist and none are BLOCKED ─────────────────────
@@ -353,9 +353,13 @@ export class SeatsService {
// No-op for status — availability is segment-scoped via JourneySegment
// seat.status = BLOCKED is the only hard gate; BOOKED is not used as a booking flag
}
async releaseSeats(seatIds: string[]) {
// Only reset seats that are physically BLOCKED back to AVAILABLE if needed
// For segment-based bookings, releasing is handled by JourneySegment deletion
if (seatIds.length > 0) {
await this.prisma.journeySegment.deleteMany({
where: { seatId: { in: seatIds } },
});
}
}
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise<string[]> {
@@ -480,6 +484,16 @@ export class SeatsService {
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
for (const hold of expired) { await this.releaseSeats(hold.seatIds); await this.prisma.seatHold.delete({ where: { id: hold.id } }); }
for (const hold of expired) {
await this.releaseSeats(hold.seatIds);
try {
await this.prisma.seatHold.delete({ where: { id: hold.id } });
} catch (err) {
// Ignore if already deleted (e.g., by another process)
if (err instanceof Error && !err.message.includes('P2025')) {
throw err;
}
}
}
}
}

View File

@@ -1,5 +1,5 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { StationsService } from './stations.service';
import { CreateStationDto } from './stations.dto';
import { JwtGuard } from '../../common/jwt.guard';
@@ -14,7 +14,16 @@ export class StationsController {
summary: 'List all stations with country information',
description: 'Returns all stations on the Ethio-Djibouti Railway with country codes (ET for Ethiopia, DJ for Djibouti)'
})
findAll() { return this.service.findAll(); }
@ApiQuery({ name: 'search', required: false, description: 'Search by station name or code' })
@ApiQuery({ name: 'country', required: false, description: 'Filter by country code (ET, DJ)' })
@ApiQuery({ name: 'operational', required: false, description: 'Filter by operational status (true, false)' })
findAll(
@Query('search') search?: string,
@Query('country') country?: string,
@Query('operational') operational?: string,
) {
return this.service.findAll({ search, country, operational });
}
@Get(':id')
@ApiOperation({
@@ -28,4 +37,20 @@ export class StationsController {
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create new station' })
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
@Patch(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update station' })
update(@Param('id') id: string, @Body() dto: Partial<CreateStationDto>) {
return this.service.update(id, dto);
}
@Delete(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete station' })
remove(@Param('id') id: string) {
return this.service.remove(id);
}
}

View File

@@ -2,14 +2,61 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateStationDto } from './stations.dto';
interface StationFilters {
search?: string;
country?: string;
operational?: string;
}
@Injectable()
export class StationsService {
constructor(private prisma: PrismaService) {}
findAll() { return this.prisma.station.findMany({ orderBy: { name: 'asc' } }); }
findAll(filters: StationFilters = {}) {
const where: any = {};
if (filters.search) {
where.OR = [
{ name: { contains: filters.search, mode: 'insensitive' } },
{ code: { contains: filters.search, mode: 'insensitive' } },
{ city: { contains: filters.search, mode: 'insensitive' } },
];
}
if (filters.country) {
where.countryCode = filters.country;
}
if (filters.operational !== undefined && filters.operational !== '') {
where.isOperational = filters.operational === 'true';
}
return this.prisma.station.findMany({
where,
orderBy: { name: 'asc' }
});
}
async findOne(id: string) {
const s = await this.prisma.station.findUnique({ where: { id } });
if (!s) throw new NotFoundException('Station not found');
return s;
}
create(dto: CreateStationDto) { return this.prisma.station.create({ data: dto }); }
create(dto: CreateStationDto) {
return this.prisma.station.create({ data: dto });
}
async update(id: string, dto: Partial<CreateStationDto>) {
await this.findOne(id); // Check if exists
return this.prisma.station.update({
where: { id },
data: dto
});
}
async remove(id: string) {
await this.findOne(id); // Check if exists
return this.prisma.station.delete({ where: { id } });
}
}

View File

@@ -27,7 +27,7 @@ export class SupportService {
private getBotReply(text: string): string {
const lower = text.toLowerCase();
if (lower.includes('cancel') || lower.includes('refund')) return 'To cancel or refund, go to My Bookings and select the booking. Refunds are processed within 3-5 business days.';
if (lower.includes('cancel') || lower.includes('refund')) return 'To cancel or refund, go to Bookings and select the booking. Refunds are processed within 3-5 business days.';
if (lower.includes('miss') || lower.includes('missed')) return 'If you missed your train, please check the Disruptions section for alternative options.';
if (lower.includes('seat')) return 'You can select or change seats during booking. Seat changes after confirmation may incur a fee.';
return 'Thank you for contacting EDR support. An agent will assist you shortly.';

View File

@@ -1,16 +1,52 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Tickets')
@Controller('tickets')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class TicketsController {
constructor(private service: TicketsService) {}
@Post('generate/:bookingId')
@ApiOperation({
summary: 'Generate ticket for booking (confirmation page)',
description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.'
})
generateTicket(@Param('bookingId') bookingId: string) {
return this.service.generate(bookingId);
}
@Patch('update-seats/:bookingId')
@ApiOperation({
summary: 'Update ticket seats before final confirmation',
description: 'Allows users to change selected seats after ticket generation. Removes old seat blocks and creates new ones for updated seats.'
})
updateSeats(@Param('bookingId') bookingId: string, @Body() body: { seatIds: string[] }) {
return this.service.updateSeats(bookingId, body.seatIds);
}
@Get()
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all tickets with optional filters' })
listTickets(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('skip') skip?: string,
@Query('take') take?: string,
) {
return this.service.listTickets({
search,
status,
skip: skip ? parseInt(skip) : 0,
take: take ? parseInt(take) : 50,
});
}
@Get(':bookingRef')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Get ticket with QR code and passenger details',
description: `Returns ticket information including:
@@ -26,6 +62,8 @@ export class TicketsController {
}
@Post(':bookingRef/validate')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Validate ticket at gate with audit logging',
description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.'
@@ -39,20 +77,37 @@ export class TicketsController {
}
@Get(':ticketId/validation-logs')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get validation logs for ticket' })
getValidationLogs(@Param('ticketId') ticketId: string) {
return this.service.getValidationLogs(ticketId);
}
@Get('offline/export')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Export tickets for offline validation' })
exportOfflineData(@Query('scheduleId') scheduleId: string) {
return this.service.exportOfflineData(scheduleId);
}
@Post('validate/offline')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Batch import offline validations' })
validateOfflineBatch(@Body() body: { validations: any[] }) {
return this.service.validateOfflineBatch(body.validations);
}
@Delete(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Delete ticket (admin only)',
description: 'Permanently deletes a ticket record and removes associated seat blocks'
})
delete(@Param('id') id: string) {
return this.service.delete(id);
}
}

View File

@@ -1,6 +1,13 @@
import { Module } from '@nestjs/common';
import { TicketsController } from './tickets.controller';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
@Module({ controllers: [TicketsController], providers: [TicketsService], exports: [TicketsService] })
@Module({
controllers: [TicketsController],
providers: [TicketsService, JwtGuard],
exports: [TicketsService, JwtGuard],
})
export class TicketsModule {}
export { TicketsController } from './tickets.controller';

View File

@@ -13,19 +13,142 @@ interface OfflineValidation {
export class TicketsService {
constructor(private prisma: PrismaService) {}
async listTickets(filters: { search?: string; status?: string; skip: number; take: number }) {
const where: any = {};
if (filters.search) {
where.OR = [
{ bookingRef: { contains: filters.search, mode: 'insensitive' } },
{ barcodePayload: { contains: filters.search, mode: 'insensitive' } },
{ booking: { bookingRef: { contains: filters.search, mode: 'insensitive' } } },
];
}
if (filters.status) {
where.booking = { status: filters.status };
}
const tickets = await this.prisma.ticket.findMany({
where,
include: {
booking: {
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } },
passenger: { include: { user: true } },
},
},
},
skip: filters.skip,
take: filters.take,
orderBy: { issuedAt: 'desc' },
});
const total = await this.prisma.ticket.count({ where });
return {
items: tickets.map((t) => ({
id: t.id,
ticketNumber: t.barcodePayload,
bookingRef: t.bookingRef,
booking: {
bookingRef: t.booking.bookingRef,
status: t.booking.status,
passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail },
contactEmail: t.booking.contactEmail,
},
schedule: t.booking.schedule,
seat: t.booking.seats[0]?.seat,
status: t.booking.status,
validatedAt: t.validatedAt,
createdAt: t.issuedAt,
})),
total,
skip: filters.skip,
take: filters.take,
};
}
async generate(bookingId: string) {
if (!bookingId) {
throw new BadRequestException('Booking ID is required');
}
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } }
},
});
if (!booking) throw new NotFoundException('Booking not found');
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`);
const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
return this.prisma.ticket.upsert({
where: { bookingId },
update: { qrPayload, barcodePayload },
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload }
const ticket = await this.prisma.ticket.upsert({
where: { bookingId },
update: { qrPayload, barcodePayload },
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
});
// Create permanent seat blocks for all booked seats
const seatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of seatIds) {
await this.prisma.seatBlock.create({
data: {
seatId,
reason: `Permanently booked in ticket ${ticket.id}`,
blockedBy: 'SYSTEM',
approvedBy: 'SYSTEM',
}
}).catch(() => null); // Ignore if already exists
}
return ticket;
}
async updateSeats(bookingId: string, newSeatIds: string[]) {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { seats: true, ticket: true },
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (!booking.ticket) throw new BadRequestException('No ticket found for this booking');
// Remove old seat blocks
const oldSeatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of oldSeatIds) {
await this.prisma.seatBlock.deleteMany({
where: {
seatId,
reason: { contains: booking.ticket.id }
}
});
}
// Remove old booking seats
await this.prisma.bookingSeat.deleteMany({ where: { bookingId } });
// Create new seat blocks
for (const seatId of newSeatIds) {
await this.prisma.seatBlock.create({
data: {
seatId,
reason: `Permanently booked in ticket ${booking.ticket.id}`,
blockedBy: 'SYSTEM',
approvedBy: 'SYSTEM',
}
}).catch(() => null);
}
// Create new booking seats (placeholder with minimal data)
for (let i = 0; i < newSeatIds.length; i++) {
await this.prisma.bookingSeat.create({
data: {
bookingId,
seatId: newSeatIds[i],
passengerName: `Passenger ${i + 1}`,
}
});
}
return { success: true, updatedSeats: newSeatIds.length };
}
async getByRef(bookingRef: string) {
@@ -147,4 +270,22 @@ export class TicketsService {
return results;
}
async delete(id: string) {
const ticket = await this.prisma.ticket.findUnique({ where: { id } });
if (!ticket) throw new NotFoundException('Ticket not found');
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: id } });
// Remove seat blocks associated with this ticket
await this.prisma.seatBlock.deleteMany({
where: {
reason: { contains: id }
}
});
await this.prisma.ticket.delete({ where: { id } });
return { deleted: true, ticketId: id };
}
}

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
/**
* Like {@link JwtGuard}, but never rejects the request.
*
* When a valid `Authorization: Bearer <jwt>` is present, `request.user` is
* populated from the JWT strategy (`{ userId, ... }`). When the token is
* missing or invalid, the request still proceeds with `request.user`
* undefined — the handler decides what to do.
*
* Used on `POST /fayda/verification/start`, which must work for both
* logged-in users (who can opt to save the verification to their account)
* and guests (anchored to a booking only).
*/
@Injectable()
export class OptionalJwtGuard extends AuthGuard('jwt') {
handleRequest<TUser = unknown>(_err: unknown, user: TUser): TUser {
return (user ?? null) as TUser;
}
}

View File

@@ -0,0 +1,71 @@
import { exportJWK, generateKeyPair, importJWK, jwtVerify, type JWK } from 'jose';
import { generateClientAssertion } from './client-assertion.util';
describe('generateClientAssertion', () => {
let privateJwk: JWK;
let publicJwk: JWK;
beforeAll(async () => {
const kp = await generateKeyPair('RS256', { extractable: true });
privateJwk = await exportJWK(kp.privateKey);
publicJwk = await exportJWK(kp.publicKey);
});
it('produces a JWT verifiable with the matching public key', async () => {
const jwt = await generateClientAssertion({
clientId: 'edr-passenger-test',
audience: 'https://esignet.example.com/token',
privateJwk,
});
const verifier = await importJWK(publicJwk, 'RS256');
const { payload, protectedHeader } = await jwtVerify(jwt, verifier, {
issuer: 'edr-passenger-test',
subject: 'edr-passenger-test',
audience: 'https://esignet.example.com/token',
});
expect(protectedHeader.alg).toBe('RS256');
expect(protectedHeader.typ).toBe('JWT');
expect(payload.iss).toBe('edr-passenger-test');
expect(payload.sub).toBe('edr-passenger-test');
expect(payload.aud).toBe('https://esignet.example.com/token');
expect(typeof payload.iat).toBe('number');
expect(typeof payload.exp).toBe('number');
});
it('defaults exp to 120 seconds after iat', async () => {
const jwt = await generateClientAssertion({
clientId: 'c',
audience: 'https://a/token',
privateJwk,
});
const verifier = await importJWK(publicJwk, 'RS256');
const { payload } = await jwtVerify(jwt, verifier);
expect(payload.exp! - payload.iat!).toBe(120);
});
it('honors a custom expiresIn', async () => {
const jwt = await generateClientAssertion({
clientId: 'c',
audience: 'https://a/token',
privateJwk,
expiresIn: '5m',
});
const verifier = await importJWK(publicJwk, 'RS256');
const { payload } = await jwtVerify(jwt, verifier);
expect(payload.exp! - payload.iat!).toBe(300);
});
it('fails verification against a wrong audience', async () => {
const jwt = await generateClientAssertion({
clientId: 'c',
audience: 'https://a/token',
privateJwk,
});
const verifier = await importJWK(publicJwk, 'RS256');
await expect(
jwtVerify(jwt, verifier, { audience: 'https://other/token' }),
).rejects.toThrow();
});
});

View File

@@ -0,0 +1,22 @@
import { SignJWT, importJWK, type JWK } from 'jose';
export interface GenerateClientAssertionInput {
clientId: string;
audience: string;
privateJwk: JWK;
expiresIn?: string;
}
export async function generateClientAssertion(
input: GenerateClientAssertionInput,
): Promise<string> {
const privateKey = await importJWK(input.privateJwk, 'RS256');
return new SignJWT({})
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
.setIssuer(input.clientId)
.setSubject(input.clientId)
.setAudience(input.audience)
.setIssuedAt()
.setExpirationTime(input.expiresIn ?? '2m')
.sign(privateKey);
}

View File

@@ -0,0 +1,65 @@
import { createHash } from 'crypto';
import {
base64Url,
generateCodeChallenge,
generateCodeVerifier,
generateState,
} from './pkce.util';
describe('pkce.util', () => {
describe('base64Url', () => {
it('strips padding and replaces + and / with - and _', () => {
const input = Buffer.from([0xfb, 0xff, 0xbf, 0xfe]);
const out = base64Url(input);
expect(out).not.toMatch(/[+/=]/);
});
});
describe('generateCodeVerifier', () => {
it('returns a base64url-safe string', () => {
expect(generateCodeVerifier()).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('produces unique values across calls', () => {
const a = generateCodeVerifier();
const b = generateCodeVerifier();
expect(a).not.toEqual(b);
});
it('produces at least 43 characters (RFC 7636 minimum)', () => {
expect(generateCodeVerifier().length).toBeGreaterThanOrEqual(43);
});
});
describe('generateCodeChallenge', () => {
it('equals base64url(sha256(verifier))', () => {
const verifier = 'fixed-test-verifier';
const expected = createHash('sha256')
.update(verifier)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
expect(generateCodeChallenge(verifier)).toBe(expected);
});
it('is deterministic for the same verifier', () => {
const verifier = generateCodeVerifier();
expect(generateCodeChallenge(verifier)).toBe(generateCodeChallenge(verifier));
});
it('differs for different verifiers', () => {
expect(generateCodeChallenge('a')).not.toBe(generateCodeChallenge('b'));
});
});
describe('generateState', () => {
it('returns a base64url-safe string', () => {
expect(generateState()).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('produces unique values across calls', () => {
expect(generateState()).not.toEqual(generateState());
});
});
});

View File

@@ -0,0 +1,21 @@
import { createHash, randomBytes } from 'crypto';
export function base64Url(buffer: Buffer): string {
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
export function generateCodeVerifier(): string {
return base64Url(randomBytes(64));
}
export function generateCodeChallenge(codeVerifier: string): string {
return base64Url(createHash('sha256').update(codeVerifier).digest());
}
export function generateState(): string {
return base64Url(randomBytes(32));
}

View File

@@ -0,0 +1,111 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from './optional-jwt.guard';
import {
CompleteVerificationResultDto,
StartVerificationDto,
VerifaydaCallbackDto,
VerificationStatusDto,
} from './verifayda.dto';
import { VerifaydaService } from './verifayda.service';
/** Shape the JWT strategy puts on `request.user` (see common/jwt.strategy.ts). */
interface AuthedUser {
userId: string;
email?: string;
role?: string;
passengerId?: string;
}
/** Minimal slices of the Express req we touch (avoids a hard dependency on
* `@types/express`, which isn't resolved in this package). */
interface RequestWithOptionalUser {
user?: AuthedUser;
}
interface RequestWithUser {
user: AuthedUser;
}
@ApiTags('Fayda Verification')
@Controller('fayda/verification')
export class VerifaydaController {
constructor(private readonly service: VerifaydaService) {}
@Post('start')
@HttpCode(HttpStatus.OK)
@UseGuards(OptionalJwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Start a VeriFayda 2.0 verification session',
description: `Creates a verification session and returns the eSignet authorize URL the frontend should send the user to.
- Works for **logged-in users** and **guests**. If a valid bearer token is present, the verification is tied to that user; when \`saveToAccount\` is true their account is marked verified on success.
- For a **PURCHASE** flow, pass \`bookingId\` to stamp the booking's seats as Fayda-verified.
- The returned \`authorizationUrl\` already carries the PKCE \`code_challenge\`, CSRF \`state\`, requested \`claims\`, and \`code_challenge_method=S256\`. The frontend simply navigates to it (full page or popup).`,
})
@ApiOkResponse({
description: 'Authorize URL the frontend should redirect the user to.',
schema: {
example: {
authorizationUrl:
'https://esignet.example.com/authorize?client_id=...&state=...&code_challenge=...',
},
},
})
async start(
@Body() dto: StartVerificationDto,
@Req() req: RequestWithOptionalUser,
): Promise<{ authorizationUrl: string }> {
const authorizationUrl = await this.service.startVerification({
purpose: dto.purpose ?? 'PURCHASE',
platform: dto.platform ?? 'WEB',
userId: req.user?.userId,
bookingId: dto.bookingId,
saveToAccount: dto.saveToAccount,
});
return { authorizationUrl };
}
@Get('complete')
@ApiOperation({
summary: 'Complete a verification (Fayda redirect / client callback lands here)',
description: `This is the registered Fayda \`redirect_uri\`. Fayda redirects the browser here with \`?code&state\``,
})
@ApiOkResponse({ type: CompleteVerificationResultDto })
async complete(
@Query() dto: VerifaydaCallbackDto,
): Promise<CompleteVerificationResultDto> {
return this.service.completeVerification(dto);
}
@Get('status')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: "Get the current user's Fayda verification status",
description:
'Returns whether the authenticated user has linked a verified Fayda identity to their account, when, and the name on file.',
})
@ApiOkResponse({ type: VerificationStatusDto })
async status(
@Req() req: RequestWithUser,
): Promise<VerificationStatusDto> {
return this.service.getVerificationStatus(req.user.userId);
}
}

View File

@@ -0,0 +1,78 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator';
export class StartVerificationDto {
@ApiPropertyOptional({
enum: ['LOGIN', 'PURCHASE'],
default: 'PURCHASE',
description: 'Reason for verification.',
})
@IsOptional()
@IsIn(['LOGIN', 'PURCHASE'])
purpose?: 'LOGIN' | 'PURCHASE';
@ApiPropertyOptional({
description:
'Booking the verification should attach to (PURCHASE flow). If omitted, the session is anchored only to the user.',
})
@IsOptional()
@IsString()
bookingId?: string;
@ApiPropertyOptional({
description:
'When true and the user is logged in, copy faydaVerified=true / faydaSub onto their User record after verification.',
})
@IsOptional()
@IsBoolean()
saveToAccount?: boolean;
@ApiPropertyOptional({
enum: ['WEB', 'MOBILE'],
default: 'WEB',
description:
'Client platform. Decides where /callback redirects on completion: a web https URL (WEB) or a custom-scheme deep link the Flutter app intercepts (MOBILE).',
})
@IsOptional()
@IsIn(['WEB', 'MOBILE'])
platform?: 'WEB' | 'MOBILE';
}
export class CompleteVerificationResultDto {
@ApiProperty({ enum: ['LOGIN', 'PURCHASE'] })
purpose: 'LOGIN' | 'PURCHASE';
@ApiProperty() verified: boolean;
@ApiPropertyOptional({ description: 'JWT (LOGIN flow only).' })
token?: string;
@ApiPropertyOptional({
description: 'Authenticated user summary (LOGIN flow only; same shape as /auth/login).',
})
user?: {
id: string;
email: string;
role: string;
passengerId?: string;
agentId?: string;
};
@ApiPropertyOptional({
description: 'Verified full name from Fayda (PURCHASE flow).',
})
fullName?: string;
}
export class VerifaydaCallbackDto {
@ApiPropertyOptional() @IsOptional() @IsString() code?: string;
@ApiPropertyOptional() @IsOptional() @IsString() state?: string;
@ApiPropertyOptional() @IsOptional() @IsString() error?: string;
@ApiPropertyOptional() @IsOptional() @IsString() error_description?: string;
}
export class VerificationStatusDto {
@ApiProperty() verified: boolean;
@ApiPropertyOptional() verifiedAt?: Date;
@ApiPropertyOptional() fullName?: string;
}

View File

@@ -0,0 +1,19 @@
import { BadGatewayException, ConflictException } from '@nestjs/common';
export class FaydaTokenExchangeException extends BadGatewayException {
constructor(message = 'Fayda token exchange failed') {
super({ code: 'FAYDA_TOKEN_EXCHANGE_FAILED', message });
}
}
export class FaydaUserInfoException extends BadGatewayException {
constructor(message = 'Fayda userinfo fetch failed') {
super({ code: 'FAYDA_USERINFO_FAILED', message });
}
}
export class FaydaIdentityConflictException extends ConflictException {
constructor(message = 'This Fayda identity is already linked to another account') {
super({ code: 'FAYDA_IDENTITY_CONFLICT', message });
}
}

View File

@@ -1,9 +1,14 @@
import { Module } from '@nestjs/common';
import { VerifaydaController } from './verifayda.controller';
import { VerifaydaService } from './verifayda.service';
import { PrismaModule } from '../../common/prisma.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PrismaModule],
// AuthModule re-exports JwtModule, giving us JwtService (same secret/expiry
// config as /auth/login) to mint tokens for the LOGIN flow.
imports: [PrismaModule, AuthModule],
controllers: [VerifaydaController],
providers: [VerifaydaService],
exports: [VerifaydaService],
})

View File

@@ -0,0 +1,569 @@
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { exportJWK, generateKeyPair, type JWK } from 'jose';
import { PrismaService } from '../../common/prisma.service';
import { FaydaConfig } from '../../config/fayda.config';
import { VerifaydaService } from './verifayda.service';
function buildPrismaMock() {
return {
faydaVerificationSession: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
},
bookingSeat: {
updateMany: jest.fn(),
},
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
passenger: { create: jest.fn() },
loyaltyAccount: { create: jest.fn() },
walletAccount: { create: jest.fn() },
userPreferences: { create: jest.fn() },
verifaydaVerification: { create: jest.fn() },
};
}
function buildJwtMock(): jest.Mocked<JwtService> {
return {
sign: jest.fn(() => 'signed.jwt.token'),
} as unknown as jest.Mocked<JwtService>;
}
function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
return {
enabled: true,
clientId: 'edr-test-client',
authorizationEndpoint: 'https://esignet.test/authorize',
tokenEndpoint: 'https://esignet.test/token',
userInfoEndpoint: 'https://esignet.test/userinfo',
redirectUri: 'http://localhost:4000/fayda/verification/complete',
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
scope: 'openid profile email',
acrValues: 'mosip:idp:acr:generated-code',
claimsLocales: 'en am',
sessionTtlMinutes: 10,
...overrides,
};
}
function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked<ConfigService> {
return {
get: jest.fn((key: string, defaultValue?: unknown) => {
if (key === 'fayda') return faydaConfig;
if (key === 'VERIFAYDA_ENABLED') return false;
return defaultValue;
}),
} as unknown as jest.Mocked<ConfigService>;
}
describe('VerifaydaService (OIDC, client-callback)', () => {
let prisma: ReturnType<typeof buildPrismaMock>;
let jwt: jest.Mocked<JwtService>;
let service: VerifaydaService;
let realPrivateJwk: JWK;
beforeAll(async () => {
const kp = await generateKeyPair('RS256', { extractable: true });
realPrivateJwk = await exportJWK(kp.privateKey);
realPrivateJwk.kty = 'RSA';
});
beforeEach(() => {
prisma = buildPrismaMock();
jwt = buildJwtMock();
const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] });
service = new VerifaydaService(
buildConfigService(cfg),
prisma as unknown as PrismaService,
jwt,
);
(global as any).fetch = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('startVerification', () => {
it('persists a session and returns a fully-formed authorize URL', async () => {
prisma.faydaVerificationSession.create.mockResolvedValue({});
const url = await service.startVerification({
purpose: 'PURCHASE',
userId: 'user-1',
saveToAccount: true,
});
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
expect(created.purpose).toBe('PURCHASE');
expect(created.platform).toBe('WEB');
expect(typeof created.state).toBe('string');
expect(typeof created.codeVerifier).toBe('string');
const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe('https://esignet.test/authorize');
expect(parsed.searchParams.get('client_id')).toBe('edr-test-client');
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
expect(parsed.searchParams.get('redirect_uri')).toBe(
'http://localhost:4000/fayda/verification/complete',
);
expect(parsed.searchParams.get('state')).toBe(created.state);
});
it('uses the same single redirect_uri regardless of platform (platform is only recorded)', async () => {
prisma.faydaVerificationSession.create.mockResolvedValue({});
const url = await service.startVerification({
purpose: 'LOGIN',
platform: 'MOBILE',
});
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
expect(created.platform).toBe('MOBILE');
expect(new URL(url).searchParams.get('redirect_uri')).toBe(
'http://localhost:4000/fayda/verification/complete',
);
});
it('throws ServiceUnavailable when fayda integration is disabled', async () => {
const disabledService = new VerifaydaService(
buildConfigService(buildConfig({ enabled: false })),
prisma as unknown as PrismaService,
jwt,
);
await expect(
disabledService.startVerification({ purpose: 'PURCHASE' }),
).rejects.toMatchObject({ status: 503 });
});
});
describe('completeVerification — validation', () => {
function pendingSession(overrides: Partial<any> = {}) {
return {
id: 'session-1',
state: 'state-abc',
codeVerifier: 'verifier-xyz',
purpose: 'PURCHASE',
platform: 'WEB',
saveToAccount: false,
status: 'PENDING',
errorCode: null,
errorDescription: null,
userId: null,
bookingId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
};
}
it('throws and marks failed when callback carries an error', async () => {
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
await expect(
service.completeVerification({
error: 'access_denied',
error_description: 'user cancelled',
state: 'state-abc',
}),
).rejects.toMatchObject({ status: 400 });
expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
});
it('throws FAYDA_MISSING_PARAMETERS when code/state absent', async () => {
await expect(service.completeVerification({})).rejects.toMatchObject({
status: 400,
});
});
it('throws FAYDA_INVALID_STATE for unknown state', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(null);
await expect(
service.completeVerification({ code: 'c', state: 'bogus' }),
).rejects.toMatchObject({ status: 400 });
});
it('throws FAYDA_INVALID_STATE for a non-pending session', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ status: 'COMPLETED' }),
);
await expect(
service.completeVerification({ code: 'c', state: 'state-abc' }),
).rejects.toMatchObject({ status: 400 });
});
it('throws FAYDA_SESSION_EXPIRED and marks failed for an expired session', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ expiresAt: new Date(Date.now() - 1000) }),
);
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
await expect(
service.completeVerification({ code: 'c', state: 'state-abc' }),
).rejects.toMatchObject({ status: 400 });
expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
});
});
describe('completeVerification — PURCHASE', () => {
function pendingSession(overrides: Partial<any> = {}) {
return {
id: 'session-1',
state: 'state-abc',
codeVerifier: 'verifier-xyz',
purpose: 'PURCHASE',
platform: 'WEB',
saveToAccount: false,
status: 'PENDING',
userId: null,
bookingId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
};
}
function mockFetchSequence(...responses: Array<Partial<Response>>) {
const queue = responses.map((r) => ({
ok: true,
status: 200,
text: async () => '',
json: async () => ({}),
headers: new Headers({ 'content-type': 'application/json' }),
...r,
}));
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
}
it('stamps the booking seats and returns { verified, fullName }', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ bookingId: 'booking-1' }),
);
prisma.faydaVerificationSession.update.mockResolvedValue({});
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
{
headers: new Headers({ 'content-type': 'application/json' }),
text: async () =>
JSON.stringify({ sub: 'fayda-sub-1', name: 'Test User' }),
},
);
const result = await service.completeVerification({
code: 'authcode',
state: 'state-abc',
});
expect(result).toMatchObject({
purpose: 'PURCHASE',
verified: true,
fullName: 'Test User',
});
expect(result.token).toBeUndefined();
expect(prisma.bookingSeat.updateMany).toHaveBeenCalledWith({
where: { bookingId: 'booking-1' },
data: expect.objectContaining({ faydaSub: 'fayda-sub-1' }),
});
});
it('saves to the User account when saveToAccount=true and no conflict', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ userId: 'user-1', saveToAccount: true }),
);
prisma.user.findFirst.mockResolvedValue(null);
prisma.user.update.mockResolvedValue({});
prisma.faydaVerificationSession.update.mockResolvedValue({});
mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
{
headers: new Headers({ 'content-type': 'application/json' }),
text: async () =>
JSON.stringify({ sub: 'fayda-sub-2', name: 'Test User' }),
},
);
const result = await service.completeVerification({
code: 'authcode',
state: 'state-abc',
});
expect(result.verified).toBe(true);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: expect.objectContaining({ faydaVerified: true, faydaSub: 'fayda-sub-2' }),
});
});
it('throws identity_conflict (409) when faydaSub belongs to another user', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ userId: 'user-1', saveToAccount: true }),
);
prisma.user.findFirst.mockResolvedValue({ id: 'other-user' });
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
{
headers: new Headers({ 'content-type': 'application/json' }),
text: async () =>
JSON.stringify({ sub: 'fayda-sub-3', name: 'Test User' }),
},
);
await expect(
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
).rejects.toMatchObject({ status: 409 });
expect(prisma.user.update).not.toHaveBeenCalled();
});
it('throws 502 when the token endpoint returns 4xx', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence({
ok: false,
status: 400,
text: async () => '{"error":"invalid_assertion"}',
});
await expect(
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
).rejects.toMatchObject({ status: 502 });
});
it('throws 502 when userinfo is an unsupported format', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
{
headers: new Headers({ 'content-type': 'text/plain' }),
text: async () => 'not-a-jwt-not-a-json',
},
);
await expect(
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
).rejects.toMatchObject({ status: 502 });
});
it('falls back to localized name (name#en) when name is missing', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ bookingId: 'booking-2' }),
);
prisma.faydaVerificationSession.update.mockResolvedValue({});
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
{
headers: new Headers({ 'content-type': 'application/json' }),
text: async () =>
JSON.stringify({
sub: 'fayda-sub-4',
'name#en': 'English Name',
'name#am': 'Amharic Name',
}),
},
);
const result = await service.completeVerification({
code: 'c',
state: 'state-abc',
});
expect(result.fullName).toBe('English Name');
expect(prisma.bookingSeat.updateMany.mock.calls[0][0].data.faydaVerifiedName).toBe(
'English Name',
);
});
});
describe('completeVerification — LOGIN', () => {
function loginSession(overrides: Partial<any> = {}) {
return {
id: 'login-session',
state: 'state-login',
codeVerifier: 'verifier-xyz',
purpose: 'LOGIN',
platform: 'WEB',
saveToAccount: false,
status: 'PENDING',
userId: null,
bookingId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
};
}
function mockLoginFetch(userInfo: Record<string, unknown>) {
const queue = [
{
ok: true,
status: 200,
json: async () => ({ access_token: 'tok', token_type: 'Bearer' }),
text: async () => '',
headers: new Headers({ 'content-type': 'application/json' }),
},
{
ok: true,
status: 200,
json: async () => ({}),
text: async () => JSON.stringify(userInfo),
headers: new Headers({ 'content-type': 'application/json' }),
},
];
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
}
/** user.findUnique answers the faydaSub lookup and the issueLoginToken id lookup. */
function mockUserFindUnique(bySub: any, fullUser: any) {
prisma.user.findUnique.mockImplementation(async (args: any) => {
if (args?.where?.faydaSub !== undefined) return bySub;
if (args?.where?.id !== undefined) return fullUser;
return null;
});
}
beforeEach(() => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession());
});
it('creates a new user when no match and returns { token, user }', async () => {
const fullUser = {
id: 'new-user',
email: 'new@example.com',
role: 'PASSENGER',
passenger: { id: 'p-new' },
agent: null,
};
mockUserFindUnique(null, fullUser);
prisma.user.findFirst.mockResolvedValue(null);
prisma.user.create.mockResolvedValue({ id: 'new-user' });
prisma.passenger.create.mockResolvedValue({ id: 'p-new' });
prisma.loyaltyAccount.create.mockResolvedValue({});
prisma.walletAccount.create.mockResolvedValue({});
prisma.userPreferences.create.mockResolvedValue({});
prisma.faydaVerificationSession.update.mockResolvedValue({});
mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' });
const result = await service.completeVerification({
code: 'c',
state: 'state-login',
});
expect(result).toMatchObject({
purpose: 'LOGIN',
verified: true,
token: 'signed.jwt.token',
user: { id: 'new-user', passengerId: 'p-new' },
});
expect(prisma.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
faydaSub: 'login-sub-1',
faydaVerified: true,
email: 'new@example.com',
}),
}),
);
expect(prisma.passenger.create).toHaveBeenCalled();
expect(jwt.sign).toHaveBeenCalledWith(
expect.objectContaining({ sub: 'new-user', passengerId: 'p-new' }),
);
});
it('logs in an existing user already linked by faydaSub', async () => {
const fullUser = {
id: 'known-user',
email: 'k@example.com',
role: 'PASSENGER',
passenger: { id: 'p-k' },
agent: null,
};
mockUserFindUnique({ id: 'known-user' }, fullUser);
prisma.faydaVerificationSession.update.mockResolvedValue({});
mockLoginFetch({ sub: 'login-sub-2', name: 'Known' });
const result = await service.completeVerification({
code: 'c',
state: 'state-login',
});
expect(result.user?.id).toBe('known-user');
expect(prisma.user.create).not.toHaveBeenCalled();
});
it('links Fayda to an existing account matched by email', async () => {
const fullUser = {
id: 'acc-1',
email: 'match@example.com',
role: 'PASSENGER',
passenger: { id: 'p-1' },
agent: null,
};
mockUserFindUnique(null, fullUser);
prisma.user.findFirst.mockResolvedValue({ id: 'acc-1', faydaSub: null });
prisma.user.update.mockResolvedValue({});
prisma.faydaVerificationSession.update.mockResolvedValue({});
mockLoginFetch({ sub: 'login-sub-3', email: 'match@example.com' });
const result = await service.completeVerification({
code: 'c',
state: 'state-login',
});
expect(result.user?.id).toBe('acc-1');
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'acc-1' },
data: expect.objectContaining({ faydaSub: 'login-sub-3' }),
}),
);
expect(prisma.user.create).not.toHaveBeenCalled();
});
it('throws identity_conflict (409) when matched account has a different faydaSub', async () => {
mockUserFindUnique(null, null);
prisma.user.findFirst.mockResolvedValue({ id: 'acc-2', faydaSub: 'someone-else' });
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockLoginFetch({ sub: 'login-sub-4', email: 'match@example.com' });
await expect(
service.completeVerification({ code: 'c', state: 'state-login' }),
).rejects.toMatchObject({ status: 409 });
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.create).not.toHaveBeenCalled();
});
});
describe('getVerificationStatus', () => {
it('returns verified=true when User row has the flag', async () => {
prisma.user.findUnique.mockResolvedValue({
faydaVerified: true,
faydaVerifiedAt: new Date('2026-01-01T00:00:00Z'),
fullName: 'Test User',
});
const result = await service.getVerificationStatus('user-1');
expect(result).toEqual({
verified: true,
verifiedAt: new Date('2026-01-01T00:00:00Z'),
fullName: 'Test User',
});
});
it('returns verified=false when User row is missing or unverified', async () => {
prisma.user.findUnique.mockResolvedValue(null);
const result = await service.getVerificationStatus('user-x');
expect(result).toEqual({ verified: false });
});
});
});

View File

@@ -1,7 +1,35 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
Logger,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../common/prisma.service';
import { JwtService } from '@nestjs/jwt';
import axios, { AxiosInstance } from 'axios';
import * as bcrypt from 'bcrypt';
import { randomBytes } from 'crypto';
import { PrismaService } from '../../common/prisma.service';
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
import {
generateCodeChallenge,
generateCodeVerifier,
generateState,
} from './utils/pkce.util';
import { generateClientAssertion } from './utils/client-assertion.util';
import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto';
import {
FaydaIdentityConflictException,
FaydaTokenExchangeException,
FaydaUserInfoException,
} from './verifayda.errors';
import {
FaydaTokenResponse,
FaydaUserInfo,
NormalizedFaydaUserInfo,
VerifaydaPurpose,
} from './verifayda.types';
export interface VerifaydaPassengerData {
fullName: string;
@@ -17,41 +45,574 @@ export interface VerifaydaVerificationResult {
failureReason?: string;
}
export interface StartVerificationInput {
purpose: VerifaydaPurpose;
platform?: FaydaPlatform;
userId?: string;
bookingId?: string;
saveToAccount?: boolean;
}
export interface FaydaUserSummary {
id: string;
email: string;
role: string;
passengerId?: string;
agentId?: string;
}
/**
* Result of completing a verification. `verified` is always true on success.
* LOGIN additionally returns a JWT + user; PURCHASE returns the verified name.
*/
export interface CompleteVerificationResult {
purpose: VerifaydaPurpose;
verified: boolean;
token?: string;
user?: FaydaUserSummary;
fullName?: string;
}
@Injectable()
export class VerifaydaService {
private readonly logger = new Logger(VerifaydaService.name);
private readonly faydaConfig: FaydaConfig;
private readonly httpClient: AxiosInstance;
private readonly enabled: boolean;
private readonly apiUrl: string;
private readonly apiKey: string;
private readonly stubEnabled: boolean;
private readonly stubApiUrl: string;
private readonly stubApiKey: string;
constructor(
private readonly config: ConfigService,
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {
this.enabled = this.config.get<boolean>('VERIFAYDA_ENABLED', false);
this.apiUrl = this.config.get<string>('VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2');
this.apiKey = this.config.get<string>('VERIFAYDA_API_KEY', '');
const fayda = this.config.get<FaydaConfig>('fayda');
if (!fayda) {
throw new Error('Fayda config namespace not registered');
}
this.faydaConfig = fayda;
this.httpClient = axios.create({
baseURL: this.apiUrl,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiKey,
this.stubEnabled = this.config.get<boolean>('VERIFAYDA_ENABLED', false);
this.stubApiUrl = this.config.get<string>(
'VERIFAYDA_API_URL',
'https://api.verifayda.gov.et/v2',
);
this.stubApiKey = this.config.get<string>('VERIFAYDA_API_KEY', '');
this.logger.log(`Verifayda configuration: enabled=${this.stubEnabled}, url=${this.stubApiUrl}`);
// Only create HTTP client if Verifayda is enabled
if (this.stubEnabled) {
this.httpClient = axios.create({
baseURL: this.stubApiUrl,
timeout: 10000,
headers: { 'Content-Type': 'application/json', 'X-API-Key': this.stubApiKey },
});
this.logger.log('Verifayda HTTP client created');
} else {
this.logger.log('Verifayda HTTP client NOT created (disabled)');
}
}
// ==========================================================================
// OIDC flow
// ==========================================================================
async startVerification(input: StartVerificationInput): Promise<string> {
if (!this.faydaConfig.enabled) {
throw new ServiceUnavailableException({
code: 'FAYDA_DISABLED',
message: 'Fayda integration is not enabled',
});
}
const state = generateState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const expiresAt = new Date(
Date.now() + this.faydaConfig.sessionTtlMinutes * 60_000,
);
await this.prisma.faydaVerificationSession.create({
data: {
state,
codeVerifier,
purpose: input.purpose,
platform: input.platform ?? 'WEB',
saveToAccount: input.saveToAccount ?? false,
userId: input.userId ?? null,
bookingId: input.bookingId ?? null,
expiresAt,
},
});
this.logger.log(
`Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'} bookingId=${input.bookingId ?? 'none'}`,
);
return this.buildAuthorizationUrl({ state, codeChallenge });
}
async completeVerification(
query: VerifaydaCallbackDto,
): Promise<CompleteVerificationResult> {
if (query.error) {
this.logger.warn(`Fayda callback returned error: ${query.error}`);
if (query.state) {
await this.markSessionFailed(
query.state,
query.error,
query.error_description,
);
}
throw new BadRequestException({
code: 'FAYDA_AUTH_ERROR',
message: query.error,
description: query.error_description,
});
}
if (!query.code || !query.state) {
throw new BadRequestException({
code: 'FAYDA_MISSING_PARAMETERS',
message: 'code and state are required',
});
}
const session = await this.prisma.faydaVerificationSession.findUnique({
where: { state: query.state },
});
if (!session || session.status !== 'PENDING') {
this.logger.warn('Fayda complete with unknown or non-pending state');
throw new BadRequestException({
code: 'FAYDA_INVALID_STATE',
message: 'Verification session is invalid or already used',
});
}
if (session.expiresAt.getTime() < Date.now()) {
await this.markSessionFailed(query.state, 'session_expired');
throw new BadRequestException({
code: 'FAYDA_SESSION_EXPIRED',
message: 'Verification session has expired; start again',
});
}
try {
const tokens = await this.exchangeCodeForTokens(
query.code,
session.codeVerifier,
);
const userInfo = await this.fetchUserInfo(tokens.access_token);
const normalized = this.normalizeUserInfo(userInfo);
if (!normalized.sub) {
throw new FaydaUserInfoException('Fayda userinfo missing required sub');
}
let result: CompleteVerificationResult;
if (session.purpose === 'PURCHASE') {
await this.handlePurchaseSuccess(session, normalized);
result = {
purpose: 'PURCHASE',
verified: true,
fullName: normalized.fullName,
};
} else {
const { userId } = await this.handleLoginSuccess(normalized);
const login = await this.issueLoginToken(userId);
result = { purpose: 'LOGIN', verified: true, ...login };
}
await this.prisma.faydaVerificationSession.update({
where: { id: session.id },
data: { status: 'COMPLETED', completedAt: new Date(), codeVerifier: '' },
});
this.logger.log(
`Fayda verification completed: purpose=${session.purpose} platform=${session.platform}`,
);
return result;
} catch (err) {
const reason = this.classifyFailureReason(err);
this.logger.error(
`Fayda verification failed: reason=${reason} message=${(err as Error).message}`,
);
await this.markSessionFailed(
query.state,
reason,
(err as Error).message,
);
throw err;
}
}
/** Loads a user (+ relations) and mints the same JWT shape as `/auth/login`. */
private async issueLoginToken(
userId: string,
): Promise<{ token: string; user: FaydaUserSummary }> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { passenger: true, agent: true },
});
if (!user) {
// Should not happen — we just resolved/created this user.
throw new UnauthorizedException({
code: 'FAYDA_LOGIN_FAILED',
message: 'Could not load the verified user',
});
}
const summary: FaydaUserSummary = {
id: user.id,
email: user.email,
role: user.role,
passengerId: user.passenger?.id,
agentId: user.agent?.id,
};
const token = this.jwt.sign({
sub: summary.id,
email: summary.email,
role: summary.role,
passengerId: summary.passengerId,
agentId: summary.agentId,
});
this.logger.log(`Fayda login issued token for user ${user.id}`);
return { token, user: summary };
}
async getVerificationStatus(userId: string): Promise<VerificationStatusDto> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { faydaVerified: true, faydaVerifiedAt: true, fullName: true },
});
return {
verified: user?.faydaVerified ?? false,
verifiedAt: user?.faydaVerifiedAt ?? undefined,
fullName: user?.fullName ?? undefined,
};
}
// ==========================================================================
// OIDC internals
// ==========================================================================
private buildAuthorizationUrl(args: {
state: string;
codeChallenge: string;
}): string {
const params = new URLSearchParams({
client_id: this.faydaConfig.clientId,
response_type: 'code',
redirect_uri: this.faydaConfig.redirectUri,
scope: this.faydaConfig.scope,
state: args.state,
code_challenge: args.codeChallenge,
code_challenge_method: 'S256',
acr_values: this.faydaConfig.acrValues,
claims_locales: this.faydaConfig.claimsLocales,
});
const claims = {
userinfo: {
name: { essential: true },
phone_number: { essential: true },
email: { essential: false },
birthdate: { essential: true },
gender: { essential: false },
picture: { essential: false },
},
id_token: {},
};
params.set('claims', JSON.stringify(claims));
return `${this.faydaConfig.authorizationEndpoint}?${params.toString()}`;
}
private async exchangeCodeForTokens(
code: string,
codeVerifier: string,
): Promise<FaydaTokenResponse> {
const clientAssertion = await generateClientAssertion({
clientId: this.faydaConfig.clientId,
audience: this.faydaConfig.tokenEndpoint,
privateJwk: this.faydaConfig.privateJwk,
});
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: this.faydaConfig.redirectUri,
client_id: this.faydaConfig.clientId,
client_assertion_type:
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: clientAssertion,
code_verifier: codeVerifier,
});
const response = await fetch(this.faydaConfig.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!response.ok) {
let detail = '';
try {
detail = await response.text();
} catch {
// ignore
}
throw new FaydaTokenExchangeException(
`Fayda token endpoint returned ${response.status}${detail ? `: ${detail}` : ''}`,
);
}
return (await response.json()) as FaydaTokenResponse;
}
private async fetchUserInfo(accessToken: string): Promise<FaydaUserInfo> {
const response = await fetch(this.faydaConfig.userInfoEndpoint, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
throw new FaydaUserInfoException(
`Fayda userinfo endpoint returned ${response.status}`,
);
}
const contentType = response.headers.get('content-type') ?? '';
const raw = await response.text();
if (contentType.includes('application/json')) {
return JSON.parse(raw) as FaydaUserInfo;
}
// Signed JWT response — decode payload (signature verification = production TODO)
if (raw.split('.').length === 3) {
const payloadB64 = raw.split('.')[1];
const normalizedB64 = payloadB64.replace(/-/g, '+').replace(/_/g, '/');
const json = Buffer.from(normalizedB64, 'base64').toString('utf8');
return JSON.parse(json) as FaydaUserInfo;
}
throw new FaydaUserInfoException(
'Unsupported Fayda userinfo response format',
);
}
private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo {
return {
sub: raw.sub,
fullName: raw.name ?? raw['name#en'] ?? raw['name#am'],
phoneNumber:
raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone,
email: raw.email,
gender: raw.gender,
birthdate: raw.birthdate,
picture: raw.picture,
};
}
private async handlePurchaseSuccess(
session: {
id: string;
userId: string | null;
bookingId: string | null;
saveToAccount: boolean;
},
normalized: NormalizedFaydaUserInfo,
): Promise<void> {
if (session.bookingId) {
await this.prisma.bookingSeat.updateMany({
where: { bookingId: session.bookingId },
data: {
faydaVerifiedAt: new Date(),
faydaSub: normalized.sub,
faydaVerifiedName: normalized.fullName ?? null,
},
});
}
if (session.userId && session.saveToAccount) {
const conflict = await this.prisma.user.findFirst({
where: {
faydaSub: normalized.sub,
NOT: { id: session.userId },
},
select: { id: true },
});
if (conflict) {
throw new FaydaIdentityConflictException();
}
await this.prisma.user.update({
where: { id: session.userId },
data: {
faydaVerified: true,
faydaVerifiedAt: new Date(),
faydaSub: normalized.sub,
},
});
}
}
/**
* Resolves the User for a LOGIN flow and returns its id (the caller mints the
* JWT via {@link issueLoginToken}). Resolution order:
* 1. Existing user already linked to this Fayda `sub`.
* 2. Existing account whose email/phone matches — linked to this `sub`.
* 3. Otherwise a fresh Fayda-backed account is created.
*/
private async handleLoginSuccess(
normalized: NormalizedFaydaUserInfo,
): Promise<{ userId: string }> {
let userId: string;
const bySub = await this.prisma.user.findUnique({
where: { faydaSub: normalized.sub },
select: { id: true },
});
if (bySub) {
userId = bySub.id;
} else {
const matchers: Array<{ email?: string; phone?: string }> = [];
if (normalized.email) matchers.push({ email: normalized.email });
if (normalized.phoneNumber) matchers.push({ phone: normalized.phoneNumber });
const existing = matchers.length
? await this.prisma.user.findFirst({
where: { OR: matchers },
select: { id: true, faydaSub: true },
})
: null;
if (existing) {
if (existing.faydaSub && existing.faydaSub !== normalized.sub) {
// The matched account is already tied to a different Fayda identity.
throw new FaydaIdentityConflictException();
}
await this.prisma.user.update({
where: { id: existing.id },
data: {
faydaSub: normalized.sub,
faydaVerified: true,
faydaVerifiedAt: new Date(),
},
});
userId = existing.id;
this.logger.log(`Fayda login linked existing user ${existing.id}`);
} else {
userId = await this.createFaydaUser(normalized);
this.logger.log(`Fayda login created new user ${userId}`);
}
}
return { userId };
}
/**
* Creates a Fayda-backed User plus the same satellite rows registration makes
* (Passenger, LoyaltyAccount, WalletAccount, UserPreferences).
*
* The user has no password — `passwordHash` is set to a bcrypt of random bytes
* so password login is impossible; they authenticate only via Fayda. When
* Fayda doesn't supply an email/phone, a deterministic placeholder derived from
* the (unique) `sub` keeps the NOT NULL + unique columns satisfied.
*/
private async createFaydaUser(
normalized: NormalizedFaydaUserInfo,
): Promise<string> {
const passwordHash = await bcrypt.hash(
randomBytes(32).toString('hex'),
10,
);
const email = normalized.email ?? `fayda_${normalized.sub}@users.fayda.local`;
const phone = normalized.phoneNumber ?? `fayda:${normalized.sub}`;
const fullName = normalized.fullName ?? 'Fayda User';
const user = await this.prisma.user.create({
data: {
fullName,
email,
phone,
passwordHash,
faydaVerified: true,
faydaVerifiedAt: new Date(),
faydaSub: normalized.sub,
},
select: { id: true },
});
const passenger = await this.prisma.passenger.create({
data: { userId: user.id },
select: { id: true },
});
await this.prisma.loyaltyAccount.create({
data: { passengerId: passenger.id },
});
await this.prisma.walletAccount.create({
data: { passengerId: passenger.id },
});
await this.prisma.userPreferences.create({ data: { userId: user.id } });
return user.id;
}
private async markSessionFailed(
state: string,
errorCode: string,
errorDescription?: string,
): Promise<void> {
await this.prisma.faydaVerificationSession.updateMany({
where: { state, status: 'PENDING' },
data: {
status: 'FAILED',
errorCode,
errorDescription: errorDescription ?? null,
completedAt: new Date(),
codeVerifier: '',
},
});
}
private classifyFailureReason(err: unknown): string {
if (err instanceof FaydaIdentityConflictException) return 'identity_conflict';
if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';
if (err instanceof FaydaUserInfoException) return 'userinfo_failed';
return 'verification_failed';
}
// ==========================================================================
// DEPRECATED: legacy stub flow
// ==========================================================================
/** @deprecated Use the OIDC flow instead. Retained until cleanup. */
async verifyNationalId(
nationalId: string,
bookingId?: string,
): Promise<VerifaydaVerificationResult> {
if (!this.enabled) {
this.logger.warn('Verifayda is disabled - skipping verification');
this.logger.log(`verifyNationalId called: stubEnabled=${this.stubEnabled}, type=${typeof this.stubEnabled}`);
if (this.stubEnabled != false || this.stubEnabled) {
this.logger.warn('Verifayda stub is disabled - returning mock data (development mode)');
// In development mode, return mock verified data
return {
verified: false,
failureReason: 'Verifayda integration is disabled',
verified: true,
passengerData: {
fullName: 'Mock Passenger',
dateOfBirth: new Date('1990-01-01'),
gender: 'Male',
nationality: 'Ethiopian',
},
};
}
@@ -62,10 +623,8 @@ export class VerifaydaService {
};
try {
this.logger.log(`Verifying national ID via Verifayda 2.0`);
this.logger.log('Verifying national ID via legacy Verifayda stub');
const response = await this.httpClient.post('/verify', requestPayload);
const { data } = response;
if (data.status === 'verified' && data.citizen) {
@@ -88,36 +647,24 @@ export class VerifaydaService {
},
});
this.logger.log('Verifayda verification successful');
return { verified: true, passengerData };
}
return {
verified: true,
passengerData,
};
} else {
const failureReason = data.message || 'Verification failed';
await this.prisma.verifaydaVerification.create({
data: {
bookingId,
nationalId,
requestPayload,
responsePayload: data,
verified: false,
failureReason,
},
});
this.logger.warn(`Verifayda verification failed: ${failureReason}`);
return {
const failureReason = data.message || 'Verification failed';
await this.prisma.verifaydaVerification.create({
data: {
bookingId,
nationalId,
requestPayload,
responsePayload: data,
verified: false,
failureReason,
};
}
},
});
return { verified: false, failureReason };
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || 'Unknown error';
const errorMessage =
error.response?.data?.message || error.message || 'Unknown error';
await this.prisma.verifaydaVerification.create({
data: {
bookingId,
@@ -127,16 +674,15 @@ export class VerifaydaService {
failureReason: errorMessage,
},
});
this.logger.error(`Verifayda API error: ${errorMessage}`);
this.logger.error(`Verifayda stub error: ${errorMessage}`);
throw new BadRequestException(
`National ID verification failed: ${errorMessage}`,
);
}
}
/** @deprecated Use `faydaConfig.enabled` for the OIDC flow. */
isEnabled(): boolean {
return this.enabled;
return this.stubEnabled;
}
}

View File

@@ -0,0 +1,36 @@
export type VerifaydaPurpose = 'LOGIN' | 'PURCHASE';
export interface FaydaTokenResponse {
access_token: string;
id_token?: string;
token_type: string;
expires_in?: number;
scope?: string;
}
export interface FaydaUserInfo {
sub: string;
name?: string;
'name#en'?: string;
'name#am'?: string;
phone_number?: string;
'phone_number#en'?: string;
'phone_number#am'?: string;
phone?: string;
email?: string;
gender?: string;
birthdate?: string;
picture?: string;
address?: Record<string, unknown>;
[key: string]: unknown;
}
export interface NormalizedFaydaUserInfo {
sub: string;
fullName?: string;
phoneNumber?: string;
email?: string;
gender?: string;
birthdate?: string;
picture?: string;
}

View File

@@ -1 +1,9 @@
VITE_API_URL=http://localhost:3002
# API Configuration
NEXT_PUBLIC_API_URL=https://your-api-domain.com
# IAM Configuration (Corporate Authentication)
NEXT_PUBLIC_IAM_ENABLED=false
NEXT_PUBLIC_IAM_API_URL=https://iam.tria-plc.com/api
# GitHub Packages Token
GITHUB_PACKAGE_TOKEN=$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

View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}

View File

@@ -0,0 +1,33 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,419 @@
# EDR Admin Portal (Backoffice)
Comprehensive admin portal for the Ethio-Djibouti Railway passenger management system. Built with Next.js 14, TypeScript, and Tailwind CSS with full dark mode support and EDR branding.
## 🚀 Enhanced Features
### Complete Admin Module Coverage
- **Overview** - Dashboard with KPIs, revenue trends, and real-time metrics
- **Operations** - Bookings, Passengers, Tickets, Live Tracking, Agent Operations
- **Master Data** - Stations, Routes, Fleet Management, Schedules, Seat Classes
- **Financial** - Pricing & Fares, Payments, Wallet Management, Promotions
- **Customer Services** - Loyalty Program, Support Center, Notifications, Food & Dining
- **Security & Compliance** - Fraud Detection, Verifayda Integration, Audit Logs
- **Analytics & Reports** - Comprehensive reporting and operational analytics
- **System** - Settings and configuration management
### UI/UX Enhancements
- **EDR Branding** - Official blue, orange, and red color scheme
- **Dark Mode** - Full dark mode support with theme persistence
- **Collapsible Sidebar** - Space-efficient navigation with categorized sections
- **Responsive Design** - Mobile-first approach with adaptive layouts
- **Loading States** - Skeleton loaders and async action feedback
- **Interactive Components** - Sortable tables, action buttons, modals
### Technical Features
- **Real API Integration** - Connected to all EDR passenger API endpoints
- **Functional CRUD Operations** - Add, edit, delete with optimistic updates
- **Advanced Data Tables** - Sorting, filtering, pagination, bulk actions
- **Form Validation** - Client-side validation with error handling
- **State Management** - Zustand for auth and theme state
- **Query Management** - React Query for server state and caching
- **Type Safety** - Full TypeScript coverage with EDR domain types
## 📋 Prerequisites
- Node.js >= 20.x
- pnpm >= 9.x
- EDR Passenger API running on http://localhost:4000
## 🛠️ Installation
### 1. Install Dependencies
From the monorepo root:
```bash
pnpm install
```
Or from the backoffice directory:
```bash
cd apps/edr-passenger-web/backoffice
pnpm install
```
### 2. Environment Configuration
Copy the environment template:
```bash
cp .env.example .env.local
```
Edit `.env.local`:
```bash
# API Configuration
NEXT_PUBLIC_API_URL=http://localhost:4000
# IAM Configuration (Corporate Authentication)
NEXT_PUBLIC_IAM_ENABLED=false
NEXT_PUBLIC_IAM_API_URL=https://iam.tria-plc.com/api
```
### 3. Start Development Server
From the backoffice directory:
```bash
pnpm dev
```
Or from the monorepo root:
```bash
pnpm --filter @edr/passenger-backoffice run dev
```
The admin portal will be available at: **http://localhost:3001**
## 🔑 Login Credentials
Use these demo credentials to access the admin portal:
| Email | Password | Role |
|-------|----------|------|
| admin@edr-platform.com | admin123 | Admin |
**Note:** This is a stub authentication flow. TODO: Integrate with real backend auth endpoint.
## 📁 Enhanced Project Structure
```
backoffice/
├── src/
│ ├── app/ # Next.js App Router pages
│ │ ├── dashboard/ # Dashboard with KPIs
│ │ ├── bookings/ # Booking management
│ │ ├── passengers/ # Passenger management
│ │ ├── stations/ # Station master data
│ │ ├── routes/ # Route management
│ │ ├── fleet/ # Train & coach management
│ │ ├── schedules/ # Trip schedules
│ │ ├── seat-classes/ # Seat class configuration
│ │ ├── pricing/ # Fare rules & pricing
│ │ ├── payments/ # Payment management
│ │ ├── tickets/ # Ticket operations
│ │ ├── agents/ # Agent operations
│ │ ├── loyalty/ # Loyalty program
│ │ ├── wallet/ # Wallet management
│ │ ├── promotions/ # Promotion management
│ │ ├── support/ # Customer support
│ │ ├── notifications/ # Notification center
│ │ ├── fraud/ # Fraud detection
│ │ ├── verifayda/ # ID verification
│ │ ├── audit/ # Audit logs
│ │ ├── live/ # Live tracking
│ │ ├── food/ # Food & dining
│ │ ├── reports/ # Analytics & reports
│ │ ├── operational-reports/ # Operational reports
│ │ ├── settings/ # System settings
│ │ └── login/ # Authentication
│ ├── components/
│ │ ├── layout/ # Layout components
│ │ │ ├── Sidebar.tsx # Collapsible navigation
│ │ │ └── Header.tsx # Top header
│ │ ├── dashboard/ # Dashboard components
│ │ │ └── StatCard.tsx # KPI cards
│ │ └── ui/ # Enhanced UI components
│ │ ├── DataTable.tsx # Advanced data table
│ │ ├── ActionButton.tsx # Loading button
│ │ ├── Badge.tsx # Status badges
│ │ ├── Modal.tsx # Modal dialogs
│ │ └── Pagination.tsx # Pagination
│ ├── lib/
│ │ ├── api/ # Comprehensive API layer
│ │ │ ├── index.ts # All EDR API services
│ │ │ ├── bookings.ts # Booking operations
│ │ │ ├── passengers.ts # Passenger operations
│ │ │ ├── routes.ts # Route operations
│ │ │ └── dashboard.ts # Dashboard data
│ │ ├── api-client.ts # Axios client
│ │ ├── auth-store.ts # Authentication state
│ │ ├── theme-store.ts # Dark mode state
│ │ └── utils.ts # Utility functions
│ ├── types/
│ │ ├── index.ts # Main types
│ │ └── edr.ts # EDR domain types
│ └── styles/
│ └── globals.css # Enhanced styles with dark mode
├── .env.example # Environment template
├── .env.local # Local environment
├── next.config.js # Next.js configuration
├── tailwind.config.js # Enhanced Tailwind config
├── tsconfig.json # TypeScript configuration
└── package.json # Dependencies
```
## 🎨 EDR Design System
### Color Palette
- **Primary Blue**: #2563eb (EDR Blue)
- **Secondary Orange**: #f97316 (EDR Orange)
- **Accent Red**: #ef4444 (EDR Red)
- **Success**: #10b981
- **Warning**: #f59e0b
- **Danger**: #ef4444
### Components
#### Enhanced DataTable
```tsx
<DataTable
data={items}
columns={[
{ key: 'name', label: 'Name', sortable: true },
{ key: 'status', label: 'Status', render: (item) => <Badge variant="status" status={item.status}>{item.status}</Badge> },
]}
actions={[
{ label: 'Edit', onClick: handleEdit, variant: 'secondary', icon: Edit },
{ label: 'Delete', onClick: handleDelete, variant: 'danger', icon: Trash2 },
]}
loading={isLoading}
/>
```
#### ActionButton with Loading
```tsx
<ActionButton
onClick={handleSubmit}
variant="primary"
icon={Plus}
loading={mutation.isPending}
>
Create Item
</ActionButton>
```
## 🔌 Complete API Integration
### Available Services
- `stationsApi` - Station CRUD operations
- `fleetApi` - Train and coach management
- `schedulesApi` - Trip schedule operations
- `seatsApi` - Seat management and blocking
- `bookingsApi` - Booking lifecycle management
- `passengersApi` - Passenger operations
- `paymentsApi` - Payment processing
- `ticketsApi` - Ticket operations
- `agentsApi` - Agent management
- `loyaltyApi` - Loyalty program
- `walletApi` - Wallet operations
- `promotionsApi` - Promotion management
- `supportApi` - Customer support
- `notificationsApi` - Notification system
- `fraudApi` - Fraud detection
- `verifaydaApi` - ID verification
- `auditApi` - Audit logging
- `liveApi` - Live tracking
- `seatClassesApi` - Seat class management
- `foodApi` - Food & dining
### Real Data Integration
All components use real API endpoints:
```tsx
const { data, isLoading } = useQuery({
queryKey: ['stations', filters],
queryFn: () => stationsApi.getAll(filters),
});
const createMutation = useMutation({
mutationFn: stationsApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stations'] });
setShowModal(false);
},
});
```
## 🌙 Dark Mode Support
Full dark mode implementation with:
- System preference detection
- Manual toggle in sidebar
- Persistent theme storage
- Semantic color variables
- Smooth transitions
## 📱 Responsive Design
- Mobile-first approach
- Collapsible sidebar on mobile
- Adaptive table layouts
- Touch-friendly interactions
- Responsive grid systems
## 🔐 Enhanced Security
- JWT token management
- Automatic token refresh
- Role-based access control
- Audit trail logging
- Fraud detection integration
## 🚀 Performance Optimizations
- React Query caching
- Optimistic updates
- Lazy loading
- Code splitting
- Image optimization
## 📊 Advanced Features
### Functional CRUD Operations
- Create, Read, Update, Delete for all entities
- Form validation and error handling
- Optimistic UI updates
- Bulk operations support
### Data Management
- Advanced filtering and search
- Sortable columns
- Pagination with page size options
- Export functionality
- Real-time updates
### User Experience
- Loading states and skeletons
- Toast notifications
- Confirmation dialogs
- Keyboard shortcuts
- Accessibility compliance
## 🎯 Available Scripts
```bash
# Development
pnpm dev # Start dev server on port 3001
# Build
pnpm build # Build for production
# Production
pnpm start # Start production server
# Linting
pnpm lint # Run ESLint
# Type Checking
pnpm type-check # Run TypeScript compiler
```
## 🚀 Deployment
### Build for Production
```bash
pnpm build
```
### Start Production Server
```bash
pnpm start
```
### Environment Variables for Production
Ensure these are set in production:
- `NEXT_PUBLIC_API_URL` - Backend API URL
- `NEXT_PUBLIC_IAM_ENABLED` - Enable IAM authentication
- `NEXT_PUBLIC_IAM_API_URL` - Corporate IAM API URL
## 📝 Development Notes
### Adding New Pages
1. Create directory in `src/app/`
2. Add `page.tsx` and `layout.tsx`
3. Update sidebar navigation
4. Create API service if needed
5. Add types to `src/types/edr.ts`
### API Integration
1. Add service to `src/lib/api/index.ts`
2. Create types in `src/types/edr.ts`
3. Use React Query hooks in components
4. Handle loading and error states
## 🔧 Customization
### Theme Customization
Update `tailwind.config.js` for custom colors:
```js
theme: {
extend: {
colors: {
edr: {
blue: { /* custom blue shades */ },
orange: { /* custom orange shades */ },
red: { /* custom red shades */ },
},
},
},
}
```
### Component Styling
Use semantic color classes:
```tsx
<div className="bg-card text-card-foreground border-border">
<h1 className="text-foreground">Title</h1>
<p className="text-muted-foreground">Description</p>
</div>
```
## 📝 TODO
- [ ] Integrate with real backend authentication endpoint
- [ ] Implement IAM authentication for back-office users
- [ ] Add real-time WebSocket connections for live updates
- [ ] Implement advanced reporting with chart exports
- [ ] Add bulk operations for data management
- [ ] Implement advanced search with filters
- [ ] Add keyboard shortcuts for power users
- [ ] Implement role-based UI permissions
- [ ] Add comprehensive error boundary handling
- [ ] Implement offline support with service workers
## 🤝 Contributing
1. Create a feature branch
2. Follow the established patterns
3. Add proper TypeScript types
4. Test thoroughly
5. Submit a pull request
## 📧 Support
For technical support or questions:
- Email: support@edr-platform.com
- Backend API Docs: http://localhost:4000/api-docs
---
**Built with ❤️ for Ethio-Djibouti Railway**

View File

@@ -0,0 +1,300 @@
const fs = require('fs');
const path = require('path');
const pages = [
{
name: 'payments',
title: 'Payments',
description: 'Manage payment transactions and refunds',
api: 'paymentsApi',
columns: `[
{ key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> },
{ key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' },
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) },
{ key: 'method', label: 'Method', render: (payment: any) => <Badge>{payment.method}</Badge> },
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
]`,
filters: `{ search: '', status: '', method: '' }`,
filterInputs: `
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="PENDING">Pending</option>
<option value="COMPLETED">Completed</option>
<option value="FAILED">Failed</option>
</select>
</div>
`
},
{
name: 'loyalty',
title: 'Loyalty Program',
description: 'Manage loyalty accounts and rewards',
api: 'loyaltyApi',
columns: `[
{ key: 'passenger', label: 'Passenger', render: (account: any) => account.passenger?.fullName || 'N/A' },
{ key: 'tier', label: 'Tier', render: (account: any) => <Badge>{account.tier}</Badge> },
{ key: 'pointsBalance', label: 'Points', render: (account: any) => account.pointsBalance?.toLocaleString() || 0 },
{ key: 'lifetimePoints', label: 'Lifetime Points', render: (account: any) => account.lifetimePoints?.toLocaleString() || 0 },
]`,
filters: `{ search: '', tier: '' }`,
filterInputs: `
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Tier</label>
<select className="input" value={filters.tier} onChange={(e) => setFilters({ ...filters, tier: e.target.value })}>
<option value="">All Tiers</option>
<option value="BRONZE">Bronze</option>
<option value="SILVER">Silver</option>
<option value="GOLD">Gold</option>
<option value="PLATINUM">Platinum</option>
</select>
</div>
`
},
{
name: 'wallet',
title: 'Wallet Management',
description: 'Manage passenger wallet accounts',
api: 'walletApi',
columns: `[
{ key: 'passenger', label: 'Passenger', render: (account: any) => account.passenger?.fullName || 'N/A' },
{ key: 'balanceMinor', label: 'Balance', render: (account: any) => formatCurrency(account.balanceMinor, 'ETB') },
{ key: 'status', label: 'Status', render: (account: any) => <Badge variant="status" status={account.isActive ? 'CONFIRMED' : 'CANCELLED'}>{account.isActive ? 'Active' : 'Inactive'}</Badge> },
]`,
filters: `{ search: '' }`,
filterInputs: `
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
`
},
{
name: 'support',
title: 'Support Center',
description: 'Manage customer support conversations',
api: 'supportApi',
columns: `[
{ key: 'subject', label: 'Subject', render: (conv: any) => conv.subject || 'No Subject' },
{ key: 'passenger', label: 'Passenger', render: (conv: any) => conv.passenger?.fullName || 'N/A' },
{ key: 'status', label: 'Status', render: (conv: any) => <Badge variant="status" status={conv.status}>{conv.status}</Badge> },
{ key: 'createdAt', label: 'Created', render: (conv: any) => formatDateTime(conv.createdAt) },
]`,
filters: `{ search: '', status: '' }`,
filterInputs: `
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="OPEN">Open</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="RESOLVED">Resolved</option>
<option value="CLOSED">Closed</option>
</select>
</div>
`
},
{
name: 'verifayda',
title: 'Verifayda Integration',
description: 'Ethiopian national ID verification logs',
api: 'verifaydaApi',
columns: `[
{ key: 'nationalId', label: 'National ID', render: (ver: any) => <span className="font-mono">{ver.nationalId}</span> },
{ key: 'fullName', label: 'Name', render: (ver: any) => ver.fullName || 'N/A' },
{ key: 'verified', label: 'Status', render: (ver: any) => <Badge variant="status" status={ver.verified ? 'CONFIRMED' : 'CANCELLED'}>{ver.verified ? 'Verified' : 'Failed'}</Badge> },
{ key: 'createdAt', label: 'Verified At', render: (ver: any) => formatDateTime(ver.createdAt) },
]`,
filters: `{ search: '', verified: '' }`,
filterInputs: `
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search by National ID..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.verified} onChange={(e) => setFilters({ ...filters, verified: e.target.value })}>
<option value="">All</option>
<option value="true">Verified</option>
<option value="false">Failed</option>
</select>
</div>
`
},
{
name: 'food',
title: 'Food & Dining',
description: 'Manage food orders and menu items',
api: 'foodApi',
columns: `[
{ key: 'orderNumber', label: 'Order #', render: (order: any) => <span className="font-mono">{order.orderNumber || order.id?.substring(0, 8)}</span> },
{ key: 'passenger', label: 'Passenger', render: (order: any) => order.passenger?.fullName || 'N/A' },
{ key: 'items', label: 'Items', render: (order: any) => order.items?.length || 0 },
{ key: 'totalMinor', label: 'Total', render: (order: any) => formatCurrency(order.totalMinor, 'ETB') },
{ key: 'status', label: 'Status', render: (order: any) => <Badge variant="status" status={order.status}>{order.status}</Badge> },
]`,
filters: `{ search: '', status: '' }`,
filterInputs: `
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="PENDING">Pending</option>
<option value="PREPARING">Preparing</option>
<option value="READY">Ready</option>
<option value="DELIVERED">Delivered</option>
</select>
</div>
`
},
{
name: 'schedules',
title: 'Schedules',
description: 'Manage train schedules and trips',
api: 'schedulesApi',
columns: `[
{ key: 'train', label: 'Train', render: (schedule: any) => schedule.train?.name || 'N/A' },
{ key: 'route', label: 'Route', render: (schedule: any) => \`\${schedule.originStation?.name || 'N/A'} → \${schedule.destinationStation?.name || 'N/A'}\` },
{ key: 'departureAt', label: 'Departure', render: (schedule: any) => formatDateTime(schedule.departureAt) },
{ key: 'status', label: 'Status', render: (schedule: any) => <Badge variant="status" status={schedule.status}>{schedule.status}</Badge> },
]`,
filters: `{ search: '', status: '' }`,
filterInputs: `
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="SCHEDULED">Scheduled</option>
<option value="ACTIVE">Active</option>
<option value="COMPLETED">Completed</option>
<option value="CANCELLED">Cancelled</option>
</select>
</div>
`
},
{
name: 'seat-classes',
title: 'Seat Classes',
description: 'Manage seat class configurations',
api: 'seatClassesApi',
columns: `[
{ key: 'name', label: 'Name', render: (cls: any) => <span className="font-medium">{cls.name}</span> },
{ key: 'description', label: 'Description', render: (cls: any) => cls.description || 'N/A' },
{ key: 'basePrice', label: 'Base Price', render: (cls: any) => formatCurrency(cls.basePrice, 'ETB') },
{ key: 'isActive', label: 'Status', render: (cls: any) => <Badge variant="status" status={cls.isActive ? 'CONFIRMED' : 'CANCELLED'}>{cls.isActive ? 'Active' : 'Inactive'}</Badge> },
]`,
filters: `{ search: '' }`,
filterInputs: `
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
`
},
{
name: 'operational-reports',
title: 'Operational Reports',
description: 'View operational reports and analytics',
api: 'reportsApi',
columns: `[
{ key: 'reportType', label: 'Type', render: (report: any) => <Badge>{report.reportType}</Badge> },
{ 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) },
]`,
filters: `{ search: '', reportType: '' }`,
filterInputs: `
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Report Type</label>
<select className="input" value={filters.reportType} onChange={(e) => setFilters({ ...filters, reportType: e.target.value })}>
<option value="">All Types</option>
<option value="REVENUE">Revenue</option>
<option value="OCCUPANCY">Occupancy</option>
<option value="PERFORMANCE">Performance</option>
</select>
</div>
`
}
];
const template = (page) => `'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 { ${page.api} } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function ${page.name.charAt(0).toUpperCase() + page.name.slice(1).replace(/-/g, '')}Page() {
const [filters, setFilters] = useState(${page.filters});
const { data, isLoading } = useQuery({
queryKey: ['${page.name}', filters],
queryFn: () => ${page.api}.${page.name === 'seat-classes' ? 'getAll()' : page.name === 'operational-reports' ? 'getOperationalReports(filters)' : `get${page.name === 'support' ? 'Conversations' : page.name === 'loyalty' ? 'Accounts' : page.name === 'wallet' ? 'Accounts' : page.name === 'verifayda' ? 'Verifications' : page.name === 'food' ? 'Orders' : 'All'}(filters)`},
});
const columns = ${page.columns};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">${page.title}</h1>
<p className="text-muted-foreground">${page.description}</p>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
${page.filterInputs}
</div>
</div>
<DataTable
data={data?.items || data || []}
columns={columns}
loading={isLoading}
emptyMessage="No ${page.title.toLowerCase()} found"
/>
</div>
);
}
`;
pages.forEach(page => {
const filePath = path.join(__dirname, 'src', 'app', page.name, 'page.tsx');
fs.writeFileSync(filePath, template(page));
console.log(`✅ Created ${page.name}/page.tsx`);
});
console.log('\\n✅ All pages created successfully!');

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EDR Passenger Backoffice</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
reactStrictMode: true,
transpilePackages: ['@edr/types', '@edr/ui-common'],
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
},
images: {
unoptimized: true, // Required for static export
},
};
module.exports = nextConfig;

View File

@@ -2,13 +2,11 @@
"name": "@edr/passenger-backoffice",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5184",
"build": "tsc -b && vite build",
"preview": "vite preview --port 5184",
"lint": "eslint src",
"test": "vitest run",
"dev": "next dev -p 5184",
"build": "next build",
"start": "next start -p 5184",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
@@ -17,23 +15,23 @@
"@tanstack/react-query": "^5.59.0",
"axios": "^1.7.7",
"clsx": "^2.1.1",
"date-fns": "^3.0.0",
"lucide-react": "^0.446.0",
"next": "^14.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"recharts": "^2.12.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@edr/eslint-config": "workspace:*",
"@edr/tsconfig": "workspace:*",
"@types/node": "^20.0.0",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.5.4",
"vite": "^5.4.8",
"vitest": "^2.1.2"
"typescript": "^5.5.4"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,12 @@
# Banner Image
Place your banner image as `banner.jpg` in this directory.
## Recommended Specifications:
- **Filename**: `banner.jpg` (or `banner.png`)
- **Dimensions**: 1920x1080px or higher
- **Aspect Ratio**: 16:9 or similar
- **Content**: Railway/train themed image, Ethio-Djibouti Railway scenery
- **Format**: JPG or PNG
The image will be used as a background on the login page with a green overlay.

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

View File

@@ -1,33 +0,0 @@
import {
useNavigate,
useLocation,
Routes,
Route,
Navigate,
} from "react-router-dom";
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
import DashboardPage from "./pages/dashboard/DashboardPage";
const sidebarItems: SidebarItem[] = [{ label: "Dashboard", href: "/" }];
const App = () => {
const navigate = useNavigate();
const location = useLocation();
return (
<DashboardLayout
title="EDR Passenger Backoffice"
sidebarItems={sidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</DashboardLayout>
);
};
export default App;

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function AgentsLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,124 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, Edit, DollarSign, Clock } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Badge from '@/components/ui/Badge';
import { agentsApi } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils';
export default function AgentsPage() {
const [filters, setFilters] = useState({ search: '', active: '' });
const { data, isLoading } = useQuery({
queryKey: ['agents', filters],
queryFn: () => agentsApi.getAll(filters),
});
const columns = [
{
key: 'agentCode',
label: 'Agent Code',
sortable: true,
render: (agent: any) => <span className="font-mono font-semibold">{agent.agentCode}</span>,
},
{
key: 'user',
label: 'Name',
render: (agent: any) => (
<div>
<div className="font-medium">{agent.user?.fullName || 'N/A'}</div>
<div className="text-sm text-gray-500">{agent.user?.email}</div>
</div>
),
},
{
key: 'commissionRate',
label: 'Commission',
render: (agent: any) => <span>{agent.commissionRate}%</span>,
},
{
key: 'active',
label: 'Status',
render: (agent: any) => (
<Badge variant="status" status={agent.active ? 'CONFIRMED' : 'CANCELLED'}>
{agent.active ? 'Active' : 'Inactive'}
</Badge>
),
},
];
const actions = [
{
label: 'View Shifts',
onClick: (agent: any) => {
window.location.href = `/agents/${agent.id}/shifts`;
},
variant: 'secondary' as const,
icon: Clock,
},
{
label: 'View Commissions',
onClick: (agent: any) => {
window.location.href = `/agents/${agent.id}/commissions`;
},
variant: 'secondary' as const,
icon: DollarSign,
},
{
label: 'Edit',
onClick: (agent: any) => console.log('Edit', agent),
variant: 'secondary' as const,
icon: Edit,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Agent Operations</h1>
<p className="text-muted-foreground">Manage booking agents and their operations</p>
</div>
<ActionButton icon={Plus}>Add Agent</ActionButton>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input
type="text"
placeholder="Search agents..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
<div>
<label className="label">Status</label>
<select
className="input"
value={filters.active}
onChange={(e) => setFilters({ ...filters, active: e.target.value })}
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No agents found"
/>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,131 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Search, Eye } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import { auditApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
export default function AuditLogsPage() {
const [filters, setFilters] = useState({ search: '', action: '', entityType: '' });
const { data, isLoading } = useQuery({
queryKey: ['audit-logs', filters],
queryFn: () => auditApi.getLogs(filters),
});
const columns = [
{
key: 'action',
label: 'Action',
sortable: true,
render: (log: any) => (
<Badge>{log.action}</Badge>
),
},
{
key: 'user',
label: 'User',
render: (log: any) => (
<div>
<div className="font-medium">{log.user?.fullName || 'System'}</div>
<div className="text-sm text-muted-foreground">{log.user?.email || 'N/A'}</div>
</div>
),
},
{
key: 'entityType',
label: 'Entity Type',
render: (log: any) => log.entityType,
},
{
key: 'entityId',
label: 'Entity ID',
render: (log: any) => (
<span className="font-mono text-sm">{log.entityId?.substring(0, 8)}...</span>
),
},
{
key: 'createdAt',
label: 'Timestamp',
sortable: true,
render: (log: any) => formatDateTime(log.createdAt),
},
];
const actions = [
{
label: 'View Details',
onClick: (log: any) => {
window.location.href = `/audit/${log.id}`;
},
variant: 'secondary' as const,
icon: Eye,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Audit Logs</h1>
<p className="text-muted-foreground">Track all system activities and changes</p>
</div>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input
type="text"
placeholder="Search logs..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
<div>
<label className="label">Action</label>
<select
className="input"
value={filters.action}
onChange={(e) => setFilters({ ...filters, action: e.target.value })}
>
<option value="">All Actions</option>
<option value="CREATE">Create</option>
<option value="UPDATE">Update</option>
<option value="DELETE">Delete</option>
<option value="LOGIN">Login</option>
<option value="LOGOUT">Logout</option>
</select>
</div>
<div>
<label className="label">Entity Type</label>
<select
className="input"
value={filters.entityType}
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
>
<option value="">All Types</option>
<option value="Booking">Booking</option>
<option value="User">User</option>
<option value="Payment">Payment</option>
<option value="Ticket">Ticket</option>
</select>
</div>
</div>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No audit logs found"
/>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function BookingsLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,374 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Filter, Download, Eye, XCircle, 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 { bookingsApi, apiClient } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import { BookingFilters } from '@/types';
export default function BookingsPage() {
const [filters, setFilters] = useState<BookingFilters>({
page: 1,
pageSize: 20,
search: '',
status: '',
});
const [selectedBooking, setSelectedBooking] = useState<any>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
const [successMessage, setSuccessMessage] = useState('');
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ['bookings', filters],
queryFn: () => bookingsApi.getAll(filters),
});
if (error) {
console.error('Bookings API Error:', error);
}
const cancelMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookings'] });
setSuccessMessage('Booking cancelled successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
alert(`Error: ${error.message || 'Failed to cancel booking'}`);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/bookings/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookings'] });
setDeleteConfirmOpen(false);
setBookingToDelete(null);
setSuccessMessage('Booking deleted successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setDeleteConfirmOpen(false);
alert(`Error: ${error.message || 'Failed to delete booking'}`);
},
});
const handleCancel = async (booking: any) => {
if (window.confirm(`Are you sure you want to cancel booking ${booking.bookingRef}? This will process a refund.`)) {
await cancelMutation.mutateAsync({ id: booking.id, reason: 'Cancelled by admin' });
}
};
const handleDeleteClick = (booking: any) => {
setBookingToDelete(booking);
setDeleteConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (bookingToDelete) {
await deleteMutation.mutateAsync(bookingToDelete.id);
}
};
const columns = [
{
key: 'bookingRef',
label: 'Reference',
sortable: true,
render: (booking: any) => (
<span className="font-mono font-semibold">{booking.bookingRef}</span>
),
},
{
key: 'passenger',
label: 'Passenger',
render: (booking: any) => (
<div>
<div className="font-medium">{booking.passenger?.fullName || booking.contactEmail || 'Guest'}</div>
<div className="text-sm text-muted-foreground">{booking.contactPhone || booking.passenger?.phone}</div>
</div>
),
},
{
key: 'status',
label: 'Status',
render: (booking: any) => (
<Badge variant="status" status={booking.status}>{booking.status}</Badge>
),
},
{
key: 'totalMinor',
label: 'Amount',
sortable: true,
render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency),
},
{
key: 'paymentStatus',
label: 'Payment',
render: (booking: any) => (
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>
{booking.paymentIntent?.status || 'PENDING'}
</Badge>
),
},
{
key: 'createdAt',
label: 'Created',
sortable: true,
render: (booking: any) => formatDateTime(booking.createdAt),
},
];
const actions = [
{
label: 'View Details',
onClick: (booking: any) => setSelectedBooking(booking),
variant: 'secondary' as const,
icon: Eye,
},
{
label: 'Cancel Booking',
onClick: handleCancel,
variant: 'danger' as const,
icon: XCircle,
show: (booking: any) => booking.status !== 'CANCELLED' && booking.status !== 'COMPLETED',
},
{
label: 'Delete',
onClick: handleDeleteClick,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Bookings</h1>
<p className="text-muted-foreground">Manage all passenger bookings</p>
</div>
<ActionButton variant="export" icon={Download}>Export</ActionButton>
</div>
<div className="card">
{successMessage && (
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
{successMessage}
</div>
)}
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
Error loading bookings: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)}
<div className="mb-4 flex flex-wrap gap-4">
<div className="flex-1">
<input
type="text"
placeholder="Search by reference, email, or phone..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
/>
</div>
<select
className="input w-48"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}
>
<option value="">All Status</option>
<option value="PENDING_PAYMENT">Pending Payment</option>
<option value="CONFIRMED">Confirmed</option>
<option value="CANCELLED">Cancelled</option>
<option value="COMPLETED">Completed</option>
</select>
<ActionButton variant="secondary" icon={Filter}>More Filters</ActionButton>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No bookings found"
/>
{data?.meta && (
<Pagination
currentPage={data.meta.page}
totalPages={data.meta.totalPages}
onPageChange={(page) => setFilters({ ...filters, page })}
/>
)}
</div>
{/* Booking Details Modal */}
<Modal
isOpen={!!selectedBooking}
onClose={() => setSelectedBooking(null)}
title="Booking Details"
size="xl"
>
{selectedBooking && (
<div className="space-y-6">
{/* Booking Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Booking Reference</label>
<p className="text-lg font-semibold font-mono">{selectedBooking.bookingRef}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Status</label>
<div className="mt-1">
<Badge variant="status" status={selectedBooking.status}>
{selectedBooking.status}
</Badge>
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Booking Type</label>
<p className="text-lg font-semibold">{selectedBooking.bookingType || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Created</label>
<p className="text-lg font-semibold">{formatDateTime(selectedBooking.createdAt)}</p>
</div>
</div>
<hr className="border-muted" />
{/* Passenger Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Name</label>
<p className="text-lg font-semibold">{selectedBooking.passenger?.fullName || selectedBooking.contactEmail || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Email</label>
<p className="text-lg font-semibold">{selectedBooking.contactEmail || selectedBooking.passenger?.email || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Phone</label>
<p className="text-lg font-semibold">{selectedBooking.contactPhone || selectedBooking.passenger?.phone || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Passenger ID</label>
<p className="text-sm font-mono">{selectedBooking.passengerId || 'N/A'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Booking Details */}
<div>
<h3 className="text-lg font-semibold mb-3">Journey Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Adults</label>
<p className="text-lg font-semibold">{selectedBooking.adultCount || 0}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Children</label>
<p className="text-lg font-semibold">{selectedBooking.childCount || 0}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Schedule ID</label>
<p className="text-sm font-mono">{selectedBooking.scheduleId || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Promo Code</label>
<p className="text-lg font-semibold">{selectedBooking.promoCode || 'None'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Payment Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Payment Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Amount</label>
<p className="text-lg font-semibold">{formatCurrency(selectedBooking.totalMinor, selectedBooking.currency)}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Payment Status</label>
<div className="mt-1">
<Badge variant="status" status={selectedBooking.paymentIntent?.status || 'PENDING'}>
{selectedBooking.paymentIntent?.status || 'PENDING'}
</Badge>
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Paid At</label>
<p className="text-lg font-semibold">{selectedBooking.paidAt ? formatDateTime(selectedBooking.paidAt) : 'Not paid'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Display Currency</label>
<p className="text-lg font-semibold">{selectedBooking.displayCurrency || selectedBooking.currency}</p>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Additional Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Additional Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Source</label>
<p className="text-lg font-semibold">{selectedBooking.source || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Last Updated</label>
<p className="text-lg font-semibold">{formatDateTime(selectedBooking.updatedAt)}</p>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
variant="secondary"
onClick={() => setSelectedBooking(null)}
>
Close
</ActionButton>
</div>
</div>
)}
</Modal>
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => {
setDeleteConfirmOpen(false);
setBookingToDelete(null);
}}
onConfirm={handleConfirmDelete}
title="Delete Booking"
message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`}
confirmText="Delete"
cancelText="Cancel"
isLoading={deleteMutation.isPending}
isDanger={true}
/>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function CoachesLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,327 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fleetApi } from '@/lib/api';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { Plus, Search, Grid3x3, Train, Edit, Trash2 } from 'lucide-react';
export default function CoachesPage() {
const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingCoach, setEditingCoach] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; coach: any | null }>({ isOpen: false, coach: null });
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['coaches', search],
queryFn: () => fleetApi.getCoaches({ search }),
});
const createMutation = useMutation({
mutationFn: fleetApi.createCoach,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coaches'] });
setShowModal(false);
setEditingCoach(null);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => fleetApi.updateCoach(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coaches'] });
setShowModal(false);
setEditingCoach(null);
},
});
const deleteMutation = useMutation({
mutationFn: fleetApi.deleteCoach,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coaches'] });
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const coachData = {
coachNumber: formData.get('coachNumber') as string,
label: formData.get('label') as string,
seatClassId: formData.get('seatClassId') as string,
coachType: formData.get('coachType') as string,
mode: formData.get('mode') as string,
seatArrangement: formData.get('seatArrangement') as string,
totalUnits: parseInt(formData.get('totalUnits') as string),
isActive: formData.get('isActive') === 'true',
};
if (editingCoach) {
await updateMutation.mutateAsync({ id: editingCoach.id, data: coachData });
} else {
await createMutation.mutateAsync(coachData);
}
};
const handleDelete = (coach: any) => {
setDeleteConfirm({ isOpen: true, coach });
};
const confirmDelete = async () => {
if (deleteConfirm.coach) {
await deleteMutation.mutateAsync(deleteConfirm.coach.id);
setDeleteConfirm({ isOpen: false, coach: null });
}
};
const coaches = data?.items || data?.data || [];
const columns = [
{
key: 'coachNumber',
label: 'Coach Number',
sortable: true,
render: (coach: any) => (
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[rgb(20,113,76)]">
<Grid3x3 className="h-4 w-4 text-white" />
</div>
<span className="font-medium">{coach.coachNumber}</span>
</div>
),
},
{
key: 'seatClass',
label: 'Seat Class',
render: (coach: any) => {
const seatClass = coach.seatClass?.name || coach.serviceClass || 'N/A';
const colorMap: Record<string, string> = {
'ECONOMY_REGULAR': 'edr-badge-info',
'ECONOMY_BED': 'edr-badge-warning',
'VIP_BED': 'edr-badge-success',
};
return (
<span className={`edr-badge ${colorMap[seatClass] || 'edr-badge-info'}`}>
{seatClass.replace(/_/g, ' ')}
</span>
);
},
},
{
key: 'totalSeats',
label: 'Total Seats',
render: (coach: any) => (
<span className="font-mono text-sm">{coach.totalSeats || coach.totalUnits || 0}</span>
),
},
{
key: 'layout',
label: 'Layout',
render: (coach: any) => (
<span className="text-sm text-muted-foreground">
{coach.layout || coach.seatLayout || coach.seatArrangement || 'N/A'}
</span>
),
},
{
key: 'status',
label: 'Status',
render: (coach: any) => {
const status = coach.isActive ? 'ACTIVE' : 'INACTIVE';
const statusMap: Record<string, string> = {
ACTIVE: 'edr-badge-success',
MAINTENANCE: 'edr-badge-warning',
INACTIVE: 'edr-badge-danger',
};
return (
<span className={`edr-badge ${statusMap[status] || 'edr-badge-info'}`}>
{status}
</span>
);
},
},
];
const actions = [
{
label: 'Edit',
onClick: (coach: any) => {
setEditingCoach(coach);
setShowModal(true);
},
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Coach Management</h1>
<p className="text-muted-foreground mt-1">Manage train coaches and configurations</p>
</div>
<ActionButton
icon={Plus}
onClick={() => {
setEditingCoach(null);
setShowModal(true);
}}
>
Add Coach
</ActionButton>
</div>
<div className="card">
<div className="flex items-center gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search coaches..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10"
/>
</div>
</div>
<DataTable
columns={columns}
data={coaches}
actions={actions}
loading={isLoading}
/>
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, coach: null })}
onConfirm={confirmDelete}
title="Delete Coach"
message={`Are you sure you want to delete coach ${deleteConfirm.coach?.coachNumber}?`}
confirmText="Delete"
isDanger={true}
warning="This coach may be assigned to schedules and trips. Deleting it may impact these systems."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingCoach(null);
}}
title={`${editingCoach ? 'Edit' : 'Add'} Coach`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Coach Number *</label>
<input
type="text"
name="coachNumber"
className="input"
defaultValue={editingCoach?.coachNumber}
required
placeholder="e.g., C001"
/>
</div>
<div>
<label className="label">Label *</label>
<input
type="text"
name="label"
className="input"
defaultValue={editingCoach?.label}
required
placeholder="e.g., Coach 1"
/>
</div>
<div>
<label className="label">Coach Type</label>
<select name="coachType" className="input" defaultValue={editingCoach?.coachType}>
<option value="passenger">Passenger</option>
<option value="sleeper">Sleeper</option>
<option value="dining">Dining</option>
<option value="baggage">Baggage</option>
</select>
</div>
<div>
<label className="label">Mode *</label>
<select name="mode" className="input" defaultValue={editingCoach?.mode || 'seat'}>
<option value="seat">Seat</option>
<option value="bed">Bed</option>
<option value="convertible">Convertible</option>
</select>
</div>
<div>
<label className="label">Seat Arrangement</label>
<input
type="text"
name="seatArrangement"
className="input"
defaultValue={editingCoach?.seatArrangement}
placeholder="e.g., 2+2"
/>
</div>
<div>
<label className="label">Total Units *</label>
<input
type="number"
name="totalUnits"
className="input"
defaultValue={editingCoach?.totalUnits}
required
min="1"
placeholder="60"
/>
</div>
<div>
<label className="label">Status</label>
<select
name="isActive"
className="input"
defaultValue={editingCoach?.isActive?.toString() || 'true'}
>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingCoach(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingCoach ? 'Update' : 'Create'} Coach
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { useAuthStore } from '@/lib/auth-store';
import { useTheme } from '@/lib/theme-store';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { isAuthenticated, user } = useAuthStore();
const { setTheme } = useTheme();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Auth is already initialized in root providers
// Just wait a tick for hydration
const timer = setTimeout(() => {
setIsLoading(false);
}, 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, router, isLoading]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-edr-green-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-slate-950">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-950 p-6">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Ticket, Users, DollarSign, TrendingUp } from 'lucide-react';
import StatCard from '@/components/dashboard/StatCard';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import { dashboardApi } from '@/lib/api/dashboard';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
export default function DashboardPage() {
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: dashboardApi.getStats,
});
const { data: revenueData, isLoading: revenueLoading } = useQuery({
queryKey: ['revenue-chart'],
queryFn: () => dashboardApi.getRevenueChart(30),
});
const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery<any>({
queryKey: ['recent-bookings'],
queryFn: () => dashboardApi.getRecentBookings(10),
});
const recentBookings = Array.isArray(recentBookingsData)
? recentBookingsData
: recentBookingsData?.items || recentBookingsData?.data || [];
const columns = [
{ key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference },
{ key: 'passenger', label: 'Passenger', render: (item: any) => item.passenger?.fullName || item.contactEmail || 'N/A' },
{ key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') },
{
key: 'status',
label: 'Status',
render: (item: any) => (
<Badge variant="status" status={item.status}>
{item.status}
</Badge>
)
},
{ key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) },
];
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground mt-1">Hello, welcome back! Here&apos;s what&apos;s happening today.</p>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Total Bookings"
value={statsLoading ? '...' : (stats?.totalBookings || 0).toLocaleString()}
icon={Ticket}
color="blue"
/>
<StatCard
title="Total Revenue"
value={statsLoading ? '...' : formatCurrency(stats?.totalRevenue || 0, 'ETB')}
icon={DollarSign}
color="green"
/>
<StatCard
title="Total Passengers"
value={statsLoading ? '...' : (stats?.totalPassengers || 0).toLocaleString()}
icon={Users}
color="purple"
/>
<StatCard
title="Occupancy Rate"
value={statsLoading ? '...' : `${stats?.occupancyRate || 0}%`}
icon={TrendingUp}
color="green"
/>
</div>
{!revenueLoading && revenueData && revenueData.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Revenue Trend (Last 30 Days)</h2>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={revenueData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
<Tooltip formatter={(value: number) => formatCurrency(value, 'ETB')} />
<Line type="monotone" dataKey="revenue" stroke="#2563eb" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
)}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Recent Bookings</h2>
<DataTable
data={recentBookings}
columns={columns}
loading={bookingsLoading}
emptyMessage="No recent bookings"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,67 @@
'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 { foodApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function FoodPage() {
const [filters, setFilters] = useState({ search: '', status: '' });
const { data, isLoading } = useQuery({
queryKey: ['food', filters],
queryFn: () => foodApi.getOrders(filters),
});
const columns = [
{ key: 'orderNumber', label: 'Order #', render: (order: any) => <span className="font-mono">{order.orderNumber || order.id?.substring(0, 8)}</span> },
{ key: 'passenger', label: 'Passenger', render: (order: any) => order.passenger?.fullName || 'N/A' },
{ key: 'items', label: 'Items', render: (order: any) => order.items?.length || 0 },
{ key: 'totalMinor', label: 'Total', render: (order: any) => formatCurrency(order.totalMinor, 'ETB') },
{ key: 'status', label: 'Status', render: (order: any) => <Badge variant="status" status={order.status}>{order.status}</Badge> },
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Food & Dining</h1>
<p className="text-muted-foreground">Manage food orders and menu items</p>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="PENDING">Pending</option>
<option value="PREPARING">Preparing</option>
<option value="READY">Ready</option>
<option value="DELIVERED">Delivered</option>
</select>
</div>
</div>
</div>
<DataTable
data={data?.items || data || []}
columns={columns}
loading={isLoading}
emptyMessage="No food & dining found"
/>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,179 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, CheckCircle, Ban } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { fraudApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
export default function FraudDetectionPage() {
const [filters, setFilters] = useState({ search: '', severity: '', status: '' });
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['fraud-alerts', filters],
queryFn: () => fraudApi.getAlerts(filters),
});
const acknowledgeMutation = useMutation({
mutationFn: fraudApi.acknowledgeAlert,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fraud-alerts'] });
alert('Alert acknowledged');
},
});
const blockUserMutation = useMutation({
mutationFn: ({ userId, reason }: any) => fraudApi.blockUser(userId, { reason }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fraud-alerts'] });
alert('User blocked successfully');
},
});
const handleAcknowledge = async (alert: any) => {
await acknowledgeMutation.mutateAsync(alert.id);
};
const handleBlockUser = async (alert: any) => {
if (confirm(`Block user ${alert.user?.email}?`)) {
await blockUserMutation.mutateAsync({
userId: alert.userId,
reason: `Fraud alert: ${alert.ruleType}`,
});
}
};
const columns = [
{
key: 'severity',
label: 'Severity',
render: (alert: any) => (
<Badge variant="status" status={alert.severity === 'HIGH' ? 'CANCELLED' : alert.severity === 'MEDIUM' ? 'PENDING' : 'CONFIRMED'}>
{alert.severity}
</Badge>
),
},
{
key: 'ruleType',
label: 'Rule Type',
render: (alert: any) => (
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-[rgb(20,113,76)]" />
<span>{alert.ruleType}</span>
</div>
),
},
{
key: 'user',
label: 'User',
render: (alert: any) => (
<div>
<div className="font-medium">{alert.user?.fullName || 'N/A'}</div>
<div className="text-sm text-muted-foreground">{alert.user?.email || 'N/A'}</div>
</div>
),
},
{
key: 'description',
label: 'Description',
render: (alert: any) => (
<span className="text-sm">{alert.description || alert.details}</span>
),
},
{
key: 'status',
label: 'Status',
render: (alert: any) => (
<Badge variant="status" status={alert.acknowledged ? 'CONFIRMED' : 'PENDING'}>
{alert.acknowledged ? 'Acknowledged' : 'Pending'}
</Badge>
),
},
{
key: 'createdAt',
label: 'Detected',
sortable: true,
render: (alert: any) => formatDateTime(alert.createdAt),
},
];
const actions = [
{
label: 'Acknowledge',
onClick: handleAcknowledge,
variant: 'primary' as const,
icon: CheckCircle,
show: (alert: any) => !alert.acknowledged,
},
{
label: 'Block User',
onClick: handleBlockUser,
variant: 'danger' as const,
icon: Ban,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Fraud Detection</h1>
<p className="text-muted-foreground">Monitor and manage fraud alerts</p>
</div>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input
type="text"
placeholder="Search alerts..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
<div>
<label className="label">Severity</label>
<select
className="input"
value={filters.severity}
onChange={(e) => setFilters({ ...filters, severity: e.target.value })}
>
<option value="">All Severities</option>
<option value="LOW">Low</option>
<option value="MEDIUM">Medium</option>
<option value="HIGH">High</option>
<option value="CRITICAL">Critical</option>
</select>
</div>
<div>
<label className="label">Status</label>
<select
className="input"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="acknowledged">Acknowledged</option>
</select>
</div>
</div>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No fraud alerts found"
/>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import type { Metadata } from 'next';
import '@/styles/globals.css';
import Providers from './providers';
export const metadata: Metadata = {
title: 'EDR Passenger Back-office',
description: 'Ethio-Djibouti Railway Passenger Back-office',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
const stored = localStorage.getItem('edr-theme');
const theme = stored ? JSON.parse(stored).state.isDark : window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme) document.documentElement.classList.add('dark');
} catch (e) {}
})();
`,
}}
/>
</head>
<body className="font-sans antialiased">
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,44 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, MapPin } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
export default function Page() {
const [filters, setFilters] = useState({ search: '' });
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Live Tracking</h1>
<p className="text-muted-foreground">Real-time train tracking and status</p>
</div>
<ActionButton icon={Plus}>Add New</ActionButton>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input
type="text"
placeholder="Search..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
</div>
</div>
<div className="card">
<p className="text-center text-muted-foreground py-12">
Live Tracking module - Connect to API endpoint
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { Train } from 'lucide-react';
export default function LoginPage() {
const [email, setEmail] = useState('admin@edr-platform.com');
const [password, setPassword] = useState('admin123');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
const { login } = useAuthStore();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await login(email, password);
router.push('/dashboard');
} catch (err: any) {
const message = err.response?.data?.message || err.message || 'Login failed. Please check your credentials.';
setError(message);
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen">
{/* Banner Image Side */}
<div className="hidden lg:flex lg:w-1/2 relative bg-gradient-to-br from-[rgb(20,113,76)] to-[rgb(15,85,57)] items-center justify-center">
<div className="absolute inset-0 bg-[url('/banner.jpg')] bg-cover bg-center opacity-20"></div>
<div className="relative z-10 text-center px-12">
<div className="flex justify-center mb-6">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-white/10 backdrop-blur-sm shadow-2xl">
<Train className="h-12 w-12 text-white" />
</div>
</div>
<h1 className="text-5xl font-bold text-white mb-4">EDR</h1>
<p className="text-lg text-white/80">Passenger Back-office</p>
</div>
</div>
{/* Login Form Side */}
<div className="flex w-full lg:w-1/2 items-center justify-center bg-gray-100 dark:bg-gray-900 p-8">
<div className="w-full max-w-md">
<div className="card">
<div className="mb-6">
<div className="mb-4 flex justify-center lg:hidden">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[rgb(20,113,76)] shadow-lg">
<Train className="h-6 w-6 text-white" />
</div>
<div className="text-4xl font-bold text-gray-900 dark:text-white ps-4">EDR</div>
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Sign in to get started.</h2>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input"
required
/>
</div>
<div>
<label className="label">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="btn btn-primary w-full disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
</div>
</div>
);
}

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