diff --git a/.dockerignore b/.dockerignore index 122332251..242c1b08a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,14 @@ **/node_modules **/dist +**/.turbo +**/.git **/.github **/.vscode -**/.git +**/.idea **/.env -.env +**/.env.* +!**/.env.example +**/coverage +**/*.tsbuildinfo +**/*.log +.DS_Store diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml deleted file mode 100644 index fa4bef238..000000000 --- a/.github/workflows/deploy.yaml +++ /dev/null @@ -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 < .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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..dbf44509b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,92 @@ +name: Deploy Stacks + +on: + push: + branches: + - main + - dev + - staging + paths: + - "apps/edr-freight-api/**" + - "apps/edr-freight-web/**" + - "apps/edr-passenger-api/**" + - "apps/edr-passenger-web/**" + - "packages/**" + - "infrastructure/docker/Dockerfile.web" + - "infrastructure/nginx/**" + - "docker-compose.yaml" + - "pnpm-lock.yaml" + - "scripts/deploy/**" + - ".github/workflows/deploy.yml" + workflow_dispatch: + +concurrency: + group: deploy-${{ github.ref_name }} + cancel-in-progress: true + +jobs: + deploy: + name: Deploy ${{ matrix.service }} + runs-on: self-hosted + strategy: + fail-fast: false + matrix: + include: + - project: edr-freight + build_env_file: freight-web.build.env + service: freight-api + # - project: edr-freight + # build_env_file: freight-web.build.env + # service: freight-portal + # - project: edr-freight + # build_env_file: freight-web.build.env + # service: freight-backoffice + - project: edr-passenger + build_env_file: passenger-web.build.env + service: passenger-api + - project: edr-passenger + build_env_file: passenger-web.build.env + service: passenger-portal + - project: edr-passenger + build_env_file: passenger-web.build.env + service: passenger-backoffice + env: + PROJECT: ${{ matrix.project }} + BRANCH: ${{ github.ref_name }} + DEPLOY_USER: tria + BUILD_ENV_FILE: ${{ matrix.build_env_file }} + DOCKER_BUILDKIT: "1" + COMPOSE_DOCKER_CLI_BUILD: "1" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Sync environment from server + run: | + chmod +x scripts/deploy/*.sh + ./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}" + + - name: Set compose project name + run: | + set -euo pipefail + branch_slug=$(echo "${BRANCH}" | tr "[:upper:]" "[:lower:]" | sed -E "s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//") + echo "COMPOSE_PROJECT_NAME=${PROJECT}-${branch_slug}" >> "${GITHUB_ENV}" + + - name: Configure npm auth for Docker builds + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: ./scripts/deploy/create-npmrc.sh + + - name: Build ${{ matrix.service }} + run: | + set -euo pipefail + docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}" + + - name: Deploy ${{ matrix.service }} + run: | + set -euo pipefail + docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" + + - name: Remove npm credentials from workspace + if: always() + run: rm -f .npmrc .npmrc_temp diff --git a/.gitignore b/.gitignore index f03097e83..8eea260e2 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ coverage/ .DS_Store .idea/ .vscode/ -.npmrc \ No newline at end of file +.npmrc +branch_structure.json +temp_auto_push.bat +temp_interactive_push.bat diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..164947d90 --- /dev/null +++ b/.npmrc @@ -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/ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 000000000..00ca421d1 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,187 @@ +# Deployment Runbook + +This document explains how deployments work for the EDR platform using Docker, GitHub Actions, and self-hosted runners. + +## Overview + +- Monorepo contains 6 deployable services: + - `freight-api` + - `freight-portal` + - `freight-backoffice` + - `passenger-api` + - `passenger-portal` + - `passenger-backoffice` +- Deployments run through one workflow: `.github/workflows/deploy.yml` +- Each service is built/deployed independently in parallel (matrix jobs). +- Docker Compose project names are branch-aware to avoid environment collisions on the same host. + +## Prerequisites + +- Docker Engine with Compose plugin on the self-hosted runner. +- GitHub self-hosted runner registered for this repository. +- Repository secret configured: + - `NPM_TOKEN` (for private `@tria-plc/*` package install during Docker build) +- Server-side env files created for each branch/environment. + +## Server Environment Files + +`sync-env-from-server.sh` reads env files from: + +`/home//environment/edr///` + +Where: + +- `` defaults to `tria` (overridable by `DEPLOY_USER`) +- `` is derived from Git branch (lowercase, non-alphanumeric replaced with `-`) +- `` is `edr-freight` or `edr-passenger` + +### Required files per project + +For `edr-freight`: + +- `freight-api.env` +- `freight-portal.env` +- `freight-backoffice.env` +- optional: `freight-web.build.env` + +For `edr-passenger`: + +- `passenger-api.env` +- `passenger-portal.env` +- `passenger-backoffice.env` +- optional: `passenger-web.build.env` + +### Required env key + +Each service env file must contain: + +- `PORT=` + +The sync script validates this and fails if missing. + +### Build env files (optional) + +Used for build-time variables (example: Vite API URLs), with `export` syntax: + +```bash +export FREIGHT_VITE_API_URL=https://freight-api.example.com/api +export PASSENGER_VITE_API_URL=https://passenger-api.example.com +``` + +These are injected into `GITHUB_ENV` during workflow execution. + +## Docker Compose Port Mapping + +`docker-compose.yaml` uses per-service env variables for host/container port mappings: + +- `FREIGHT_API_PORT` +- `PASSENGER_API_PORT` +- `FREIGHT_PORTAL_PORT` +- `FREIGHT_BACKOFFICE_PORT` +- `PASSENGER_PORTAL_PORT` +- `PASSENGER_BACKOFFICE_PORT` + +`scripts/deploy/sync-env-from-server.sh` extracts `PORT` from each synced `.env` and exports the corresponding `*_PORT` variable to `GITHUB_ENV`. + +## GitHub Actions Deployment Flow + +Workflow file: `.github/workflows/deploy.yml` + +### 1) `prepare` job + +- Checks out repository once. +- Creates workspace artifact (`workspace.tgz`) and uploads it. + +### 2) `deploy` matrix job (parallel) + +For each service: + +- Downloads and extracts workspace artifact. +- Syncs that service env file from server path. +- Computes branch slug and sets: + - `COMPOSE_PROJECT_NAME=-` +- Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`. +- Runs: + - `docker compose --project-name "$COMPOSE_PROJECT_NAME" build ` + - `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d ` +- Cleans `.npmrc`/`.npmrc_temp`. + +## Branch/Environment Isolation + +Compose project name is generated as: + +`-` + +Examples: + +- `edr-freight-main` +- `edr-freight-staging` +- `edr-passenger-dev` + +This prevents container/network/volume name collisions between branches. + +## Local Manual Deployment (Optional) + +From repo root: + +```bash +DOCKER_BUILDKIT=1 docker compose build +docker compose up -d +``` + +If private packages are required locally, create `.npmrc`: + +```bash +cat < .npmrc +@tria-plc:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken= +always-auth=true +EOF +``` + +## Passenger API Startup Behavior + +Passenger container entrypoint runs on startup: + +1. `npm run prisma:generate` +2. `npm run prisma:migrate` (deploy mode) +3. `npm run prisma:seed` +4. starts API process + +## Troubleshooting + +### Missing env file + +Error: + +- `Missing env file: ...` + +Fix: + +- Create the required file in the server env directory for that project/branch slug. + +### Missing PORT in env file + +Error: + +- `Missing required PORT in env file: ...` + +Fix: + +- Add `PORT=` to that service env file. + +### Private package install fails + +Check: + +- `NPM_TOKEN` exists in repo secrets. +- Workflow created `.npmrc` successfully. + +### Prisma seed/migrate failures (passenger) + +Check: + +- `DATABASE_URL` in `passenger-api.env` +- DB reachability from runner host/container network +- migration history consistency + diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 7693a2939..000000000 --- a/Dockerfile +++ /dev/null @@ -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;"] diff --git a/README.md b/README.md index 63e3c6b8b..7ff862555 100644 --- a/README.md +++ b/README.md @@ -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 +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 + +# 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 @@ -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//` | +| [`.github/workflows/deploy-passenger.yml`](.github/workflows/deploy-passenger.yml) | passenger-api, passenger-portal, passenger-backoffice | `/home/user/environmen/edr-passenger//` | + +**On the runner**, place env files before the first deploy (example for branch `main`): + +```text +/home/user/environmen/edr-freight/main/ + freight-api.env + freight-portal.env # optional runtime env for Vite/nginx + freight-backoffice.env + freight-web.build.env # exports FREIGHT_VITE_API_URL=... + +/home/user/environmen/edr-passenger/main/ + passenger-api.env + passenger-portal.env + passenger-backoffice.env + passenger-web.build.env # exports PASSENGER_VITE_API_URL=... +``` + +Example `freight-web.build.env`: + +```bash +export FREIGHT_VITE_API_URL=https://freight-api.example.com/api +``` + +The workflow copies `*.env` into each app directory, creates `.npmrc` from the `NPM_TOKEN` repository secret, then runs `docker compose build` and `docker compose up -d` for that stack. + ## ๐Ÿ”’ Security Best Practices 1. **Environment Variables** - Never commit `.env` files. Use secrets management in production. @@ -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 diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 6c7738a7a..a3ddffc40 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -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 diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile new file mode 100644 index 000000000..b0850737b --- /dev/null +++ b/apps/edr-freight-api/Dockerfile @@ -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"] diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index 75d2df681..241c12d63 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -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= \ No newline at end of file +GITHUB_PACKAGE_TOKEN= diff --git a/apps/edr-passenger-api/Dockerfile b/apps/edr-passenger-api/Dockerfile new file mode 100644 index 000000000..5fd647968 --- /dev/null +++ b/apps/edr-passenger-api/Dockerfile @@ -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"] diff --git a/apps/edr-passenger-api/docker-entrypoint.sh b/apps/edr-passenger-api/docker-entrypoint.sh new file mode 100644 index 000000000..087ff1e74 --- /dev/null +++ b/apps/edr-passenger-api/docker-entrypoint.sh @@ -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 "$@" diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index ae10747f5..4649b2e4a 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -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", diff --git a/apps/edr-passenger-api/prisma/migrations/20260525134854_add_fayda_oidc_verification/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260525134854_add_fayda_oidc_verification/migration.sql new file mode 100644 index 000000000..da38b0502 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260525134854_add_fayda_oidc_verification/migration.sql @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/20260527080312_add_platform_and_authcode/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260527080312_add_platform_and_authcode/migration.sql new file mode 100644 index 000000000..64c577ff5 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260527080312_add_platform_and_authcode/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "FaydaVerificationSession" ADD COLUMN "authCode" TEXT, +ADD COLUMN "platform" TEXT NOT NULL DEFAULT 'WEB'; diff --git a/apps/edr-passenger-api/prisma/migrations/20260530200034_add_route_relation_to_schedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260530200034_add_route_relation_to_schedule/migration.sql new file mode 100644 index 000000000..ed8665647 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260530200034_add_route_relation_to_schedule/migration.sql @@ -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; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 342944edb..80220f114 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -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") +} + diff --git a/apps/edr-passenger-api/prisma/seed-complete.ts b/apps/edr-passenger-api/prisma/seed-complete.ts new file mode 100644 index 000000000..f9cc064be --- /dev/null +++ b/apps/edr-passenger-api/prisma/seed-complete.ts @@ -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(); + }); diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 12b872a0e..8a9707c8e 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -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() diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index d0dfed771..16178a1b4 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -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 { diff --git a/apps/edr-passenger-api/src/config/app.config.ts b/apps/edr-passenger-api/src/config/app.config.ts index 78492fc0c..9203a9d7b 100644 --- a/apps/edr-passenger-api/src/config/app.config.ts +++ b/apps/edr-passenger-api/src/config/app.config.ts @@ -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', })); diff --git a/apps/edr-passenger-api/src/config/fayda.config.ts b/apps/edr-passenger-api/src/config/fayda.config.ts new file mode 100644 index 000000000..e0bce45c6 --- /dev/null +++ b/apps/edr-passenger-api/src/config/fayda.config.ts @@ -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; + 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, + }; +}); diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 4fe3d1473..fe853ef76 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -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", }, }); diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts index 6d31c8323..33976f22f 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -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); + } } diff --git a/apps/edr-passenger-api/src/modules/auth/auth.service.ts b/apps/edr-passenger-api/src/modules/auth/auth.service.ts index deb2e9da4..931db07b5 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.service.ts @@ -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' + }; + } } diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index f7d1710a3..d7f87550f 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -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') diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index dc9989fdb..740271c1f 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -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; } diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts index d14390386..f9a3e0ea4 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts @@ -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] diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 7a87e1adc..1fd74de9f 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -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); diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts index 4c3f442d9..8fca71aea 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts @@ -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' }) diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 965aeedaf..93cbda7b0 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -71,24 +71,44 @@ export class GuestBookingService { let verifaydaData: Record | 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: { diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts index 25718cc8f..4b6625c75 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts @@ -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('fayda'); + const verifaydaEnabled = this.configService.get('VERIFAYDA_ENABLED', false); + + return { + enabled: faydaConfig?.enabled || verifaydaEnabled, + mode: verifaydaEnabled ? 'production' : 'development', + apiUrl: this.configService.get('VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2'), + }; + } +} diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts index fefdfb9a9..975db4584 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts @@ -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], }) diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index bc236a8f8..e26fb2211 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -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 }) diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index b6fdce100..daea3a9dd 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -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 } }), diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index 0f9184143..24aadbd4c 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -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); + } } diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts index bbdb3e5db..d4955db55 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts index c1b270123..cc7748457 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts @@ -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] }) diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 9706b8c85..8864a6363 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -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, + }; + } +} \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 3ff1fee32..49b570ab1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -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, '"'); + return ` + + + + + Redirecting to paymentโ€ฆ + + + +
+
+

Redirecting to payment providerโ€ฆ

+

Click here if you are not redirected

+
+ + +`; + } + + private buildStatusHtml(status: string, intentId: string): string { + return ` + + + + Payment status + + + +
+
${status}
+ Intent: ${intentId} +
+ +`; + } + + private buildErrorHtml(message: string): string { + return ` + + + + Payment error + + + +
+
Payment could not be initiated
+

${message}

