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/.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 c35b3cfb2..7ff862555 100644 --- a/README.md +++ b/README.md @@ -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. 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 2daee96b4..80dea3263 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -132,4 +132,4 @@ 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 531a64bfd..f33ae399e 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -54,6 +54,7 @@ "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.0", "tsconfig-paths": "^4.2.0", + "@prisma/client": "^6.19.3", "typeorm": "^0.3.30" }, "devDependencies": { @@ -62,7 +63,6 @@ "@nestjs/cli": "^11.0.21", "@nestjs/schematics": "^11.1.0", "@nestjs/testing": "^11.1.19", - "@prisma/client": "^6.19.3", "@types/bcrypt": "^5.0.2", "@types/jest": "^29.5.11", "@types/node": "^20.10.6", diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 1f3ea7e04..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 }); } } @@ -162,7 +162,7 @@ async function seedSchedules(trains: any[], stations: any[], routes: any[]) { 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 diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 5ea253efd..3e6e8f4b0 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -219,31 +219,32 @@ 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") //.addServer('http://localhost:4000', 'Development') // .addServer("https://api.edr-platform.com", "Production") .build(); @@ -255,6 +256,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..4d091656f 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,142 @@ 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 + +#### User Preferences +- Language, notification settings, etc. + +--- + +### Use Cases + +1. **App Initialization**: Fetch on app load to get user context + +2. **Profile Pre-fill**: Use data to auto-fill booking forms + +3. **Verification Check**: Check \`faydaVerified\` before registration + +4. **Loyalty Display**: Show tier and points in UI + +5. **Wallet Balance**: Display available balance + +--- + +### Authentication +- **Required**: JWT Bearer Token +- Token must be valid and not expired +- Returns profile for authenticated user only`, + }) + @ApiResponse({ + status: 200, + description: 'User profile retrieved successfully', + schema: { + example: { + id: 'user-uuid-123', + email: 'kelemu@email.com', + phone: '+251911234567', + fullName: 'Kelemu Abebe', + role: 'PASSENGER', + nationality: 'Ethiopian', + nationalityCode: 'ET', + nationalId: null, + passportNumber: null, + faydaVerified: true, + faydaVerifiedAt: '2024-01-15T10:30:00.000Z', + lastLoginAt: '2024-01-20T14:22:00.000Z', + createdAt: '2023-12-01T08:00:00.000Z', + passenger: { + id: 'passenger-uuid-456', + preferredLanguage: 'am', + loyalty: { + tier: 'SILVER', + pointsBalance: 1500, + lifetimePoints: 3000 + }, + wallet: { + balanceMinor: 50000, + currency: 'ETB' + } + }, + preferences: { + emailNotifications: true, + smsNotifications: true, + language: 'am' + } + } + } + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - Invalid or missing JWT token', + schema: { + example: { + statusCode: 401, + message: 'Unauthorized' + } + } + }) + getProfile(@Request() req: any) { + console.log('Profile request - User from JWT:', req.user); + if (!req.user || !req.user.userId) { + throw new UnauthorizedException('User not authenticated'); + } + return this.service.getProfile(req.user.userId); + } } 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 ae8018dc3..e71562f2e 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.service.ts @@ -143,4 +143,70 @@ 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, + }, + }); + + if (!user) throw new UnauthorizedException('User not found'); + + return { + id: user.id, + email: user.email, + phone: user.phone, + fullName: user.fullName, + role: user.role, + nationality: user.nationality, + nationalityCode: user.nationalityCode, + nationalId: user.nationalId, + passportNumber: user.passportNumber, + faydaVerified: user.faydaVerified, + faydaVerifiedAt: user.faydaVerifiedAt, + lastLoginAt: user.lastLoginAt, + createdAt: user.createdAt, + passenger: user.passenger ? { + id: user.passenger.id, + preferredLanguage: user.passenger.preferredLanguage, + loyalty: user.passenger.loyalty ? { + tier: user.passenger.loyalty.tier, + pointsBalance: user.passenger.loyalty.pointsBalance, + lifetimePoints: user.passenger.loyalty.lifetimePoints, + } : null, + wallet: user.passenger.wallet ? { + balanceMinor: user.passenger.wallet.balanceMinor, + currency: user.passenger.wallet.currency, + } : null, + } : null, + preferences: user.preferences, + }; + } + + async logout(userId: string) { + // Invalidate all active sessions for this user + await this.prisma.session.deleteMany({ + where: { userId } + }); + + // Log the logout action + await this.createAuditLog(userId, 'USER_LOGOUT', 'User', userId, null, null); + + return { + success: true, + message: 'Logged out successfully' + }; + } } 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/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 8c2bc69a4..1214acebc 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,28 +71,42 @@ export class GuestBookingService { let verifaydaData: Record | undefined; let nationality = passenger.nationality; - // Verifayda verification ONLY for Ethiopian nationals with National ID - const isEthiopian = !passenger.nationality || passenger.nationality === 'Ethiopian' || - (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && !passenger.passportCountry); + // Determine if passenger is Ethiopian + const isEthiopian = passenger.nationality === 'Ethiopian' || + passenger.nationality === 'ETHIOPIAN' || + passenger.idDocumentType === IdDocumentType.NATIONAL_ID; - if (isEthiopian && 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}` - ); + // 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 = 'Ethiopian'; - } else if (passenger.idDocumentType === IdDocumentType.PASSPORT) { + } + // 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'); - } else if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && !isEthiopian) { - // Non-Ethiopian with national ID (e.g., Djiboutian national ID) + } + // 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'; } @@ -175,11 +189,23 @@ 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`; + } + } + 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: firstPassenger.phone || `+251${uniqueId.replace(/[^0-9]/g, '').slice(0, 9)}`, passwordHash: await bcrypt.hash(Math.random().toString(36), 10), role: 'PASSENGER', }, 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 266eedf09..c257be623 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -1,11 +1,12 @@ -import { Body, Controller, Get, Param, Post, UseGuards, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, UseGuards, Query, Request } 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 { VerifaydaService } from '../verifayda/verifayda.service'; +import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; -@ApiTags('Passenger') +@ApiTags('Passengers') @Controller('passengers') export class PassengersController { constructor( @@ -55,14 +56,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, @@ -84,21 +122,143 @@ 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: '[LEGACY] Save all passenger details before seat selection', + description: `**Note:** This endpoint is legacy. Consider using \`POST /passengers/register\` instead. + +Saves all passenger details to database before proceeding to seat selection. + +- Required step in booking flow +- Saves details for all passengers in booking +- Supports both logged-in users and guests +- Prevents data loss if user navigates away + +**Migration:** Use \`POST /passengers/register\` for new implementations.`, + }) + @ApiResponse({ + status: 201, + description: 'Passenger details saved successfully', + schema: { + example: { + count: 2, + passengerIds: ['uuid-1', 'uuid-2'], + passengers: [ + { + id: 'uuid-1', + passengerName: 'John Doe', + dateOfBirth: '1990-01-01T00:00:00.000Z', + nationality: 'Ethiopian' + }, + { + id: 'uuid-2', + passengerName: 'Jane Doe', + dateOfBirth: '1992-05-15T00:00:00.000Z', + nationality: 'Ethiopian' + } + ], + message: 'Passenger details saved successfully' + } + } + }) + @ApiResponse({ status: 400, description: 'Validation error - passengers array required' }) + savePassengers(@Body() body: any) { + const passengers = body.passengers || (Array.isArray(body) ? body : [body]); + return this.service.savePassengers(passengers, body.userId, body.deviceId); } @Post('traveler-profiles') 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..4a48cb8b5 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,4 @@ -import { IsString, IsOptional, IsDateString, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsDateString, IsEnum, IsBoolean } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateTravelerProfileDto { @@ -19,7 +19,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 +74,184 @@ export class RegisterInternationalPassengerDto { @IsString() deviceId?: string; } + +export class SavePassengerDetailsDto { + @ApiProperty({ example: 'Abebe Kebede' }) + @IsString() + name: string; + + @ApiProperty({ example: '1985-03-15' }) + @IsDateString() + dateOfBirth: string; + + @ApiProperty({ example: 'ETHIOPIAN' }) + @IsString() + nationality: string; + + @ApiPropertyOptional({ example: 'ET123456789' }) + @IsOptional() + @IsString() + nationalId?: string; + + @ApiPropertyOptional({ example: 'P1234567' }) + @IsOptional() + @IsString() + passportNumber?: string; + + @ApiPropertyOptional({ example: 'Kenya' }) + @IsOptional() + @IsString() + passportCountry?: string; + + @ApiPropertyOptional({ example: '+251911234567' }) + @IsOptional() + @IsString() + phone?: string; + + @ApiPropertyOptional({ example: 'email@example.com' }) + @IsOptional() + @IsString() + email?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + faydaSub?: string; +} + +export class SavePassengersDto { + @ApiProperty({ 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.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 9fb265c99..2d839cb6e 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -1,6 +1,7 @@ -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; @@ -11,7 +12,10 @@ interface PassengerFilters { @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; @@ -126,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, + 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', }; } @@ -161,4 +179,95 @@ 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 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', + }; + } +} \ No newline at end of file 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 379cc4de1..820c5b8a0 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -277,7 +277,10 @@ export class SchedulesService { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } }); if (!schedule) throw new NotFoundException('Schedule not found'); - // Delete related records first + // 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 } }); 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-web/backoffice/next.config.js b/apps/edr-passenger-web/backoffice/next.config.js index dcde34d31..a286d1a26 100644 --- a/apps/edr-passenger-web/backoffice/next.config.js +++ b/apps/edr-passenger-web/backoffice/next.config.js @@ -1,10 +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/src/app/agents/page.tsx b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx index c8213ee0a..d220bdd1a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx @@ -53,13 +53,17 @@ export default function AgentsPage() { const actions = [ { label: 'View Shifts', - onClick: (agent: any) => window.location.href = `/agents/${agent.id}/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`, + onClick: (agent: any) => { + window.location.href = `/agents/${agent.id}/commissions`; + }, variant: 'secondary' as const, icon: DollarSign, }, diff --git a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx index 95fc248e9..6f73de13e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx @@ -58,7 +58,9 @@ export default function AuditLogsPage() { const actions = [ { label: 'View Details', - onClick: (log: any) => window.location.href = `/audit/${log.id}`, + onClick: (log: any) => { + window.location.href = `/audit/${log.id}`; + }, variant: 'secondary' as const, icon: Eye, }, diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index 371fa0c96..8ae8dd642 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -193,7 +193,7 @@ export default function CoachesPage() { columns={columns} data={coaches} actions={actions} - isLoading={isLoading} + loading={isLoading} /> diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index 6a91b5e12..ee4f5b9ce 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -20,14 +20,14 @@ export default function DashboardPage() { queryFn: () => dashboardApi.getRevenueChart(30), }); - const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery({ + const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery({ queryKey: ['recent-bookings'], queryFn: () => dashboardApi.getRecentBookings(10), }); const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData - : recentBookingsData?.items || recentBookingsData?.data || []; + : recentBookingsData?.items || recentBookingsData?.data || []; const columns = [ { key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference }, @@ -49,7 +49,7 @@ export default function DashboardPage() {

Dashboard

-

Welcome back! Here's what's happening today.

+

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

diff --git a/apps/edr-passenger-web/backoffice/src/app/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/layout.tsx index 669969b60..23838f62c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/layout.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/layout.tsx @@ -1,10 +1,7 @@ import type { Metadata } from 'next'; -import { Inter } from 'next/font/google'; import '@/styles/globals.css'; import Providers from './providers'; -const inter = Inter({ subsets: ['latin'] }); - export const metadata: Metadata = { title: 'EDR Passenger Back-office', description: 'Ethio-Djibouti Railway Passenger Back-office', @@ -32,7 +29,7 @@ export default function RootLayout({ }} /> - + {children} diff --git a/apps/edr-passenger-web/backoffice/src/app/loyalty/page.tsx b/apps/edr-passenger-web/backoffice/src/app/loyalty/page.tsx index 0e5938b4e..75d52f940 100644 --- a/apps/edr-passenger-web/backoffice/src/app/loyalty/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/loyalty/page.tsx @@ -56,7 +56,7 @@ export default function LoyaltyPage() {
formatDateTime(payment.createdAt) }, ]; + const actions: any[] = []; + return (
@@ -57,8 +59,9 @@ export default function PaymentsPage() {
diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 0ca0774b6..c555cd114 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -14,12 +14,16 @@ interface RouteStop { stationId: string; sequence: number; distanceKm?: number; + distanceFromOrigin?: number; } export default function RoutesPage() { const [showModal, setShowModal] = useState(false); const [editingRoute, setEditingRoute] = useState(null); const [stops, setStops] = useState([]); + const [originStationId, setOriginStationId] = useState(''); + const [destinationStationId, setDestinationStationId] = useState(''); + const [destinationDistance, setDestinationDistance] = useState(undefined); const queryClient = useQueryClient(); const { data: routes, isLoading: routesLoading } = useQuery({ @@ -65,21 +69,38 @@ export default function RoutesPage() { e.preventDefault(); const formData = new FormData(e.currentTarget); - if (stops.length < 2) { - alert('Route must have at least 2 stops'); + if (!originStationId || !destinationStationId) { + alert('Please select origin and destination stations'); return; } - const stopsArray = stops.map((stop, idx) => { - const stopData: any = { - stationId: stop.stationId, - sequence: idx + 1, - }; - if (idx > 0 && stop.distanceKm) { - stopData.distanceKm = stop.distanceKm; - } - return stopData; - }); + if (originStationId === destinationStationId) { + alert('Origin and destination must be different'); + return; + } + + // Sort middle stops by distance from origin + const sortedMiddleStops = [...stops].sort((a, b) => + (a.distanceFromOrigin || 0) - (b.distanceFromOrigin || 0) + ); + + // Calculate distanceKm (distance from previous stop) + const stopsArray = [ + { stationId: originStationId, sequence: 1, distanceKm: 0 }, + ...sortedMiddleStops.map((stop, idx) => { + const prevDistance = idx === 0 ? 0 : (sortedMiddleStops[idx - 1].distanceFromOrigin || 0); + return { + stationId: stop.stationId, + sequence: idx + 2, + distanceKm: (stop.distanceFromOrigin || 0) - prevDistance, + }; + }), + { + stationId: destinationStationId, + sequence: sortedMiddleStops.length + 2, + distanceKm: (destinationDistance || 0) - (sortedMiddleStops.length > 0 ? (sortedMiddleStops[sortedMiddleStops.length - 1].distanceFromOrigin || 0) : 0), + }, + ]; const routeData = { code: formData.get('code') as string, @@ -100,7 +121,7 @@ export default function RoutesPage() { }; const addStop = () => { - setStops([...stops, { stationId: '', sequence: stops.length + 1 }]); + setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: 0 }]); }; const removeStop = (index: number) => { @@ -113,6 +134,20 @@ export default function RoutesPage() { setStops(updated); }; + const generateRouteCode = (originId: string, destId: string) => { + if (!originId || !destId) return ''; + const origin = stations?.items?.find((s: any) => s.id === originId); + const dest = stations?.items?.find((s: any) => s.id === destId); + return origin && dest ? `${origin.code}-${dest.code}` : ''; + }; + + const generateRouteName = (originId: string, destId: string) => { + if (!originId || !destId) return ''; + const origin = stations?.items?.find((s: any) => s.id === originId); + const dest = stations?.items?.find((s: any) => s.id === destId); + return origin && dest ? `${origin.name} - ${dest.name}` : ''; + }; + const handleDelete = async (route: any) => { if (confirm(`Are you sure you want to delete ${route.name}?`)) { await deleteMutation.mutateAsync(route.id); @@ -139,6 +174,35 @@ export default function RoutesPage() { label: 'Edit', onClick: (route: any) => { setEditingRoute(route); + const routeStops = route.stops || []; + if (routeStops.length >= 2) { + setOriginStationId(routeStops[0].stationId); + setDestinationStationId(routeStops[routeStops.length - 1].stationId); + + // Calculate cumulative distance for destination + let cumulativeDistance = 0; + routeStops.forEach((stop: any, idx: number) => { + if (idx > 0) { + cumulativeDistance += stop.distanceKm || 0; + } + }); + setDestinationDistance(cumulativeDistance); + + // Calculate distance from origin for middle stops + const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => { + let distFromOrigin = 0; + for (let i = 1; i <= idx + 1; i++) { + distFromOrigin += routeStops[i].distanceKm || 0; + } + return { + stationId: stop.stationId, + sequence: stop.sequence, + distanceKm: stop.distanceKm, + distanceFromOrigin: distFromOrigin, + }; + }); + setStops(middleStops); + } setShowModal(true); }, variant: 'secondary' as const, @@ -163,6 +227,9 @@ export default function RoutesPage() { icon={Plus} onClick={() => { setEditingRoute(null); + setOriginStationId(''); + setDestinationStationId(''); + setDestinationDistance(undefined); setStops([]); setShowModal(true); }} @@ -172,7 +239,7 @@ export default function RoutesPage() {
{ setShowModal(false); setEditingRoute(null); + setOriginStationId(''); + setDestinationStationId(''); + setDestinationDistance(undefined); setStops([]); }} title={`${editingRoute ? 'Edit' : 'Add'} Route`} size="lg" > -
+ +
+
+ + +
+
+ + +
+
+
@@ -198,9 +305,10 @@ export default function RoutesPage() { type="text" name="code" className="input" - defaultValue={editingRoute?.code} + value={generateRouteCode(originStationId, destinationStationId)} + readOnly required - placeholder="e.g., ADD-DJI" + placeholder="Select stations to generate" disabled={!!editingRoute} />
@@ -210,9 +318,10 @@ export default function RoutesPage() { type="text" name="name" className="input" - defaultValue={editingRoute?.name} + value={generateRouteName(originStationId, destinationStationId)} + readOnly required - placeholder="e.g., Addis Ababa โ€“ Djibouti" + placeholder="Select stations to generate" />
@@ -252,56 +361,66 @@ export default function RoutesPage() {
- - - Add Stop - +
- {stops.length === 0 && ( -

No stops added. Click "Add Stop" to begin.

- )} +
+ {/* Origin Stop */} +
+
+ 1 +
+
+ {originStationId ? ( + + {stations?.items?.find((s: any) => s.id === originStationId)?.name || 'Unknown'} + {' '}({stations?.items?.find((s: any) => s.id === originStationId)?.code || 'N/A'}) + + ) : ( + Select origin station above + )} +
+
+ 0 km +
+
-
+ {/* Intermediate Stops */} {stops.map((stop, index) => ( -
-
- {index + 1} +
+
+ {index + 2}
-
-
- -
-
- updateStop(index, 'distanceKm', e.target.value ? parseFloat(e.target.value) : undefined)} - disabled={index === 0} - min="0" - step="0.1" - /> -
+
+ +
+
+ updateStop(index, 'distanceFromOrigin', e.target.value ? parseFloat(e.target.value) : undefined)} + min="0" + step="0.1" + required + />
))} + + {/* Add Intermediate Stop Button */} + {originStationId && destinationStationId && ( +
+ + Add Intermediate Stop + +
+ )} + + {/* Destination Stop */} +
+
+ {stops.length + 2} +
+
+ {destinationStationId ? ( + + {stations?.items?.find((s: any) => s.id === destinationStationId)?.name || 'Unknown'} + {' '}({stations?.items?.find((s: any) => s.id === destinationStationId)?.code || 'N/A'}) + + ) : ( + Select destination station above + )} +
+
+ {destinationStationId && ( + setDestinationDistance(e.target.value ? parseFloat(e.target.value) : undefined)} + min="0" + step="0.1" + required + /> + )} +
+
@@ -322,6 +487,9 @@ export default function RoutesPage() { onClick={() => { setShowModal(false); setEditingRoute(null); + setOriginStationId(''); + setDestinationStationId(''); + setDestinationDistance(undefined); setStops([]); }} > diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index b6c2acc41..962f3ca54 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -77,6 +77,14 @@ export default function SchedulesPage() { }, }); + const removeCoachMutation = useMutation({ + mutationFn: ({ scheduleId, coachId }: { scheduleId: string; coachId: string }) => + schedulesApi.removeCoachAssignment(scheduleId, coachId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['schedules'] }); + }, + }); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); @@ -119,6 +127,12 @@ export default function SchedulesPage() { setShowCoachModal(true); }; + const handleRemoveCoach = async (schedule: any, coachId: string) => { + if (confirm('Remove this coach from the schedule?')) { + await removeCoachMutation.mutateAsync({ scheduleId: schedule.id, coachId }); + } + }; + const handleToggleCoach = (coachId: string) => { setSelectedCoaches(prev => { const exists = prev.find(c => c.coachId === coachId); @@ -157,9 +171,21 @@ export default function SchedulesPage() { return (
{schedule.coachAssignments?.slice(0, 3).map((assignment: any) => ( - - {assignment.coach?.coachNumber || 'N/A'} - +
+ + {assignment.coach?.coachNumber || 'N/A'} + + +
))} {coachCount > 3 && ( @@ -238,7 +264,7 @@ export default function SchedulesPage() {
(null); + const [blockReason, setBlockReason] = useState(''); const queryClient = useQueryClient(); const { data: schedulesData } = useQuery({ @@ -17,114 +21,85 @@ export default function SeatsPage() { queryFn: () => schedulesApi.getAll(), }); - const { data, isLoading } = useQuery({ - queryKey: ['seats', selectedSchedule], - queryFn: () => selectedSchedule ? seatsApi.getBySchedule(selectedSchedule) : Promise.resolve([]), + const { data: seatMapData, isLoading } = useQuery({ + queryKey: ['seatmap', selectedSchedule], + queryFn: () => selectedSchedule ? seatsApi.getSeatMap(selectedSchedule) : Promise.resolve(null), enabled: !!selectedSchedule, }); const blockMutation = useMutation({ mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seats'] }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['seatmap'] }); + setShowBlockModal(false); + setSelectedSeat(null); + setBlockReason(''); + }, }); const unblockMutation = useMutation({ mutationFn: (seatId: string) => seatsApi.unblock(seatId), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seats'] }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['seatmap'] }); + }, }); - const seats = Array.isArray(data) ? data : data?.items || data?.data || []; const schedules = schedulesData?.items || schedulesData?.data || []; + const coaches = seatMapData?.coaches || []; - const columns = [ - { - key: 'seatNumber', - label: 'Seat Number', - render: (seat: any) => ( -
-
- -
- {seat.seatNumber} -
- ), - }, - { - key: 'coach', - label: 'Coach', - render: (seat: any) => ( - {seat.coach?.coachNumber || 'N/A'} - ), - }, - { - key: 'seatClass', - label: 'Class', - render: (seat: any) => { - const seatClass = seat.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: 'position', - label: 'Position', - render: (seat: any) => ( - - {seat.position || seat.seatPosition || 'N/A'} - - ), - }, - { - key: 'status', - label: 'Status', - render: (seat: any) => { - const isBlocked = seat.isBlocked || seat.status === 'BLOCKED'; - const isBooked = seat.isBooked || seat.status === 'BOOKED'; - - if (isBlocked) return Blocked; - if (isBooked) return Booked; - return Available; - }, - }, - ]; + const handleBlock = (seat: any) => { + setSelectedSeat(seat); + setShowBlockModal(true); + }; - const actions = [ - { - label: 'Block', - onClick: (seat: any) => blockMutation.mutate({ seatId: seat.id, reason: 'Manual block' }), - variant: 'secondary' as const, - icon: Lock, - show: (seat: any) => !seat.isBlocked && seat.status !== 'BLOCKED', - }, - { - label: 'Unblock', - onClick: (seat: any) => unblockMutation.mutate(seat.id), - variant: 'secondary' as const, - icon: Unlock, - show: (seat: any) => seat.isBlocked || seat.status === 'BLOCKED', - }, - ]; + const handleUnblock = async (seat: any) => { + if (confirm('Are you sure you want to unblock this seat?')) { + await unblockMutation.mutateAsync(seat.id); + } + }; + + const submitBlock = async () => { + if (!blockReason.trim()) { + alert('Please provide a reason for blocking'); + return; + } + await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason }); + }; + + const getSeatStatus = (seat: any) => { + if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED'; + if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED'; + if (seat.status === 'HELD') return 'HELD'; + return 'AVAILABLE'; + }; + + const getSeatColor = (status: string) => { + switch (status) { + case 'AVAILABLE': return 'bg-green-500'; + case 'BOOKED': return 'bg-red-500'; + case 'HELD': return 'bg-yellow-500'; + case 'BLOCKED': return 'bg-gray-500'; + default: return 'bg-gray-300'; + } + }; + + const filteredCoaches = coaches.filter((coach: any) => + search ? coach.coachNumber?.toLowerCase().includes(search.toLowerCase()) : true + ); return (

Seat Management

-

Manage seat availability and blocking

+

View and manage seat availability by schedule

+
- - setSearch(e.target.value)} - className="input pl-10" - /> + +
+ + setSearch(e.target.value)} + className="input pl-10" + /> +
- {selectedSchedule ? ( - - ) : ( + {!selectedSchedule ? (
- Select a schedule to view seats + +

Select a schedule to view seat map

+
+ ) : isLoading ? ( +
+
+

Loading seats...

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

No coaches found for this schedule

+
+ ) : ( +
+ {/* Legend */} +
+
+
+ Available +
+
+
+ Booked +
+
+
+ Held +
+
+
+ Blocked +
+
+ + {/* Coaches */} + {filteredCoaches.map((coach: any) => { + const seats = coach.seats || []; + const seatClass = coach.seatClass?.name || 'N/A'; + const availableCount = seats.filter((s: any) => getSeatStatus(s) === 'AVAILABLE').length; + const bookedCount = seats.filter((s: any) => getSeatStatus(s) === 'BOOKED').length; + const blockedCount = seats.filter((s: any) => getSeatStatus(s) === 'BLOCKED').length; + + return ( +
+
+
+

+ Coach {coach.coachNumber} - {coach.label} +

+

+ {seatClass} โ€ข {seats.length} seats +

+
+
+ Available: {availableCount} + Booked: {bookedCount} + Blocked: {blockedCount} +
+
+ +
+ {seats.map((seat: any) => { + const status = getSeatStatus(seat); + const color = getSeatColor(status); + const canBlock = status === 'AVAILABLE'; + const canUnblock = status === 'BLOCKED'; + + return ( +
+
+ {seat.seatNumber} +
+ {(canBlock || canUnblock) && ( +
+ {canBlock && ( + + )} + {canUnblock && ( + + )} +
+ )} +
+ ); + })} +
+
+ ); + })}
)}
+ + {/* Block Modal */} + { + setShowBlockModal(false); + setSelectedSeat(null); + setBlockReason(''); + }} + title="Block Seat" + size="md" + > +
+

+ Block seat {selectedSeat?.seatNumber} in Coach{' '} + {selectedSeat?.coach?.coachNumber} +

+
+ +