Fare and route-coach, production checklist updates

This commit is contained in:
Stephanos A
2026-07-02 22:42:15 +03:00
parent 4aadf588d4
commit 200476dfd6
37 changed files with 1672 additions and 248 deletions

View File

@@ -170,13 +170,36 @@ jobs:
- name: Build ${{ matrix.service }} - name: Build ${{ matrix.service }}
run: | run: |
set -euo pipefail 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 }}" 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 }} - name: Deploy ${{ matrix.service }}
run: | run: |
set -euo pipefail set -euo pipefail
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate 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 - name: Remove npm credentials from workspace
if: always() if: always()
run: rm -f .npmrc .npmrc_temp run: rm -f .npmrc .npmrc_temp

View File

@@ -121,13 +121,59 @@ This ensures `docker ps` shows `0.0.0.0:<port>-><port>/tcp` with matching ports.
### Runtime ### Runtime
The final image runs: The final image uses Next.js `output: 'standalone'` and runs:
```bash ```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}-<service>:<sha8>`).
### 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:<previous-sha> 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 ## GitHub Actions Deployment Flow
@@ -147,9 +193,10 @@ For each service:
- Computes branch slug and sets: - Computes branch slug and sets:
- `COMPOSE_PROJECT_NAME=<project>-<branch-slug>` - `COMPOSE_PROJECT_NAME=<project>-<branch-slug>`
- Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`. - Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`.
- Runs: - For `passenger-api` and `payment-api`: builds and runs the migration image as a gated step before the app image.
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" build <service>` - Builds the service image and tags it with the short git SHA.
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d <service>` - Runs `docker compose up -d <service> --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`. - Cleans `.npmrc`/`.npmrc_temp`.
## Branch/Environment Isolation ## Branch/Environment Isolation

View File

@@ -48,6 +48,7 @@
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^4.18.2", "express": "^4.18.2",
"helmet": "^8.0.0",
"jose": "^5.10.0", "jose": "^5.10.0",
"pg": "^8.21.0", "pg": "^8.21.0",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",

View File

@@ -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;

View File

@@ -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");

View File

@@ -88,7 +88,9 @@ model SeatClass {
coachTypeId String coachTypeId String
name String name String
description 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 premiumMinor Int @default(0) // flat fee per passenger
insuranceFeeMinor Int @default(0) // flat fee per passenger insuranceFeeMinor Int @default(0) // flat fee per passenger
isActive Boolean @default(true) isActive Boolean @default(true)
@@ -100,6 +102,7 @@ model SeatClass {
segmentFares SegmentFareRule[] segmentFares SegmentFareRule[]
@@unique([coachTypeId, name]) @@unique([coachTypeId, name])
@@index([coachTypeId]) @@index([coachTypeId])
@@index([coachTypeId, nationalityType, bedPosition])
@@schema("passenger") @@schema("passenger")
} }
@@ -420,9 +423,10 @@ model Coach {
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE' status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
coachType CoachType @relation(fields: [coachTypeId], references: [id]) coachType CoachType @relation(fields: [coachTypeId], references: [id])
seats Seat[] seats Seat[]
assignments CoachAssignment[] assignments CoachAssignment[]
routeTemplates RouteCoachTemplate[]
@@index([coachTypeId]) @@index([coachTypeId])
@@index([sequence]) @@index([sequence])
@@schema("passenger") @@schema("passenger")
@@ -998,6 +1002,7 @@ model Route {
fareRules RouteFareRule[] fareRules RouteFareRule[]
segmentFares SegmentFareRule[] segmentFares SegmentFareRule[]
schedules TrainSchedule[] schedules TrainSchedule[]
coachTemplates RouteCoachTemplate[]
@@schema("passenger") @@schema("passenger")
} }
@@ -1015,6 +1020,20 @@ model RouteStop {
@@schema("passenger") @@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 { model RouteFareRule {
id String @id @default(uuid()) id String @id @default(uuid())
routeId String routeId String

View File

@@ -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 = [ const seatClasses = [
{ name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900, premiumMinor: 50, insuranceFeeMinor: 25 }, // LOCAL rates
{ name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800, premiumMinor: 45, insuranceFeeMinor: 20 }, { name: 'Economy Regular (Local)', coachCode: 'HSC', nationalityType: 'LOCAL', bedPosition: null, baseFareMinor: 3000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600, premiumMinor: 30, insuranceFeeMinor: 15 }, { name: 'Economy Bed Upper (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 4000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550, premiumMinor: 28, insuranceFeeMinor: 14 }, { name: 'Economy Bed Middle (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'MIDDLE',baseFareMinor: 5500, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500, premiumMinor: 25, insuranceFeeMinor: 12 }, { name: 'Economy Bed Lower (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250, premiumMinor: 12, insuranceFeeMinor: 6 }, { 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) { for (const sc of seatClasses) {
const ct = await prisma.coachType.findUnique({ where: { id: sc.coachCode } }); const ct = await prisma.coachType.findUnique({ where: { id: sc.coachCode } });
await prisma.seatClass.upsert({ await prisma.seatClass.upsert({
where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } }, where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } },
update: {}, update: { nationalityType: sc.nationalityType, bedPosition: sc.bedPosition, baseFareMinor: sc.baseFareMinor },
create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor, premiumMinor: sc.premiumMinor, insuranceFeeMinor: sc.insuranceFeeMinor }, 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`); 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`); 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() { async function seedCoaches() {
@@ -431,34 +503,61 @@ async function seedTrips() {
async function seedFareRules() { async function seedFareRules() {
console.log('\n💰 Seeding fare rules...'); console.log('\n💰 Seeding fare rules...');
const route = await prisma.route.findUnique({ where: { code: 'Route-101' } }); 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 seatClasses = await prisma.seatClass.findMany();
const validFrom = new Date('2024-01-01'); const validFrom = new Date('2024-01-01');
const fareRules = []; // Delete existing FareRule rows so re-seed is idempotent
for (const sc of seatClasses) { await prisma.fareRule.deleteMany({});
fareRules.push({
routeId: route!.id, const fareRules: any[] = [];
seatClassId: sc.id, for (const route of [{ code: 'Route-101' }, { code: 'Route-102' }, { code: 'Route-201' }, { code: 'Route-202' }]) {
passengerCategory: 'ADULT' as const, for (const sc of seatClasses) {
baseFareMinor: sc.baseFareMinor, fareRules.push({
currency: 'ETB', route: route.code,
validFrom, seatClassId: sc.id,
}); baseFareMinor: sc.baseFareMinor,
fareRules.push({ currency: 'ETB',
routeId: route!.id, validFrom,
seatClassId: sc.id, });
passengerCategory: 'CHILD' as const, }
baseFareMinor: Math.floor(sc.baseFareMinor * 0.5),
discountPercent: 10,
currency: 'ETB',
validFrom,
});
} }
await Promise.all( 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() { async function seedCurrency() {
@@ -759,6 +858,22 @@ async function main() {
console.log('🌱 Comprehensive EDR Seed Starting...\n'); console.log('🌱 Comprehensive EDR Seed Starting...\n');
const steps: Array<[string, () => Promise<unknown>]> = [ const steps: Array<[string, () => Promise<unknown>]> = [
['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; let failed = 0;

View File

@@ -1,9 +1,4 @@
import { import {Logger, Module, OnApplicationBootstrap} from '@nestjs/common';
MiddlewareConsumer,
Module,
NestModule,
OnApplicationBootstrap,
} from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerModule } from '@nestjs/throttler';
import { DynamicThrottlerGuard } from './common/dynamic-throttler.guard'; import { DynamicThrottlerGuard } from './common/dynamic-throttler.guard';
import { APP_GUARD, APP_FILTER } from '@nestjs/core'; 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 { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module';
import { HealthModule } from './modules/health/health.module'; import { HealthModule } from './modules/health/health.module';
import { TasksModule } from './modules/tasks/tasks.module'; import { TasksModule } from './modules/tasks/tasks.module';
import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module';
@Module({ @Module({
imports: [ imports: [
@@ -136,7 +130,6 @@ import { ConfigurableFareModule } from './modules/configurable-fare/configurable
ExcessBaggageModule, ExcessBaggageModule,
HealthModule, HealthModule,
TasksModule, TasksModule,
ConfigurableFareModule,
], ],
providers: [ providers: [
{ provide: APP_GUARD, useClass: DynamicThrottlerGuard }, { provide: APP_GUARD, useClass: DynamicThrottlerGuard },
@@ -147,6 +140,7 @@ import { ConfigurableFareModule } from './modules/configurable-fare/configurable
], ],
}) })
export class AppModule implements OnApplicationBootstrap { export class AppModule implements OnApplicationBootstrap {
private readonly logger = new Logger(AppModule.name);
constructor( constructor(
private readonly seeder: DataSeeder, private readonly seeder: DataSeeder,
private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder, private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder,
@@ -157,17 +151,17 @@ export class AppModule implements OnApplicationBootstrap {
try { try {
await this.seeder.run(); await this.seeder.run();
} catch (err) { } 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 { try {
await this.edrPassengerOrgSeeder.run(); await this.edrPassengerOrgSeeder.run();
} catch (err) { } 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 { try {
await this.passengerStaffUsersSeeder.run(); await this.passengerStaffUsersSeeder.run();
} catch (err) { } 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);
} }
} }
} }

View File

@@ -3,6 +3,17 @@ import { Reflector } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerStorage, getOptionsToken, getStorageToken } from '@nestjs/throttler'; import { ThrottlerGuard, ThrottlerStorage, getOptionsToken, getStorageToken } from '@nestjs/throttler';
import { SystemConfigService, CONFIG_KEYS } from '../modules/system-config/system-config.service'; 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() @Injectable()
export class DynamicThrottlerGuard extends ThrottlerGuard { export class DynamicThrottlerGuard extends ThrottlerGuard {
constructor( constructor(
@@ -29,9 +40,17 @@ export class DynamicThrottlerGuard extends ThrottlerGuard {
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_DEFAULT_TTL_MS), this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_DEFAULT_TTL_MS),
]); ]);
this.throttlers = [ const url: string = context.switchToHttp().getRequest<{ url: string }>().url ?? '';
{ name: 'default', ttl: defaultTtl, limit: defaultLimit }, 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); return super.canActivate(context);
} }

View File

@@ -1,8 +1,29 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
@Injectable() @Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { 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 onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); } async onModuleDestroy() { await this.$disconnect(); }
} }

View File

@@ -1,9 +1,23 @@
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
export default registerAs('app', () => ({ export default registerAs('app', () => {
port: parseInt(process.env.PORT ?? '4000', 10), const isProd = process.env.NODE_ENV === 'production';
jwtSecret: process.env.JWT_SECRET ?? 'dev-secret',
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d', if (isProd && !process.env.JWT_SECRET) {
frontendUrl: process.env.PORTAL_URL ?? 'http://localhost:3000', throw new Error('JWT_SECRET environment variable is required in production');
portalUrl: process.env.BACK_OFFICE_URL ?? 'http://localhost:3001', }
})); 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',
};
});

View File

@@ -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), // 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. // which happens before ConfigModule.forRoot() would populate it. Must be the very first import.
import "dotenv/config"; import "dotenv/config";
import "reflect-metadata"; import "reflect-metadata";
import { NestFactory } from "@nestjs/core"; 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 { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import helmet from "helmet";
import { AppModule } from "./app.module"; import { AppModule } from "./app.module";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter"; import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor"; import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor";
@@ -14,14 +15,22 @@ import { SessionActivityInterceptor } from "./common/interceptors/session-activi
// Set timezone to Africa/Addis_Ababa (EAT - UTC+3) for Ethiopian Railway operations // Set timezone to Africa/Addis_Ababa (EAT - UTC+3) for Ethiopian Railway operations
process.env.TZ = 'Africa/Addis_Ababa'; 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() { async function bootstrap() {
// rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers // 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. // (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
const app = await NestFactory.create(AppModule, { rawBody: true }); 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 // 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 // `/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.enableVersioning({ type: VersioningType.URI });
app.enableCors({ app.enableCors({
@@ -29,6 +38,9 @@ async function bootstrap() {
process.env.PORTAL_URL ?? "http://localhost:5174", 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()); app.useGlobalFilters(new HttpExceptionFilter());
@@ -38,6 +50,7 @@ async function bootstrap() {
); );
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidUnknownValues: false })); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidUnknownValues: false }));
if (process.env.NODE_ENV !== 'production') {
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle("EDR Passenger API") .setTitle("EDR Passenger API")
.setDescription( .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) - Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
- Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets - Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
- Complete audit trail per leg for compliance and reporting - 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 ### Booking Type Matrix
@@ -240,28 +253,28 @@ For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`ret
### Step 3: Passenger Information & Verification ### Step 3: Passenger Information & Verification
**For Ethiopian Passengers:** **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:** **For International Passengers:**
\`POST /passengers/register-international\` — Passport information collection \`POST /passengers/register-international\` Passport information collection
### Step 4: View Seat Map ### 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. For round-trips, call this twice: once for outbound scheduleId, once for return scheduleId.
### Step 5: Hold Seats ### Step 5: Hold Seats
\`POST /seats/hold\` to reserve seats for 15 minutes. \`POST /seats/hold\` to reserve seats for 15 minutes.
- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\` - ONE_WAY / TRANSIT outbound leg: one hold call \`holdId\`
- TRANSIT leg-2: second hold call → \`leg2HoldId\` - TRANSIT leg-2: second hold call \`leg2HoldId\`
- ROUND_TRIP return: second hold call → \`returnHoldId\` - ROUND_TRIP return: second hold call \`returnHoldId\`
- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\` - ROUND_TRIP_TRANSIT: four hold calls \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
### Step 6: Create Booking ### Step 6: Create Booking
Choose the right endpoint and bookingType: Choose the right endpoint and bookingType:
- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\` - **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\` - **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\` - **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\` - **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 ### Step 7: Process Payment
\`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian) \`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian)
@@ -406,10 +419,12 @@ Payment providers send notifications to:
operationsSorter: "alpha", operationsSorter: "alpha",
}, },
}); });
} // end if (NODE_ENV !== 'production')
const port = process.env.PORT ?? 4000; const port = process.env.PORT ?? 4000;
await app.listen(port); await app.listen(port);
console.log(`🚀 EDR Passenger API running on port ${port}`); const logger = new Logger('Bootstrap');
console.log(`📚 Swagger: http://localhost:${port}/api-docs`); logger.log(`EDR Passenger API running on port ${port}`);
logger.log(`Swagger: http://localhost:${port}/api-docs`);
} }
bootstrap(); bootstrap();