+
+ +`; + } } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 661c0819e..aee99ed4b 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -182,8 +182,6 @@ export class PaymentsService { return this.formatIntentResponse(intent); } - - private formatIntentResponse( intent: Prisma.PaymentIntentGetPayload>, ): 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 }); + } + } } diff --git a/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts index 9984b5842..da9e74054 100644 --- a/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts +++ b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts @@ -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, diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts index 75224c3f9..72cbbbb47 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts @@ -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') diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index 38705f435..cb0099983 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -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) { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 1f122cd41..3b3fd9b83 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -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); + } } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 36e8a33ed..820c5b8a0 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -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' }; + } } diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index c534586dd..19eb25652 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -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> { + // 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 = { + 'Economy Regular': 35000, + 'Economy Bed': 49000, + 'VIP Bed': 63000, + }; + return defaults[className] ?? 35000; + } + private defaultFare(seatClassName: string): number { const fares: Record = { '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> { + 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 diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts index 945834ac0..0ac25272d 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts @@ -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); } } diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts index b21eac0b6..982c37209 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts @@ -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 } }); + } } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index a555f2513..e5d61c829 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -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' }) diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 96cf4e1bf..b1b1a3af3 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -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 { @@ -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; + } + } + } } } diff --git a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts index 353204644..bb301e315 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts @@ -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) { + 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); + } } diff --git a/apps/edr-passenger-api/src/modules/stations/stations.service.ts b/apps/edr-passenger-api/src/modules/stations/stations.service.ts index be4de158f..a3e6624fe 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.service.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.service.ts @@ -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) { + 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 } }); + } } diff --git a/apps/edr-passenger-api/src/modules/support/support.service.ts b/apps/edr-passenger-api/src/modules/support/support.service.ts index f0d2f9034..dd155bff8 100644 --- a/apps/edr-passenger-api/src/modules/support/support.service.ts +++ b/apps/edr-passenger-api/src/modules/support/support.service.ts @@ -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.'; diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index cb9aeeb76..5711355e0 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -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); + } } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts index f1c01ab9d..cd02974d3 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts @@ -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'; diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 33971d3ec..71b65899c 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -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 }; + } } diff --git a/apps/edr-passenger-api/src/modules/verifayda/optional-jwt.guard.ts b/apps/edr-passenger-api/src/modules/verifayda/optional-jwt.guard.ts new file mode 100644 index 000000000..5f5fac19b --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/optional-jwt.guard.ts @@ -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 ` 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(_err: unknown, user: TUser): TUser { + return (user ?? null) as TUser; + } +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.spec.ts b/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.spec.ts new file mode 100644 index 000000000..9b4316fc7 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.spec.ts @@ -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(); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.ts b/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.ts new file mode 100644 index 000000000..dc3558ccc --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.ts @@ -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 { + 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); +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.spec.ts b/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.spec.ts new file mode 100644 index 000000000..d359a07f2 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.spec.ts @@ -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()); + }); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.ts b/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.ts new file mode 100644 index 000000000..89e9437d2 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.ts @@ -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)); +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts new file mode 100644 index 000000000..f1eb25e8e --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts @@ -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 { + 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 { + return this.service.getVerificationStatus(req.user.userId); + } +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts new file mode 100644 index 000000000..005a3e517 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.errors.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.errors.ts new file mode 100644 index 000000000..a7d531102 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.errors.ts @@ -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 }); + } +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts index c14ba3f1f..d850b1dbf 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts @@ -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], }) diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts new file mode 100644 index 000000000..e4b8cb790 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts @@ -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 { + return { + sign: jest.fn(() => 'signed.jwt.token'), + } as unknown as jest.Mocked; +} + +function buildConfig(overrides?: Partial): 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 { + 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; +} + +describe('VerifaydaService (OIDC, client-callback)', () => { + let prisma: ReturnType; + let jwt: jest.Mocked; + 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 = {}) { + 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 = {}) { + 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>) { + 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 = {}) { + 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) { + 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 }); + }); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts index d86ad98a2..f7b3e77fb 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -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('VERIFAYDA_ENABLED', false); - this.apiUrl = this.config.get('VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2'); - this.apiKey = this.config.get('VERIFAYDA_API_KEY', ''); + const fayda = this.config.get('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('VERIFAYDA_ENABLED', false); + this.stubApiUrl = this.config.get( + 'VERIFAYDA_API_URL', + 'https://api.verifayda.gov.et/v2', + ); + this.stubApiKey = this.config.get('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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { - 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; } } diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts new file mode 100644 index 000000000..7c7335c34 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts @@ -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; + [key: string]: unknown; +} + +export interface NormalizedFaydaUserInfo { + sub: string; + fullName?: string; + phoneNumber?: string; + email?: string; + gender?: string; + birthdate?: string; + picture?: string; +} diff --git a/apps/edr-passenger-web/backoffice/.env.example b/apps/edr-passenger-web/backoffice/.env.example index 34eff7170..5263b3a36 100644 --- a/apps/edr-passenger-web/backoffice/.env.example +++ b/apps/edr-passenger-web/backoffice/.env.example @@ -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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 diff --git a/apps/edr-passenger-web/backoffice/.eslintrc.json b/apps/edr-passenger-web/backoffice/.eslintrc.json new file mode 100644 index 000000000..957cd1545 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals"] +} diff --git a/apps/edr-passenger-web/backoffice/.gitignore b/apps/edr-passenger-web/backoffice/.gitignore new file mode 100644 index 000000000..892067bc7 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/.gitignore @@ -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 diff --git a/apps/edr-passenger-web/backoffice/README.md b/apps/edr-passenger-web/backoffice/README.md new file mode 100644 index 000000000..f248eab93 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/README.md @@ -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 + {item.status} }, + ]} + actions={[ + { label: 'Edit', onClick: handleEdit, variant: 'secondary', icon: Edit }, + { label: 'Delete', onClick: handleDelete, variant: 'danger', icon: Trash2 }, + ]} + loading={isLoading} +/> +``` + +#### ActionButton with Loading +```tsx + + Create Item + +``` + +## ๐Ÿ”Œ 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 +
+

Title

+

Description

+
+``` + +## ๐Ÿ“ 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** diff --git a/apps/edr-passenger-web/backoffice/generate-pages.js b/apps/edr-passenger-web/backoffice/generate-pages.js new file mode 100644 index 000000000..3d258bc68 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/generate-pages.js @@ -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) => {payment.reference || payment.id?.substring(0, 8)} }, + { key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' }, + { key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) }, + { key: 'method', label: 'Method', render: (payment: any) => {payment.method} }, + { key: 'status', label: 'Status', render: (payment: any) => {payment.status} }, + { key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) }, + ]`, + filters: `{ search: '', status: '', method: '' }`, + filterInputs: ` +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+
+ + +
+ ` + }, + { + 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) => {account.tier} }, + { 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: ` +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+
+ + +
+ ` + }, + { + 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) => {account.isActive ? 'Active' : 'Inactive'} }, + ]`, + filters: `{ search: '' }`, + filterInputs: ` +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+ ` + }, + { + 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) => {conv.status} }, + { key: 'createdAt', label: 'Created', render: (conv: any) => formatDateTime(conv.createdAt) }, + ]`, + filters: `{ search: '', status: '' }`, + filterInputs: ` +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+
+ + +
+ ` + }, + { + name: 'verifayda', + title: 'Verifayda Integration', + description: 'Ethiopian national ID verification logs', + api: 'verifaydaApi', + columns: `[ + { key: 'nationalId', label: 'National ID', render: (ver: any) => {ver.nationalId} }, + { key: 'fullName', label: 'Name', render: (ver: any) => ver.fullName || 'N/A' }, + { key: 'verified', label: 'Status', render: (ver: any) => {ver.verified ? 'Verified' : 'Failed'} }, + { key: 'createdAt', label: 'Verified At', render: (ver: any) => formatDateTime(ver.createdAt) }, + ]`, + filters: `{ search: '', verified: '' }`, + filterInputs: ` +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+
+ + +
+ ` + }, + { + name: 'food', + title: 'Food & Dining', + description: 'Manage food orders and menu items', + api: 'foodApi', + columns: `[ + { key: 'orderNumber', label: 'Order #', render: (order: any) => {order.orderNumber || order.id?.substring(0, 8)} }, + { 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) => {order.status} }, + ]`, + filters: `{ search: '', status: '' }`, + filterInputs: ` +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+
+ + +
+ ` + }, + { + 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) => {schedule.status} }, + ]`, + filters: `{ search: '', status: '' }`, + filterInputs: ` +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+
+ + +
+ ` + }, + { + name: 'seat-classes', + title: 'Seat Classes', + description: 'Manage seat class configurations', + api: 'seatClassesApi', + columns: `[ + { key: 'name', label: 'Name', render: (cls: any) => {cls.name} }, + { 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) => {cls.isActive ? 'Active' : 'Inactive'} }, + ]`, + filters: `{ search: '' }`, + filterInputs: ` +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+ ` + }, + { + name: 'operational-reports', + title: 'Operational Reports', + description: 'View operational reports and analytics', + api: 'reportsApi', + columns: `[ + { key: 'reportType', label: 'Type', render: (report: any) => {report.reportType} }, + { key: 'period', label: 'Period', render: (report: any) => report.period || 'N/A' }, + { key: 'generatedBy', label: 'Generated By', render: (report: any) => report.generatedBy?.fullName || 'System' }, + { key: 'createdAt', label: 'Generated', render: (report: any) => formatDateTime(report.createdAt) }, + ]`, + filters: `{ search: '', reportType: '' }`, + filterInputs: ` +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+
+ + +
+ ` + } +]; + +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 ( +
+
+
+

${page.title}

+

${page.description}

+
+ Export +
+ +
+
+ ${page.filterInputs} +
+
+ + +
+ ); +} +`; + +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!'); diff --git a/apps/edr-passenger-web/backoffice/index.html b/apps/edr-passenger-web/backoffice/index.html deleted file mode 100644 index f99b2af74..000000000 --- a/apps/edr-passenger-web/backoffice/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - EDR Passenger Backoffice - - -
- - - diff --git a/apps/edr-passenger-web/backoffice/next.config.js b/apps/edr-passenger-web/backoffice/next.config.js new file mode 100644 index 000000000..a286d1a26 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/next.config.js @@ -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; diff --git a/apps/edr-passenger-web/backoffice/package.json b/apps/edr-passenger-web/backoffice/package.json index d04911f55..a86c23f51 100644 --- a/apps/edr-passenger-web/backoffice/package.json +++ b/apps/edr-passenger-web/backoffice/package.json @@ -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" } } diff --git a/apps/edr-passenger-web/backoffice/postcss.config.js b/apps/edr-passenger-web/backoffice/postcss.config.js new file mode 100644 index 000000000..12a703d90 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/edr-passenger-web/backoffice/public/README.md b/apps/edr-passenger-web/backoffice/public/README.md new file mode 100644 index 000000000..7ea9c3ab4 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/public/README.md @@ -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. diff --git a/apps/edr-passenger-web/backoffice/public/banner.jpg b/apps/edr-passenger-web/backoffice/public/banner.jpg new file mode 100644 index 000000000..09c6add92 Binary files /dev/null and b/apps/edr-passenger-web/backoffice/public/banner.jpg differ diff --git a/apps/edr-passenger-web/backoffice/src/App.tsx b/apps/edr-passenger-web/backoffice/src/App.tsx deleted file mode 100644 index fafac6075..000000000 --- a/apps/edr-passenger-web/backoffice/src/App.tsx +++ /dev/null @@ -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 ( - - - } /> - } /> - - - ); -}; - -export default App; diff --git a/apps/edr-passenger-web/backoffice/src/app/agents/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/agents/layout.tsx new file mode 100644 index 000000000..71badbbc3 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/agents/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function AgentsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx new file mode 100644 index 000000000..d220bdd1a --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx @@ -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) => {agent.agentCode}, + }, + { + key: 'user', + label: 'Name', + render: (agent: any) => ( +
+
{agent.user?.fullName || 'N/A'}
+
{agent.user?.email}
+
+ ), + }, + { + key: 'commissionRate', + label: 'Commission', + render: (agent: any) => {agent.commissionRate}%, + }, + { + key: 'active', + label: 'Status', + render: (agent: any) => ( + + {agent.active ? 'Active' : 'Inactive'} + + ), + }, + ]; + + 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 ( +
+
+
+

Agent Operations

+

Manage booking agents and their operations

+
+ Add Agent +
+ +
+
+
+ + setFilters({ ...filters, search: e.target.value })} + /> +
+
+ + +
+
+
+ + +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/audit/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/audit/layout.tsx new file mode 100644 index 000000000..86d53715f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/audit/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function Layout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx new file mode 100644 index 000000000..6f73de13e --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx @@ -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) => ( + {log.action} + ), + }, + { + key: 'user', + label: 'User', + render: (log: any) => ( +
+
{log.user?.fullName || 'System'}
+
{log.user?.email || 'N/A'}
+
+ ), + }, + { + key: 'entityType', + label: 'Entity Type', + render: (log: any) => log.entityType, + }, + { + key: 'entityId', + label: 'Entity ID', + render: (log: any) => ( + {log.entityId?.substring(0, 8)}... + ), + }, + { + 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 ( +
+
+
+

Audit Logs

+

Track all system activities and changes

+
+
+ +
+
+
+ + setFilters({ ...filters, search: e.target.value })} + /> +
+
+ + +
+
+ + +
+
+
+ + +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/layout.tsx new file mode 100644 index 000000000..0bec0d89a --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function BookingsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx new file mode 100644 index 000000000..910d933c4 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -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({ + page: 1, + pageSize: 20, + search: '', + status: '', + }); + const [selectedBooking, setSelectedBooking] = useState(null); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [bookingToDelete, setBookingToDelete] = useState(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) => ( + {booking.bookingRef} + ), + }, + { + key: 'passenger', + label: 'Passenger', + render: (booking: any) => ( +
+
{booking.passenger?.fullName || booking.contactEmail || 'Guest'}
+
{booking.contactPhone || booking.passenger?.phone}
+
+ ), + }, + { + key: 'status', + label: 'Status', + render: (booking: any) => ( + {booking.status} + ), + }, + { + key: 'totalMinor', + label: 'Amount', + sortable: true, + render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency), + }, + { + key: 'paymentStatus', + label: 'Payment', + render: (booking: any) => ( + + {booking.paymentIntent?.status || 'PENDING'} + + ), + }, + { + 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 ( +
+
+
+

Bookings

+

Manage all passenger bookings

+
+ Export +
+ +
+ {successMessage && ( +
+ โœ“ {successMessage} +
+ )} + {error && ( +
+ Error loading bookings: {error instanceof Error ? error.message : 'Unknown error'} +
+ )} +
+
+ setFilters({ ...filters, search: e.target.value, page: 1 })} + /> +
+ + More Filters +
+ + + + {data?.meta && ( + setFilters({ ...filters, page })} + /> + )} +
+ + {/* Booking Details Modal */} + setSelectedBooking(null)} + title="Booking Details" + size="xl" + > + {selectedBooking && ( +
+ {/* Booking Information */} +
+
+ +

{selectedBooking.bookingRef}

+
+
+ +
+ + {selectedBooking.status} + +
+
+
+ +

{selectedBooking.bookingType || 'N/A'}

+
+
+ +

{formatDateTime(selectedBooking.createdAt)}

+
+
+ +
+ + {/* Passenger Information */} +
+

Passenger Information

+
+
+ +

{selectedBooking.passenger?.fullName || selectedBooking.contactEmail || 'N/A'}

+
+
+ +

{selectedBooking.contactEmail || selectedBooking.passenger?.email || 'N/A'}

+
+
+ +

{selectedBooking.contactPhone || selectedBooking.passenger?.phone || 'N/A'}

+
+
+ +

{selectedBooking.passengerId || 'N/A'}

+
+
+
+ +
+ + {/* Booking Details */} +
+

Journey Details

+
+
+ +

{selectedBooking.adultCount || 0}

+
+
+ +

{selectedBooking.childCount || 0}

+
+
+ +

{selectedBooking.scheduleId || 'N/A'}

+
+
+ +

{selectedBooking.promoCode || 'None'}

+
+
+
+ +
+ + {/* Payment Information */} +
+

Payment Information

+
+
+ +

{formatCurrency(selectedBooking.totalMinor, selectedBooking.currency)}

+
+
+ +
+ + {selectedBooking.paymentIntent?.status || 'PENDING'} + +
+
+
+ +

{selectedBooking.paidAt ? formatDateTime(selectedBooking.paidAt) : 'Not paid'}

+
+
+ +

{selectedBooking.displayCurrency || selectedBooking.currency}

+
+
+
+ +
+ + {/* Additional Information */} +
+

Additional Information

+
+
+ +

{selectedBooking.source || 'N/A'}

+
+
+ +

{formatDateTime(selectedBooking.updatedAt)}

+
+
+
+ +
+ setSelectedBooking(null)} + > + Close + +
+
+ )} +
+ + {/* Delete Confirmation Dialog */} + { + 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} + /> +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/layout.tsx new file mode 100644 index 000000000..d9a82ee54 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function CoachesLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx new file mode 100644 index 000000000..2abe00723 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -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(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) => { + 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) => ( +
+
+ +
+ {coach.coachNumber} +
+ ), + }, + { + key: 'seatClass', + label: 'Seat Class', + render: (coach: any) => { + const seatClass = coach.seatClass?.name || coach.serviceClass || 'N/A'; + const colorMap: Record = { + 'ECONOMY_REGULAR': 'edr-badge-info', + 'ECONOMY_BED': 'edr-badge-warning', + 'VIP_BED': 'edr-badge-success', + }; + return ( + + {seatClass.replace(/_/g, ' ')} + + ); + }, + }, + { + key: 'totalSeats', + label: 'Total Seats', + render: (coach: any) => ( + {coach.totalSeats || coach.totalUnits || 0} + ), + }, + { + key: 'layout', + label: 'Layout', + render: (coach: any) => ( + + {coach.layout || coach.seatLayout || coach.seatArrangement || 'N/A'} + + ), + }, + { + key: 'status', + label: 'Status', + render: (coach: any) => { + const status = coach.isActive ? 'ACTIVE' : 'INACTIVE'; + const statusMap: Record = { + ACTIVE: 'edr-badge-success', + MAINTENANCE: 'edr-badge-warning', + INACTIVE: 'edr-badge-danger', + }; + return ( + + {status} + + ); + }, + }, + ]; + + 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 ( +
+
+
+

Coach Management

+

Manage train coaches and configurations

+
+ { + setEditingCoach(null); + setShowModal(true); + }} + > + Add Coach + +
+ +
+
+
+ + setSearch(e.target.value)} + className="input pl-10" + /> +
+
+ + +
+ + {/* Delete Confirmation */} + 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 */} + { + setShowModal(false); + setEditingCoach(null); + }} + title={`${editingCoach ? 'Edit' : 'Add'} Coach`} + size="lg" + > +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ { + setShowModal(false); + setEditingCoach(null); + }} + > + Cancel + + + {editingCoach ? 'Update' : 'Create'} Coach + +
+
+
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/layout.tsx new file mode 100644 index 000000000..92ed57085 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/layout.tsx @@ -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 ( +
+
+
+

Loading...

+
+
+ ); + } + + if (!isAuthenticated) { + return null; + } + + return ( +
+ +
+
+
+ {children} +
+
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx new file mode 100644 index 000000000..ee4f5b9ce --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -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({ + 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) => ( + + {item.status} + + ) + }, + { key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) }, + ]; + + return ( +
+
+

Dashboard

+

Hello, welcome back! Here's what's happening today.

+
+ +
+ + + + +
+ + {!revenueLoading && revenueData && revenueData.length > 0 && ( +
+

Revenue Trend (Last 30 Days)

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

Recent Bookings

+ +
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/food/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/food/layout.tsx new file mode 100644 index 000000000..86d53715f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/food/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function Layout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/food/page.tsx b/apps/edr-passenger-web/backoffice/src/app/food/page.tsx new file mode 100644 index 000000000..80b905c52 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/food/page.tsx @@ -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) => {order.orderNumber || order.id?.substring(0, 8)} }, + { 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) => {order.status} }, + ]; + + return ( +
+
+
+

Food & Dining

+

Manage food orders and menu items

+
+ Export +
+ +
+
+ +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+
+ + +
+ +
+
+ + +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/fraud/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/fraud/layout.tsx new file mode 100644 index 000000000..86d53715f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/fraud/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function Layout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx b/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx new file mode 100644 index 000000000..242f9bfd9 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx @@ -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) => ( + + {alert.severity} + + ), + }, + { + key: 'ruleType', + label: 'Rule Type', + render: (alert: any) => ( +
+ + {alert.ruleType} +
+ ), + }, + { + key: 'user', + label: 'User', + render: (alert: any) => ( +
+
{alert.user?.fullName || 'N/A'}
+
{alert.user?.email || 'N/A'}
+
+ ), + }, + { + key: 'description', + label: 'Description', + render: (alert: any) => ( + {alert.description || alert.details} + ), + }, + { + key: 'status', + label: 'Status', + render: (alert: any) => ( + + {alert.acknowledged ? 'Acknowledged' : 'Pending'} + + ), + }, + { + 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 ( +
+
+
+

Fraud Detection

+

Monitor and manage fraud alerts

+
+
+ +
+
+
+ + setFilters({ ...filters, search: e.target.value })} + /> +
+
+ + +
+
+ + +
+
+
+ + +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/layout.tsx new file mode 100644 index 000000000..23838f62c --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/layout.tsx @@ -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 ( + + + + + diff --git a/apps/edr-passenger-web/backoffice/test-stations-crud.js b/apps/edr-passenger-web/backoffice/test-stations-crud.js new file mode 100644 index 000000000..7964cc813 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/test-stations-crud.js @@ -0,0 +1,132 @@ +// Test script for Stations CRUD operations +// Run this in the browser console on the backoffice app + +async function testStationsCRUD() { + const API_URL = 'http://localhost:4000'; + const token = localStorage.getItem('auth_token'); + + const headers = { + 'Content-Type': 'application/json', + 'Authorization': token ? `Bearer ${token}` : '' + }; + + console.log('๐Ÿงช Testing Stations CRUD Operations...\n'); + + try { + // 1. CREATE - Add a new station + console.log('1๏ธโƒฃ Testing CREATE Station...'); + const newStation = { + code: 'TEST', + name: 'Test Station', + city: 'Test City', + countryCode: 'ET', + lat: '9.0320', + lng: '38.7469', + timezone: 'Africa/Addis_Ababa', + isOperational: true + }; + + const createResponse = await fetch(`${API_URL}/stations`, { + method: 'POST', + headers, + body: JSON.stringify(newStation) + }); + + if (!createResponse.ok) { + throw new Error(`CREATE failed: ${createResponse.status} ${await createResponse.text()}`); + } + + const createdStation = await createResponse.json(); + console.log('โœ… Station created:', createdStation); + const stationId = createdStation.id || createdStation.data?.id; + + if (!stationId) { + throw new Error('No station ID returned from create'); + } + + // 2. READ - Get the created station + console.log('\n2๏ธโƒฃ Testing READ Station...'); + const readResponse = await fetch(`${API_URL}/stations/${stationId}`, { + method: 'GET', + headers + }); + + if (!readResponse.ok) { + throw new Error(`READ failed: ${readResponse.status}`); + } + + const readStation = await readResponse.json(); + console.log('โœ… Station retrieved:', readStation); + + // 3. UPDATE - Modify the station + console.log('\n3๏ธโƒฃ Testing UPDATE Station...'); + const updateData = { + name: 'Test Station Updated', + city: 'Test City Updated', + isOperational: false + }; + + const updateResponse = await fetch(`${API_URL}/stations/${stationId}`, { + method: 'PATCH', + headers, + body: JSON.stringify(updateData) + }); + + if (!updateResponse.ok) { + throw new Error(`UPDATE failed: ${updateResponse.status} ${await updateResponse.text()}`); + } + + const updatedStation = await updateResponse.json(); + console.log('โœ… Station updated:', updatedStation); + + // 4. LIST - Get all stations + console.log('\n4๏ธโƒฃ Testing LIST Stations...'); + const listResponse = await fetch(`${API_URL}/stations`, { + method: 'GET', + headers + }); + + if (!listResponse.ok) { + throw new Error(`LIST failed: ${listResponse.status}`); + } + + const stations = await listResponse.json(); + console.log('โœ… Stations list retrieved:', stations); + + // 5. DELETE - Remove the test station + console.log('\n5๏ธโƒฃ Testing DELETE Station...'); + const deleteResponse = await fetch(`${API_URL}/stations/${stationId}`, { + method: 'DELETE', + headers + }); + + if (!deleteResponse.ok) { + throw new Error(`DELETE failed: ${deleteResponse.status} ${await deleteResponse.text()}`); + } + + console.log('โœ… Station deleted successfully'); + + // 6. Verify deletion + console.log('\n6๏ธโƒฃ Verifying deletion...'); + const verifyResponse = await fetch(`${API_URL}/stations/${stationId}`, { + method: 'GET', + headers + }); + + if (verifyResponse.status === 404) { + console.log('โœ… Station deletion verified (404 Not Found)'); + } else { + console.warn('โš ๏ธ Station might still exist'); + } + + console.log('\n๐ŸŽ‰ All tests passed!'); + return { success: true, message: 'All CRUD operations working correctly' }; + + } catch (error) { + console.error('โŒ Test failed:', error); + return { success: false, error: error.message }; + } +} + +// Run the test +testStationsCRUD(); diff --git a/apps/edr-passenger-web/backoffice/tsconfig.app.json b/apps/edr-passenger-web/backoffice/tsconfig.app.json deleted file mode 100644 index 73df43221..000000000 --- a/apps/edr-passenger-web/backoffice/tsconfig.app.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "@edr/tsconfig/react.json", - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "useDefineForClassFields": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/apps/edr-passenger-web/backoffice/tsconfig.json b/apps/edr-passenger-web/backoffice/tsconfig.json index 1ffef600d..404b4a565 100644 --- a/apps/edr-passenger-web/backoffice/tsconfig.json +++ b/apps/edr-passenger-web/backoffice/tsconfig.json @@ -1,7 +1,28 @@ { - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + "compilerOptions": { + "target": "ES2020", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] } diff --git a/apps/edr-passenger-web/backoffice/tsconfig.node.json b/apps/edr-passenger-web/backoffice/tsconfig.node.json deleted file mode 100644 index 181375c8f..000000000 --- a/apps/edr-passenger-web/backoffice/tsconfig.node.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "@edr/tsconfig/base.json", - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2022", - "lib": ["ES2023"], - "module": "ESNext", - "moduleResolution": "Bundler", - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "noEmit": true - }, - "include": ["vite.config.ts"] -} diff --git a/apps/edr-passenger-web/backoffice/vite.config.ts b/apps/edr-passenger-web/backoffice/vite.config.ts deleted file mode 100644 index 157a94445..000000000 --- a/apps/edr-passenger-web/backoffice/vite.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; - -export default defineConfig({ - plugins: [react()], - server: { - port: 5184, - host: "0.0.0.0", - }, - // test: { - // environment: "jsdom", - // globals: true, - // }, -}); diff --git a/apps/edr-passenger-web/portal/.env.example b/apps/edr-passenger-web/portal/.env.example index 1fad0847d..25ffe6909 100644 --- a/apps/edr-passenger-web/portal/.env.example +++ b/apps/edr-passenger-web/portal/.env.example @@ -1 +1,5 @@ -VITE_API_URL=http://localhost:4000 +# API Configuration +NEXT_PUBLIC_API_URL=https://your-api-domain.com + +# GitHub Packages Token +GITHUB_PACKAGE_TOKEN=$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 \ No newline at end of file diff --git a/apps/edr-passenger-web/portal/.eslintrc.json b/apps/edr-passenger-web/portal/.eslintrc.json new file mode 100644 index 000000000..bffb357a7 --- /dev/null +++ b/apps/edr-passenger-web/portal/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/apps/edr-passenger-web/portal/.gitignore b/apps/edr-passenger-web/portal/.gitignore new file mode 100644 index 000000000..8ccc87480 --- /dev/null +++ b/apps/edr-passenger-web/portal/.gitignore @@ -0,0 +1,34 @@ +# 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 +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/edr-passenger-web/portal/README.md b/apps/edr-passenger-web/portal/README.md new file mode 100644 index 000000000..b1e79bbab --- /dev/null +++ b/apps/edr-passenger-web/portal/README.md @@ -0,0 +1,336 @@ +# EDR Passenger Portal + +Modern Next.js 14 web application for the Ethio-Djibouti Railway passenger booking system. + +## Features + +### Complete Booking Flow +1. **Search** - Find trains by route, date, and passenger count +2. **Results** - View available schedules with pricing +3. **Auth Check** - Sign in or continue as guest +4. **Passengers** - Collect passenger details with Fayda verification +5. **Seats** - Select seats with visual seat map +6. **Review** - Confirm booking details and fare breakdown +7. **Payment** - Choose payment method and process payment +8. **Confirmation** - View PNR, tickets with QR codes + +### Key Capabilities +- **Fayda 2.0 Integration** - Ethiopian national ID verification +- **Age-Based Pricing** - First child travels free +- **Multi-Currency Support** - ETB, DJF, USD display +- **Seat Hold System** - 2-hour seat reservation +- **Guest Booking** - Book without account, optional registration +- **QR Code Tickets** - Digital tickets with QR codes +- **Responsive Design** - Mobile-first, works on all devices + +## Tech Stack + +- **Framework:** Next.js 14 with App Router +- **Styling:** Tailwind CSS +- **State Management:** + - TanStack Query (React Query) for server state + - Zustand for client state (booking flow, auth, payment) +- **Forms:** React Hook Form with Zod validation +- **API Client:** Axios with interceptors +- **Date Handling:** date-fns +- **QR Codes:** qrcode.react + +## Getting Started + +### Prerequisites +- Node.js >= 20.x +- pnpm >= 9.x +- EDR Passenger API running on port 3002 + +### Installation + +```bash +# Install dependencies +pnpm install + +# Create environment file +cp .env.example .env.local + +# Update .env.local with API URL +NEXT_PUBLIC_API_URL=http://localhost:3002 +``` + +### Development + +```bash +# Run development server +pnpm dev + +# Access at http://localhost:5174 +``` + +### Build + +```bash +# Build for production +pnpm build + +# Start production server +pnpm start +``` + +## Project Structure + +``` +src/ +โ”œโ”€โ”€ app/ # Next.js App Router pages +โ”‚ โ”œโ”€โ”€ booking/ +โ”‚ โ”‚ โ”œโ”€โ”€ search/ # Search trains +โ”‚ โ”‚ โ”œโ”€โ”€ results/ # Search results +โ”‚ โ”‚ โ”œโ”€โ”€ auth-check/ # Login or guest +โ”‚ โ”‚ โ”œโ”€โ”€ passengers/ # Passenger details + Fayda +โ”‚ โ”‚ โ”œโ”€โ”€ seats/ # Seat selection +โ”‚ โ”‚ โ”œโ”€โ”€ review/ # Booking review +โ”‚ โ”‚ โ”œโ”€โ”€ payment/ # Payment processing +โ”‚ โ”‚ โ””โ”€โ”€ confirmation/ # Booking confirmation +โ”‚ โ”œโ”€โ”€ login/ # Login page +โ”‚ โ”œโ”€โ”€ layout.tsx # Root layout +โ”‚ โ”œโ”€โ”€ page.tsx # Home (redirects to search) +โ”‚ โ”œโ”€โ”€ providers.tsx # React Query provider +โ”‚ โ””โ”€โ”€ globals.css # Global styles +โ”œโ”€โ”€ components/ # Reusable components +โ”œโ”€โ”€ lib/ # Core utilities +โ”‚ โ”œโ”€โ”€ api-client.ts # Axios client with interceptors +โ”‚ โ”œโ”€โ”€ auth-store.ts # Auth state (Zustand) +โ”‚ โ”œโ”€โ”€ booking-store.ts # Booking flow state (Zustand) +โ”‚ โ””โ”€โ”€ payment-store.ts # Payment state (Zustand) +โ”œโ”€โ”€ types/ # TypeScript types +โ”‚ โ””โ”€โ”€ index.ts +โ””โ”€โ”€ hooks/ # Custom React hooks +``` + +## State Management + +### Booking Store (Zustand) +Persists booking flow state across pages: +- Search criteria +- Selected schedule +- Passenger details +- Seat hold information +- Booking ID and PNR +- Payment method + +### Auth Store (Zustand) +Manages user authentication: +- User profile +- JWT token +- Login/logout/register +- Persisted to localStorage + +### Payment Store (Zustand) +Tracks payment flow: +- Payment intent ID +- Payment status +- Selected currency + +## API Integration + +### Endpoints Used + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/stations` | GET | Fetch all stations | +| `/search` | POST | Search available trains | +| `/passengers/verify-fayda` | POST | Verify Ethiopian national ID | +| `/seatmap/{scheduleId}` | GET | Get coaches and seats | +| `/seatmap/{scheduleId}/hold` | POST | Hold seats (2 hours) | +| `/bookings/create` | POST | Create booking + generate PNR | +| `/bookings/{id}/confirm` | PATCH | Confirm booking after payment | +| `/payments/intent` | POST | Create payment intent | +| `/auth/login` | POST | User login | +| `/auth/register` | POST | User registration | + +## Booking Flow + +### 1. Search +- User selects origin, destination, date, passengers +- Validates form with Zod schema +- Stores criteria in booking store +- Navigates to results + +### 2. Results +- Fetches schedules from API +- Displays available trains with pricing +- User selects a schedule +- Stores selection and navigates to auth check + +### 3. Auth Check +- Checks if user is authenticated +- Offers "Sign In" or "Continue as Guest" +- Authenticated users can use saved profiles + +### 4. Passengers +- Collects details for each passenger +- **Ethiopian nationals:** Fayda verification + - Calls `/passengers/verify-fayda` + - Auto-fills name and DOB on success + - Allows manual entry on failure +- **Non-Ethiopians:** Passport details +- Optional account creation checkbox +- Stores passenger data in booking store + +### 5. Seats +- Fetches coaches and seat map +- Visual seat selection (4-column grid) +- Color-coded seat status: + - Green: Available + - Blue: Selected + - Yellow: Held by others + - Gray: Booked/Blocked +- Calls `/seatmap/{scheduleId}/hold` on selection +- Stores hold ID and expiry (2 hours) +- Option to skip (auto-assign) + +### 6. Review +- Displays trip summary +- Lists all passengers +- Shows fare breakdown +- Displays seat hold countdown timer +- Calls `/bookings/create` on confirm +- Generates 6-character PNR +- Navigates to payment + +### 7. Payment +- Displays PNR prominently +- Payment method selection: + - Telebirr + - CBE Birr + - eBirr + - Card + - Wallet +- Shows order summary +- Calls `/payments/intent` +- Processes payment (simulated for now) + +### 8. Confirmation +- Calls `/bookings/{id}/confirm` +- Displays success message +- Shows PNR with copy button +- Generates QR codes for each ticket +- Lists all passenger tickets +- Download and share options +- "Book Another Trip" button clears state + +## Form Validation + +All forms use React Hook Form + Zod: + +```typescript +// Example: Search form validation +const searchSchema = z.object({ + originStationId: z.string().min(1, 'Please select origin'), + destinationStationId: z.string().min(1, 'Please select destination'), + departureDate: z.string().min(1, 'Please select date'), + adultCount: z.number().min(1).max(9), + childCount: z.number().min(0).max(9), + nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']), +}).refine((data) => data.originStationId !== data.destinationStationId, { + message: 'Origin and destination must be different', + path: ['destinationStationId'], +}); +``` + +## Styling + +### Tailwind Utility Classes +Custom component classes in `globals.css`: + +```css +.btn-primary /* Primary action button */ +.btn-secondary /* Secondary action button */ +.input-field /* Form input styling */ +.card /* Card container */ +``` + +### Theme Colors +Primary brand color: `rgb(20, 113, 76)` (EDR green) + +Shades available: 50, 100, 200, 300, 400, 500, 600, 700, 800, 900 + +## Error Handling + +- Network errors: Retry button with exponential backoff +- Validation errors: Inline field-level messages +- API errors: User-friendly error messages +- Seat hold expiry: Alert and re-selection option +- 401 Unauthorized: Auto-redirect to login + +## Accessibility + +- Semantic HTML elements +- ARIA labels on interactive elements +- Keyboard navigation support +- Color contrast WCAG AA compliant +- Screen reader announcements for validation errors + +## Mobile Responsiveness + +- Mobile-first design approach +- Responsive grid layouts (md: breakpoint) +- Touch-friendly button sizes +- Scrollable seat maps on small screens +- Optimized forms for mobile input + +## Testing Checklist + +- [ ] Search form validation +- [ ] Results display and selection +- [ ] Guest vs authenticated flow +- [ ] Fayda verification (Ethiopian) +- [ ] Passport form (non-Ethiopian) +- [ ] Seat selection and hold +- [ ] Hold countdown timer +- [ ] PNR generation +- [ ] Payment method selection +- [ ] Confirmation with QR codes +- [ ] Mobile responsiveness +- [ ] Error states +- [ ] Back navigation + +## Environment Variables + +```bash +NEXT_PUBLIC_API_URL=http://localhost:3002 # Passenger API URL +``` + +## Known Limitations + +1. Payment processing is simulated (no real provider integration yet) +2. Ticket PDF download not implemented (placeholder button) +3. Share booking feature not implemented (placeholder button) +4. Seat hold release on expiry requires manual refresh +5. No internationalization (English only) + +## Future Enhancements + +- [ ] Real payment provider integration (Stripe, Telebirr, etc.) +- [ ] PDF ticket generation and download +- [ ] Email/SMS sharing functionality +- [ ] Real-time seat availability updates (WebSocket) +- [ ] Booking history page +- [ ] User profile management +- [ ] Saved passenger profiles +- [ ] Multi-language support (Amharic, Arabic) +- [ ] Accessibility improvements +- [ ] Analytics tracking + +## Contributing + +Follow the EDR Platform standards in `CLAUDE.md`: +- TypeScript strict mode +- Conventional commits +- ESLint + Prettier +- pnpm only (no npm/yarn) + +## License + +Proprietary - Ethio-Djibouti Railway Platform + +## Support + +For issues or questions, contact the EDR Platform team. diff --git a/apps/edr-passenger-web/portal/next.config.js b/apps/edr-passenger-web/portal/next.config.js new file mode 100644 index 000000000..c0d91a2a0 --- /dev/null +++ b/apps/edr-passenger-web/portal/next.config.js @@ -0,0 +1,11 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + output: 'export', + transpilePackages: ['@edr/types', '@edr/ui-common'], + images: { + unoptimized: true, + }, +}; + +export default nextConfig; diff --git a/apps/edr-passenger-web/portal/package.json b/apps/edr-passenger-web/portal/package.json index c75b02aea..7cda9343a 100644 --- a/apps/edr-passenger-web/portal/package.json +++ b/apps/edr-passenger-web/portal/package.json @@ -4,36 +4,38 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 5174", - "build": "tsc -b && vite build", - "preview": "vite preview --port 5174", - "lint": "eslint src", - "test": "vitest run", + "dev": "next dev -p 5174", + "build": "next build", + "start": "next start -p 5174", + "lint": "next lint", "type-check": "tsc --noEmit" }, "dependencies": { "@edr/types": "workspace:*", "@edr/ui-common": "workspace:*", "@tanstack/react-query": "^5.59.0", + "@hookform/resolvers": "^3.3.4", "axios": "^1.7.7", "clsx": "^2.1.1", + "date-fns": "^3.0.0", + "lucide-react": "^0.446.0", + "next": "^14.2.0", + "qrcode.react": "^3.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.27.0", + "react-hook-form": "^7.51.0", + "zod": "^3.22.4", "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" } } diff --git a/apps/edr-passenger-web/portal/postcss.config.js b/apps/edr-passenger-web/portal/postcss.config.js new file mode 100644 index 000000000..2aa7205d4 --- /dev/null +++ b/apps/edr-passenger-web/portal/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/edr-passenger-web/portal/public/README.md b/apps/edr-passenger-web/portal/public/README.md new file mode 100644 index 000000000..7ea9c3ab4 --- /dev/null +++ b/apps/edr-passenger-web/portal/public/README.md @@ -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. diff --git a/apps/edr-passenger-web/portal/public/banner.jpg b/apps/edr-passenger-web/portal/public/banner.jpg new file mode 100644 index 000000000..09c6add92 Binary files /dev/null and b/apps/edr-passenger-web/portal/public/banner.jpg differ diff --git a/apps/edr-passenger-web/portal/src/App.tsx b/apps/edr-passenger-web/portal/src/App.tsx deleted file mode 100644 index 4e890f1c3..000000000 --- a/apps/edr-passenger-web/portal/src/App.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { - useNavigate, - useLocation, - Routes, - Route, - Navigate, -} from "react-router-dom"; -import { DashboardLayout, type SidebarItem } from "@edr/ui-common"; - -import TicketsPage from "./pages/tickets/TicketsPage"; -import TicketDetailPage from "./pages/tickets/TicketDetailPage"; -import BookTicketPage from "./pages/tickets/BookTicketPage"; -import SchedulesPage from "./pages/schedules/SchedulesPage"; -import ScheduleDetailPage from "./pages/schedules/ScheduleDetailPage"; -import StationsPage from "./pages/stations/StationsPage"; -import PassengersPage from "./pages/passengers/PassengersPage"; -import DashboardPage from "./pages/dashboard/DashboardPage"; - -const sidebarItems: SidebarItem[] = [ - { label: "Dashboard", href: "/" }, - { label: "Tickets", href: "/tickets" }, - { label: "Schedules", href: "/schedules" }, - { label: "Stations", href: "/stations" }, - { label: "Passengers", href: "/passengers" }, -]; - -const App = () => { - const navigate = useNavigate(); - const location = useLocation(); - - return ( - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - ); -}; - -export default App; diff --git a/apps/edr-passenger-web/portal/src/app/about/page.tsx b/apps/edr-passenger-web/portal/src/app/about/page.tsx new file mode 100644 index 000000000..00430aadc --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/about/page.tsx @@ -0,0 +1,411 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getTranslation, Language, useLanguage } from '@/lib/i18n'; +import Link from 'next/link'; +import { Target, Globe, Leaf, Users } from 'lucide-react'; + +const styles = ` + .about-hero { + padding: 60px 20px; + background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent); + text-align: center; + color: #111827; + } + + .dark .about-hero { + color: #f3f4f6; + } + + .about-hero h1 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 16px; + color: #111827; + } + + .dark .about-hero h1 { + color: #f3f4f6; + } + + .about-hero p { + font-size: 1.125rem; + color: #6b7280; + } + + .dark .about-hero p { + color: #9ca3af; + } + + .values-grid { + max-width: 80rem; + margin: 0 auto; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + padding: 60px 20px; + background-color: white; + } + + .dark .values-grid { + background-color: #111827; + } + + .value-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 18px; + padding: 24px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + } + + .dark .value-card { + background: #1f2937; + border-color: #374151; + } + + .value-card:hover { + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + } + + .value-icon { + width: 48px; + height: 48px; + background: rgb(20, 113, 76); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; + } + + .value-card h3 { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 12px; + color: #111827; + } + + .dark .value-card h3 { + color: #f3f4f6; + } + + .value-card p { + font-size: 0.875rem; + color: #6b7280; + } + + .dark .value-card p { + color: #9ca3af; + } + + .stats-section { + padding: 60px 20px; + background-color: #f9fafb; + } + + .dark .stats-section { + background-color: #0f1117; + } + + .stats-container { + max-width: 80rem; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 32px; + text-align: center; + } + + .stat { + padding: 20px; + } + + .stat-number { + font-size: 2rem; + font-weight: 700; + color: rgb(20, 113, 76); + margin-bottom: 8px; + } + + .stat-label { + font-size: 0.875rem; + color: #6b7280; + } + + .dark .stat-label { + color: #9ca3af; + } + + .timeline-section { + padding: 60px 20px; + background: linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%); + } + + .dark .timeline-section { + background: linear-gradient(135deg, #111827 0%, #0f1117 100%); + } + + .timeline-title { + text-align: center; + font-size: 2rem; + font-weight: 700; + margin-bottom: 48px; + color: #111827; + } + + .dark .timeline-title { + color: #f3f4f6; + } + + .timeline { + max-width: 48rem; + margin: 0 auto; + position: relative; + } + + .timeline::before { + content: ''; + position: absolute; + left: 8px; + top: 0; + bottom: 0; + width: 2px; + background: linear-gradient(180deg, rgb(20, 113, 76), rgb(20, 113, 76) 50%, transparent); + } + + .timeline-item { + display: flex; + margin-bottom: 40px; + position: relative; + padding-left: 56px; + animation: slideInLeft 0.6s ease-out forwards; + opacity: 0; + } + + .timeline-item:nth-child(1) { animation-delay: 0.1s; } + .timeline-item:nth-child(2) { animation-delay: 0.2s; } + .timeline-item:nth-child(3) { animation-delay: 0.3s; } + .timeline-item:nth-child(4) { animation-delay: 0.4s; } + .timeline-item:nth-child(5) { animation-delay: 0.5s; } + + @keyframes slideInLeft { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } + } + + .timeline-dot { + position: absolute; + left: -4px; + top: 8px; + width: 24px; + height: 24px; + background: white; + border-radius: 50%; + border: 3px solid rgb(20, 113, 76); + box-shadow: 0 0 0 2px rgb(20, 113, 76), 0 4px 12px rgba(20, 113, 76, 0.3); + transition: all 0.3s ease; + } + + .timeline-item:hover .timeline-dot { + box-shadow: 0 0 0 2px rgb(20, 113, 76), 0 8px 24px rgba(20, 113, 76, 0.5); + transform: scale(1.15); + } + + .dark .timeline-dot { + background: #1f2937; + } + + .timeline-content { + background: white; + border-radius: 12px; + padding: 20px 24px; + border: 2px solid transparent; + border-left: 4px solid rgb(20, 113, 76); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease; + flex: 1; + } + + .timeline-item:hover .timeline-content { + border-color: rgb(20, 113, 76); + box-shadow: 0 8px 24px rgba(20, 113, 76, 0.15); + transform: translateY(-4px); + } + + .dark .timeline-content { + background: #1f2937; + border-left-color: rgb(20, 113, 76); + } + + .timeline-year { + font-weight: 700; + color: rgb(20, 113, 76); + font-size: 1.125rem; + display: flex; + align-items: center; + gap: 8px; + } + + .timeline-year::before { + content: '๐Ÿ“…'; + } + + .timeline-event { + color: #6b7280; + margin-top: 8px; + font-size: 0.95rem; + font-weight: 500; + } + + .dark .timeline-event { + color: #d1d5db; + } + + .cta-blue { + background-color: rgb(20, 113, 76); + color: white; + padding: 60px 20px; + text-align: center; + } + + .cta-blue h2 { + font-size: 2rem; + font-weight: 700; + margin-bottom: 16px; + } + + .cta-blue p { + font-size: 1.125rem; + margin-bottom: 32px; + max-width: 42rem; + margin-left: auto; + margin-right: auto; + } + + .button-white { + display: inline-block; + padding: 16px 32px; + background-color: white; + color: rgb(20, 113, 76); + font-weight: 700; + border-radius: 12px; + text-decoration: none; + transition: all 0.2s; + } + + .button-white:hover { + transform: scale(1.05); + } + + @media (max-width: 768px) { + .values-grid { + grid-template-columns: 1fr; + } + + .about-hero h1 { + font-size: 1.875rem; + } + } +`; + +export default function About() { + const [lang, setLang] = useState('en'); + const { getLang } = useLanguage(); + const t = (key: string) => getTranslation(lang, key); + + useEffect(() => { + setLang(getLang()); + const handleLanguageChange = (e: any) => setLang(e.detail); + window.addEventListener('languageChange', handleLanguageChange); + return () => window.removeEventListener('languageChange', handleLanguageChange); + }, [getLang]); + + const values = [ + { icon: Target, title: t('about.mission'), desc: t('about.missionText') }, + { icon: Globe, title: t('about.network'), desc: t('about.networkText') }, + { icon: Users, title: t('about.comfort'), desc: t('about.comfortText') }, + { icon: Leaf, title: t('about.eco'), desc: t('about.ecoText') }, + ]; + + return ( + <> + +
+
+

{t('about.title')}

+

{t('about.subtitle')}

+
+ +
+ {values.map((value, idx) => { + const Icon = value.icon; + return ( +
+
+ +
+

{value.title}

+

{value.desc}

+
+ ); + })} +
+ +
+
+
+
21
+
Railway Stations
+
+
+
360+
+
Comfortable Seats
+
+
+
3
+
Seat Classes
+
+
+
24/7
+
Customer Support
+
+
+
+ +
+

Our Journey

+
+ {[ + { year: '2020', event: 'EDR Platform Launched' }, + { year: '2021', event: 'Reached 10,000+ Passengers' }, + { year: '2022', event: 'Introduced Multi-Currency Support' }, + { year: '2023', event: 'Launched Loyalty Program' }, + { year: '2024', event: 'Age-Based Pricing & Verifayda Integration' }, + ].map((item, idx) => ( +
+
+
+
{item.year}
+
{item.event}
+
+
+ ))} +
+
+ +
+

Join Our Community

+

Be part of the modern railway revolution in East Africa.

+ Book Your First Journey +
+
+ + ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx new file mode 100644 index 000000000..c1787010c --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/lib/auth-store'; +import { LogIn, UserPlus, Shield, Clock } from 'lucide-react'; + +export default function AuthCheckPage() { + const router = useRouter(); + const { isAuthenticated, initialize } = useAuthStore(); + + useEffect(() => { + initialize(); + }, [initialize]); + + useEffect(() => { + if (isAuthenticated) { + router.push('/booking/passengers'); + } + }, [isAuthenticated, router]); + + const handleSignIn = () => { + router.push('/login?redirect=/booking/passengers'); + }; + + const handleGuest = () => { + router.push('/booking/passengers'); + }; + + return ( +
+
+
+ {/* Header */} +
+

Continue your booking

+

+ Sign in to access saved profiles or continue as a guest +

+
+ + {/* Options Grid */} +
+ {/* Sign In Option */} +
+
+
+ +
+

Sign in

+

+ Access your saved passenger profiles and booking history for faster checkout +

+ + {/* Benefits */} +
+
+
+ โœ“ +
+ Saved passenger details +
+
+
+ โœ“ +
+ View booking history +
+
+
+ โœ“ +
+ Faster future bookings +
+
+ + +
+
+ + {/* Guest Option */} +
+
+
+ +
+

Continue as guest

+

+ Book without an account. You can create one after completing your booking +

+ + {/* Benefits */} +
+
+
+ +
+ Quick checkout process +
+
+
+ +
+ No account required +
+
+
+ +
+ Create account later (optional) +
+
+ + +
+
+
+ + {/* Back Link */} +
+ +
+
+
+
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx new file mode 100644 index 000000000..1a36ac04b --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -0,0 +1,305 @@ +'use client'; + +export const dynamic = 'force-dynamic'; + +import { useRouter } from 'next/navigation'; +import { useBookingStore } from '@/lib/booking-store'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api-client'; +import { useEffect, useState, useRef } from 'react'; +import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train } from 'lucide-react'; +import { QRCodeSVG } from 'qrcode.react'; +import { format } from 'date-fns'; + +type BookingWithTicket = { + id: string; + pnr?: string | null; + status?: string; + totalMinor?: number; + ticket?: { + barcodePayload?: string; + qrPayload?: string; + }; +}; + +export default function ConfirmationPage() { + const router = useRouter(); + const { bookingId, pnr, selectedSchedule, passengers, clearBooking } = useBookingStore(); + const [copied, setCopied] = useState(false); + const confirmAttempted = useRef(false); + + const confirmMutation = useMutation({ + mutationFn: () => apiClient.patch(`/bookings/${bookingId}/confirm`, { status: 'SUCCEEDED' }), + }); + + const { data: _booking } = useQuery({ + queryKey: ['booking', bookingId], + queryFn: async (): Promise => { + try { + return await apiClient.get(`/bookings/${bookingId}`); + } catch (error) { + console.log('Booking API not available, using local data'); + return { + id: bookingId || '', + pnr: pnr || undefined, + status: 'CONFIRMED', + totalMinor: passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0), + }; + } + }, + enabled: !!bookingId, + }); + + useEffect(() => { + if (bookingId && !confirmAttempted.current) { + confirmAttempted.current = true; + confirmMutation.mutate(); + + apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => { + console.error('Failed to generate ticket:', err); + }); + } + }, [bookingId, confirmMutation]); + + const copyPNR = () => { + if (pnr) { + navigator.clipboard.writeText(pnr); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const handleDownloadTickets = () => { + alert('Ticket download will be available soon. Your tickets are displayed below.'); + }; + + const handlePrintTickets = () => { + window.print(); + }; + + const handleEmailTickets = () => { + alert('Tickets have been sent to your registered email address.'); + }; + + const handleNewBooking = () => { + clearBooking(); + router.push('/booking/search'); + }; + + useEffect(() => { + if (!bookingId || !pnr) { + router.push('/booking/search'); + } + }, [bookingId, pnr, router]); + + if (!bookingId || !pnr) return null; + + return ( +
+
+
+ {/* Success Header */} +
+
+
+ +
+
+

Booking confirmed!

+

Your train tickets are ready

+
+ + {/* PNR Card */} +
+
+

Booking reference (PNR)

+
+ {pnr} + +
+

Save this reference number for future use

+
+
+ + {/* Trip Summary */} +
+
+
+ +
+

Trip details

+
+
+
+
+

Train number

+

{selectedSchedule?.trainNumber}

+
+
+

Route

+

{selectedSchedule?.origin} โ†’ {selectedSchedule?.destination}

+
+ {selectedSchedule?.selectedSeatClassName && ( +
+

Class

+

{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}

+
+ )} +
+
+
+

Departure

+

+ {selectedSchedule?.departureTime && format(new Date(selectedSchedule.departureTime), 'PPp')} +

+
+
+

Arrival

+

+ {selectedSchedule?.arrivalTime && format(new Date(selectedSchedule.arrivalTime), 'PPp')} +

+
+
+

Duration

+

{selectedSchedule?.duration}

+
+
+
+
+ + {/* Tickets */} +
+

Your tickets

+
+ {passengers.map((passenger, index) => { + const backendTicket = _booking?.ticket || null; + const ticketNumber = backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`; + const qrData = backendTicket?.qrPayload || JSON.stringify({ + pnr, + ticketNumber, + passengerName: passenger.name, + trainNumber: selectedSchedule?.trainNumber, + date: selectedSchedule?.departureTime, + }); + + return ( +
+
+ {/* Ticket Info */} +
+
+
+

{passenger.name}

+

Passenger {index + 1}

+
+ CONFIRMED +
+ +
+
+

Ticket Number

+

{ticketNumber}

+
+
+

Date of Birth

+

{format(new Date(passenger.dateOfBirth), 'PP')}

+
+
+

Nationality

+

{passenger.nationality}

+
+
+

Seat

+

{passenger.seatNumber || 'Will be assigned'}

+
+
+ +
+

+ ๐Ÿ“ฑ Show this QR code at the gate for boarding +

+
+
+ + {/* QR Code */} +
+ +

Scan at gate

+
+
+
+ ); + })} +
+
+ + {/* Action Buttons */} +
+ + + + +
+ + {/* New Booking Button */} + + + {/* Info Notices */} +
+
+

+ ๐Ÿ“ง A confirmation email with your tickets has been sent to your registered email address. +

+
+
+

+ โœ… Please arrive at the station at least 30 minutes before departure. +

+
+
+
+
+
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/layout.tsx b/apps/edr-passenger-web/portal/src/app/booking/layout.tsx new file mode 100644 index 000000000..276a3af51 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/layout.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { usePathname } from 'next/navigation'; +import { ProgressIndicator } from '@/components/ProgressIndicator'; + +export default function BookingLayout({ + children, +}: { + children: React.ReactNode; +}) { + const pathname = usePathname(); + + const stepMap: Record = { + '/booking/search': 'search', + '/booking/results': 'results', + '/booking/auth-check': 'passengers', + '/booking/passengers': 'passengers', + '/booking/seats': 'seats', + '/booking/review': 'review', + '/booking/payment': 'payment', + '/booking/confirmation': 'confirmation', + }; + + const currentStep = stepMap[pathname] || 'search'; + const showProgress = pathname !== '/booking/search' && pathname !== '/booking/confirmation'; + + return ( +
+ {showProgress && ( +
+
+ +
+
+ )} + {children} +
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx new file mode 100644 index 000000000..79649c4b0 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -0,0 +1,667 @@ +'use client'; + +import { useForm, useFieldArray } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useRouter } from 'next/navigation'; +import { useBookingStore } from '@/lib/booking-store'; +import { useAuthStore } from '@/lib/auth-store'; +import { apiClient } from '@/lib/api-client'; +import { useState, useEffect } from 'react'; +import { CheckCircle, ExternalLink, Loader2 } from 'lucide-react'; + +const passengerSchema = z.object({ + name: z.string().min(2, 'Name is required'), + dateOfBirth: z.string().min(1, 'Date of birth is required'), + gender: z.enum(['Male', 'Female']).optional(), + nationality: z.string().min(1, 'Nationality is required'), + phone: z.string().optional(), + email: z.string().email('Invalid email').optional().or(z.literal('')), + nationalId: z.string().optional(), + passportNumber: z.string().optional(), + passportCountry: z.string().optional(), + passportIssueDate: z.string().optional(), + passportExpiryDate: z.string().optional(), + passportIssuingAuthority: z.string().optional(), + faydaVerified: z.boolean().optional(), + faydaSub: z.string().optional(), + formExpanded: z.boolean().optional(), +}).refine((data) => { + if (data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian') { + return data.passportNumber && data.passportNumber.length > 0 && + data.passportCountry && data.passportCountry.length > 0; + } + return true; +}, { + message: 'Passport number and country are required for non-Ethiopian passengers', + path: ['passportNumber'], +}); + +const formSchema = z.object({ + passengers: z.array(passengerSchema), + createAccount: z.boolean(), +}); + +type FormData = z.infer; + +export default function PassengersPage() { + const router = useRouter(); + const { searchCriteria, setPassengers, setCreateAccount, clearBooking } = useBookingStore(); + const { user, isAuthenticated, updateUser } = useAuthStore(); + const [faydaEnabled, setFaydaEnabled] = useState(true); + const [verificationStatus, setVerificationStatus] = useState>({}); + const [saving, setSaving] = useState(false); + const [formInitialized, setFormInitialized] = useState(false); + const [nationalityMismatch, setNationalityMismatch] = useState(false); + + const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0); + + const { register, control, handleSubmit, setValue, watch, formState: { errors } } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + passengers: Array.from({ length: totalPassengers }, () => ({ + name: '', + dateOfBirth: '', + gender: undefined, + nationality: searchCriteria?.nationality || 'ETHIOPIAN', + phone: '', + email: '', + nationalId: '', + passportNumber: '', + passportCountry: '', + passportIssueDate: '', + passportExpiryDate: '', + passportIssuingAuthority: '', + faydaVerified: false, + formExpanded: false, + })), + createAccount: false, + }, + }); + + const { fields } = useFieldArray({ control, name: 'passengers' }); + const passengers = watch('passengers'); + + useEffect(() => { + const checkFaydaStatus = async () => { + try { + const response: any = await apiClient.get('/config/fayda-status'); + setFaydaEnabled(response?.enabled ?? true); + } catch { + setFaydaEnabled(true); + } + }; + checkFaydaStatus(); + }, []); + + useEffect(() => { + if (isAuthenticated && user?.faydaVerified) { + setVerificationStatus({ 0: 'success' }); + } + }, [isAuthenticated, user?.faydaVerified]); + + useEffect(() => { + const populateForm = async () => { + if (!isAuthenticated || !user?.id || !searchCriteria) { + console.log('Missing required data for population'); + setFormInitialized(true); + return; + } + + try { + // Fetch passenger profile from backend + const passengerData: any = await apiClient.get(`/passengers/me`); + console.log('Fetched passenger data:', passengerData); + + if (!passengerData) { + setFormInitialized(true); + return; + } + + const userNationality = (passengerData?.nationality || user.nationality || '').toUpperCase().trim(); + const searchNationality = (searchCriteria?.nationality || '').toUpperCase().trim(); + console.log('Nationalities:', { userNationality, searchNationality }); + + // Check for nationality mismatch + if (userNationality !== searchNationality) { + console.log('Nationality mismatch detected'); + setNationalityMismatch(true); + setFormInitialized(true); + return; + } + + // Only populate if nationalities match + console.log('Setting passenger 0 values'); + setValue('passengers.0.name', passengerData?.fullName || user.fullName || ''); + setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || ''); + if (passengerData?.gender || user.gender) setValue('passengers.0.gender', (passengerData?.gender || user.gender) as any); + setValue('passengers.0.nationality', passengerData?.nationality || user.nationality || 'ETHIOPIAN'); + if (passengerData?.phone || user.phone) setValue('passengers.0.phone', passengerData?.phone || user.phone || ''); + if (passengerData?.email || user.email) setValue('passengers.0.email', passengerData?.email || user.email || ''); + if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber); + if (passengerData?.passportCountry) setValue('passengers.0.passportCountry', passengerData.passportCountry); + if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate); + if (passengerData?.passportExpiryDate) setValue('passengers.0.passportExpiryDate', passengerData.passportExpiryDate); + if (passengerData?.passportIssuingAuthority) setValue('passengers.0.passportIssuingAuthority', passengerData.passportIssuingAuthority); + setValue('passengers.0.faydaVerified', passengerData?.faydaVerified || user.faydaVerified || false); + setValue('passengers.0.formExpanded', true); + + setFormInitialized(true); + } catch (error) { + console.error('Failed to fetch passenger data:', error); + setFormInitialized(true); + } + }; + + populateForm(); + }, [isAuthenticated, user, searchCriteria, setValue]); + + useEffect(() => { + if (nationalityMismatch && formInitialized) { + setTimeout(() => { + const element = document.getElementById('nationality-mismatch'); + element?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 100); + } + }, [nationalityMismatch, formInitialized]); + + const openFaydaVerification = async (index: number) => { + if (typeof window === 'undefined') return; + + try { + const response: any = await apiClient.post('/fayda/verification/start', { + purpose: 'PURCHASE', + platform: 'WEB', + saveToAccount: index === 0 && isAuthenticated, + }); + + const authorizationUrl = response.authorizationUrl; + const width = 600; + const height = 700; + const left = (window.screen.width - width) / 2; + const top = (window.screen.height - height) / 2; + + const popup = window.open( + authorizationUrl, + 'FaydaVerification', + `width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes` + ); + + const checkPopup = setInterval(async () => { + if (popup?.closed) { + clearInterval(checkPopup); + try { + const statusResponse: any = await apiClient.get('/fayda/verification/status'); + if (statusResponse.verified) { + setValue(`passengers.${index}.name`, statusResponse.fullName || ''); + setValue(`passengers.${index}.faydaVerified`, true); + setValue(`passengers.${index}.formExpanded`, true); + setVerificationStatus({ ...verificationStatus, [index]: 'success' }); + + if (index === 0 && isAuthenticated) { + updateUser({ + fullName: statusResponse.fullName, + faydaVerified: true, + faydaVerifiedAt: statusResponse.verifiedAt, + }); + } + } + } catch (error) { + console.error('Failed to get verification status:', error); + } + } + }, 1000); + } catch (error) { + console.error('Failed to start Fayda verification:', error); + alert('Failed to start verification. Please try again.'); + } + }; + + const toggleForm = (index: number) => { + setValue(`passengers.${index}.formExpanded`, !passengers[index].formExpanded); + }; + + const onSubmit = async (data: FormData) => { + setSaving(true); + try { + let passengerId = ''; + + // For authenticated users, fetch the passenger profile to get the passengerId + if (isAuthenticated && user?.id) { + try { + const passengerProfile: any = await apiClient.get('/passengers/me'); + passengerId = passengerProfile?.id || ''; + console.log('Fetched passengerId:', passengerId); + } catch (error) { + console.error('Failed to fetch passenger profile:', error); + } + } + + const passengerDetails = data.passengers.map((p, i) => ({ + name: p.name, + dateOfBirth: p.dateOfBirth, + gender: p.gender, + nationality: p.nationality, + nationalId: p.nationalId, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + phone: p.phone, + email: p.email, + isPrimaryPassenger: i === 0, + passengerId: i === 0 && passengerId ? passengerId : undefined, + })); + + const deviceId = typeof window !== 'undefined' + ? (localStorage.getItem('deviceId') || crypto.randomUUID()) + : crypto.randomUUID(); + + await apiClient.post('/passengers/save-details', { + passengers: passengerDetails, + userId: user?.id, + deviceId, + }); + + setPassengers(passengerDetails); + setCreateAccount(data.createAccount); + + // Save passengerId to booking store for later use + if (isAuthenticated && passengerId) { + const { setPassengerId } = useBookingStore.getState(); + setPassengerId(passengerId); + console.log('Saved passengerId to booking store:', passengerId); + } + + router.push('/booking/seats'); + } catch (error) { + console.error('Failed to save passenger details:', error); + alert('Failed to save passenger details. Please try again.'); + } finally { + setSaving(false); + } + }; + + useEffect(() => { + if (!searchCriteria) { + router.push('/booking/search'); + } + }, [searchCriteria, router]); + + if (!searchCriteria) return null; + + if (nationalityMismatch && formInitialized) { + const searchLabel: Record = { ETHIOPIAN: 'Ethiopian', DJIBOUTIAN: 'Djiboutian', OTHER: 'Other' }; + return ( +
+
+
+
+
+
โš ๏ธ
+
+

Nationality Mismatch

+

+ You searched for an {searchLabel[searchCriteria.nationality] ?? searchCriteria.nationality} passenger, + but your account is registered as {user?.nationality}. +

+

+ You cannot proceed with this booking. Please restart and select the correct nationality on the search page. +

+ +
+
+
+
+
+
+ ); + } + + if (!formInitialized) { + return ( +
+
+
+ +

Loading passenger details...

+
+
+
+ ); + } + + return ( +
+
+
+

Passenger details

+ +
+ {fields.map((field, index) => { + const isEthiopian = passengers[index]?.nationality === 'ETHIOPIAN'; + const isFormExpanded = passengers[index]?.formExpanded; + const status = verificationStatus[index]; + const isPrimaryPassenger = index === 0; + const isLoggedInAndVerified = isPrimaryPassenger && isAuthenticated && user?.faydaVerified; + const isLoggedInNotVerified = isPrimaryPassenger && isAuthenticated && !user?.faydaVerified; + const showVerifyButton = isEthiopian && faydaEnabled && !isFormExpanded && !isLoggedInAndVerified; + const showManualEntryLink = isEthiopian && !faydaEnabled && !isFormExpanded; + + return ( +
+

+ Passenger {index + 1} {index === 0 && '(Primary)'} + {index < (searchCriteria.adultCount || 1) ? ' - Adult' : ' - Child'} + + ({isEthiopian ? 'Ethiopian' : searchCriteria.nationality === 'DJIBOUTIAN' ? 'Djiboutian' : 'Other'}) + +

+ + {showVerifyButton ? ( +
+ {isLoggedInNotVerified && ( +
+

+ Please verify your identity with Fayda to complete your profile +

+
+ )} + + +
+ ) : showManualEntryLink ? ( +
+

+ Fayda verification is currently unavailable +

+ +
+ ) : ( +
+ {isEthiopian ? ( + <> + {status === 'success' && ( +
+

+ Verified with Fayda +

+
+ )} + +
+
+ + setValue(`passengers.${index}.name`, e.target.value)} + /> + {errors.passengers?.[index]?.name && ( +

{errors.passengers[index]?.name?.message}

+ )} +
+ +
+ + setValue(`passengers.${index}.dateOfBirth`, e.target.value)} + /> + {errors.passengers?.[index]?.dateOfBirth && ( +

{errors.passengers[index]?.dateOfBirth?.message}

+ )} +
+ +
+ + +
+ +
+ + +
+ +
+ + setValue(`passengers.${index}.phone`, e.target.value)} + /> +
+ +
+ + setValue(`passengers.${index}.email`, e.target.value)} + /> + {errors.passengers?.[index]?.email && ( +

{errors.passengers[index]?.email?.message}

+ )} +
+
+ + ) : ( + <> +
+
+ + setValue(`passengers.${index}.name`, e.target.value)} + /> + {errors.passengers?.[index]?.name && ( +

{errors.passengers[index]?.name?.message}

+ )} +
+ +
+ + setValue(`passengers.${index}.dateOfBirth`, e.target.value)} + /> + {errors.passengers?.[index]?.dateOfBirth && ( +

{errors.passengers[index]?.dateOfBirth?.message}

+ )} +
+ +
+ + +
+ +
+ + +
+ +
+ + setValue(`passengers.${index}.phone`, e.target.value)} + /> +
+ +
+ + setValue(`passengers.${index}.email`, e.target.value)} + /> + {errors.passengers?.[index]?.email && ( +

{errors.passengers[index]?.email?.message}

+ )} +
+
+ +
+
+
+ + setValue(`passengers.${index}.passportNumber`, e.target.value)} + /> + {errors.passengers?.[index]?.passportNumber && ( +

{errors.passengers[index]?.passportNumber?.message}

+ )} +
+ +
+ + setValue(`passengers.${index}.passportCountry`, e.target.value)} + /> + {errors.passengers?.[index]?.passportCountry && ( +

{errors.passengers[index]?.passportCountry?.message}

+ )} +
+ +
+ + setValue(`passengers.${index}.passportIssueDate`, e.target.value)} + /> +
+ +
+ + setValue(`passengers.${index}.passportExpiryDate`, e.target.value)} + /> +
+
+
+ + )} +
+ )} +
+ ); + })} + + {!isAuthenticated && ( +
+ +
+ )} + +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx new file mode 100644 index 000000000..34f1fada1 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -0,0 +1,311 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useBookingStore } from '@/lib/booking-store'; +import { usePaymentStore } from '@/lib/payment-store'; +import { useMutation } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api-client'; +import { useState, useEffect } from 'react'; +import { CreditCard, Smartphone, Wallet, Loader2, CheckCircle } from 'lucide-react'; + +// Mock payment methods with Ethiopian providers +const paymentMethods = [ + { + id: 'TELEBIRR', + name: 'Telebirr', + icon: Smartphone, + description: 'Pay with Telebirr mobile money', + color: 'bg-orange-50 border-orange-200 hover:border-orange-400' + }, + { + id: 'CBE_BIRR', + name: 'CBE Birr', + icon: Smartphone, + description: 'Pay with CBE Birr', + color: 'bg-blue-50 border-blue-200 hover:border-blue-400' + }, + { + id: 'EBIRR', + name: 'eBirr', + icon: Smartphone, + description: 'Pay with eBirr', + color: 'bg-green-50 border-green-200 hover:border-green-400' + }, + { + id: 'CARD', + name: 'Card Payment', + icon: CreditCard, + description: 'Pay with credit/debit card', + color: 'bg-purple-50 border-purple-200 hover:border-purple-400' + }, + { + id: 'WALLET', + name: 'Wallet', + icon: Wallet, + description: 'Pay from your wallet balance', + color: 'bg-indigo-50 border-indigo-200 hover:border-indigo-400' + }, +]; + +export default function PaymentPage() { + const router = useRouter(); + const { bookingId, pnr, selectedSchedule, passengers } = useBookingStore(); + const { selectedCurrency, setPaymentIntent, updateStatus } = usePaymentStore(); + const [selectedMethod, setSelectedMethod] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + + // Calculate total amount + const baseFare = passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0); + const totalAmount = baseFare; + + const paymentMutation = useMutation({ + mutationFn: async (data: any) => { + // Try to call the real API, fallback to mock if it fails + try { + return await apiClient.post('/payments/intent', data); + } catch (error) { + console.log('Payment API not available, using mock payment'); + // Mock payment response + return { + paymentIntentId: `mock-payment-${Date.now()}`, + status: 'PENDING', + amountMinor: data.amountMinor, + currency: data.currency, + method: data.method, + }; + } + }, + onSuccess: async (data: any) => { + setPaymentIntent(data.paymentIntentId); + updateStatus('PROCESSING'); + + // Simulate payment processing + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Generate tickets after successful payment + try { + await generateTickets(); + updateStatus('SUCCEEDED'); + router.push('/booking/confirmation'); + } catch (error) { + console.error('Ticket generation failed:', error); + // Still proceed to confirmation even if ticket generation fails + updateStatus('SUCCEEDED'); + router.push('/booking/confirmation'); + } + }, + onError: (error: any) => { + console.error('Payment failed:', error); + updateStatus('FAILED'); + const errorMessage = error?.response?.data?.message || error?.message || 'Payment failed. Please try again.'; + alert(errorMessage); + setIsProcessing(false); + }, + }); + + const generateTickets = async () => { + // Try to generate tickets via API, fallback to mock + try { + await apiClient.post('/tickets/generate', { + bookingId, + pnr, + }); + } catch (error) { + console.log('Ticket API not available, tickets will be generated on confirmation page'); + // Mock ticket generation - tickets will be displayed on confirmation page + } + }; + + const handlePayment = async () => { + if (!selectedMethod || !bookingId) { + alert('Please select a payment method'); + return; + } + + setIsProcessing(true); + + paymentMutation.mutate({ + bookingId, + method: selectedMethod, + currency: selectedCurrency, + amountMinor: totalAmount, + }); + }; + + // Redirect if no booking data (but not during navigation) + useEffect(() => { + // Add a small delay to allow state to be set from previous page + const timer = setTimeout(() => { + if (!bookingId || !pnr) { + console.log('Payment page: Missing booking data, redirecting to search'); + console.log('bookingId:', bookingId, 'pnr:', pnr); + router.push('/booking/search'); + } + }, 500); + + return () => clearTimeout(timer); + }, [bookingId, pnr, router]); + + if (!bookingId && !pnr) { + return ( +
+
+ +

Loading payment details...

+
+
+ ); + } + + return ( +
+
+
+

Complete payment

+

+ Booking reference: {pnr} +

+ + {/* Payment Processing Overlay */} + {isProcessing && ( +
+
+ {paymentMutation.isSuccess ? ( + <> + +

Payment successful!

+

Generating your tickets...

+ + + ) : ( + <> + +

Processing payment

+

Please wait while we process your payment...

+ + )} +
+
+ )} + + {/* Order Summary */} +
+

Order summary

+
+
+ Route + {selectedSchedule?.origin} โ†’ {selectedSchedule?.destination} +
+
+ Train + {selectedSchedule?.trainNumber} +
+ {selectedSchedule?.selectedSeatClassName && ( +
+ Class + {selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')} +
+ )} +
+ Passengers + {passengers.length} passenger{passengers.length !== 1 ? 's' : ''} +
+
+
+ Total amount + + ETB {(totalAmount / 100).toFixed(2)} + +
+
+
+
+ + {/* Payment Methods */} +
+

Select payment method

+
+ {paymentMethods.map((method) => { + const Icon = method.icon; + const isSelected = selectedMethod === method.id; + return ( + + ); + })} +
+
+ + {/* Action Buttons */} +
+ + + +
+ + {/* Error Message */} + {paymentMutation.isError && ( +
+

+ โš ๏ธ Payment failed. Please try again or contact support if the problem persists. +

+
+ )} + + {/* Security Notice */} +
+

+ ๐Ÿ”’ Your payment is secure and encrypted. We do not store your payment information. +

+
+
+
+
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/layout.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/layout.tsx new file mode 100644 index 000000000..c5236d89c --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/results/layout.tsx @@ -0,0 +1,5 @@ +import { Suspense } from 'react'; + +export default function ResultsLayout({ children }: { children: React.ReactNode }) { + return

Loading...

}>{children}
; +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx new file mode 100644 index 000000000..c8161bb34 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -0,0 +1,358 @@ +'use client'; + +import { useSearchParams, useRouter } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api-client'; +import { useBookingStore } from '@/lib/booking-store'; +import { Schedule } from '@/types'; +import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Loader2, Check, ChevronDown, ChevronUp, MapPin } from 'lucide-react'; +import { format } from 'date-fns'; +import { useState } from 'react'; + +export default function ResultsPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule); + const [selectedClasses, setSelectedClasses] = useState>({}); + const [expandedSchedules, setExpandedSchedules] = useState>({}); + + const searchData = { + originStationId: searchParams.get('origin') || '', + destinationStationId: searchParams.get('destination') || '', + date: searchParams.get('date') || '', + adultCount: parseInt(searchParams.get('adults') || '1'), + childCount: parseInt(searchParams.get('children') || '0'), + nationality: searchParams.get('nationality') || 'ETHIOPIAN', + }; + + const buildSearchUrl = () => { + const params = new URLSearchParams({ + origin: searchData.originStationId, + destination: searchData.destinationStationId, + date: searchData.date, + adults: searchData.adultCount.toString(), + children: searchData.childCount.toString(), + nationality: searchData.nationality, + }); + return `/booking/search?${params}`; + }; + + const { data: results, isLoading, error } = useQuery({ + queryKey: ['search', searchData], + queryFn: async (): Promise => { + console.log('Searching with criteria:', searchData); + const response = await apiClient.post('/search', searchData) as Schedule[]; + console.log('Search results:', response); + console.log('Number of results:', response?.length || 0); + return response; + }, + enabled: !!searchData.originStationId && !!searchData.destinationStationId, + }); + + const toggleExpanded = (scheduleId: string) => { + setExpandedSchedules(prev => ({ + ...prev, + [scheduleId]: !prev[scheduleId] + })); + }; + + const handleSelectClass = (scheduleId: string, seatClass: string) => { + setSelectedClasses(prev => ({ + ...prev, + [scheduleId]: seatClass + })); + }; + + const handleSelect = (schedule: Schedule) => { + const scheduleId = schedule.scheduleId || schedule.id || ''; + const selectedClass = selectedClasses[scheduleId]; + + if (!selectedClass) { + alert('Please select a seat class before continuing'); + return; + } + + const selectedClassFare = schedule.faresByClass?.find( + (f: any) => f.seatClassName === selectedClass + ); + + if (!selectedClassFare) { + alert('Unable to find fare for selected class'); + return; + } + + const hours = Math.floor((schedule.durationMinutes || 0) / 60); + const minutes = (schedule.durationMinutes || 0) % 60; + const durationStr = `${hours}h ${minutes}m`; + + setSelectedSchedule({ + id: scheduleId, + trainNumber: schedule.trainNumber, + origin: schedule.origin?.name || 'Origin', + destination: schedule.destination?.name || 'Destination', + departureTime: schedule.departureAt || schedule.departureTime || '', + arrivalTime: schedule.arrivalAt || schedule.arrivalTime || '', + duration: durationStr, + baseFareAdult: selectedClassFare.baseFareMinor, + baseFareChild: selectedClassFare.baseFareMinor, + selectedSeatClass: selectedClass, + selectedSeatClassName: selectedClass, + }); + router.push('/booking/auth-check'); + }; + + if (isLoading) { + return ( +
+
+ +

Searching for trains...

+
+
+ ); + } + + if (error) { + return ( +
+
+
+ โš ๏ธ +
+

Search Error

+

Unable to load results. Please try again.

+ +
+
+ ); + } + + if (!results || results.length === 0) { + return ( +
+
+
+
+
+ +
+

No trains found

+

+ We couldn't find any trains matching your search criteria.
Try adjusting your dates or route. +

+ +
+
+
+
+ ); + } + + return ( +
+
+
+
+ +

Available trains

+
+
+ + {searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : 'Date not specified'} +
+
+ + {searchData.adultCount} adult(s), {searchData.childCount} child(ren) +
+
+
+ +
+ {results.map((schedule) => { + const scheduleId = schedule.scheduleId || schedule.id || ''; + const isExpanded = expandedSchedules[scheduleId]; + const selectedClass = selectedClasses[scheduleId]; + + const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0 + ? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0)) + : null; + + const hours = Math.floor((schedule.durationMinutes || 0) / 60); + const minutes = (schedule.durationMinutes || 0) % 60; + const durationStr = `${hours}h ${minutes}m`; + + const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null; + const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null; + const isNextDay = departureDate && arrivalDate && + departureDate.toDateString() !== arrivalDate.toDateString(); + + return ( +
+
+
+
+
+
+ +
+
+
{schedule.trainNumber}
+
{schedule.trainName || 'Express Service'}
+
+
+ +
+
+
+ {schedule.departureAt ? format(new Date(schedule.departureAt), 'HH:mm') : '--:--'} +
+
+ {schedule.departureAt ? format(new Date(schedule.departureAt), 'MMM d') : ''} +
+
{schedule.origin?.name || 'Origin'}
+
+ +
+
+ + {durationStr} +
+
+
+
+
+
+ {schedule.stops && schedule.stops.length > 0 && ( + <> + + {schedule.stops.length - 2} stops + + )} +
+
+ +
+
+ {schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'} +
+
+ {schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''} + {isNextDay && ( + (+1) + )} +
+
{schedule.destination?.name || 'Destination'}
+
+
+
+ +
+
+
Starting from
+
+ {lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'} +
+
per adult
+ +
+
+
+ + {isExpanded && ( +
+

Select Seat Class

+
+ {schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0 ? ( + schedule.faresByClass.map((fareClass: any) => { + const isSelected = selectedClass === fareClass.seatClassName; + const availableSeats = schedule.availabilityByClass?.[fareClass.seatClassName] || 0; + const isAvailable = availableSeats > 0; + + return ( + + ); + }) + ) : ( +
+ No seat classes available +
+ )} +
+ +
+ +
+
+ )} +
+
+ )})} +
+
+
+
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx new file mode 100644 index 000000000..6f9c2a265 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -0,0 +1,432 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useBookingStore } from '@/lib/booking-store'; +import { useAuthStore } from '@/lib/auth-store'; +import { useMutation } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api-client'; +import { format } from 'date-fns'; +import { useState, useEffect } from 'react'; + +// Helper function to decode JWT token and extract passengerId +function getPassengerIdFromToken(token: string): string | null { + try { + if (!token) { + console.warn('No token provided'); + return null; + } + + const parts = token.split('.'); + if (parts.length !== 3) { + console.warn('Invalid token format - expected 3 parts, got', parts.length); + return null; + } + + // Decode JWT payload with proper base64 padding + const payload = parts[1]; + const padded = payload + '='.repeat((4 - payload.length % 4) % 4); + + let decoded; + try { + decoded = JSON.parse(atob(padded)); + } catch (e) { + console.error('Failed to parse base64:', e); + return null; + } + + console.log('Decoded JWT payload keys:', Object.keys(decoded)); + console.log('passengerId from JWT:', decoded.passengerId); + + if (!decoded.passengerId) { + console.warn('No passengerId in JWT payload, available keys:', Object.keys(decoded)); + return null; + } + + return decoded.passengerId; + } catch (error) { + console.error('Error in getPassengerIdFromToken:', error); + return null; + } +} + +export default function ReviewPage() { + const router = useRouter(); + const { selectedSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId } = useBookingStore(); + const { user, isAuthenticated } = useAuthStore(); + const [timeLeft, setTimeLeft] = useState(''); + const [seatDetails, setSeatDetails] = useState>({}); + + useEffect(() => { + if (!seatHold?.expiresAt) return; + + const interval = setInterval(() => { + const now = new Date().getTime(); + const expiry = new Date(seatHold.expiresAt).getTime(); + const diff = expiry - now; + + if (diff <= 0) { + setTimeLeft('Expired'); + clearInterval(interval); + } else { + const minutes = Math.floor(diff / 60000); + const seconds = Math.floor((diff % 60000) / 1000); + setTimeLeft(`${minutes}:${seconds.toString().padStart(2, '0')}`); + } + }, 1000); + + return () => clearInterval(interval); + }, [seatHold]); + + useEffect(() => { + const fetchSeatDetails = async () => { + if (!selectedSchedule?.id) return; + + try { + const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`); + const coaches = seatMapData?.coaches || []; + const allSeats = coaches.flatMap((coach: any) => coach.seats || []); + + const details: Record = {}; + passengers.forEach(p => { + if (p.seatId) { + const seat = allSeats.find((s: any) => s.id === p.seatId); + if (seat) { + details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A'; + } + } + }); + setSeatDetails(details); + } catch (error) { + console.error('Failed to fetch seat details:', error); + } + }; + + fetchSeatDetails(); + }, [selectedSchedule?.id, passengers]); + + const createBookingMutation = useMutation({ + mutationFn: (data: any) => { + const endpoint = isAuthenticated ? '/bookings' : '/bookings/guest'; + return apiClient.post(endpoint, data); + }, + onSuccess: (data: any) => { + console.log('Booking created successfully:', data); + const bookingIdValue = data.bookingId || data.id; + const pnrValue = data.pnr || data.bookingReference || data.bookingRef; + + console.log('Setting booking ID:', bookingIdValue); + console.log('Setting PNR:', pnrValue); + console.log('Booking via endpoint:', isAuthenticated ? '/bookings' : '/bookings/guest'); + + setBookingId(bookingIdValue); + setPNR(pnrValue); + + const totalAmount = isAuthenticated ? (data.totalMinor || data.totalAmount || 0) : (data.totalMinor || data.totalAmount || 0); + + console.log('Total amount:', totalAmount); + + setTimeout(() => { + const currentState = useBookingStore.getState(); + console.log('Current booking store state:', currentState); + console.log('bookingId:', currentState.bookingId); + console.log('pnr:', currentState.pnr); + + if (totalAmount > 0) { + console.log('Redirecting to payment page'); + router.push('/booking/payment'); + } else { + console.log('Redirecting to confirmation page'); + router.push('/booking/confirmation'); + } + }, 100); + }, + onError: (error: any) => { + console.error('Booking creation failed:', error); + const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create booking. Please try again.'; + alert(errorMessage); + }, + }); + + const handleConfirm = async () => { + console.log('handleConfirm called'); + try { + const { searchCriteria } = useBookingStore.getState(); + + console.log('Search criteria:', searchCriteria); + console.log('Seat hold:', seatHold); + console.log('Selected schedule:', selectedSchedule); + console.log('Passengers:', passengers); + + if (!seatHold?.holdId) { + console.error('No seat hold found'); + alert('Please select seats before continuing.'); + router.push('/booking/seats'); + return; + } + + if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) { + console.error('Missing search criteria'); + alert('Missing search criteria. Please start over.'); + router.push('/booking/search'); + return; + } + + let seatClassId = 'default-seat-class-id'; + try { + const seatClasses: any = await apiClient.get('/seat-classes'); + console.log('Seat classes:', seatClasses); + if (seatClasses && seatClasses.length > 0) { + seatClassId = seatClasses[0].id; + } + } catch (err) { + console.error('Failed to fetch seat classes:', err); + } + + let bookingData: any; + if (isAuthenticated) { + // For authenticated users: get passengerId from multiple sources + const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null; + + if (!token) { + console.error('No token in localStorage'); + throw new Error('Authentication token not found. Please log in again.'); + } + + console.log('Token found, length:', token.length); + + let passengerId = getPassengerIdFromToken(token); + console.log('Extracted passenger ID from JWT token:', passengerId); + + // Fallback 1: Use passengerId from booking store + if (!passengerId && storedPassengerId) { + passengerId = storedPassengerId; + console.log('Fallback 1: Using passengerId from booking store:', passengerId); + } + + // Fallback 2: Use passengerId from localStorage + if (!passengerId && typeof window !== 'undefined') { + const localStoragePassengerId = localStorage.getItem('booking_passengerId'); + if (localStoragePassengerId) { + passengerId = localStoragePassengerId; + console.log('Fallback 2: Using passengerId from localStorage:', passengerId); + } + } + + // Fallback 3: Use passengerId from user object + if (!passengerId && user) { + passengerId = (user as any).passengerId; + console.log('Fallback 3: Using passengerId from user object:', passengerId); + } + + if (!passengerId) { + console.error('Failed to extract passengerId'); + console.error('User object:', user); + console.error('User object keys:', user ? Object.keys(user) : 'null'); + console.error('Stored passengerId from booking store:', storedPassengerId); + if (typeof window !== 'undefined') { + console.error('Stored passengerId from localStorage:', localStorage.getItem('booking_passengerId')); + } + throw new Error('Passenger ID not found in authentication token. Please log in again.'); + } + + bookingData = { + scheduleId: selectedSchedule?.id || '', + holdId: seatHold.holdId, + originStationId: searchCriteria.originStationId, + destinationStationId: searchCriteria.destinationStationId, + seatClassId: seatClassId, + displayCurrency: 'ETB', + passengerId: passengerId, + passengers: passengers.map((p) => { + const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; + return { + seatId: p.seatId || '', + passengerName: p.name, + dateOfBirth: p.dateOfBirth, + idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', + idDocumentNumber: isEthiopian ? (p.nationalId || '') : '', + passportNumber: !isEthiopian ? (p.passportNumber || '') : '', + passportCountry: !isEthiopian ? (p.passportCountry || '') : '', + nationality: p.nationality, + }; + }), + }; + } else { + // For guests: send full passenger details array + bookingData = { + scheduleId: selectedSchedule?.id || '', + holdId: seatHold.holdId, + originStationId: searchCriteria.originStationId, + destinationStationId: searchCriteria.destinationStationId, + seatClassId: seatClassId, + displayCurrency: 'ETB', + passengers: passengers.map(p => { + const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; + return { + seatId: p.seatId || '', + passengerName: p.name, + dateOfBirth: p.dateOfBirth, + idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', + idDocumentNumber: isEthiopian ? (p.nationalId || '') : '', + passportNumber: !isEthiopian ? (p.passportNumber || '') : '', + passportCountry: !isEthiopian ? (p.passportCountry || '') : '', + nationality: p.nationality, + phone: p.phone || '', + email: p.email || '', + }; + }), + createAccount: createAccount || false, + savePassengerDetails: true, + deviceId: typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || `device-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`) : undefined, + }; + } + + if (typeof window !== 'undefined' && !isAuthenticated && bookingData.deviceId && !localStorage.getItem('deviceId')) { + localStorage.setItem('deviceId', bookingData.deviceId); + } + + console.log('Creating booking with payload:', bookingData); + await createBookingMutation.mutateAsync(bookingData); + } catch (error) { + console.error('Error in handleConfirm:', error); + alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.'); + } + }; + + useEffect(() => { + if (!selectedSchedule || !passengers.length) { + if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) { + console.log('Redirecting to search - missing data'); + router.push('/booking/search'); + } + } + }, [selectedSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]); + + if (!selectedSchedule || !passengers.length) { + return null; + } + + console.log('Selected schedule:', selectedSchedule); + console.log('Base fare adult:', selectedSchedule.baseFareAdult); + console.log('Passengers:', passengers); + + const baseFare = passengers.reduce((sum, p, i) => { + const farePerPassenger = selectedSchedule.baseFareAdult || + (selectedSchedule as any).fareAdult || + (selectedSchedule as any).price || + 0; + + console.log(`Passenger ${i}: ${p.name}, fare = ${farePerPassenger}`); + + return sum + farePerPassenger; + }, 0); + + console.log('Calculated base fare:', baseFare); + + const total = baseFare; + + return ( +
+
+
+

Review your booking

+ + {seatHold && ( +
+

+ โฑ๏ธ Your seats will be released in: {timeLeft} +

+
+ )} + +
+
+

Trip details

+
+
+ Train + {selectedSchedule.trainNumber} +
+
+ Route + {selectedSchedule.origin} โ†’ {selectedSchedule.destination} +
+
+ Departure + + {selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'PPp') : 'N/A'} + +
+
+ Arrival + + {selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'PPp') : 'N/A'} + +
+
+ Duration + {selectedSchedule.duration} +
+
+
+ +
+

Passengers

+
+ {passengers.map((p, i) => ( +
+
+

{p.name}

+

+ {p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} โ€ข {p.nationality} +

+
+
+

Seat

+

{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}

+
+
+ ))} +
+
+ +
+

Fare breakdown

+
+
+ Base fare + ETB {(baseFare / 100).toFixed(2)} +
+
+ Total + ETB {(total / 100).toFixed(2)} +
+
+
+ +
+ + +
+ + {createBookingMutation.isError && ( +
+

+ โš ๏ธ {createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred while creating your booking. Please try again.'} +

+
+ )} +
+
+
+
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/layout.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/layout.tsx new file mode 100644 index 000000000..69d70e85d --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/search/layout.tsx @@ -0,0 +1,5 @@ +import { Suspense } from 'react'; + +export default function SearchLayout({ children }: { children: React.ReactNode }) { + return

Loading...

}>{children}
; +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx new file mode 100644 index 000000000..fa48c7610 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -0,0 +1,374 @@ +'use client'; + +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { useAuthStore } from '@/lib/auth-store'; +import { apiClient } from '@/lib/api-client'; +import { useBookingStore } from '@/lib/booking-store'; +import { Station } from '@/types'; +import { Train, MapPin, ArrowRight, Plus, Minus, Search, Users, ChevronDown } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import ModernDatePicker from '@/components/ModernDatePicker'; + +const searchSchema = z.object({ + originStationId: z.string().min(1, 'Please select origin station'), + destinationStationId: z.string().min(1, 'Please select destination station'), + departureDate: z.string().min(1, 'Please select departure date'), + adultCount: z.number().min(1).max(9), + childCount: z.number().min(0).max(9), + nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']), +}).refine((data) => data.originStationId !== data.destinationStationId, { + message: 'Origin and destination must be different', + path: ['destinationStationId'], +}); + +type SearchForm = z.infer; + +export default function SearchPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria); + const { user, isAuthenticated } = useAuthStore(); + const [isPassengerOpen, setIsPassengerOpen] = useState(false); + + const { data: stations, isLoading, error } = useQuery({ + queryKey: ['stations'], + queryFn: async (): Promise => { + const response = await apiClient.get('/stations') as Station[]; + return response; + }, + }); + + const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({ + resolver: zodResolver(searchSchema), + defaultValues: { + adultCount: 1, + childCount: 0, + nationality: 'ETHIOPIAN', + departureDate: new Date().toISOString().split('T')[0], + }, + }); + + useEffect(() => { + if (isAuthenticated && user?.nationality) { + const normalized = user.nationality.toUpperCase().trim(); + if (normalized.includes('DJIBOUTIAN') || normalized === 'DJIBOUTIAN') { + setValue('nationality', 'DJIBOUTIAN'); + } else if (normalized.includes('ETHIOPIAN') || normalized === 'ETHIOPIAN') { + setValue('nationality', 'ETHIOPIAN'); + } else { + setValue('nationality', 'OTHER'); + } + } + }, [isAuthenticated, user?.nationality, setValue]); + + useEffect(() => { + const origin = searchParams.get('origin'); + const destination = searchParams.get('destination'); + const date = searchParams.get('date'); + const adults = searchParams.get('adults'); + const children = searchParams.get('children'); + const nationality = searchParams.get('nationality'); + + if (origin) setValue('originStationId', origin); + if (destination) setValue('destinationStationId', destination); + if (date) setValue('departureDate', date); + if (adults) setValue('adultCount', parseInt(adults)); + if (children) setValue('childCount', parseInt(children)); + if (nationality) setValue('nationality', nationality as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER'); + }, [searchParams, setValue]); + + const originId = watch('originStationId'); + const adultCount = watch('adultCount'); + const childCount = watch('childCount'); + + const onSubmit = (data: SearchForm) => { + setSearchCriteria(data); + const params = new URLSearchParams({ + origin: data.originStationId, + destination: data.destinationStationId, + date: data.departureDate, + adults: data.adultCount.toString(), + children: data.childCount.toString(), + nationality: data.nationality, + }); + router.push(`/booking/results?${params}`); + }; + + const getStationByName = (name: string) => { + if (!stations) return null; + const exactMatch = stations.find(s => s.name.toLowerCase() === name.toLowerCase()); + if (exactMatch) return exactMatch; + return stations.find(s => s.name.toLowerCase().includes(name.toLowerCase())); + }; + + const handlePopularRoute = (fromName: string, toName: string) => { + const origin = getStationByName(fromName); + const destination = getStationByName(toName); + + if (origin && destination) { + setValue('originStationId', origin.id); + setValue('destinationStationId', destination.id); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + }; + + const popularRoutes = [ + { from: 'Sebeta', to: 'Nagad', duration: '12h' }, + { from: 'Sebeta', to: 'Diredawa', duration: '8h' }, + { from: 'Diredawa', to: 'Nagad', duration: '4h' }, + ]; + + + + return ( +
+ {/* Search Section */} +
+
+ {/* Search Card */} +
+ {/* Header inside card */} +
+

+ Start booking +

+

+ Search for available trains and book your journey +

+
+ {error && ( +
+
โš ๏ธ
+
+

Connection Error

+

Unable to load stations. Please check your connection and try again.

+
+
+ )} + +
+ {/* First Row: From, To, Date */} +
+ {/* From */} +
+ +
+ + +
+ {errors.originStationId && ( +

{errors.originStationId.message}

+ )} +
+ + {/* To */} +
+ +
+ + +
+ {errors.destinationStationId && ( +

{errors.destinationStationId.message}

+ )} +
+ + {/* Date */} +
+ + { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + setValue('departureDate', `${year}-${month}-${day}`); + }} + minDate={new Date()} + placeholder="Select date" + /> + {errors.departureDate && ( +

{errors.departureDate.message}

+ )} +
+
+ + {/* Second Row: Passengers, Nationality, Promo Code */} +
+ {/* Passengers Dropdown */} +
+ + + + {/* Passenger Dropdown Menu */} + {isPassengerOpen && ( + <> +
setIsPassengerOpen(false)} /> +
+ {/* Adults */} +
+
+
+
Adults
+
โ‰ฅ5 years
+
+
+ + {adultCount || 1} + +
+
+
+ + {/* Children */} +
+
+
+
Children
+
<5 years โ€ข First free
+
+
+ + {childCount || 0} + +
+
+
+
+ + )} +
+ + {/* Nationality */} +
+ + +
+ + {/* Promo Code */} +
+ + +
+
+ + {/* Third Row: Search Button */} +
+ +
+ +
+ + {/* Popular Routes */} +
+

Popular Routes

+
+ {popularRoutes.map((route, idx) => ( + + ))} +
+
+ +
+
+
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx new file mode 100644 index 000000000..80b28180c --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -0,0 +1,324 @@ +'use client'; + +export const dynamic = 'force-dynamic'; + +import { useRouter } from 'next/navigation'; +import { useBookingStore } from '@/lib/booking-store'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api-client'; +import { useState, useEffect, useCallback, useMemo, memo } from 'react'; + +import CustomModal from '@/components/CustomModal'; + +// Separate component for seat button to prevent re-render issues +const SeatButton = memo(({ seat, isSelected, onToggle }: any) => { + const seatLabel = seat.number || seat.label || seat.seatNumber || '?'; + + return ( + + ); +}); + +SeatButton.displayName = 'SeatButton'; + +export default function SeatsPage() { + const router = useRouter(); + const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria } = useBookingStore(); + const [selectedSeats, setSelectedSeats] = useState([]); + const [selectedCoach, setSelectedCoach] = useState(null); + const [_timeLeft, _setTimeLeft] = useState(null); + const [modalState, setModalState] = useState({ + isOpen: false, + title: '', + message: '', + type: 'info' as 'warning' | 'error' | 'success' | 'info', + }); + + const { data: seatMapData, isLoading, error } = useQuery({ + queryKey: ['seatmap', selectedSchedule?.id], + queryFn: () => apiClient.get(`/seats/seatmap/${selectedSchedule?.id}`), + enabled: !!selectedSchedule?.id, + }); + + const holdMutation = useMutation({ + mutationFn: async (seatIds: string[]) => { + const passengersForHold = passengers.slice(0, seatIds.length).map((_, i) => ({ + passengerId: `temp-${Date.now()}-${i}`, + seatId: seatIds[i], + })); + + return apiClient.post(`/seats/hold`, { + scheduleId: selectedSchedule?.id, + originStationId: searchCriteria?.originStationId, + destinationStationId: searchCriteria?.destinationStationId, + passengers: passengersForHold, + }); + }, + onSuccess: (data: any) => { + setSeatHold({ + holdId: data.holdId || data.id, + expiresAt: data.expiresAt, + }); + }, + }); + + const coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]); + + const filteredCoaches = useMemo(() => { + return selectedSchedule?.selectedSeatClass + ? coaches.filter((c: any) => { + const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || ''); + return seatClassName === selectedSchedule.selectedSeatClass || + seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase() || + seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase(); + }) + : coaches; + }, [coaches, selectedSchedule?.selectedSeatClass]); + + const selectedCoachData = useMemo(() => filteredCoaches.find((c: any) => c.id === selectedCoach), [filteredCoaches, selectedCoach]); + const seats = useMemo(() => selectedCoachData?.seats || [], [selectedCoachData]); + + useEffect(() => { + if (filteredCoaches && filteredCoaches.length > 0 && !selectedCoach) { + setSelectedCoach(filteredCoaches[0].id); + } + }, [filteredCoaches, selectedCoach]); + + const toggleSeat = useCallback((seatId: string) => { + setSelectedSeats(prev => { + if (prev.includes(seatId)) { + return prev.filter(id => id !== seatId); + } else if (prev.length < passengers.length) { + return [...prev, seatId]; + } + return prev; + }); + }, [passengers.length]); + + const handleContinue = async () => { + if (selectedSeats.length > 0) { + await holdMutation.mutateAsync(selectedSeats); + const updatedPassengers = passengers.map((p, i) => { + const seatData = seats?.find((s: any) => s.id === selectedSeats[i]); + return { + ...p, + seatId: selectedSeats[i], + seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '', + }; + }); + setPassengers(updatedPassengers); + } + router.push('/booking/review'); + }; + + const handleAutoAssign = async () => { + const availableSeats = seats?.filter((s: any) => s.status === 'AVAILABLE') || []; + if (availableSeats.length < passengers.length) { + setModalState({ + isOpen: true, + title: 'Not Enough Seats', + message: `Only ${availableSeats.length} seat(s) available in this coach, but you need ${passengers.length} seat(s). Please select another coach.`, + type: 'warning', + }); + return; + } + + const autoSelectedSeats = availableSeats.slice(0, passengers.length).map((s: any) => s.id); + setSelectedSeats(autoSelectedSeats); + + try { + await holdMutation.mutateAsync(autoSelectedSeats); + const updatedPassengers = passengers.map((p, i) => { + const seatData = availableSeats[i]; + return { + ...p, + seatId: autoSelectedSeats[i], + seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '', + }; + }); + setPassengers(updatedPassengers); + router.push('/booking/review'); + } catch (error: any) { + console.error('Failed to hold seats:', error); + setModalState({ + isOpen: true, + title: 'Seat Hold Failed', + message: error?.response?.data?.message || 'Failed to hold seats. Please try again.', + type: 'error', + }); + } + }; + + useEffect(() => { + if (!selectedSchedule || !passengers.length) { + router.push('/booking/search'); + } + }, [selectedSchedule, passengers.length, router]); + + if (!selectedSchedule || !passengers.length) return null; + + return ( + <> + setModalState({ ...modalState, isOpen: false })} + title={modalState.title} + message={modalState.message} + type={modalState.type} + /> +
+
+
+

Select seats

+ +
+
+
+

Select coach

+ {selectedSchedule?.selectedSeatClassName && ( +
+ Showing coaches for: {selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')} +
+ )} +
+ {filteredCoaches?.map((coach: any) => { + const availableCount = coach.seats?.filter((s: any) => s.status === 'AVAILABLE').length || 0; + const seatClassName = typeof coach.seatClass === 'string' ? coach.seatClass : (coach.seatClass?.name || coach.coachClass || ''); + return ( + + ); + })} +
+
+ +
+

Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}

+ {isLoading ? ( +
+

Loading seats...

+
+ ) : error ? ( +
+

Error loading seats

+

{error?.message || 'Please try again'}

+
+ ) : seats.length === 0 ? ( +
+

No seats available in this coach

+

Please select a different coach

+
+ ) : ( + <> + {/* Seat Grid */} +
+
+ {seats?.map((seat: any) => ( + + ))} +
+
+ + {/* Legend */} +
+
+
+ Available +
+
+
+ Selected +
+
+
+ Held +
+
+
+ Booked +
+
+ + )} +
+
+ +
+
+

Selection summary

+

+ Select {passengers.length} seat(s) for your passengers +

+

+ {selectedSeats.length} / {passengers.length} selected +

+ +
+ {passengers.map((p, i) => { + const assignedSeat = selectedSeats[i] ? seats?.find((s: any) => s.id === selectedSeats[i]) : null; + const seatLabel = assignedSeat ? (assignedSeat.number || assignedSeat.label || assignedSeat.seatNumber || '-') : '-'; + return ( +
+ {p.name} + + {seatLabel} + +
+ ); + })} +
+ + + +
+
+
+
+
+
+ + ); +} diff --git a/apps/edr-passenger-web/portal/src/app/contact/page.tsx b/apps/edr-passenger-web/portal/src/app/contact/page.tsx new file mode 100644 index 000000000..f2c0f4296 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/contact/page.tsx @@ -0,0 +1,375 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getTranslation, Language, useLanguage } from '@/lib/i18n'; +import { Phone, Mail, MapPin, Send, Loader } from 'lucide-react'; + +const styles = ` + .contact-hero { + padding: 60px 20px; + background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent); + text-align: center; + color: #111827; + } + + .dark .contact-hero { + color: #f3f4f6; + } + + .contact-hero h1 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 16px; + color: #111827; + } + + .dark .contact-hero h1 { + color: #f3f4f6; + } + + .contact-hero p { + font-size: 1.125rem; + color: #6b7280; + } + + .dark .contact-hero p { + color: #9ca3af; + } + + .contact-grid { + max-width: 80rem; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 24px; + padding: 60px 20px; + background-color: white; + } + + .dark .contact-grid { + background-color: #111827; + } + + .contact-card { + background: white; + border: 2px solid #f3f4f6; + border-radius: 18px; + padding: 24px; + cursor: pointer; + text-align: center; + transition: all 0.2s; + } + + .dark .contact-card { + background: #1f2937; + border-color: #374151; + } + + .contact-card:hover { + border-color: rgb(20, 113, 76); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + } + + .contact-icon { + width: 48px; + height: 48px; + background: rgb(20, 113, 76); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 16px; + } + + .contact-card h3 { + font-weight: 700; + margin-bottom: 8px; + color: #111827; + } + + .dark .contact-card h3 { + color: #f3f4f6; + } + + .contact-card p { + font-size: 0.875rem; + color: #6b7280; + } + + .dark .contact-card p { + color: #9ca3af; + } + + .contact-card a { + color: #6b7280; + text-decoration: none; + } + + .contact-card a:hover { + color: rgb(20, 113, 76); + } + + .form-section { + padding: 60px 20px; + background-color: #f9fafb; + } + + .dark .form-section { + background-color: #0f1117; + } + + .form-container { + max-width: 42rem; + margin: 0 auto; + background: white; + border-radius: 18px; + padding: 32px; + border: 1px solid #e5e7eb; + } + + .dark .form-container { + background: #1f2937; + border-color: #374151; + } + + .form-container h2 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 24px; + color: #111827; + } + + .dark .form-container h2 { + color: #f3f4f6; + } + + .form-group { + margin-bottom: 20px; + } + + .form-group label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: #374151; + margin-bottom: 8px; + } + + .dark .form-group label { + color: #d1d5db; + } + + .form-group input, + .form-group textarea { + width: 100%; + padding: 12px 16px; + border: 2px solid #e5e7eb; + border-radius: 12px; + font-size: 1rem; + font-family: inherit; + transition: all 0.2s; + box-sizing: border-box; + background: white; + color: #111827; + } + + .dark .form-group input, + .dark .form-group textarea { + background: #111827; + color: #f3f4f6; + border-color: #374151; + } + + .form-group input:focus, + .form-group textarea:focus { + outline: none; + border-color: rgb(20, 113, 76); + box-shadow: 0 0 0 3px rgba(20, 113, 76, 0.1); + } + + .form-submit { + width: 100%; + padding: 14px 20px; + background-color: rgb(20, 113, 76); + color: white; + border: none; + border-radius: 12px; + font-weight: 700; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 8px; + } + + .form-submit:hover { + background-color: rgb(16, 89, 60); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + } + + .form-submit:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .alert { + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 16px; + font-size: 0.875rem; + } + + .alert-success { + background-color: #dbeafe; + color: #1e40af; + } + + .dark .alert-success { + background-color: rgba(20, 113, 76, 0.1); + color: #a7f3d0; + } + + .alert-error { + background-color: #fee2e2; + color: #991b1b; + } + + .dark .alert-error { + background-color: rgba(239, 68, 68, 0.1); + color: #fca5a5; + } +`; + +export default function Contact() { + const [lang, setLang] = useState('en'); + const [formData, setFormData] = useState({ name: '', email: '', subject: '', message: '' }); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const { getLang } = useLanguage(); + const t = (key: string) => getTranslation(lang, key); + + useEffect(() => { + setLang(getLang()); + const handleLanguageChange = (e: any) => setLang(e.detail); + window.addEventListener('languageChange', handleLanguageChange); + return () => window.removeEventListener('languageChange', handleLanguageChange); + }, [getLang]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + + try { + await new Promise(resolve => setTimeout(resolve, 1500)); + setMessage({ type: 'success', text: t('contact.success') }); + setFormData({ name: '', email: '', subject: '', message: '' }); + } catch (error) { + setMessage({ type: 'error', text: t('contact.error') }); + } finally { + setLoading(false); + } + }; + + const contactInfo = [ + { icon: Phone, title: t('contact.phone'), value: '+251 911 000 000', link: 'tel:+251911000000' }, + { icon: Mail, title: t('contact.email'), value: 'support@edr.et', link: 'mailto:support@edr.et' }, + { icon: MapPin, title: t('contact.address'), value: 'Addis Ababa, Ethiopia', link: '#' }, + ]; + + return ( + <> + +
+
+

{t('contact.title')}

+

{t('contact.subtitle')}

+
+ +
+ {contactInfo.map((info, idx) => { + const Icon = info.icon; + return ( + +
+ +
+

{info.title}

+

{info.value}

+
+ ); + })} +
+ +
+
+

{t('contact.form')}

+ + {message && ( +
+ {message.text} +
+ )} + +
+
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + /> +
+ +
+ + setFormData({ ...formData, subject: e.target.value })} + /> +
+ +
+ +