From 200476dfd61c980d6c42b531b56b09c99aa0c581 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 2 Jul 2026 22:42:15 +0300 Subject: [PATCH] Fare and route-coach, production checklist updates --- .github/workflows/deploy.yml | 23 + DEPLOYMENT.md | 59 ++- apps/edr-passenger-api/package.json | 1 + .../migration.sql | 25 + .../migration.sql | 6 + apps/edr-passenger-api/prisma/schema.prisma | 27 +- apps/edr-passenger-api/prisma/seed.ts | 175 +++++-- apps/edr-passenger-api/src/app.module.ts | 16 +- .../src/common/dynamic-throttler.guard.ts | 25 +- .../src/common/prisma.service.ts | 23 +- .../src/config/app.config.ts | 28 +- apps/edr-passenger-api/src/main.ts | 51 ++- .../modules/currencies/currencies.module.ts | 4 +- .../modules/currencies/currencies.service.ts | 13 +- .../src/modules/currency/currency.module.ts | 3 +- .../src/modules/currency/currency.service.ts | 75 ++- .../fare-engine/fare-engine.service.ts | 47 +- .../modules/schedules/routes.controller.ts | 35 +- .../src/modules/schedules/routes.dto.ts | 11 + .../src/modules/schedules/routes.service.ts | 40 +- .../src/modules/schedules/schedules.dto.ts | 2 +- .../modules/schedules/schedules.service.ts | 18 +- .../src/modules/search/search.service.ts | 2 +- .../src/modules/tasks/tasks.module.ts | 3 +- .../src/modules/tasks/tasks.service.ts | 52 +++ .../backoffice/next.config.js | 3 +- .../backoffice/src/app/routes/page.tsx | 241 ++++++++-- .../backoffice/src/app/schedules/page.tsx | 360 ++++++++++++--- .../src/app/tariff-rates/layout.tsx | 5 + .../backoffice/src/app/tariff-rates/page.tsx | 428 ++++++++++++++++++ .../src/components/layout/Sidebar.tsx | 3 +- .../backoffice/src/lib/api/index.ts | 8 + apps/edr-passenger-web/portal/next.config.js | 3 +- docker-compose.yaml | 36 ++ .../docker/Dockerfile.passenger-web | 34 +- infrastructure/nginx/spa.conf | 16 +- pnpm-lock.yaml | 19 +- 37 files changed, 1672 insertions(+), 248 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260702163828_add_route_coach_template/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260702165853_add_seat_class_nationality_bed_position/migration.sql create mode 100644 apps/edr-passenger-web/backoffice/src/app/tariff-rates/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fcd560a95..72ad6de66 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -170,13 +170,36 @@ jobs: - name: Build ${{ matrix.service }} run: | set -euo pipefail + IMAGE_TAG="${COMPOSE_PROJECT_NAME}-${{ matrix.service }}:${GITHUB_SHA::8}" docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}" + # Tag with git SHA for rollback capability + CONTAINER_NAME=$(docker compose --project-name "${COMPOSE_PROJECT_NAME}" config --services | grep "${{ matrix.service }}" | head -1) + docker tag "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}" "${IMAGE_TAG}" 2>/dev/null || true + echo "IMAGE_TAG=${IMAGE_TAG}" >> "${GITHUB_ENV}" - name: Deploy ${{ matrix.service }} run: | set -euo pipefail docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate + - name: Verify deployment health + if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + run: | + set -euo pipefail + PORT=$(grep '^PORT=' "${SERVICE_ENV_FILE}" | cut -d= -f2) + echo "Waiting for service to become healthy on port ${PORT}..." + for i in $(seq 1 12); do + if wget -qO- "http://localhost:${PORT}/health/ready" 2>/dev/null | grep -q '"status":"ok"'; then + echo "Service is healthy." + exit 0 + fi + echo "Attempt ${i}/12 — not ready yet, waiting 10s..." + sleep 10 + done + echo "Service failed health check after 120s — rolling back" + docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate || true + exit 1 + - name: Remove npm credentials from workspace if: always() run: rm -f .npmrc .npmrc_temp diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 2818dcd97..463cea535 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -121,13 +121,59 @@ This ensures `docker ps` shows `0.0.0.0:->/tcp` with matching ports. ### Runtime -The final image runs: +The final image uses Next.js `output: 'standalone'` and runs: ```bash -npx next start +node server.js ``` -Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose) to determine which port to listen on. +Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose). The standalone output bundles only the required `node_modules`, producing a significantly smaller image than a full `pnpm deploy`. + +## Rollback Procedure + +Each build is tagged with the short git SHA (`${COMPOSE_PROJECT_NAME}-:`). + +### Rollback a single service + +```bash +# 1. Find the last known-good image tag +docker images | grep passenger-api + +# 2. Re-tag it as the current image +docker tag edr-passenger-main-passenger-api: edr-passenger-main-passenger-api:latest + +# 3. Restart the container from the previous image +docker compose --project-name edr-passenger-main up -d passenger-api --force-recreate +``` + +### Rollback via re-run + +Alternatively, trigger a `workflow_dispatch` on the last known-good commit SHA from the GitHub Actions UI — this rebuilds and redeploys that exact commit. + +## Production Security Checklist + +Before deploying to production, verify: + +- [ ] `JWT_SECRET`, `JWT_ACCESS_TOKEN_SECRET`, `JWT_REFRESH_TOKEN_SECRET` are set to random 32+ char strings (`openssl rand -hex 32`) +- [ ] `DATABASE_URL` includes `?sslmode=require&connection_limit=10` +- [ ] `WAAFI_INSECURE_TLS` is `false` (app will refuse to start if `true` in production) +- [ ] `NODE_ENV=production` is set +- [ ] `GITHUB_PACKAGE_TOKEN` is a scoped read-only token, not a personal admin token +- [ ] No `.env` files are committed to the repository (`git status` should show none) + +## Data Retention Policy + +The `TasksService` runs a daily purge cron at 02:00 EAT that automatically deletes: + +| Table | Retention | +|---|---| +| `OtpCode` | 1 hour after expiry or verification | +| `FaydaVerificationSession` | 1 hour after expiry or completion | +| `AuditLog` | 365 days | +| `PaymentWebhookEvent` | 90 days | +| `GateValidationLog` | 180 days | + +No manual intervention is required. Monitor the `TasksService` log output for purge counts. ## GitHub Actions Deployment Flow @@ -147,9 +193,10 @@ For each service: - 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 ` +- For `passenger-api` and `payment-api`: builds and runs the migration image as a gated step before the app image. +- Builds the service image and tags it with the short git SHA. +- Runs `docker compose up -d --force-recreate`. +- For API services: polls `GET /health/ready` every 10s for up to 120s. Fails the job if the service does not become healthy. - Cleans `.npmrc`/`.npmrc_temp`. ## Branch/Environment Isolation diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index d21dab37f..45a52dd8f 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -48,6 +48,7 @@ "class-validator": "^0.14.0", "dotenv": "^17.4.2", "express": "^4.18.2", + "helmet": "^8.0.0", "jose": "^5.10.0", "pg": "^8.21.0", "qrcode": "^1.5.3", diff --git a/apps/edr-passenger-api/prisma/migrations/20260702163828_add_route_coach_template/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260702163828_add_route_coach_template/migration.sql new file mode 100644 index 000000000..232f9b956 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260702163828_add_route_coach_template/migration.sql @@ -0,0 +1,25 @@ +-- AlterTable: change distanceKm from Decimal to Double Precision on RouteStop +ALTER TABLE "RouteStop" ALTER COLUMN "distanceKm" TYPE DOUBLE PRECISION; + +-- CreateTable +CREATE TABLE "RouteCoachTemplate" ( + "id" TEXT NOT NULL, + "routeId" TEXT NOT NULL, + "coachId" TEXT NOT NULL, + "positionNumber" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RouteCoachTemplate_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "RouteCoachTemplate_routeId_idx" ON "RouteCoachTemplate"("routeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "RouteCoachTemplate_routeId_positionNumber_key" ON "RouteCoachTemplate"("routeId", "positionNumber"); + +-- AddForeignKey +ALTER TABLE "RouteCoachTemplate" ADD CONSTRAINT "RouteCoachTemplate_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RouteCoachTemplate" ADD CONSTRAINT "RouteCoachTemplate_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260702165853_add_seat_class_nationality_bed_position/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260702165853_add_seat_class_nationality_bed_position/migration.sql new file mode 100644 index 000000000..b70865401 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260702165853_add_seat_class_nationality_bed_position/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "SeatClass" ADD COLUMN "bedPosition" TEXT, +ADD COLUMN "nationalityType" TEXT; + +-- CreateIndex +CREATE INDEX "SeatClass_coachTypeId_nationalityType_bedPosition_idx" ON "SeatClass"("coachTypeId", "nationalityType", "bedPosition"); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 82cd15f89..68b3ac234 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -88,7 +88,9 @@ model SeatClass { coachTypeId String name String description String? - baseFareMinor Int @default(0) // per-km rate + nationalityType String? // 'LOCAL' | 'INTERNATIONAL' + bedPosition String? // 'UPPER' | 'MIDDLE' | 'LOWER' | null for regular seat + baseFareMinor Int @default(0) // per-km rate (tariff decimal × 100000) premiumMinor Int @default(0) // flat fee per passenger insuranceFeeMinor Int @default(0) // flat fee per passenger isActive Boolean @default(true) @@ -100,6 +102,7 @@ model SeatClass { segmentFares SegmentFareRule[] @@unique([coachTypeId, name]) @@index([coachTypeId]) + @@index([coachTypeId, nationalityType, bedPosition]) @@schema("passenger") } @@ -420,9 +423,10 @@ model Coach { status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE' createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - coachType CoachType @relation(fields: [coachTypeId], references: [id]) - seats Seat[] - assignments CoachAssignment[] + coachType CoachType @relation(fields: [coachTypeId], references: [id]) + seats Seat[] + assignments CoachAssignment[] + routeTemplates RouteCoachTemplate[] @@index([coachTypeId]) @@index([sequence]) @@schema("passenger") @@ -998,6 +1002,7 @@ model Route { fareRules RouteFareRule[] segmentFares SegmentFareRule[] schedules TrainSchedule[] + coachTemplates RouteCoachTemplate[] @@schema("passenger") } @@ -1015,6 +1020,20 @@ model RouteStop { @@schema("passenger") } +model RouteCoachTemplate { + id String @id @default(uuid()) + routeId String + coachId String + positionNumber Int + createdAt DateTime @default(now()) + route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) + coach Coach @relation(fields: [coachId], references: [id]) + + @@unique([routeId, positionNumber]) + @@index([routeId]) + @@schema("passenger") +} + model RouteFareRule { id String @id @default(uuid()) routeId String diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index c898e8231..0e9f85079 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -166,21 +166,41 @@ async function seedCoachTypesAndClasses() { }); } + // Tariff rates: baseFareMinor = tariff_decimal × 100000 + // Formula: fare = km × (baseFareMinor / 100000) × 1.02 × exchangeRate + // LOCAL = Ethiopian or Djiboutian nationals + // INTERNATIONAL = all other nationalities const seatClasses = [ - { name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900, premiumMinor: 50, insuranceFeeMinor: 25 }, - { name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800, premiumMinor: 45, insuranceFeeMinor: 20 }, - { name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600, premiumMinor: 30, insuranceFeeMinor: 15 }, - { name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550, premiumMinor: 28, insuranceFeeMinor: 14 }, - { name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500, premiumMinor: 25, insuranceFeeMinor: 12 }, - { name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250, premiumMinor: 12, insuranceFeeMinor: 6 }, + // LOCAL rates + { name: 'Economy Regular (Local)', coachCode: 'HSC', nationalityType: 'LOCAL', bedPosition: null, baseFareMinor: 3000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Upper (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 4000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Middle (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'MIDDLE',baseFareMinor: 5500, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Lower (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'VIP Bed Upper (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 7500, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'VIP Bed Lower (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 }, + // INTERNATIONAL rates + { name: 'Economy Regular (Intl)', coachCode: 'HSC', nationalityType: 'INTERNATIONAL', bedPosition: null, baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Upper (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Middle (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'MIDDLE',baseFareMinor: 11000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Lower (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 12000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'VIP Bed Upper (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 15000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'VIP Bed Lower (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 16000, premiumMinor: 0, insuranceFeeMinor: 0 }, ]; for (const sc of seatClasses) { const ct = await prisma.coachType.findUnique({ where: { id: sc.coachCode } }); await prisma.seatClass.upsert({ where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } }, - update: {}, - create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor, premiumMinor: sc.premiumMinor, insuranceFeeMinor: sc.insuranceFeeMinor }, + update: { nationalityType: sc.nationalityType, bedPosition: sc.bedPosition, baseFareMinor: sc.baseFareMinor }, + create: { + coachTypeId: ct!.id, + name: sc.name, + nationalityType: sc.nationalityType, + bedPosition: sc.bedPosition, + baseFareMinor: sc.baseFareMinor, + premiumMinor: sc.premiumMinor, + insuranceFeeMinor: sc.insuranceFeeMinor, + }, }); } console.log(` ✅ ${coachTypes.length} coach types, ${seatClasses.length} seat classes created`); @@ -238,6 +258,58 @@ async function seedRoute() { }); } console.log(` ✅ Route with ${returnStationCodes.length} stops created`); + + // Full cross-border route: Sebeta → Nagad (all 15 stations) + const fullRoute = await prisma.route.upsert({ + where: { code: 'Route-201' }, + update: {}, + create: { + code: 'Route-201', + name: 'Sebeta - Nagad (Full Cross-Border)', + description: 'Full Ethio-Djibouti cross-border route from Sebeta to Nagad', + effectiveFrom: new Date('2026-01-01'), + effectiveUntil: new Date('2034-12-31'), + active: true, + }, + }); + + // Cumulative distances from Sebeta (km) for all 15 stations + const fullStationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE', 'ADG', 'AYS', 'DAW', 'ALS', 'HOL', 'NAG']; + const fullDistancesKm = [0, 11.5, 67.2, 89.9, 106.7, 180.2, 231.6, 293.6, 413.0, 453.0, 498.0, 531.0, 601.0, 632.0, 656.0]; + for (let i = 0; i < fullStationCodes.length; i++) { + const station = await prisma.station.findUnique({ where: { code: fullStationCodes[i] } }); + await prisma.routeStop.upsert({ + where: { routeId_sequence: { routeId: fullRoute.id, sequence: i + 1 } }, + update: { distanceKm: fullDistancesKm[i] }, + create: { routeId: fullRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullDistancesKm[i] }, + }); + } + + // Full cross-border return route: Nagad → Sebeta + const fullReturnRoute = await prisma.route.upsert({ + where: { code: 'Route-202' }, + update: {}, + create: { + code: 'Route-202', + name: 'Nagad - Sebeta (Full Cross-Border Return)', + description: 'Full Ethio-Djibouti cross-border return route from Nagad to Sebeta', + effectiveFrom: new Date('2026-01-01'), + effectiveUntil: new Date('2034-12-31'), + active: true, + }, + }); + + const fullReturnStationCodes = ['NAG', 'HOL', 'ALS', 'DAW', 'AYS', 'ADG', 'DRE', 'BIK', 'MIS', 'MTE', 'ADM', 'MOJ', 'BSH', 'LEB', 'SBT']; + const fullReturnDistancesKm = [0, 24.0, 55.0, 125.0, 158.0, 203.0, 243.0, 362.4, 424.4, 475.8, 549.3, 566.1, 588.8, 644.5, 656.0]; + for (let i = 0; i < fullReturnStationCodes.length; i++) { + const station = await prisma.station.findUnique({ where: { code: fullReturnStationCodes[i] } }); + await prisma.routeStop.upsert({ + where: { routeId_sequence: { routeId: fullReturnRoute.id, sequence: i + 1 } }, + update: { distanceKm: fullReturnDistancesKm[i] }, + create: { routeId: fullReturnRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullReturnDistancesKm[i] }, + }); + } + console.log(` ✅ Full cross-border routes (Route-201, Route-202) with 15 stops each created`); } async function seedCoaches() { @@ -431,34 +503,61 @@ async function seedTrips() { async function seedFareRules() { console.log('\n💰 Seeding fare rules...'); const route = await prisma.route.findUnique({ where: { code: 'Route-101' } }); + const returnRoute = await prisma.route.findUnique({ where: { code: 'Route-102' } }); const seatClasses = await prisma.seatClass.findMany(); const validFrom = new Date('2024-01-01'); - const fareRules = []; - for (const sc of seatClasses) { - fareRules.push({ - routeId: route!.id, - seatClassId: sc.id, - passengerCategory: 'ADULT' as const, - baseFareMinor: sc.baseFareMinor, - currency: 'ETB', - validFrom, - }); - fareRules.push({ - routeId: route!.id, - seatClassId: sc.id, - passengerCategory: 'CHILD' as const, - baseFareMinor: Math.floor(sc.baseFareMinor * 0.5), - discountPercent: 10, - currency: 'ETB', - validFrom, - }); + // Delete existing FareRule rows so re-seed is idempotent + await prisma.fareRule.deleteMany({}); + + const fareRules: any[] = []; + for (const route of [{ code: 'Route-101' }, { code: 'Route-102' }, { code: 'Route-201' }, { code: 'Route-202' }]) { + for (const sc of seatClasses) { + fareRules.push({ + route: route.code, + seatClassId: sc.id, + baseFareMinor: sc.baseFareMinor, + currency: 'ETB', + validFrom, + }); + } } await Promise.all( - fareRules.map(fr => prisma.routeFareRule.create({ data: fr })) + fareRules.map(fr => prisma.fareRule.create({ data: fr })) ); - console.log(` ✅ ${fareRules.length} fare rules for ADULT/CHILD categories created`); + console.log(` ✅ ${fareRules.length} fare rules created in FareRule table`); + + const allRoutes = await prisma.route.findMany({ + where: { code: { in: ['Route-101', 'Route-102', 'Route-201', 'Route-202'] } }, + }); + const routeFareRules: any[] = []; + for (const r of allRoutes) { + for (const sc of seatClasses) { + routeFareRules.push({ + routeId: r.id, + seatClassId: sc.id, + passengerCategory: 'ADULT' as const, + baseFareMinor: sc.baseFareMinor, + currency: 'ETB', + validFrom, + }); + // CHILD: same per-km rate as ADULT — age-based free/paid logic is handled + // at booking time (first child free, subsequent children full fare). + routeFareRules.push({ + routeId: r.id, + seatClassId: sc.id, + passengerCategory: 'CHILD' as const, + baseFareMinor: sc.baseFareMinor, + currency: 'ETB', + validFrom, + }); + } + } + await Promise.all( + routeFareRules.map(fr => prisma.routeFareRule.create({ data: fr }).catch(() => {})) + ); + console.log(` ✅ ${routeFareRules.length} route fare rules for ADULT/CHILD categories created`); } async function seedCurrency() { @@ -758,7 +857,23 @@ async function runStep(name: string, step: () => Promise): Promise Promise]> = [ + const steps: Array<[string, () => Promise]> = [ + ['System Users', seedSystemUsers], + ['Stations', seedStations], + ['Coach Types & Classes', seedCoachTypesAndClasses], + ['Route', seedRoute], + ['Coaches', seedCoaches], + ['Trips', seedTrips], + ['Fare Rules', seedFareRules], + ['Currency', seedCurrency], + ['Payment Methods', seedPaymentMethods], + ['Segment Fares', seedSegmentFares], + ['Notification Templates', seedNotificationTemplates], + ['Menu & Food', seedMenuAndFood], + ['Promotions', seedPromotions], + ['FAQ', seedFAQ], + ['Fraud Rules', seedFraudRules], + ['Kulubbi Package', seedKulubbiPackage], ]; let failed = 0; diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index f4bac0f0b..ba7a18086 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -1,9 +1,4 @@ -import { - MiddlewareConsumer, - Module, - NestModule, - OnApplicationBootstrap, -} from '@nestjs/common'; +import {Logger, Module, OnApplicationBootstrap} from '@nestjs/common'; import { ThrottlerModule } from '@nestjs/throttler'; import { DynamicThrottlerGuard } from './common/dynamic-throttler.guard'; import { APP_GUARD, APP_FILTER } from '@nestjs/core'; @@ -66,7 +61,6 @@ import { PackagesModule } from './modules/packages/packages.module'; import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module'; import { HealthModule } from './modules/health/health.module'; import { TasksModule } from './modules/tasks/tasks.module'; -import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module'; @Module({ imports: [ @@ -136,7 +130,6 @@ import { ConfigurableFareModule } from './modules/configurable-fare/configurable ExcessBaggageModule, HealthModule, TasksModule, - ConfigurableFareModule, ], providers: [ { provide: APP_GUARD, useClass: DynamicThrottlerGuard }, @@ -147,6 +140,7 @@ import { ConfigurableFareModule } from './modules/configurable-fare/configurable ], }) export class AppModule implements OnApplicationBootstrap { + private readonly logger = new Logger(AppModule.name); constructor( private readonly seeder: DataSeeder, private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder, @@ -157,17 +151,17 @@ export class AppModule implements OnApplicationBootstrap { try { await this.seeder.run(); } catch (err) { - console.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message); + this.logger.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message); } try { await this.edrPassengerOrgSeeder.run(); } catch (err) { - console.error('[EdrPassengerOrgSeeder] Seed failed (non-fatal):', (err as Error).message); + this.logger.error('[EdrPassengerOrgSeeder] Seed failed (non-fatal):', (err as Error).message); } try { await this.passengerStaffUsersSeeder.run(); } catch (err) { - console.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message); + this.logger.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message); } } } diff --git a/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts b/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts index 7a450bfaf..5ad8232ec 100644 --- a/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts +++ b/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts @@ -3,6 +3,17 @@ import { Reflector } from '@nestjs/core'; import { ThrottlerGuard, ThrottlerStorage, getOptionsToken, getStorageToken } from '@nestjs/throttler'; import { SystemConfigService, CONFIG_KEYS } from '../modules/system-config/system-config.service'; +// Route-prefix → throttler tier mapping. +// Evaluated in order; first match wins. +const ROUTE_TIERS: Array<{ prefix: string; tier: 'auth' | 'strict' | 'default' }> = [ + { prefix: '/auth', tier: 'auth' }, + { prefix: '/fayda/verification',tier: 'auth' }, + { prefix: '/bookings', tier: 'strict' }, + { prefix: '/passengers', tier: 'strict' }, + { prefix: '/payments', tier: 'strict' }, + { prefix: '/wallet', tier: 'strict' }, +]; + @Injectable() export class DynamicThrottlerGuard extends ThrottlerGuard { constructor( @@ -29,9 +40,17 @@ export class DynamicThrottlerGuard extends ThrottlerGuard { this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_DEFAULT_TTL_MS), ]); - this.throttlers = [ - { name: 'default', ttl: defaultTtl, limit: defaultLimit }, - ]; + const url: string = context.switchToHttp().getRequest<{ url: string }>().url ?? ''; + const matched = ROUTE_TIERS.find(({ prefix }) => url.startsWith(prefix)); + const tier = matched?.tier ?? 'default'; + + if (tier === 'auth') { + this.throttlers = [{ name: 'auth', ttl: authTtl, limit: authLimit }]; + } else if (tier === 'strict') { + this.throttlers = [{ name: 'strict', ttl: strictTtl, limit: strictLimit }]; + } else { + this.throttlers = [{ name: 'default', ttl: defaultTtl, limit: defaultLimit }]; + } return super.canActivate(context); } diff --git a/apps/edr-passenger-api/src/common/prisma.service.ts b/apps/edr-passenger-api/src/common/prisma.service.ts index 75d4eaa24..9789791ad 100644 --- a/apps/edr-passenger-api/src/common/prisma.service.ts +++ b/apps/edr-passenger-api/src/common/prisma.service.ts @@ -1,8 +1,29 @@ -import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(PrismaService.name); + + constructor() { + super({ + // In production set connection_limit and pool_timeout in DATABASE_URL: + // ?connection_limit=10&pool_timeout=20&sslmode=require + log: + process.env.NODE_ENV === 'development' + ? [{ emit: 'event', level: 'query' }, { emit: 'stdout', level: 'warn' }, { emit: 'stdout', level: 'error' }] + : [{ emit: 'stdout', level: 'warn' }, { emit: 'stdout', level: 'error' }], + }); + + if (process.env.NODE_ENV === 'development') { + (this as any).$on('query', (e: { query: string; duration: number }) => { + if (e.duration > 500) { + this.logger.warn(`Slow query (${e.duration}ms): ${e.query}`); + } + }); + } + } + async onModuleInit() { await this.$connect(); } async onModuleDestroy() { await this.$disconnect(); } } diff --git a/apps/edr-passenger-api/src/config/app.config.ts b/apps/edr-passenger-api/src/config/app.config.ts index 9203a9d7b..5ed75a2b3 100644 --- a/apps/edr-passenger-api/src/config/app.config.ts +++ b/apps/edr-passenger-api/src/config/app.config.ts @@ -1,9 +1,23 @@ import { registerAs } from '@nestjs/config'; -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.PORTAL_URL ?? 'http://localhost:3000', - portalUrl: process.env.BACK_OFFICE_URL ?? 'http://localhost:3001', -})); +export default registerAs('app', () => { + const isProd = process.env.NODE_ENV === 'production'; + + if (isProd && !process.env.JWT_SECRET) { + throw new Error('JWT_SECRET environment variable is required in production'); + } + if (isProd && !process.env.JWT_ACCESS_TOKEN_SECRET) { + throw new Error('JWT_ACCESS_TOKEN_SECRET environment variable is required in production'); + } + if (isProd && !process.env.JWT_REFRESH_TOKEN_SECRET) { + throw new Error('JWT_REFRESH_TOKEN_SECRET environment variable is required in production'); + } + + return { + port: parseInt(process.env.PORT ?? '4000', 10), + jwtSecret: process.env.JWT_SECRET ?? 'dev-secret', + jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d', + 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/main.ts b/apps/edr-passenger-api/src/main.ts index 004ef1336..cbdae37e3 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -1,11 +1,12 @@ -// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM +// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM // modules read process.env at module-load time (e.g. MinioModule.register reads MINIO_ENDPOINT), // which happens before ConfigModule.forRoot() would populate it. Must be the very first import. import "dotenv/config"; import "reflect-metadata"; import { NestFactory } from "@nestjs/core"; -import { ValidationPipe, VersioningType } from "@nestjs/common"; +import { Logger, ValidationPipe, VersioningType } from "@nestjs/common"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; +import helmet from "helmet"; import { AppModule } from "./app.module"; import { HttpExceptionFilter } from "./common/filters/http-exception.filter"; import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor"; @@ -14,21 +15,32 @@ import { SessionActivityInterceptor } from "./common/interceptors/session-activi // Set timezone to Africa/Addis_Ababa (EAT - UTC+3) for Ethiopian Railway operations process.env.TZ = 'Africa/Addis_Ababa'; +// Safety guard: prevent insecure TLS from being enabled in production +if (process.env.NODE_ENV === 'production' && process.env.WAAFI_INSECURE_TLS === 'true') { + throw new Error('WAAFI_INSECURE_TLS=true is not allowed in production'); +} + async function bootstrap() { // rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers // (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed. const app = await NestFactory.create(AppModule, { rawBody: true }); + // Security headers + app.use(helmet()); + // URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under // `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay - // version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend. + // version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend. app.enableVersioning({ type: VersioningType.URI }); app.enableCors({ origin: [ process.env.PORTAL_URL ?? "http://localhost:5174", - process.env.BACK_OFFICE_URL ?? "http://localhost:5184", + process.env.BACK_OFFICE_URL ?? "http://localhost:5184", ], + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'Accept-Language', 'X-Request-ID'], + credentials: true, }); app.useGlobalFilters(new HttpExceptionFilter()); @@ -38,6 +50,7 @@ async function bootstrap() { ); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidUnknownValues: false })); + if (process.env.NODE_ENV !== 'production') { const config = new DocumentBuilder() .setTitle("EDR Passenger API") .setDescription( @@ -130,7 +143,7 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m - Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps) - Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets - Complete audit trail per leg for compliance and reporting -- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode +- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode ### Booking Type Matrix @@ -240,28 +253,28 @@ For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`ret ### Step 3: Passenger Information & Verification **For Ethiopian Passengers:** -\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years) +\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years) **For International Passengers:** -\`POST /passengers/register-international\` — Passport information collection +\`POST /passengers/register-international\` — Passport information collection ### Step 4: View Seat Map -\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats. +\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats. For round-trips, call this twice: once for outbound scheduleId, once for return scheduleId. ### Step 5: Hold Seats \`POST /seats/hold\` to reserve seats for 15 minutes. -- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\` -- TRANSIT leg-2: second hold call → \`leg2HoldId\` -- ROUND_TRIP return: second hold call → \`returnHoldId\` -- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\` +- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\` +- TRANSIT leg-2: second hold call → \`leg2HoldId\` +- ROUND_TRIP return: second hold call → \`returnHoldId\` +- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\` ### Step 6: Create Booking Choose the right endpoint and bookingType: -- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\` -- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\` -- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\` -- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\` +- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\` +- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\` +- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\` +- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\` ### Step 7: Process Payment \`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian) @@ -406,10 +419,12 @@ Payment providers send notifications to: operationsSorter: "alpha", }, }); + } // end if (NODE_ENV !== 'production') const port = process.env.PORT ?? 4000; await app.listen(port); - console.log(`🚀 EDR Passenger API running on port ${port}`); - console.log(`📚 Swagger: http://localhost:${port}/api-docs`); + const logger = new Logger('Bootstrap'); + logger.log(`EDR Passenger API running on port ${port}`); + logger.log(`Swagger: http://localhost:${port}/api-docs`); } bootstrap(); diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts index 909452144..0adb94656 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts @@ -2,9 +2,11 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; import { CurrenciesController } from './currencies.controller'; import { CurrenciesService } from './currencies.service'; +import { CurrencyModule } from '../currency/currency.module'; +import { PrismaModule } from '../../common/prisma.module'; @Module({ - imports: [HttpModule], + imports: [HttpModule, PrismaModule, CurrencyModule], controllers: [CurrenciesController], providers: [CurrenciesService], exports: [CurrenciesService], diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts index ee96ee9cd..2ccc79b70 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts @@ -1,10 +1,14 @@ import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; +import { CurrencyService } from '../currency/currency.service'; import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto'; @Injectable() export class CurrenciesService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private currencyService: CurrencyService, + ) {} async getAllCurrencies() { const rates = await this.prisma.currencyExchangeRate.findMany({ @@ -108,7 +112,12 @@ export class CurrenciesService { } async syncExchangeRates() { - return { message: 'Exchange rates synced successfully', synced: 0 }; + await this.currencyService.syncExchangeRates(); + const rates = await this.prisma.currencyExchangeRate.findMany({ + orderBy: { effectiveDate: 'desc' }, + take: 10, + }); + return { message: 'Exchange rates synced successfully', synced: rates.length }; } private getCurrencyName(code: string): string { diff --git a/apps/edr-passenger-api/src/modules/currency/currency.module.ts b/apps/edr-passenger-api/src/modules/currency/currency.module.ts index 445f31e05..635ab74b0 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.module.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; import { CurrencyService } from './currency.service'; import { PrismaModule } from '../../common/prisma.module'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, HttpModule], providers: [CurrencyService], exports: [CurrencyService], }) diff --git a/apps/edr-passenger-api/src/modules/currency/currency.service.ts b/apps/edr-passenger-api/src/modules/currency/currency.service.ts index 8ac2056ab..4666d4aaa 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.service.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts @@ -4,6 +4,9 @@ import { NotFoundException, BadRequestException, } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; import { PrismaService } from '../../common/prisma.service'; import { Currency } from '@prisma/client'; @@ -21,7 +24,11 @@ const CHARGE_CURRENCY_DECIMALS: Record = { export class CurrencyService { private readonly logger = new Logger(CurrencyService.name); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) {} async convertEtbMinorToChargeMajor( amountMinorEtb: number, @@ -81,14 +88,11 @@ export class CurrencyService { fromCurrency: Currency, toCurrency: Currency, ): Promise { + if (fromCurrency === toCurrency) return 1; + const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({ - where: { - fromCurrency, - toCurrency, - }, - orderBy: { - effectiveDate: 'desc', - }, + where: { fromCurrency, toCurrency }, + orderBy: { effectiveDate: 'desc' }, }); if (!exchangeRate) { @@ -98,26 +102,61 @@ export class CurrencyService { return 1.0; } + const ageMs = Date.now() - exchangeRate.effectiveDate.getTime(); + if (ageMs > 2 * 24 * 60 * 60 * 1000) { + this.logger.warn( + `Stale exchange rate for ${fromCurrency}->${toCurrency}: last updated ${exchangeRate.effectiveDate.toISOString()}`, + ); + } + return Number(exchangeRate.rate); } async syncExchangeRates(): Promise { - this.logger.log('Syncing exchange rates from external provider'); + this.logger.log('Syncing exchange rates from central bank API'); const today = this.todayUtc(); - const rates = [ - { from: 'ETB', to: 'ETB', rate: 1.0 }, - { from: 'ETB', to: 'DJF', rate: 3.25 }, - { from: 'ETB', to: 'USD', rate: 0.018 }, - { from: 'DJF', to: 'ETB', rate: 0.3077 }, - { from: 'USD', to: 'ETB', rate: 55.56 }, + // Fallback rates used when the API is unreachable + const fallbackRates = [ + { from: Currency.ETB, to: Currency.ETB, rate: 1.0 }, + { from: Currency.ETB, to: Currency.DJF, rate: 3.25 }, + { from: Currency.ETB, to: Currency.USD, rate: 0.018 }, + { from: Currency.DJF, to: Currency.ETB, rate: 0.3077 }, + { from: Currency.USD, to: Currency.ETB, rate: 55.56 }, ]; - for (const { from, to, rate } of rates) { - await this.upsertRate(from as Currency, to as Currency, rate, today, 'EXTERNAL_API'); + const apiUrl = this.configService.get('EXCHANGE_RATE_API_URL'); + if (apiUrl) { + try { + const response = await firstValueFrom( + this.httpService.get>(apiUrl, { timeout: 5000 }), + ); + // Expected response shape: { "ETB_DJF": 3.25, "ETB_USD": 0.018, ... } + const data = response.data; + const apiRates = [ + { from: Currency.ETB, to: Currency.ETB, rate: 1.0 }, + { from: Currency.ETB, to: Currency.DJF, rate: data['ETB_DJF'] ?? fallbackRates[1].rate }, + { from: Currency.ETB, to: Currency.USD, rate: data['ETB_USD'] ?? fallbackRates[2].rate }, + { from: Currency.DJF, to: Currency.ETB, rate: data['DJF_ETB'] ?? fallbackRates[3].rate }, + { from: Currency.USD, to: Currency.ETB, rate: data['USD_ETB'] ?? fallbackRates[4].rate }, + ]; + for (const { from, to, rate } of apiRates) { + await this.upsertRate(from, to, rate, today, 'CENTRAL_BANK_API'); + } + this.logger.log('Exchange rates synced from central bank API'); + return; + } catch (err) { + this.logger.warn( + `Central bank API unreachable (${(err as Error).message}), falling back to configured rates`, + ); + } } - this.logger.log('Exchange rates synced successfully'); + // Fallback: persist the static rates so the DB always has a current row + for (const { from, to, rate } of fallbackRates) { + await this.upsertRate(from, to, rate, today, 'FALLBACK'); + } + this.logger.log('Exchange rates synced using fallback values'); } async listRates() { diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index e7126bc8e..592aadacd 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -4,8 +4,6 @@ import { CurrencyService } from '../currency/currency.service'; import { FareCalculateDto, resolveCurrencyFromNationality } from './fare-engine.dto'; import { Currency } from '@prisma/client'; -const TAX_RATE = 0.05; - @Injectable() export class FareEngineService { constructor( @@ -32,6 +30,22 @@ export class FareEngineService { if (!seatClass) throw new NotFoundException('Seat class not found'); if (!seatClass.isActive) throw new BadRequestException('Seat class is not active'); + // Resolve nationality type: Ethiopian and Djiboutian are LOCAL, everyone else INTERNATIONAL + const nationalityUpper = (dto.nationality ?? '').toUpperCase(); + const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') + ? 'LOCAL' : 'INTERNATIONAL'; + + // Find the nationality-specific seat class for the same coach type and bed position. + // Falls back to the requested seatClass if no nationality-specific one exists. + const nationalitySeatClass = await this.prisma.seatClass.findFirst({ + where: { + coachTypeId: seatClass.coachTypeId, + nationalityType, + bedPosition: seatClass.bedPosition ?? null, + isActive: true, + }, + }) ?? seatClass; + // Calculate distance: distanceKm represents cumulative distance from route origin // For a segment, distance = destination.distanceKm - origin.distanceKm const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!; @@ -102,9 +116,10 @@ export class FareEngineService { ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; fareSource = 'SCHEDULE_FARE_RULE'; } else { - // Default: distance-based using live SeatClass rate - ratePerKmMinor = seatClass.baseFareMinor; - baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm); + // Default: distance-based using tariff formula: km × rate × 1.02 + // baseFareMinor stores the per-km rate (tariff decimal × 100000) + ratePerKmMinor = nationalitySeatClass.baseFareMinor; + baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm * 1.02); fareSource = 'SEAT_CLASS_BASE_FARE'; } @@ -138,8 +153,7 @@ export class FareEngineService { } const afterDiscountMinor = subtotalMinor - discountMinor; - const taxMinor = Math.round(afterDiscountMinor * TAX_RATE); - const totalEtbMinor = afterDiscountMinor + taxMinor; + const totalEtbMinor = afterDiscountMinor; const billingCurrency = resolveCurrencyFromNationality(dto.nationality); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); @@ -147,8 +161,9 @@ export class FareEngineService { const calculation = [ `Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`, - `Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`, - `Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} = ${baseFarePerPassengerMinor} ETB minor`, + `Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType} → ${nationalitySeatClass.name}`, + `Rate per km: ${ratePerKmMinor} ETB minor (${nationalitySeatClass.name})`, + `Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} × 1.02 = ${baseFarePerPassengerMinor} ETB minor`, `Premium/pax: ${premiumPerPassenger} ETB minor`, `Insurance/pax: ${insurancePerPassenger} ETB minor`, `Total fare/pax: ${farePerPassengerMinor} ETB minor`, @@ -160,10 +175,8 @@ export class FareEngineService { ``, `Subtotal: ${subtotalMinor} ETB minor`, `Discount: ${promoLabel} → -${discountMinor} ETB minor`, - `Tax (5%): +${taxMinor} ETB minor`, `Total (ETB): ${totalEtbMinor} ETB minor`, ``, - `Nationality: ${dto.nationality ?? 'unspecified'} → ${billingCurrency}`, `Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`, `Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`, `Fare source: ${fareSource}`, @@ -174,8 +187,8 @@ export class FareEngineService { routeCode: route.code, originName: originStation?.name ?? dto.originStationId, destinationName: destStation?.name ?? dto.destinationStationId, - seatClassId: seatClass.id, - seatClassName: seatClass.name, + seatClassId: nationalitySeatClass.id, + seatClassName: nationalitySeatClass.name, totalDistanceKm, ratePerKmMinor, baseFarePerPassengerMinor, @@ -188,7 +201,6 @@ export class FareEngineService { paidChildrenCount, subtotalMinor, discountMinor, - taxMinor, totalMinor: totalEtbMinor, billingCurrency, totalInBillingCurrency, @@ -313,16 +325,13 @@ export class FareEngineService { const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); return fareRules.map(rule => { const seatClassId = rule.seatClassId; - const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE); - const totalMinor = rule.baseFareMinor + taxMinor; return { seatClassId, seatClassName: 'Unknown', baseFareMinor: rule.baseFareMinor, - taxMinor, - totalMinor, + totalMinor: rule.baseFareMinor, billingCurrency, - totalInBillingCurrency: Math.round(totalMinor * exchangeRate), + totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), exchangeRate, source: 'FARE_RULE', }; 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 72cbbbb47..1cd382862 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts @@ -1,7 +1,7 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { RoutesService } from './routes.service'; -import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto'; +import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; import { JwtGuard } from '../../common/jwt.guard'; @ApiTags('Routes') @@ -93,4 +93,35 @@ Route stops carry distanceKm for fare-by-distance calculations.`, @ApiResponse({ status: 200, description: 'Schedules with train and terminal station details' }) @ApiResponse({ status: 404, description: 'Route not found' }) getSchedules(@Param('id') id: string) { return this.service.getSchedulesForRoute(id); } + + // ── Route Coach Template ─────────────────────────────────────────────────── + + @Get(':id/coaches') + @ApiOperation({ summary: 'Get the default coach lineup for this route' }) + @ApiParam({ name: 'id', description: 'Route UUID' }) + @ApiResponse({ status: 200, description: 'Ordered coach template with coach and coach type details' }) + @ApiResponse({ status: 404, description: 'Route not found' }) + getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); } + + @Put(':id/coaches') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Set the default coach lineup for this route', + description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.', + }) + @ApiParam({ name: 'id', description: 'Route UUID' }) + @ApiResponse({ status: 200, description: 'Updated coach template' }) + @ApiResponse({ status: 400, description: 'Duplicate positions or inactive coach' }) + @ApiResponse({ status: 404, description: 'Route or coach not found' }) + setCoachTemplate(@Param('id') id: string, @Body() dto: SetRouteCoachTemplateDto) { + return this.service.setRouteCoachTemplate(id, dto); + } + + @Delete(':id/coaches') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Clear the default coach lineup for this route' }) + @ApiParam({ name: 'id', description: 'Route UUID' }) + @ApiResponse({ status: 200, description: 'Template cleared' }) + @ApiResponse({ status: 404, description: 'Route not found' }) + clearCoachTemplate(@Param('id') id: string) { return this.service.removeRouteCoachTemplate(id); } } diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts index bce25fea5..bcd4dfcee 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -44,3 +44,14 @@ export class UpdateRouteDto { @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string; @ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[]; } + +export class RouteCoachTemplateItemDto { + @ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string; + @ApiProperty({ example: 1, description: 'Position in the train consist (1 = first coach)' }) @IsInt() @Min(1) positionNumber: number; +} + +export class SetRouteCoachTemplateDto { + @ApiProperty({ type: [RouteCoachTemplateItemDto], description: 'Ordered list of coaches for this route. Replaces the existing template.' }) + @IsArray() @ValidateNested({ each: true }) @Type(() => RouteCoachTemplateItemDto) + coaches: RouteCoachTemplateItemDto[]; +} 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 2a7254f37..479f6b0ca 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; -import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto'; +import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; @Injectable() @@ -202,6 +202,44 @@ export class RoutesService { }); } + async getRouteCoachTemplate(routeId: string) { + const route = await this.prisma.route.findUnique({ where: { id: routeId } }); + if (!route) throw new NotFoundException('Route not found'); + return this.prisma.routeCoachTemplate.findMany({ + where: { routeId }, + include: { coach: { include: { coachType: true } } }, + orderBy: { positionNumber: 'asc' }, + }); + } + + async setRouteCoachTemplate(routeId: string, dto: SetRouteCoachTemplateDto) { + const route = await this.prisma.route.findUnique({ where: { id: routeId } }); + if (!route) throw new NotFoundException('Route not found'); + + const coachIds = dto.coaches.map(c => c.coachId); + const coaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } }); + if (coaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found'); + const inactive = coaches.find(c => c.status !== 'ACTIVE'); + if (inactive) throw new BadRequestException(`Coach ${inactive.number} is not active`); + + const positions = dto.coaches.map(c => c.positionNumber); + if (new Set(positions).size !== positions.length) throw new BadRequestException('Duplicate positionNumber values'); + + await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } }); + await this.prisma.routeCoachTemplate.createMany({ + data: dto.coaches.map(c => ({ routeId, coachId: c.coachId, positionNumber: c.positionNumber })), + }); + + return this.getRouteCoachTemplate(routeId); + } + + async removeRouteCoachTemplate(routeId: string) { + const route = await this.prisma.route.findUnique({ where: { id: routeId } }); + if (!route) throw new NotFoundException('Route not found'); + await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } }); + return { deleted: true, routeId }; + } + // ── Used by SchedulesService ─────────────────────────────────────────────── /** diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index b6363e085..044768e90 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -119,7 +119,7 @@ export class BulkCreateSchedulesDto { @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) plannedTimes?: PlannedStopTimeDto[]; - @ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' }) + @ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule. Overrides the route coach template if provided.' }) @IsOptional() @IsArray() @IsString({ each: true }) coachIds?: string[]; } 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 567b9400a..2d194fd7b 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -46,6 +46,8 @@ export class SchedulesService { const schedule = await this.createSchedule(createDto); scheduleIds.push(schedule.id); + // createSchedule already auto-applies the route coach template; + // only override if explicit coachIds are provided if (dto.coachIds && dto.coachIds.length > 0) { await this.assignCoaches( schedule.id, @@ -177,6 +179,18 @@ export class SchedulesService { const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap); + // Auto-apply route coach template if one is defined + const coachTemplates = await this.prisma.routeCoachTemplate.findMany({ + where: { routeId: dto.routeId }, + orderBy: { positionNumber: 'asc' }, + }); + if (coachTemplates.length > 0) { + await this.assignCoaches( + schedule.id, + coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })), + ); + } + return this.getSchedule(schedule.id); } @@ -583,10 +597,10 @@ export class SchedulesService { await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } }); - const data = coaches.map((c, idx) => ({ + const data = coaches.map((c) => ({ scheduleId, coachId: c.coachId, - positionNumber: idx + 1, + positionNumber: c.positionNumber, isOperational: true, })); 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 0440e697e..6f3bc5a67 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -468,7 +468,7 @@ export class SearchService { insuranceFeeMinor: fare.insurancePerPassenger, totalBaseFareMinor: fare.subtotalMinor, discountMinor: fare.discountMinor, - taxesFeesMinor: fare.taxMinor, + taxesFeesMinor: 0, loyaltyRedemptionMinor: loyaltyMinor, totalMinor, currency: 'ETB', diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts index 9759ff297..fb8a29a6e 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../../common/prisma.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { CurrencyModule } from '../currency/currency.module'; import { TasksService } from './tasks.service'; @Module({ - imports: [PrismaModule, NotificationsModule], + imports: [PrismaModule, NotificationsModule, CurrencyModule], providers: [TasksService], }) export class TasksModule {} diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index bc52cc613..401a37ddb 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -2,12 +2,20 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; +import { CurrencyService } from '../currency/currency.service'; /** Maximum time (hours) a passenger has to pay after booking. */ const MAX_PAYMENT_HOURS = 2; /** Minutes before departure: cutoff for new bookings and payment deadline. */ const CUTOFF_MINUTES = 30; +// Retention windows +const OTP_RETENTION_HOURS = 1; +const FAYDA_SESSION_RETENTION_HOURS = 1; +const AUDIT_LOG_RETENTION_DAYS = 365; +const WEBHOOK_EVENT_RETENTION_DAYS = 90; +const GATE_LOG_RETENTION_DAYS = 180; + /** * payment_deadline = MIN(booking_time + 2h, departure_time - 30min) */ @@ -32,6 +40,7 @@ export class TasksService { constructor( private readonly prisma: PrismaService, private readonly sms: SmsClientService, + private readonly currencyService: CurrencyService, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -243,4 +252,47 @@ export class TasksService { this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`); } } + + // ───────────────────────────────────────────────────────────────────────── + // Daily at 01:00 EAT: fetch mid-market rates from central bank API. + // ───────────────────────────────────────────────────────────────────────── + @Cron('0 1 * * *', { timeZone: 'Africa/Addis_Ababa' }) + async syncExchangeRates() { + try { + await this.currencyService.syncExchangeRates(); + } catch (err) { + this.logger.error(`Exchange rate sync failed: ${(err as Error).message}`); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Daily at 02:00 EAT: purge expired/stale records to enforce data retention. + // ───────────────────────────────────────────────────────────────────────── + @Cron('0 2 * * *') + async purgeExpiredData() { + const now = new Date(); + + const otpCutoff = new Date(now.getTime() - OTP_RETENTION_HOURS * 60 * 60 * 1000); + const faydaCutoff = new Date(now.getTime() - FAYDA_SESSION_RETENTION_HOURS * 60 * 60 * 1000); + const auditCutoff = new Date(now.getTime() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000); + const webhookCutoff = new Date(now.getTime() - WEBHOOK_EVENT_RETENTION_DAYS * 24 * 60 * 60 * 1000); + const gateCutoff = new Date(now.getTime() - GATE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000); + + const [otps, faydaSessions, auditLogs, webhookEvents, gateLogs] = await Promise.all([ + this.prisma.otpCode.deleteMany({ + where: { OR: [{ expiresAt: { lte: otpCutoff } }, { verified: true, createdAt: { lte: otpCutoff } }] }, + }), + this.prisma.faydaVerificationSession.deleteMany({ + where: { OR: [{ expiresAt: { lte: faydaCutoff } }, { status: { in: ['COMPLETED', 'FAILED'] }, createdAt: { lte: faydaCutoff } }] }, + }), + this.prisma.auditLog.deleteMany({ where: { createdAt: { lte: auditCutoff } } }), + this.prisma.paymentWebhookEvent.deleteMany({ where: { receivedAt: { lte: webhookCutoff } } }), + this.prisma.gateValidationLog.deleteMany({ where: { validatedAt: { lte: gateCutoff } } }), + ]); + + this.logger.log( + `Data retention purge: ${otps.count} OTPs, ${faydaSessions.count} Fayda sessions, ` + + `${auditLogs.count} audit logs, ${webhookEvents.count} webhook events, ${gateLogs.count} gate logs deleted`, + ); + } } diff --git a/apps/edr-passenger-web/backoffice/next.config.js b/apps/edr-passenger-web/backoffice/next.config.js index 5690be409..ba1deb613 100644 --- a/apps/edr-passenger-web/backoffice/next.config.js +++ b/apps/edr-passenger-web/backoffice/next.config.js @@ -1,12 +1,13 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + output: 'standalone', 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, + unoptimized: false, }, }; 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 e7ac34e57..882ff0917 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -1,15 +1,15 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Edit, Trash2, X, Search } from 'lucide-react'; +import { Plus, Edit, Trash2, X, Search, Train, Save, GripVertical } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { routesApi } from '@/lib/api/routes'; -import { stationsApi } from '@/lib/api'; +import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api'; interface RouteStop { stationId: string; @@ -18,7 +18,153 @@ interface RouteStop { distanceFromOrigin?: number; } +type Tab = 'routes' | 'coaches'; + +function RouteCoachesTab({ routes }: { routes: any[] }) { + const queryClient = useQueryClient(); + const [selectedRouteId, setSelectedRouteId] = useState(''); + const [coachRows, setCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]); + + const { data: coaches } = useQuery({ + queryKey: ['coaches-all'], + queryFn: () => fleetApi.getCoaches(), + }); + + const { data: template, isLoading: templateLoading } = useQuery({ + queryKey: ['route-coaches', selectedRouteId], + queryFn: () => routeCoachTemplatesApi.get(selectedRouteId), + enabled: !!selectedRouteId, + }); + + const saveMutation = useMutation({ + mutationFn: () => routeCoachTemplatesApi.set(selectedRouteId, coachRows), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['route-coaches', selectedRouteId] }), + }); + + const clearMutation = useMutation({ + mutationFn: () => routeCoachTemplatesApi.clear(selectedRouteId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['route-coaches', selectedRouteId] }); + setCoachRows([]); + }, + }); + + // Sync coachRows when template loads + useEffect(() => { + const rows: any[] = Array.isArray(template) ? template : (template as any)?.coaches ?? []; + if (rows.length) setCoachRows(rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber }))); + }, [template]); + + const allCoaches: any[] = (coaches as any)?.items ?? (Array.isArray(coaches) ? coaches : []); + + const addRow = () => setCoachRows([...coachRows, { coachId: '', positionNumber: coachRows.length + 1 }]); + const removeRow = (i: number) => { + const updated = coachRows.filter((_, idx) => idx !== i); + setCoachRows(updated.map((r, idx) => ({ ...r, positionNumber: idx + 1 }))); + }; + const updateRow = (i: number, field: 'coachId', val: string) => { + const updated = [...coachRows]; + updated[i] = { ...updated[i], [field]: val }; + setCoachRows(updated); + }; + + const selectedCoachIds = new Set(coachRows.map((r) => r.coachId).filter(Boolean)); + + const handleCoachDragStart = (e: React.DragEvent, index: number) => { + e.dataTransfer.setData('coach-index', index.toString()); + }; + const handleCoachDragOver = (e: React.DragEvent) => { + e.preventDefault(); + (e.currentTarget as HTMLElement).style.opacity = '0.5'; + }; + const handleCoachDragLeave = (e: React.DragEvent) => { + (e.currentTarget as HTMLElement).style.opacity = '1'; + }; + const handleCoachDrop = (e: React.DragEvent, targetIndex: number) => { + e.preventDefault(); + (e.currentTarget as HTMLElement).style.opacity = '1'; + const sourceIndex = parseInt(e.dataTransfer.getData('coach-index')); + if (sourceIndex === targetIndex) return; + const reordered = [...coachRows]; + const [moved] = reordered.splice(sourceIndex, 1); + reordered.splice(targetIndex, 0, moved); + setCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 }))); + }; + + return ( +
+
+
+ + +
+
+ + {selectedRouteId && ( + <> + {templateLoading ? ( +

Loading template…

+ ) : ( +
+ {coachRows.length > 0 && ( +

Drag to reorder coaches

+ )} + {coachRows.map((row, i) => ( +
handleCoachDragStart(e, i)} + onDragOver={handleCoachDragOver} + onDragLeave={handleCoachDragLeave} + onDrop={(e) => handleCoachDrop(e, i)} + className="flex gap-2 items-center p-3 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors" + > + +
+ {row.positionNumber} +
+
+ +
+ +
+ ))} + +
+ Add Coach +
+ {coachRows.length > 0 && ( + clearMutation.mutate()} loading={clearMutation.isPending}> + Clear + + )} + saveMutation.mutate()} loading={saveMutation.isPending}> + Save Template + +
+
+
+ )} + + )} +
+ ); +} + export default function RoutesPage() { + const [activeTab, setActiveTab] = useState('routes'); const [showModal, setShowModal] = useState(false); const [editingRoute, setEditingRoute] = useState(null); const [stops, setStops] = useState([]); @@ -244,42 +390,69 @@ export default function RoutesPage() {

Routes

-

Manage railway routes

+

Manage railway routes and coach templates

- { - setEditingRoute(null); - setOriginStationId(''); - setDestinationStationId(''); - setDestinationDistance(undefined); - setStops([]); - setSearch(''); - setShowModal(true); - }} - > - Add Route - + {activeTab === 'routes' && ( + { + setEditingRoute(null); + setOriginStationId(''); + setDestinationStationId(''); + setDestinationDistance(undefined); + setStops([]); + setSearch(''); + setShowModal(true); + }} + > + Add Route + + )}
-
- - setSearch(e.target.value)} - className="input pl-10 w-full" - /> + {/* Tabs */} +
+ {(['routes', 'coaches'] as Tab[]).map((tab) => ( + + ))}
- + {activeTab === 'routes' && ( + <> +
+ + setSearch(e.target.value)} + className="input pl-10 w-full" + /> +
+ + + + )} + + {activeTab === 'coaches' && ( + + )} (null); const [selectedSchedules, setSelectedSchedules] = useState>(new Set()); @@ -59,12 +61,45 @@ export default function SchedulesPage() { trainId: '', routeId: '', startDateTime: '', - durationHours: '12', - repeatEveryDays: '1', - forNextDays: '30', - coachIds: [] as string[], + durationHours: '10', + repeatEveryDays: '2', + forNextDays: '15', }); + const [bulkCoachRows, setBulkCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]); + + const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); + const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]); + + const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({ + queryKey: ['route-coaches', addForm.routeId], + queryFn: () => routeCoachTemplatesApi.get(addForm.routeId), + enabled: !!addForm.routeId, + }); + + useEffect(() => { + if (!addForm.routeId) { setAddCoachRows([]); return; } + const rows: any[] = Array.isArray(singleRouteTemplate) ? singleRouteTemplate : (singleRouteTemplate as any)?.coaches ?? []; + setAddCoachRows(rows.length ? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })) : []); + }, [singleRouteTemplate, addForm.routeId]); + + // Fetch route coach template when route changes + const { data: routeTemplate, isLoading: templateLoading } = useQuery({ + queryKey: ['route-coaches', bulkForm.routeId], + queryFn: () => routeCoachTemplatesApi.get(bulkForm.routeId), + enabled: !!bulkForm.routeId, + }); + + useEffect(() => { + if (!bulkForm.routeId) { setBulkCoachRows([]); return; } + const rows: any[] = Array.isArray(routeTemplate) ? routeTemplate : (routeTemplate as any)?.coaches ?? []; + setBulkCoachRows( + rows.length + ? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })) + : [] + ); + }, [routeTemplate, bulkForm.routeId]); + const [editForm, setEditForm] = useState({ departureAt: '', arrivalAt: '', @@ -121,8 +156,8 @@ export default function SchedulesPage() { durationHours: '12', repeatEveryDays: '1', forNextDays: '30', - coachIds: [], }); + setBulkCoachRows([]); setError(null); }, onError: (err: any) => { @@ -130,6 +165,20 @@ export default function SchedulesPage() { }, }); + const createScheduleMutation = useMutation({ + mutationFn: (data: any) => apiClient.post('/schedules', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['schedules'] }); + setShowAddModal(false); + setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); + setAddCoachRows([]); + setError(null); + }, + onError: (err: any) => { + setError(err.response?.data?.message || 'Failed to create schedule'); + }, + }); + const updateScheduleMutation = useMutation({ mutationFn: (data: { id: string; payload: any }) => apiClient.patch(`/schedules/${data.id}`, data.payload), @@ -187,13 +236,30 @@ export default function SchedulesPage() { forNextDays: parseInt(bulkForm.forNextDays), }; - if (bulkForm.coachIds.length > 0) { - payload.coachIds = bulkForm.coachIds; + const validCoaches = bulkCoachRows.filter((r) => r.coachId); + if (validCoaches.length > 0) { + payload.coachIds = validCoaches.map((r) => r.coachId); } await bulkGenerateMutation.mutateAsync(payload); }; + const handleAddSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + const dep = new Date(addForm.departureAt); + const arr = new Date(addForm.arrivalAt); + if (arr <= dep) { setError('Arrival must be after departure'); return; } + const validCoaches = addCoachRows.filter((r) => r.coachId); + await createScheduleMutation.mutateAsync({ + trainId: addForm.trainId, + routeId: addForm.routeId, + departureAt: dep.toISOString(), + arrivalAt: arr.toISOString(), + ...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }), + }); + }; + const handleEditSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -396,6 +462,13 @@ export default function SchedulesPage() { }, ] as any; + const cancelScheduleMutation = useMutation({ + mutationFn: (id: string) => apiClient.patch(`/schedules/${id}/status`, { status: 'CANCELLED' }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules'] }), + }); + + const [cancelConfirm, setCancelConfirm] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null }); + const scheduleActions = [ { label: 'Edit', @@ -403,6 +476,13 @@ export default function SchedulesPage() { variant: 'secondary' as const, icon: Edit, }, + { + label: 'Cancel', + onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }), + variant: 'danger' as const, + icon: X, + hidden: (schedule: Schedule) => schedule.status === 'CANCELLED', + }, { label: 'Delete', onClick: handleDelete, @@ -428,6 +508,13 @@ export default function SchedulesPage() { Delete {selectedSchedules.size} Schedule{selectedSchedules.size !== 1 ? 's' : ''} )} + { setError(null); setShowAddModal(true); }} + > + Add Schedule + { @@ -530,6 +617,22 @@ export default function SchedulesPage() {
+ setCancelConfirm({ isOpen: false, item: null })} + onConfirm={async () => { + if (cancelConfirm.item) { + await cancelScheduleMutation.mutateAsync(cancelConfirm.item.id); + setCancelConfirm({ isOpen: false, item: null }); + } + }} + title="Cancel Schedule" + message={`Cancel the schedule departing ${cancelConfirm.item ? new Date(cancelConfirm.item.departureAt).toLocaleString() : ''}? Passengers with bookings will need to be notified separately.`} + confirmText="Cancel Schedule" + isDanger={true} + isLoading={cancelScheduleMutation.isPending} + /> + setDeleteConfirm({ isOpen: false, item: null })} @@ -549,6 +652,107 @@ export default function SchedulesPage() { warning="Schedules with existing bookings cannot be deleted." /> + { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }} + title="Add Schedule" + size="lg" + > +
+ {error &&
{error}
} + +
+
+ + +
+
+ + +
+
+ +
+
+ + setAddForm({ ...addForm, departureAt: e.target.value })} required /> +
+
+ + setAddForm({ ...addForm, arrivalAt: e.target.value })} required /> +
+
+ +
+
+ +
+ {singleTemplateLoading && addForm.routeId && ( + Loading template… + )} + {!addForm.routeId && Select a route to load its coach template} + r.coachId).length >= coaches.length} + onClick={() => setAddCoachRows([...addCoachRows, { coachId: '', positionNumber: addCoachRows.length + 1 }])}> + Add Coach + +
+
+ {addCoachRows.length === 0 ? ( +

No coaches assigned.

+ ) : ( +
+ {addCoachRows.length > 1 &&

Drag to reorder

} + {addCoachRows.map((row, i) => { + const selectedIds = new Set(addCoachRows.map((r) => r.coachId).filter(Boolean)); + return ( +
e.dataTransfer.setData('add-coach-idx', i.toString())} + onDragOver={(e) => { e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '0.5'; }} + onDragLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1'; }} + onDrop={(e) => { + e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '1'; + const src = parseInt(e.dataTransfer.getData('add-coach-idx')); + if (src === i) return; + const reordered = [...addCoachRows]; + const [moved] = reordered.splice(src, 1); + reordered.splice(i, 0, moved); + setAddCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 }))); + }} + className="flex gap-2 items-center p-2 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors" + > + + {row.positionNumber} + + +
+ ); + })} +
+ )} +
+ +
+ { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}>Cancel + Create Schedule +
+
+
+ { @@ -561,8 +765,8 @@ export default function SchedulesPage() { durationHours: '12', repeatEveryDays: '1', forNextDays: '30', - coachIds: [], }); + setBulkCoachRows([]); }} title="Bulk Generate Schedules" size="lg" @@ -611,7 +815,7 @@ export default function SchedulesPage() {
- + setBulkForm({ ...bulkForm, durationHours: e.target.value })} className="input" /> @@ -638,7 +842,7 @@ export default function SchedulesPage() { setBulkForm({ ...bulkForm, repeatEveryDays: e.target.value })} className="input" /> @@ -649,61 +853,91 @@ export default function SchedulesPage() { setBulkForm({ ...bulkForm, forNextDays: e.target.value })} className="input" />
-
+
- - + +
+ {templateLoading && bulkForm.routeId && ( + Loading template… + )} + {!bulkForm.routeId && ( + Select a route to load its coach template + )} + r.coachId).length >= coaches.length} + onClick={() => setBulkCoachRows([...bulkCoachRows, { coachId: '', positionNumber: bulkCoachRows.length + 1 }])}> + Add Coach + +
-
- {coaches.length === 0 ? ( -

No coaches available

- ) : ( - coaches.map((coach: Coach) => ( -
+ )}
@@ -713,10 +947,10 @@ export default function SchedulesPage() { schedules, starting from the specified date, repeating every{' '} {bulkForm.repeatEveryDays} days for the next{' '} {bulkForm.forNextDays} days. - {bulkForm.coachIds.length > 0 && ( + {bulkCoachRows.filter(r => r.coachId).length > 0 && ( <> {' '} - Each schedule will have {bulkForm.coachIds.length} coach(es) assigned. + Each schedule will have {bulkCoachRows.filter(r => r.coachId).length} coach(es) assigned. )}

@@ -736,8 +970,8 @@ export default function SchedulesPage() { durationHours: '12', repeatEveryDays: '1', forNextDays: '30', - coachIds: [], }); + setBulkCoachRows([]); }} > Cancel @@ -756,7 +990,7 @@ export default function SchedulesPage() { setEditingSchedule(null); setError(null); }} - title={`Edit Schedule - ${editingSchedule?.train?.name} (${editingSchedule?.train?.number})`} + title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''} → ${editingSchedule?.destinationStation?.name ?? ''}`} size="lg" > {editingSchedule && ( diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/layout.tsx new file mode 100644 index 000000000..16bf304a7 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function TariffRatesLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx new file mode 100644 index 000000000..bc8f31eaf --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx @@ -0,0 +1,428 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Edit, Trash2, Search } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { apiClient } from '@/lib/api-client'; + +const NATIONALITY_TYPES = ['LOCAL', 'INTERNATIONAL'] as const; +const BED_POSITIONS = ['UPPER', 'MIDDLE', 'LOWER'] as const; +const COACH_TYPE_LABELS: Record = { + HSC: 'Regular Seat (Hard Seat)', + HBC: 'Economy Bed (Hard Berth)', + SBC: 'VIP Bed (Soft Berth)', +}; + +// Tariff reference rates per the official policy document +const TARIFF_REFERENCE: Record> = { + LOCAL: { + 'HSC-null': 0.03, + 'HBC-UPPER': 0.04, + 'HBC-MIDDLE': 0.055, + 'HBC-LOWER': 0.06, + 'SBC-UPPER': 0.075, + 'SBC-LOWER': 0.08, + }, + INTERNATIONAL: { + 'HSC-null': 0.06, + 'HBC-UPPER': 0.08, + 'HBC-MIDDLE': 0.11, + 'HBC-LOWER': 0.12, + 'SBC-UPPER': 0.15, + 'SBC-LOWER': 0.16, + }, +}; + +function getTariffRef(nationalityType: string, coachCode: string, bedPosition: string | null) { + const key = `${coachCode}-${bedPosition ?? 'null'}`; + return TARIFF_REFERENCE[nationalityType]?.[key]; +} + +export default function TariffRatesPage() { + const [search, setSearch] = useState(''); + const [showModal, setShowModal] = useState(false); + const [editingClass, setEditingClass] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null }); + const [formError, setFormError] = useState(null); + const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(''); + const [selectedBedPosition, setSelectedBedPosition] = useState(''); + const [selectedNationalityType, setSelectedNationalityType] = useState('LOCAL'); + const queryClient = useQueryClient(); + + const { data: classesData, isLoading } = useQuery({ + queryKey: ['seat-classes'], + queryFn: () => apiClient.get('/seat-classes'), + }); + + const { data: coachTypesData } = useQuery({ + queryKey: ['coach-types'], + queryFn: () => apiClient.get('/fleet/coach-types'), + }); + + const createMutation = useMutation({ + mutationFn: (data: any) => apiClient.post('/seat-classes', data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); closeModal(); }, + onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to save'), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/seat-classes/${id}`, data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); closeModal(); }, + onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to update'), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/seat-classes/${id}`), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); setDeleteConfirm({ isOpen: false, item: null }); }, + onError: (e: any) => setDeleteConfirm(prev => ({ ...prev, error: e?.response?.data?.message || e?.message || 'Delete failed' })), + }); + + const closeModal = () => { + setShowModal(false); + setEditingClass(null); + setFormError(null); + setSelectedCoachTypeId(''); + setSelectedBedPosition(''); + setSelectedNationalityType('LOCAL'); + }; + + const openEdit = (cls: any) => { + setEditingClass(cls); + setSelectedCoachTypeId(cls.coachTypeId || ''); + setSelectedBedPosition(cls.bedPosition || ''); + setSelectedNationalityType(cls.nationalityType || 'LOCAL'); + setFormError(null); + setShowModal(true); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + const fd = new FormData(e.currentTarget); + const payload: any = { + coachTypeId: selectedCoachTypeId, + name: fd.get('name') as string, + nationalityType: selectedNationalityType, + bedPosition: selectedBedPosition || null, + baseFareMinor: parseInt(fd.get('baseFareMinor') as string), + isActive: fd.get('isActive') === 'true', + }; + if (editingClass) { + await updateMutation.mutateAsync({ id: editingClass.id, data: payload }); + } else { + await createMutation.mutateAsync(payload); + } + }; + + const coachTypesArray: any[] = Array.isArray(coachTypesData) + ? coachTypesData + : (coachTypesData as any)?.data || (coachTypesData as any)?.items || []; + + const allClasses: any[] = Array.isArray(classesData) + ? classesData + : (classesData as any)?.items || (classesData as any)?.data || []; + + // Only show classes that have nationalityType set (tariff-managed rows) + const tariffClasses = allClasses.filter((c: any) => c.nationalityType); + + const displayed = tariffClasses.filter((c: any) => { + if (!search) return true; + const s = search.toLowerCase(); + return ( + c.name?.toLowerCase().includes(s) || + c.nationalityType?.toLowerCase().includes(s) || + c.bedPosition?.toLowerCase().includes(s) || + c.coachType?.name?.toLowerCase().includes(s) + ); + }); + + // Auto-suggest name from selections + const suggestName = () => { + const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId); + if (!ct) return ''; + const label = COACH_TYPE_LABELS[ct.code] || ct.name; + const pos = selectedBedPosition ? ` ${selectedBedPosition.charAt(0) + selectedBedPosition.slice(1).toLowerCase()}` : ''; + const nat = selectedNationalityType === 'LOCAL' ? 'Local' : 'Intl'; + return `${label}${pos} (${nat})`; + }; + + // Auto-suggest baseFareMinor from tariff reference + const suggestRate = () => { + const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId); + if (!ct) return ''; + const ref = getTariffRef(selectedNationalityType, ct.code, selectedBedPosition || null); + // baseFareMinor = tariff_decimal × 100000 + return ref ? Math.round(ref * 100000).toString() : ''; + }; + + const columns = [ + { + key: 'nationalityType', label: 'Passenger Type', + render: (c: any) => ( + + {c.nationalityType === 'LOCAL' ? 'Local' : 'International'} + + ), + }, + { + key: 'coachType', label: 'Coach Type', + render: (c: any) => {c.coachType?.name || c.coachTypeId}, + }, + { + key: 'bedPosition', label: 'Berth Position', + render: (c: any) => c.bedPosition + ? {c.bedPosition} + : Standard, + }, + { + key: 'name', label: 'Class Name', + render: (c: any) => {c.name}, + }, + { + key: 'baseFareMinor', label: 'Rate per km (minor)', + render: (c: any) => { + const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId); + const ref = ct ? getTariffRef(c.nationalityType, ct.code, c.bedPosition) : undefined; + const tariffMinor = ref ? Math.round(ref * 100000) : undefined; + const matches = tariffMinor === c.baseFareMinor; + return ( +
+ {c.baseFareMinor} + {tariffMinor !== undefined && ( + + {matches ? '✓ tariff' : `tariff: ${tariffMinor}`} + + )} +
+ ); + }, + }, + { + key: 'isActive', label: 'Status', + render: (c: any) => ( + + {c.isActive ? 'Active' : 'Inactive'} + + ), + }, + ]; + + const actions = [ + { label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: openEdit }, + { + label: 'Delete', icon: Trash2, variant: 'danger' as const, + onClick: (c: any) => setDeleteConfirm({ isOpen: true, item: c }), + }, + ]; + + const selectedCoachType = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId); + const isBedCoach = selectedCoachType?.code === 'HBC' || selectedCoachType?.code === 'SBC'; + + return ( +
+
+
+

Tariff Rates

+

+ Manage per-km fare rates by nationality, coach type, and berth position per the official EDR tariff policy +

+
+ { setEditingClass(null); setFormError(null); setShowModal(true); }}> + Add Rate + +
+ + {/* Tariff reference card */} +
+