View File

@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { CurrenciesController } from './currencies.controller'; import { CurrenciesController } from './currencies.controller';
import { CurrenciesService } from './currencies.service'; import { CurrenciesService } from './currencies.service';
import { CurrencyModule } from '../currency/currency.module';
import { PrismaModule } from '../../common/prisma.module';
@Module({ @Module({
imports: [HttpModule], imports: [HttpModule, PrismaModule, CurrencyModule],
controllers: [CurrenciesController], controllers: [CurrenciesController],
providers: [CurrenciesService], providers: [CurrenciesService],
exports: [CurrenciesService], exports: [CurrenciesService],

View File

@@ -1,10 +1,14 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { CurrencyService } from '../currency/currency.service';
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto'; import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
@Injectable() @Injectable()
export class CurrenciesService { export class CurrenciesService {
constructor(private prisma: PrismaService) {} constructor(
private prisma: PrismaService,
private currencyService: CurrencyService,
) {}
async getAllCurrencies() { async getAllCurrencies() {
const rates = await this.prisma.currencyExchangeRate.findMany({ const rates = await this.prisma.currencyExchangeRate.findMany({
@@ -108,7 +112,12 @@ export class CurrenciesService {
} }
async syncExchangeRates() { 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 { private getCurrencyName(code: string): string {

View File

@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { CurrencyService } from './currency.service'; import { CurrencyService } from './currency.service';
import { PrismaModule } from '../../common/prisma.module'; import { PrismaModule } from '../../common/prisma.module';
@Module({ @Module({
imports: [PrismaModule], imports: [PrismaModule, HttpModule],
providers: [CurrencyService], providers: [CurrencyService],
exports: [CurrencyService], exports: [CurrencyService],
}) })

View File

@@ -4,6 +4,9 @@ import {
NotFoundException, NotFoundException,
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
@@ -21,7 +24,11 @@ const CHARGE_CURRENCY_DECIMALS: Record<string, number> = {
export class CurrencyService { export class CurrencyService {
private readonly logger = new Logger(CurrencyService.name); 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( async convertEtbMinorToChargeMajor(
amountMinorEtb: number, amountMinorEtb: number,
@@ -81,14 +88,11 @@ export class CurrencyService {
fromCurrency: Currency, fromCurrency: Currency,
toCurrency: Currency, toCurrency: Currency,
): Promise<number> { ): Promise<number> {
if (fromCurrency === toCurrency) return 1;
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({ const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
where: { where: { fromCurrency, toCurrency },
fromCurrency, orderBy: { effectiveDate: 'desc' },
toCurrency,
},
orderBy: {
effectiveDate: 'desc',
},
}); });
if (!exchangeRate) { if (!exchangeRate) {
@@ -98,26 +102,61 @@ export class CurrencyService {
return 1.0; 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); return Number(exchangeRate.rate);
} }
async syncExchangeRates(): Promise<void> { async syncExchangeRates(): Promise<void> {
this.logger.log('Syncing exchange rates from external provider'); this.logger.log('Syncing exchange rates from central bank API');
const today = this.todayUtc(); const today = this.todayUtc();
const rates = [ // Fallback rates used when the API is unreachable
{ from: 'ETB', to: 'ETB', rate: 1.0 }, const fallbackRates = [
{ from: 'ETB', to: 'DJF', rate: 3.25 }, { from: Currency.ETB, to: Currency.ETB, rate: 1.0 },
{ from: 'ETB', to: 'USD', rate: 0.018 }, { from: Currency.ETB, to: Currency.DJF, rate: 3.25 },
{ from: 'DJF', to: 'ETB', rate: 0.3077 }, { from: Currency.ETB, to: Currency.USD, rate: 0.018 },
{ from: 'USD', to: 'ETB', rate: 55.56 }, { 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) { const apiUrl = this.configService.get<string>('EXCHANGE_RATE_API_URL');
await this.upsertRate(from as Currency, to as Currency, rate, today, 'EXTERNAL_API'); if (apiUrl) {
try {
const response = await firstValueFrom(
this.httpService.get<Record<string, number>>(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() { async listRates() {

View File

@@ -4,8 +4,6 @@ import { CurrencyService } from '../currency/currency.service';
import { FareCalculateDto, resolveCurrencyFromNationality } from './fare-engine.dto'; import { FareCalculateDto, resolveCurrencyFromNationality } from './fare-engine.dto';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
const TAX_RATE = 0.05;
@Injectable() @Injectable()
export class FareEngineService { export class FareEngineService {
constructor( constructor(
@@ -32,6 +30,22 @@ export class FareEngineService {
if (!seatClass) throw new NotFoundException('Seat class not found'); if (!seatClass) throw new NotFoundException('Seat class not found');
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active'); 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 // Calculate distance: distanceKm represents cumulative distance from route origin
// For a segment, distance = destination.distanceKm - origin.distanceKm // For a segment, distance = destination.distanceKm - origin.distanceKm
const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!; const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!;
@@ -102,9 +116,10 @@ export class FareEngineService {
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = 'SCHEDULE_FARE_RULE'; fareSource = 'SCHEDULE_FARE_RULE';
} else { } else {
// Default: distance-based using live SeatClass rate // Default: distance-based using tariff formula: km × rate × 1.02
ratePerKmMinor = seatClass.baseFareMinor; // baseFareMinor stores the per-km rate (tariff decimal × 100000)
baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm); ratePerKmMinor = nationalitySeatClass.baseFareMinor;
baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm * 1.02);
fareSource = 'SEAT_CLASS_BASE_FARE'; fareSource = 'SEAT_CLASS_BASE_FARE';
} }
@@ -138,8 +153,7 @@ export class FareEngineService {
} }
const afterDiscountMinor = subtotalMinor - discountMinor; const afterDiscountMinor = subtotalMinor - discountMinor;
const taxMinor = Math.round(afterDiscountMinor * TAX_RATE); const totalEtbMinor = afterDiscountMinor;
const totalEtbMinor = afterDiscountMinor + taxMinor;
const billingCurrency = resolveCurrencyFromNationality(dto.nationality); const billingCurrency = resolveCurrencyFromNationality(dto.nationality);
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
@@ -147,8 +161,9 @@ export class FareEngineService {
const calculation = [ const calculation = [
`Distance: ${totalDistanceKm} km (${originStation?.name}${destStation?.name})`, `Distance: ${totalDistanceKm} km (${originStation?.name}${destStation?.name})`,
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`, `Nationality: ${dto.nationality ?? 'unspecified'}${nationalityType}${nationalitySeatClass.name}`,
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} = ${baseFarePerPassengerMinor} ETB minor`, `Rate per km: ${ratePerKmMinor} ETB minor (${nationalitySeatClass.name})`,
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} × 1.02 = ${baseFarePerPassengerMinor} ETB minor`,
`Premium/pax: ${premiumPerPassenger} ETB minor`, `Premium/pax: ${premiumPerPassenger} ETB minor`,
`Insurance/pax: ${insurancePerPassenger} ETB minor`, `Insurance/pax: ${insurancePerPassenger} ETB minor`,
`Total fare/pax: ${farePerPassengerMinor} ETB minor`, `Total fare/pax: ${farePerPassengerMinor} ETB minor`,
@@ -160,10 +175,8 @@ export class FareEngineService {
``, ``,
`Subtotal: ${subtotalMinor} ETB minor`, `Subtotal: ${subtotalMinor} ETB minor`,
`Discount: ${promoLabel} → -${discountMinor} ETB minor`, `Discount: ${promoLabel} → -${discountMinor} ETB minor`,
`Tax (5%): +${taxMinor} ETB minor`,
`Total (ETB): ${totalEtbMinor} ETB minor`, `Total (ETB): ${totalEtbMinor} ETB minor`,
``, ``,
`Nationality: ${dto.nationality ?? 'unspecified'}${billingCurrency}`,
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`, `Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`, `Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
`Fare source: ${fareSource}`, `Fare source: ${fareSource}`,
@@ -174,8 +187,8 @@ export class FareEngineService {
routeCode: route.code, routeCode: route.code,
originName: originStation?.name ?? dto.originStationId, originName: originStation?.name ?? dto.originStationId,
destinationName: destStation?.name ?? dto.destinationStationId, destinationName: destStation?.name ?? dto.destinationStationId,
seatClassId: seatClass.id, seatClassId: nationalitySeatClass.id,
seatClassName: seatClass.name, seatClassName: nationalitySeatClass.name,
totalDistanceKm, totalDistanceKm,
ratePerKmMinor, ratePerKmMinor,
baseFarePerPassengerMinor, baseFarePerPassengerMinor,
@@ -188,7 +201,6 @@ export class FareEngineService {
paidChildrenCount, paidChildrenCount,
subtotalMinor, subtotalMinor,
discountMinor, discountMinor,
taxMinor,
totalMinor: totalEtbMinor, totalMinor: totalEtbMinor,
billingCurrency, billingCurrency,
totalInBillingCurrency, totalInBillingCurrency,
@@ -313,16 +325,13 @@ export class FareEngineService {
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
return fareRules.map(rule => { return fareRules.map(rule => {
const seatClassId = rule.seatClassId; const seatClassId = rule.seatClassId;
const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE);
const totalMinor = rule.baseFareMinor + taxMinor;
return { return {
seatClassId, seatClassId,
seatClassName: 'Unknown', seatClassName: 'Unknown',
baseFareMinor: rule.baseFareMinor, baseFareMinor: rule.baseFareMinor,
taxMinor, totalMinor: rule.baseFareMinor,
totalMinor,
billingCurrency, billingCurrency,
totalInBillingCurrency: Math.round(totalMinor * exchangeRate), totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
exchangeRate, exchangeRate,
source: 'FARE_RULE', source: 'FARE_RULE',
}; };

View File

@@ -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 { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { RoutesService } from './routes.service'; 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'; import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Routes') @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: 200, description: 'Schedules with train and terminal station details' })
@ApiResponse({ status: 404, description: 'Route not found' }) @ApiResponse({ status: 404, description: 'Route not found' })
getSchedules(@Param('id') id: string) { return this.service.getSchedulesForRoute(id); } 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); }
} }

View File

@@ -44,3 +44,14 @@ export class UpdateRouteDto {
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string; @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[]; @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[];
}

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service'; 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'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
@Injectable() @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 ─────────────────────────────────────────────── // ── Used by SchedulesService ───────────────────────────────────────────────
/** /**

View File

@@ -119,7 +119,7 @@ export class BulkCreateSchedulesDto {
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: 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 }) @IsOptional() @IsArray() @IsString({ each: true })
coachIds?: string[]; coachIds?: string[];
} }

View File

@@ -46,6 +46,8 @@ export class SchedulesService {
const schedule = await this.createSchedule(createDto); const schedule = await this.createSchedule(createDto);
scheduleIds.push(schedule.id); 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) { if (dto.coachIds && dto.coachIds.length > 0) {
await this.assignCoaches( await this.assignCoaches(
schedule.id, schedule.id,
@@ -177,6 +179,18 @@ export class SchedulesService {
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap); 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); return this.getSchedule(schedule.id);
} }
@@ -583,10 +597,10 @@ export class SchedulesService {
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } }); await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
const data = coaches.map((c, idx) => ({ const data = coaches.map((c) => ({
scheduleId, scheduleId,
coachId: c.coachId, coachId: c.coachId,
positionNumber: idx + 1, positionNumber: c.positionNumber,
isOperational: true, isOperational: true,
})); }));

View File

@@ -468,7 +468,7 @@ export class SearchService {
insuranceFeeMinor: fare.insurancePerPassenger, insuranceFeeMinor: fare.insurancePerPassenger,
totalBaseFareMinor: fare.subtotalMinor, totalBaseFareMinor: fare.subtotalMinor,
discountMinor: fare.discountMinor, discountMinor: fare.discountMinor,
taxesFeesMinor: fare.taxMinor, taxesFeesMinor: 0,
loyaltyRedemptionMinor: loyaltyMinor, loyaltyRedemptionMinor: loyaltyMinor,
totalMinor, totalMinor,
currency: 'ETB', currency: 'ETB',

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma.module'; import { PrismaModule } from '../../common/prisma.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { CurrencyModule } from '../currency/currency.module';
import { TasksService } from './tasks.service'; import { TasksService } from './tasks.service';
@Module({ @Module({
imports: [PrismaModule, NotificationsModule], imports: [PrismaModule, NotificationsModule, CurrencyModule],
providers: [TasksService], providers: [TasksService],
}) })
export class TasksModule {} export class TasksModule {}

View File

@@ -2,12 +2,20 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule'; import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { SmsClientService } from '../notifications/sms-client.service'; import { SmsClientService } from '../notifications/sms-client.service';
import { CurrencyService } from '../currency/currency.service';
/** Maximum time (hours) a passenger has to pay after booking. */ /** Maximum time (hours) a passenger has to pay after booking. */
const MAX_PAYMENT_HOURS = 2; const MAX_PAYMENT_HOURS = 2;
/** Minutes before departure: cutoff for new bookings and payment deadline. */ /** Minutes before departure: cutoff for new bookings and payment deadline. */
const CUTOFF_MINUTES = 30; 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) * payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
*/ */
@@ -32,6 +40,7 @@ export class TasksService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly sms: SmsClientService, 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)`); 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`,
);
}
} }

View File

@@ -1,12 +1,13 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: 'standalone',
reactStrictMode: true, reactStrictMode: true,
transpilePackages: ['@edr/types', '@edr/ui-common'], transpilePackages: ['@edr/types', '@edr/ui-common'],
env: { env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000', NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
}, },
images: { images: {
unoptimized: true, unoptimized: false,
}, },
}; };

View File

@@ -1,15 +1,15 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { routesApi } from '@/lib/api/routes'; import { routesApi } from '@/lib/api/routes';
import { stationsApi } from '@/lib/api'; import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
interface RouteStop { interface RouteStop {
stationId: string; stationId: string;
@@ -18,7 +18,153 @@ interface RouteStop {
distanceFromOrigin?: number; 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 (
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="flex-1">
<label className="label">Select Route</label>
<select className="input" value={selectedRouteId} onChange={(e) => { setSelectedRouteId(e.target.value); setCoachRows([]); }}>
<option value=""> choose a route </option>
{routes.map((r: any) => <option key={r.id} value={r.id}>{r.name} ({r.code})</option>)}
</select>
</div>
</div>
{selectedRouteId && (
<>
{templateLoading ? (
<p className="text-sm text-muted-foreground">Loading template</p>
) : (
<div className="space-y-2">
{coachRows.length > 0 && (
<p className="text-xs text-muted-foreground">Drag <GripVertical className="inline h-3 w-3" /> to reorder coaches</p>
)}
{coachRows.map((row, i) => (
<div
key={i}
draggable
onDragStart={(e) => 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"
>
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="w-8 text-center text-sm font-medium text-muted-foreground flex-shrink-0">
{row.positionNumber}
</div>
<div className="flex-1">
<select className="input input-sm" value={row.coachId} onChange={(e) => updateRow(i, 'coachId', e.target.value)}>
<option value="">Select Coach</option>
{allCoaches
.filter((c: any) => !selectedCoachIds.has(c.id) || c.id === row.coachId)
.map((c: any) => (
<option key={c.id} value={c.id}>{c.number} {c.coachType?.name ?? c.type}</option>
))}
</select>
</div>
<button type="button" onClick={() => removeRow(i)} className="p-1 text-destructive hover:bg-destructive/10 rounded flex-shrink-0">
<X className="h-4 w-4" />
</button>
</div>
))}
<div className="flex justify-between pt-2">
<ActionButton type="button" variant="secondary" size="sm" icon={Plus} onClick={addRow}>Add Coach</ActionButton>
<div className="flex gap-2">
{coachRows.length > 0 && (
<ActionButton type="button" variant="danger" size="sm" onClick={() => clearMutation.mutate()} loading={clearMutation.isPending}>
Clear
</ActionButton>
)}
<ActionButton type="button" size="sm" icon={Save} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
Save Template
</ActionButton>
</div>
</div>
</div>
)}
</>
)}
</div>
);
}
export default function RoutesPage() { export default function RoutesPage() {
const [activeTab, setActiveTab] = useState<Tab>('routes');
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingRoute, setEditingRoute] = useState<any>(null); const [editingRoute, setEditingRoute] = useState<any>(null);
const [stops, setStops] = useState<RouteStop[]>([]); const [stops, setStops] = useState<RouteStop[]>([]);
@@ -244,42 +390,69 @@ export default function RoutesPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">Routes</h1> <h1 className="text-2xl font-bold">Routes</h1>
<p className="text-muted-foreground">Manage railway routes</p> <p className="text-muted-foreground">Manage railway routes and coach templates</p>
</div> </div>
<ActionButton {activeTab === 'routes' && (
icon={Plus} <ActionButton
onClick={() => { icon={Plus}
setEditingRoute(null); onClick={() => {
setOriginStationId(''); setEditingRoute(null);
setDestinationStationId(''); setOriginStationId('');
setDestinationDistance(undefined); setDestinationStationId('');
setStops([]); setDestinationDistance(undefined);
setSearch(''); setStops([]);
setShowModal(true); setSearch('');
}} setShowModal(true);
> }}
Add Route >
</ActionButton> Add Route
</ActionButton>
)}
</div> </div>
<div className="relative mb-6"> {/* Tabs */}
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <div className="flex gap-1 border-b">
<input {(['routes', 'coaches'] as Tab[]).map((tab) => (
type="text" <button
placeholder="Search by code, name, or description..." key={tab}
value={search} onClick={() => setActiveTab(tab)}
onChange={(e) => setSearch(e.target.value)} className={`px-4 py-2 text-sm font-medium capitalize transition-colors border-b-2 -mb-px ${
className="input pl-10 w-full" activeTab === tab
/> ? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab === 'coaches' ? 'Coach Templates' : 'Routes'}
</button>
))}
</div> </div>
<DataTable {activeTab === 'routes' && (
data={displayedRoutes} <>
columns={routeColumns} <div className="relative">
actions={routeActions} <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
loading={routesLoading} <input
emptyMessage={search ? "No routes match your search" : "No routes found"} type="text"
/> placeholder="Search by code, name, or description..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10 w-full"
/>
</div>
<DataTable
data={displayedRoutes}
columns={routeColumns}
actions={routeActions}
loading={routesLoading}
emptyMessage={search ? 'No routes match your search' : 'No routes found'}
/>
</>
)}
{activeTab === 'coaches' && (
<RouteCoachesTab routes={filteredRoutes} />
)}
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirm.isOpen} isOpen={deleteConfirm.isOpen}

View File

@@ -1,13 +1,14 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Loader2, Zap, Trash2, Edit, Search, X } from 'lucide-react'; import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { routeCoachTemplatesApi } from '@/lib/api';
interface Schedule { interface Schedule {
id: string; id: string;
@@ -46,6 +47,7 @@ interface Coach {
export default function SchedulesPage() { export default function SchedulesPage() {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null); const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(new Set()); const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(new Set());
@@ -59,12 +61,45 @@ export default function SchedulesPage() {
trainId: '', trainId: '',
routeId: '', routeId: '',
startDateTime: '', startDateTime: '',
durationHours: '12', durationHours: '10',
repeatEveryDays: '1', repeatEveryDays: '2',
forNextDays: '30', forNextDays: '15',
coachIds: [] as string[],
}); });
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({ const [editForm, setEditForm] = useState({
departureAt: '', departureAt: '',
arrivalAt: '', arrivalAt: '',
@@ -121,8 +156,8 @@ export default function SchedulesPage() {
durationHours: '12', durationHours: '12',
repeatEveryDays: '1', repeatEveryDays: '1',
forNextDays: '30', forNextDays: '30',
coachIds: [],
}); });
setBulkCoachRows([]);
setError(null); setError(null);
}, },
onError: (err: any) => { 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({ const updateScheduleMutation = useMutation({
mutationFn: (data: { id: string; payload: any }) => mutationFn: (data: { id: string; payload: any }) =>
apiClient.patch(`/schedules/${data.id}`, data.payload), apiClient.patch(`/schedules/${data.id}`, data.payload),
@@ -187,13 +236,30 @@ export default function SchedulesPage() {
forNextDays: parseInt(bulkForm.forNextDays), forNextDays: parseInt(bulkForm.forNextDays),
}; };
if (bulkForm.coachIds.length > 0) { const validCoaches = bulkCoachRows.filter((r) => r.coachId);
payload.coachIds = bulkForm.coachIds; if (validCoaches.length > 0) {
payload.coachIds = validCoaches.map((r) => r.coachId);
} }
await bulkGenerateMutation.mutateAsync(payload); await bulkGenerateMutation.mutateAsync(payload);
}; };
const handleAddSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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<HTMLFormElement>) => { const handleEditSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
@@ -396,6 +462,13 @@ export default function SchedulesPage() {
}, },
] as any; ] 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 = [ const scheduleActions = [
{ {
label: 'Edit', label: 'Edit',
@@ -403,6 +476,13 @@ export default function SchedulesPage() {
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Edit, 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', label: 'Delete',
onClick: handleDelete, onClick: handleDelete,
@@ -428,6 +508,13 @@ export default function SchedulesPage() {
Delete {selectedSchedules.size} Schedule{selectedSchedules.size !== 1 ? 's' : ''} Delete {selectedSchedules.size} Schedule{selectedSchedules.size !== 1 ? 's' : ''}
</ActionButton> </ActionButton>
)} )}
<ActionButton
icon={Plus}
variant="secondary"
onClick={() => { setError(null); setShowAddModal(true); }}
>
Add Schedule
</ActionButton>
<ActionButton <ActionButton
icon={Zap} icon={Zap}
onClick={() => { onClick={() => {
@@ -530,6 +617,22 @@ export default function SchedulesPage() {
</div> </div>
</div> </div>
<ConfirmDialog
isOpen={cancelConfirm.isOpen}
onClose={() => 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}
/>
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirm.isOpen} isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, item: null })} onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
@@ -549,6 +652,107 @@ export default function SchedulesPage() {
warning="Schedules with existing bookings cannot be deleted." warning="Schedules with existing bookings cannot be deleted."
/> />
<Modal
isOpen={showAddModal}
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}
title="Add Schedule"
size="lg"
>
<form onSubmit={handleAddSubmit} className="space-y-4">
{error && <div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">{error}</div>}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Train *</label>
<select className="input" value={addForm.trainId} onChange={(e) => setAddForm({ ...addForm, trainId: e.target.value })} required>
<option value="">Select Train</option>
{trains.map((t: Train) => <option key={t.id} value={t.id}>{t.number} ({t.name})</option>)}
</select>
</div>
<div>
<label className="label">Route *</label>
<select className="input" value={addForm.routeId} onChange={(e) => setAddForm({ ...addForm, routeId: e.target.value })} required>
<option value="">Select Route</option>
{routes.map((r: Route) => <option key={r.id} value={r.id}>{r.code} ({r.name})</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Departure *</label>
<input type="datetime-local" className="input" value={addForm.departureAt} onChange={(e) => setAddForm({ ...addForm, departureAt: e.target.value })} required />
</div>
<div>
<label className="label">Arrival *</label>
<input type="datetime-local" className="input" value={addForm.arrivalAt} onChange={(e) => setAddForm({ ...addForm, arrivalAt: e.target.value })} required />
</div>
</div>
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-2">
<label className="label mb-0">Coaches</label>
<div className="flex items-center gap-3">
{singleTemplateLoading && addForm.routeId && (
<span className="text-xs text-muted-foreground flex items-center gap-1"><Loader2 className="h-3 w-3 animate-spin" /> Loading template</span>
)}
{!addForm.routeId && <span className="text-xs text-muted-foreground">Select a route to load its coach template</span>}
<ActionButton type="button" variant="secondary" size="sm" icon={Plus}
disabled={addCoachRows.filter(r => r.coachId).length >= coaches.length}
onClick={() => setAddCoachRows([...addCoachRows, { coachId: '', positionNumber: addCoachRows.length + 1 }])}>
Add Coach
</ActionButton>
</div>
</div>
{addCoachRows.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">No coaches assigned.</p>
) : (
<div className="space-y-2">
{addCoachRows.length > 1 && <p className="text-xs text-muted-foreground">Drag <GripVertical className="inline h-3 w-3" /> to reorder</p>}
{addCoachRows.map((row, i) => {
const selectedIds = new Set(addCoachRows.map((r) => r.coachId).filter(Boolean));
return (
<div key={i} draggable
onDragStart={(e) => 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"
>
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="w-6 text-center text-xs text-muted-foreground flex-shrink-0">{row.positionNumber}</span>
<select className="input input-sm flex-1" value={row.coachId}
onChange={(e) => { const u = [...addCoachRows]; u[i] = { ...u[i], coachId: e.target.value }; setAddCoachRows(u); }}>
<option value="">Select Coach</option>
{coaches.filter((c: Coach) => !selectedIds.has(c.id) || c.id === row.coachId).map((c: Coach) => (
<option key={c.id} value={c.id}>{c.number || c.coachNumber} {c.coachType?.name} (Cap: {c.capacity})</option>
))}
</select>
<button type="button" onClick={() => setAddCoachRows(addCoachRows.filter((_, idx) => idx !== i).map((r, idx) => ({ ...r, positionNumber: idx + 1 })))} className="p-1 text-destructive hover:bg-destructive/10 rounded flex-shrink-0">
<X className="h-4 w-4" />
</button>
</div>
);
})}
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton type="button" variant="secondary" onClick={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}>Cancel</ActionButton>
<ActionButton type="submit" loading={createScheduleMutation.isPending}>Create Schedule</ActionButton>
</div>
</form>
</Modal>
<Modal <Modal
isOpen={showModal} isOpen={showModal}
onClose={() => { onClose={() => {
@@ -561,8 +765,8 @@ export default function SchedulesPage() {
durationHours: '12', durationHours: '12',
repeatEveryDays: '1', repeatEveryDays: '1',
forNextDays: '30', forNextDays: '30',
coachIds: [],
}); });
setBulkCoachRows([]);
}} }}
title="Bulk Generate Schedules" title="Bulk Generate Schedules"
size="lg" size="lg"
@@ -611,7 +815,7 @@ export default function SchedulesPage() {
</div> </div>
<div> <div>
<label className="label">Start Date & Time *</label> <label className="label">Departure Date & Time *</label>
<input <input
type="datetime-local" type="datetime-local"
value={bulkForm.startDateTime} value={bulkForm.startDateTime}
@@ -627,7 +831,7 @@ export default function SchedulesPage() {
<input <input
type="number" type="number"
min="1" min="1"
value={bulkForm.durationHours} placeholder={bulkForm.durationHours}
onChange={(e) => setBulkForm({ ...bulkForm, durationHours: e.target.value })} onChange={(e) => setBulkForm({ ...bulkForm, durationHours: e.target.value })}
className="input" className="input"
/> />
@@ -638,7 +842,7 @@ export default function SchedulesPage() {
<input <input
type="number" type="number"
min="1" min="1"
value={bulkForm.repeatEveryDays} placeholder={bulkForm.repeatEveryDays}
onChange={(e) => setBulkForm({ ...bulkForm, repeatEveryDays: e.target.value })} onChange={(e) => setBulkForm({ ...bulkForm, repeatEveryDays: e.target.value })}
className="input" className="input"
/> />
@@ -649,61 +853,91 @@ export default function SchedulesPage() {
<input <input
type="number" type="number"
min="1" min="1"
value={bulkForm.forNextDays} placeholder={bulkForm.forNextDays}
onChange={(e) => setBulkForm({ ...bulkForm, forNextDays: e.target.value })} onChange={(e) => setBulkForm({ ...bulkForm, forNextDays: e.target.value })}
className="input" className="input"
/> />
</div> </div>
</div> </div>
<div> <div className="border-t pt-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<label className="label">Coaches (Optional)</label> <label className="label mb-0">Coaches</label>
<button <div className="flex items-center gap-3">
type="button" {templateLoading && bulkForm.routeId && (
onClick={() => { <span className="text-xs text-muted-foreground flex items-center gap-1"><Loader2 className="h-3 w-3 animate-spin" /> Loading template</span>
if (bulkForm.coachIds.length === coaches.length) { )}
setBulkForm({ ...bulkForm, coachIds: [] }); {!bulkForm.routeId && (
} else { <span className="text-xs text-muted-foreground">Select a route to load its coach template</span>
setBulkForm({ ...bulkForm, coachIds: coaches.map((c: Coach) => c.id) }); )}
} <ActionButton type="button" variant="secondary" size="sm" icon={Plus}
}} disabled={bulkCoachRows.filter(r => r.coachId).length >= coaches.length}
className="text-xs text-primary hover:underline" onClick={() => setBulkCoachRows([...bulkCoachRows, { coachId: '', positionNumber: bulkCoachRows.length + 1 }])}>
> Add Coach
{bulkForm.coachIds.length === coaches.length ? 'Deselect All' : 'Select All'} </ActionButton>
</button> </div>
</div> </div>
<div className="border border-border rounded-lg p-3 max-h-64 overflow-y-auto space-y-2">
{coaches.length === 0 ? ( {bulkCoachRows.length === 0 ? (
<p className="text-sm text-muted-foreground">No coaches available</p> <p className="text-sm text-muted-foreground py-2">No coaches assigned schedules will be created without coach assignments.</p>
) : ( ) : (
coaches.map((coach: Coach) => ( <div className="space-y-2">
<label key={coach.id} className="flex items-center gap-2 cursor-pointer"> {bulkCoachRows.length > 1 && (
<input <p className="text-xs text-muted-foreground">Drag <GripVertical className="inline h-3 w-3" /> to reorder</p>
type="checkbox" )}
checked={bulkForm.coachIds.includes(coach.id)} {bulkCoachRows.map((row, i) => {
onChange={(e) => { const selectedIds = new Set(bulkCoachRows.map((r) => r.coachId).filter(Boolean));
if (e.target.checked) { return (
setBulkForm({ <div
...bulkForm, key={i}
coachIds: [...bulkForm.coachIds, coach.id], draggable
}); onDragStart={(e) => e.dataTransfer.setData('bulk-coach-idx', i.toString())}
} else { onDragOver={(e) => { e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '0.5'; }}
setBulkForm({ onDragLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1'; }}
...bulkForm, onDrop={(e) => {
coachIds: bulkForm.coachIds.filter((id) => id !== coach.id), e.preventDefault();
}); (e.currentTarget as HTMLElement).style.opacity = '1';
} const src = parseInt(e.dataTransfer.getData('bulk-coach-idx'));
if (src === i) return;
const reordered = [...bulkCoachRows];
const [moved] = reordered.splice(src, 1);
reordered.splice(i, 0, moved);
setBulkCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 })));
}} }}
className="rounded" className="flex gap-2 items-center p-2 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors"
/> >
<span className="text-sm"> <GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
{coach.sequence || 'N/A'} - {coach.number || coach.coachNumber} - {coach.coachType?.name} (Cap: {coach.capacity}) <span className="w-6 text-center text-xs text-muted-foreground flex-shrink-0">{row.positionNumber}</span>
</span> <select
</label> className="input input-sm flex-1"
)) value={row.coachId}
)} onChange={(e) => {
</div> const updated = [...bulkCoachRows];
updated[i] = { ...updated[i], coachId: e.target.value };
setBulkCoachRows(updated);
}}
>
<option value="">Select Coach</option>
{coaches
.filter((c: Coach) => !selectedIds.has(c.id) || c.id === row.coachId)
.map((c: Coach) => (
<option key={c.id} value={c.id}>
{c.number || c.coachNumber} {c.coachType?.name} (Cap: {c.capacity})
</option>
))}
</select>
<button
type="button"
onClick={() => setBulkCoachRows(bulkCoachRows.filter((_, idx) => idx !== i).map((r, idx) => ({ ...r, positionNumber: idx + 1 })))}
className="p-1 text-destructive hover:bg-destructive/10 rounded flex-shrink-0"
>
<X className="h-4 w-4" />
</button>
</div>
);
})}
</div>
)}
</div> </div>
<div className="bg-blue-50 p-4 rounded-lg"> <div className="bg-blue-50 p-4 rounded-lg">
@@ -713,10 +947,10 @@ export default function SchedulesPage() {
schedules, starting from the specified date, repeating every{' '} schedules, starting from the specified date, repeating every{' '}
<strong>{bulkForm.repeatEveryDays}</strong> days for the next{' '} <strong>{bulkForm.repeatEveryDays}</strong> days for the next{' '}
<strong>{bulkForm.forNextDays}</strong> days. <strong>{bulkForm.forNextDays}</strong> days.
{bulkForm.coachIds.length > 0 && ( {bulkCoachRows.filter(r => r.coachId).length > 0 && (
<> <>
{' '} {' '}
Each schedule will have <strong>{bulkForm.coachIds.length}</strong> coach(es) assigned. Each schedule will have <strong>{bulkCoachRows.filter(r => r.coachId).length}</strong> coach(es) assigned.
</> </>
)} )}
</p> </p>
@@ -736,8 +970,8 @@ export default function SchedulesPage() {
durationHours: '12', durationHours: '12',
repeatEveryDays: '1', repeatEveryDays: '1',
forNextDays: '30', forNextDays: '30',
coachIds: [],
}); });
setBulkCoachRows([]);
}} }}
> >
Cancel Cancel
@@ -756,7 +990,7 @@ export default function SchedulesPage() {
setEditingSchedule(null); setEditingSchedule(null);
setError(null); setError(null);
}} }}
title={`Edit Schedule - ${editingSchedule?.train?.name} (${editingSchedule?.train?.number})`} title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''} ${editingSchedule?.destinationStation?.name ?? ''}`}
size="lg" size="lg"
> >
{editingSchedule && ( {editingSchedule && (

View File

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

View File

@@ -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<string, string> = {
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<string, Record<string, number>> = {
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<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
const [formError, setFormError] = useState<string | null>(null);
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState('');
const [selectedBedPosition, setSelectedBedPosition] = useState<string>('');
const [selectedNationalityType, setSelectedNationalityType] = useState<string>('LOCAL');
const queryClient = useQueryClient();
const { data: classesData, isLoading } = useQuery({
queryKey: ['seat-classes'],
queryFn: () => apiClient.get<any>('/seat-classes'),
});
const { data: coachTypesData } = useQuery({
queryKey: ['coach-types'],
queryFn: () => apiClient.get<any>('/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<HTMLFormElement>) => {
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) => (
<Badge variant="status" status={c.nationalityType === 'LOCAL' ? 'CONFIRMED' : 'INFO'}>
{c.nationalityType === 'LOCAL' ? 'Local' : 'International'}
</Badge>
),
},
{
key: 'coachType', label: 'Coach Type',
render: (c: any) => <span className="text-sm">{c.coachType?.name || c.coachTypeId}</span>,
},
{
key: 'bedPosition', label: 'Berth Position',
render: (c: any) => c.bedPosition
? <span className="font-mono text-sm">{c.bedPosition}</span>
: <span className="text-muted-foreground text-xs">Standard</span>,
},
{
key: 'name', label: 'Class Name',
render: (c: any) => <span className="font-medium">{c.name}</span>,
},
{
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 (
<div className="flex items-center gap-2">
<span className="font-mono font-medium">{c.baseFareMinor}</span>
{tariffMinor !== undefined && (
<span className={`text-xs px-1.5 py-0.5 rounded ${matches ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'}`}>
{matches ? '✓ tariff' : `tariff: ${tariffMinor}`}
</span>
)}
</div>
);
},
},
{
key: 'isActive', label: 'Status',
render: (c: any) => (
<Badge variant="status" status={c.isActive ? 'CONFIRMED' : 'CANCELLED'}>
{c.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
];
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Tariff Rates</h1>
<p className="text-muted-foreground">
Manage per-km fare rates by nationality, coach type, and berth position per the official EDR tariff policy
</p>
</div>
<ActionButton icon={Plus} onClick={() => { setEditingClass(null); setFormError(null); setShowModal(true); }}>
Add Rate
</ActionButton>
</div>
{/* Tariff reference card */}
<div className="card bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-2">Official Tariff Formula</h3>
<p className="text-sm text-blue-800 dark:text-blue-300 font-mono">
Fare = KM × rate × 1.02 × ExchangeRate
</p>
<p className="text-xs text-blue-700 dark:text-blue-400 mt-1">
Rate is stored as <strong>baseFareMinor = tariff_decimal × 100,000</strong> (e.g. 0.03 3000). The ×1.02 insurance coefficient is applied automatically by the fare engine.
</p>
</div>
<div className="card">
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by name, nationality, berth position..."
className="input pl-10 w-full"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<DataTable
data={displayed}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'}
/>
</div>
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => 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."
/>
<Modal
isOpen={showModal}
onClose={closeModal}
title={`${editingClass ? 'Edit' : 'Add'} Tariff Rate`}
size="lg"
>
<form key={editingClass?.id ?? 'new'} onSubmit={handleSubmit} className="space-y-4">
{formError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-200">
{formError}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Passenger Nationality *</label>
<select
className="input"
value={selectedNationalityType}
onChange={(e) => setSelectedNationalityType(e.target.value)}
required
>
<option value="LOCAL">Local (Ethiopian / Djiboutian)</option>
<option value="INTERNATIONAL">International (Foreign nationals)</option>
</select>
</div>
<div>
<label className="label">Coach Type *</label>
<select
className="input"
value={selectedCoachTypeId}
onChange={(e) => { setSelectedCoachTypeId(e.target.value); setSelectedBedPosition(''); }}
required
>
<option value="">Select coach type</option>
{coachTypesArray.map((ct: any) => (
<option key={ct.id} value={ct.id}>
{ct.code} {ct.name}
</option>
))}
</select>
</div>
{isBedCoach && (
<div>
<label className="label">Berth Position *</label>
<select
className="input"
value={selectedBedPosition}
onChange={(e) => setSelectedBedPosition(e.target.value)}
required={isBedCoach}
>
<option value="">Select berth position</option>
{(selectedCoachType?.code === 'HBC'
? BED_POSITIONS
: (['UPPER', 'LOWER'] as const)
).map((pos) => (
<option key={pos} value={pos}>{pos}</option>
))}
</select>
<p className="text-xs text-muted-foreground mt-1">
{selectedCoachType?.code === 'HBC' ? 'Economy Bed: Upper / Middle / Lower' : 'VIP Bed: Upper / Lower'}
</p>
</div>
)}
<div>
<label className="label">Class Name *</label>
<input
type="text"
name="name"
className="input"
defaultValue={editingClass?.name || ''}
key={editingClass?.id ?? `new-${selectedCoachTypeId}-${selectedBedPosition}-${selectedNationalityType}`}
placeholder={suggestName() || 'e.g. Economy Bed Upper (Local)'}
required
/>
{!editingClass && suggestName() && (
<p className="text-xs text-muted-foreground mt-1">
Suggested:{' '}
<button
type="button"
className="text-primary underline"
onClick={(e) => {
const inp = (e.currentTarget.closest('.space-y-4')?.querySelector('input[name=name]') as HTMLInputElement);
if (inp) inp.value = suggestName();
}}
>
{suggestName()}
</button>
</p>
)}
</div>
<div>
<label className="label">Base Fare Minor (per km) *</label>
<input
type="number"
name="baseFareMinor"
className="input"
defaultValue={editingClass?.baseFareMinor ?? ''}
key={editingClass?.id ?? `rate-${selectedCoachTypeId}-${selectedBedPosition}-${selectedNationalityType}`}
placeholder={suggestRate() || 'e.g. 3000'}
min="0"
required
/>
{suggestRate() && (
<p className="text-xs text-muted-foreground mt-1">
Official tariff rate:{' '}
<button
type="button"
className="text-primary underline"
onClick={(e) => {
const inp = (e.currentTarget.closest('.space-y-4')?.querySelector('input[name=baseFareMinor]') as HTMLInputElement);
if (inp) inp.value = suggestRate();
}}
>
{suggestRate()}
</button>
{' '}(= {(parseInt(suggestRate()) / 100000).toFixed(3)} ETB/km)
</p>
)}
</div>
<div>
<label className="label">Status</label>
<select name="isActive" className="input" defaultValue={editingClass?.isActive !== false ? 'true' : 'false'}>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton type="button" variant="secondary" onClick={closeModal}>Cancel</ActionButton>
<ActionButton type="submit" loading={createMutation.isPending || updateMutation.isPending}>
{editingClass ? 'Update' : 'Create'} Rate
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -89,7 +89,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
title: 'Financial', title: 'Financial',
items: [ items: [
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin }, { 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: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage },
{ name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.payments.view }, { name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.payments.view },
{ name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin }, { name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin },

View File

@@ -442,6 +442,14 @@ export const excessBaggageApi = {
delete: (id: string) => apiClient.delete(`/agents/excess-baggage/${id}`), delete: (id: string) => apiClient.delete(`/agents/excess-baggage/${id}`),
}; };
// Route Coach Templates API
export const routeCoachTemplatesApi = {
get: (routeId: string) => apiClient.get<any>(`/routes/${routeId}/coaches`),
set: (routeId: string, coaches: Array<{ coachId: string; positionNumber: number }>) =>
apiClient.put<any>(`/routes/${routeId}/coaches`, { coaches }),
clear: (routeId: string) => apiClient.delete(`/routes/${routeId}/coaches`),
};
// System Config API // System Config API
export const systemConfigApi = { export const systemConfigApi = {
getAll: () => apiClient.get<Record<string, string>>('/config'), getAll: () => apiClient.get<Record<string, string>>('/config'),

View File

@@ -1,9 +1,10 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: 'standalone',
reactStrictMode: true, reactStrictMode: true,
transpilePackages: ['@edr/types', '@edr/ui-common'], transpilePackages: ['@edr/types', '@edr/ui-common'],
images: { images: {
unoptimized: true, unoptimized: false,
}, },
}; };

View File

@@ -30,6 +30,18 @@ services:
- apps/edr-passenger-api/.env - apps/edr-passenger-api/.env
extra_hosts: extra_hosts:
- "paymentcallback.triaplc.com:10.18.7.179" - "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: freight-portal:
build: build:
context: . context: .
@@ -72,6 +84,18 @@ services:
- "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}" - "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}"
env_file: env_file:
- apps/edr-passenger-web/portal/.env - 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: passenger-backoffice:
build: build:
context: . context: .
@@ -86,6 +110,18 @@ services:
- "${PASSENGER_BACKOFFICE_PORT:-5184}:${PASSENGER_BACKOFFICE_PORT:-5184}" - "${PASSENGER_BACKOFFICE_PORT:-5184}:${PASSENGER_BACKOFFICE_PORT:-5184}"
env_file: env_file:
- apps/edr-passenger-web/backoffice/.env - 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: payment-api:
build: build:

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# #
# Next.js Dockerfile for passenger web (portal + backoffice). # 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): # Build example (portal):
# DOCKER_BUILDKIT=1 docker build \ # DOCKER_BUILDKIT=1 docker build \
@@ -16,8 +16,6 @@ ARG PORT=5174
ARG NEXT_PUBLIC_API_URL ARG NEXT_PUBLIC_API_URL
FROM node:24.15.0-alpine AS base FROM node:24.15.0-alpine AS base
RUN apk add --no-cache libc6-compat 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 PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable RUN corepack enable
@@ -46,20 +44,30 @@ RUN if [ -z "$NEXT_PUBLIC_API_URL" ]; then \
COPY --from=installer /app/ . COPY --from=installer /app/ .
COPY --from=pruner /app/out/full/ . COPY --from=pruner /app/out/full/ .
RUN pnpm turbo build --filter="${APP_PACKAGE}..." RUN pnpm turbo build --filter="${APP_PACKAGE}..."
FROM base AS deployer
ARG APP_PACKAGE # Runner: copy the standalone output produced by Next.js.
COPY --from=builder /app/ . # With output:'standalone' in a pnpm monorepo, Next.js emits:
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ # .next/standalone/ <- shared workspace node_modules
pnpm deploy --filter="${APP_PACKAGE}" --prod --legacy /deploy # .next/standalone/<APP_PATH>/ <- 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 FROM node:24.15.0-alpine AS runner
ARG APP_PATH ARG APP_PATH
ARG PORT=5174 ARG PORT=5174
ENV PORT=${PORT} ENV PORT=${PORT}
ENV NODE_ENV=production ENV NODE_ENV=production
WORKDIR /app WORKDIR /app
COPY --from=deployer /deploy . RUN addgroup --system --gid 1001 nodejs \
COPY --from=builder /app/${APP_PATH}/.next .next && adduser --system --uid 1001 --ingroup nodejs nextjs
COPY --from=builder /app/${APP_PATH}/public public # Copy the entire standalone tree (includes root node_modules symlinks)
USER node 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 <APP_PATH>/server.js inside the standalone tree
WORKDIR /app/${APP_PATH}
EXPOSE ${PORT} EXPOSE ${PORT}
CMD ["npx", "next", "start"] CMD ["node", "server.js"]

View File

@@ -4,10 +4,24 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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 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 / { location / {
try_files $uri $uri/ /index.html; 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";
}
} }

19
pnpm-lock.yaml generated
View File

@@ -525,6 +525,9 @@ importers:
express: express:
specifier: ^4.18.2 specifier: ^4.18.2
version: 4.22.2 version: 4.22.2
helmet:
specifier: ^8.0.0
version: 8.2.0
jose: jose:
specifier: ^5.10.0 specifier: ^5.10.0
version: 5.10.0 version: 5.10.0
@@ -7108,6 +7111,10 @@ packages:
headers-polyfill@5.0.1: headers-polyfill@5.0.1:
resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} 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: helper-date@1.0.1:
resolution: {integrity: sha512-wU3VOwwTJvGr/w5rZr3cprPHO+hIhlblTJHD6aFBrKLuNbf4lAmkawd2iK3c6NbJEvY7HAmDpqjOFSI5/+Ey2w==} resolution: {integrity: sha512-wU3VOwwTJvGr/w5rZr3cprPHO+hIhlblTJHD6aFBrKLuNbf4lAmkawd2iK3c6NbJEvY7HAmDpqjOFSI5/+Ey2w==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
@@ -12632,10 +12639,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@types/react' - '@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: 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/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': 7.17.8(react@19.2.6) '@mantine/hooks': 9.3.0(react@19.2.6)
clsx: 2.1.1 clsx: 2.1.1
dayjs: 1.11.21 dayjs: 1.11.21
react: 19.2.6 react: 19.2.6
@@ -15545,7 +15552,7 @@ snapshots:
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) '@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/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/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/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) '@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) '@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 '@types/set-cookie-parser': 2.4.10
set-cookie-parser: 3.1.0 set-cookie-parser: 3.1.0
helmet@8.2.0: {}
helper-date@1.0.1: helper-date@1.0.1:
dependencies: dependencies:
date.js: 0.3.3 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): 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: 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/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/hooks': 7.17.8(react@19.2.6)
'@tabler/icons-react': 3.44.0(react@19.2.6) '@tabler/icons-react': 3.44.0(react@19.2.6)
'@tanstack/match-sorter-utils': 8.19.4 '@tanstack/match-sorter-utils': 8.19.4