Official Tariff Formula

+

+ Fare = KM × rate × 1.02 × ExchangeRate +

+

+ Rate is stored as baseFareMinor = tariff_decimal × 100,000 (e.g. 0.03 → 3000). The ×1.02 insurance coefficient is applied automatically by the fare engine. +

+
+ +
+
+ + setSearch(e.target.value)} + /> +
+ +
+ + setDeleteConfirm({ isOpen: false, item: null })} + onConfirm={() => deleteMutation.mutate(deleteConfirm.item?.id)} + title="Delete Tariff Rate" + message={`Delete "${deleteConfirm.item?.name}"? This will affect fare calculations for this class.`} + confirmText="Delete" + isDanger + isLoading={deleteMutation.isPending} + error={deleteConfirm.error} + warning="Bookings in progress may be affected. Ensure a replacement rate exists." + /> + + +
+ {formError && ( +
+ {formError} +
+ )} + +
+
+ + +
+ +
+ + +
+ + {isBedCoach && ( +
+ + +

+ {selectedCoachType?.code === 'HBC' ? 'Economy Bed: Upper / Middle / Lower' : 'VIP Bed: Upper / Lower'} +

+
+ )} + +
+ + + {!editingClass && suggestName() && ( +

+ Suggested:{' '} + +

+ )} +
+ +
+ + + {suggestRate() && ( +

+ Official tariff rate:{' '} + + {' '}(= {(parseInt(suggestRate()) / 100000).toFixed(3)} ETB/km) +

+ )} +
+ +
+ + +
+
+ +
+ Cancel + + {editingClass ? 'Update' : 'Create'} Rate + +
+
+
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index f5ad5be69..9e1f7bb61 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -89,7 +89,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ title: 'Financial', items: [ { name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin }, - // { name: 'Configurable Fares', href: '/fare-management', icon: Settings, permission: PERMS.admin }, + { name: 'Tariff Rates', href: '/tariff-rates', icon: Banknote, permission: PERMS.admin }, + { name: 'Fare Rules', href: '/fare-management', icon: Settings, permission: PERMS.admin }, { name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage }, { name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.payments.view }, { name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin }, diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 2da6e0d16..5dd23ef2c 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -442,6 +442,14 @@ export const excessBaggageApi = { delete: (id: string) => apiClient.delete(`/agents/excess-baggage/${id}`), }; +// Route Coach Templates API +export const routeCoachTemplatesApi = { + get: (routeId: string) => apiClient.get(`/routes/${routeId}/coaches`), + set: (routeId: string, coaches: Array<{ coachId: string; positionNumber: number }>) => + apiClient.put(`/routes/${routeId}/coaches`, { coaches }), + clear: (routeId: string) => apiClient.delete(`/routes/${routeId}/coaches`), +}; + // System Config API export const systemConfigApi = { getAll: () => apiClient.get>('/config'), diff --git a/apps/edr-passenger-web/portal/next.config.js b/apps/edr-passenger-web/portal/next.config.js index 2818d502d..e9d57a5f0 100644 --- a/apps/edr-passenger-web/portal/next.config.js +++ b/apps/edr-passenger-web/portal/next.config.js @@ -1,9 +1,10 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + output: 'standalone', reactStrictMode: true, transpilePackages: ['@edr/types', '@edr/ui-common'], images: { - unoptimized: true, + unoptimized: false, }, }; diff --git a/docker-compose.yaml b/docker-compose.yaml index db3da060a..cc6dcd1e3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -30,6 +30,18 @@ services: - apps/edr-passenger-api/.env extra_hosts: - "paymentcallback.triaplc.com:10.18.7.179" + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:4000/health/ready"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + deploy: + resources: + limits: + cpus: '1.0' + memory: 1G freight-portal: build: context: . @@ -72,6 +84,18 @@ services: - "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}" env_file: - apps/edr-passenger-web/portal/.env + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:${PASSENGER_PORTAL_PORT:-5174}/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M passenger-backoffice: build: context: . @@ -86,6 +110,18 @@ services: - "${PASSENGER_BACKOFFICE_PORT:-5184}:${PASSENGER_BACKOFFICE_PORT:-5184}" env_file: - apps/edr-passenger-web/backoffice/.env + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:${PASSENGER_BACKOFFICE_PORT:-5184}/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M payment-api: build: diff --git a/infrastructure/docker/Dockerfile.passenger-web b/infrastructure/docker/Dockerfile.passenger-web index 61b58736b..111d66c81 100644 --- a/infrastructure/docker/Dockerfile.passenger-web +++ b/infrastructure/docker/Dockerfile.passenger-web @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # # Next.js Dockerfile for passenger web (portal + backoffice). -# Builds with turbo, runs with Node.js (not nginx). +# Builds with turbo, runs with Node.js standalone output. # # Build example (portal): # DOCKER_BUILDKIT=1 docker build \ @@ -16,8 +16,6 @@ ARG PORT=5174 ARG NEXT_PUBLIC_API_URL FROM node:24.15.0-alpine AS base RUN apk add --no-cache libc6-compat -# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit -# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds. ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable @@ -46,20 +44,30 @@ RUN if [ -z "$NEXT_PUBLIC_API_URL" ]; then \ COPY --from=installer /app/ . COPY --from=pruner /app/out/full/ . RUN pnpm turbo build --filter="${APP_PACKAGE}..." -FROM base AS deployer -ARG APP_PACKAGE -COPY --from=builder /app/ . -RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ - pnpm deploy --filter="${APP_PACKAGE}" --prod --legacy /deploy + +# Runner: copy the standalone output produced by Next.js. +# With output:'standalone' in a pnpm monorepo, Next.js emits: +# .next/standalone/ <- shared workspace node_modules +# .next/standalone// <- app server.js + app-level node_modules +# .next/static/ <- hashed client assets +# public/ <- static public files +# We copy the full standalone tree to /app, then WORKDIR into the app +# subdirectory so `node server.js` resolves correctly. FROM node:24.15.0-alpine AS runner ARG APP_PATH ARG PORT=5174 ENV PORT=${PORT} ENV NODE_ENV=production WORKDIR /app -COPY --from=deployer /deploy . -COPY --from=builder /app/${APP_PATH}/.next .next -COPY --from=builder /app/${APP_PATH}/public public -USER node +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 --ingroup nodejs nextjs +# Copy the entire standalone tree (includes root node_modules symlinks) +COPY --from=builder --chown=nextjs:nodejs /app/${APP_PATH}/.next/standalone ./ +# Overlay the hashed static assets and public files at the path Next.js expects +COPY --from=builder --chown=nextjs:nodejs /app/${APP_PATH}/.next/static ./${APP_PATH}/.next/static +COPY --from=builder --chown=nextjs:nodejs /app/${APP_PATH}/public ./${APP_PATH}/public +USER nextjs +# server.js lives at /server.js inside the standalone tree +WORKDIR /app/${APP_PATH} EXPOSE ${PORT} -CMD ["npx", "next", "start"] +CMD ["node", "server.js"] diff --git a/infrastructure/nginx/spa.conf b/infrastructure/nginx/spa.conf index 87a5d7688..83629210c 100644 --- a/infrastructure/nginx/spa.conf +++ b/infrastructure/nginx/spa.conf @@ -4,10 +4,24 @@ server { root /usr/share/nginx/html; index index.html; + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/wasm; + gzip_min_length 1024; location / { try_files $uri $uri/ /index.html; } + + # Cache static assets aggressively + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8051dab4a..bfd3261c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -525,6 +525,9 @@ importers: express: specifier: ^4.18.2 version: 4.22.2 + helmet: + specifier: ^8.0.0 + version: 8.2.0 jose: specifier: ^5.10.0 version: 5.10.0 @@ -7108,6 +7111,10 @@ packages: headers-polyfill@5.0.1: resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} + helmet@8.2.0: + resolution: {integrity: sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==} + engines: {node: '>=18.0.0'} + helper-date@1.0.1: resolution: {integrity: sha512-wU3VOwwTJvGr/w5rZr3cprPHO+hIhlblTJHD6aFBrKLuNbf4lAmkawd2iK3c6NbJEvY7HAmDpqjOFSI5/+Ey2w==} engines: {node: '>=4.0'} @@ -12632,10 +12639,10 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@mantine/dates@7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 7.17.8(react@19.2.6) + '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 9.3.0(react@19.2.6) clsx: 2.1.1 dayjs: 1.11.21 react: 19.2.6 @@ -15545,7 +15552,7 @@ snapshots: '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/hooks': 7.17.8(react@19.2.6) '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -19089,6 +19096,8 @@ snapshots: '@types/set-cookie-parser': 2.4.10 set-cookie-parser: 3.1.0 + helmet@8.2.0: {} + helper-date@1.0.1: dependencies: date.js: 0.3.3 @@ -20457,7 +20466,7 @@ snapshots: mantine-react-table@2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/hooks': 7.17.8(react@19.2.6) '@tabler/icons-react': 3.44.0(react@19.2.6) '@tanstack/match-sorter-utils': 8.19.4