diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 78c15cba1..fcd560a95 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -50,7 +50,7 @@ jobs: SERVICES=() - NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$" + NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$|^scripts/deploy/sync-env-from-server-jenkins[.]sh$" GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$" diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 9edf388b9..b24cf6c83 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -32,7 +32,8 @@ "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js", - "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts" + "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts", + "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" }, "dependencies": { "@edr/api-common": "workspace:*", @@ -84,13 +85,15 @@ "@types/node": "^20.14.0", "@types/pg": "^8.6.7", "@types/supertest": "^6.0.2", + "@types/vorpal": "^1.12.8", "jest": "^29.7.0", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "vorpal": "^1.12.0" }, "jest": { "moduleFileExtensions": [ diff --git a/apps/edr-freight-api/src/scripts/cmds/index.ts b/apps/edr-freight-api/src/scripts/cmds/index.ts new file mode 100644 index 000000000..369aff010 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/cmds/index.ts @@ -0,0 +1,10 @@ +import type Vorpal from "vorpal"; +import type { CommandContext } from "./types"; + +import { registerSeedTestContracts } from "./seed-test-contracts.cmd"; +import { registerSeedTestSchedules } from "./seed-test-schedules.cmd"; + +export function registerCommands(vorpal: Vorpal, ctx: CommandContext): void { + registerSeedTestContracts(vorpal, ctx); + registerSeedTestSchedules(vorpal, ctx); +} diff --git a/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts b/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts new file mode 100644 index 000000000..17ba79182 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts @@ -0,0 +1,358 @@ +import type Vorpal from "vorpal"; +import { DataSource } from "typeorm"; +import type { CommandContext } from "./types"; +import { Company, CompanyType, CompanyKind, CompanyStatus, CompanyNationality } from "../../modules/companies/entities/company.entity"; +import { CompanyProfile, ProfileStatus, ProfileType } from "../../modules/companies/entities/company-profile.entity"; +import { ExternalProfile } from "../../modules/companies/entities/external-profile.entity"; +import { Yard } from "../../modules/rule-engine/entities/yard.entity"; +import { ServiceType } from "../../modules/rule-engine/entities/service-type.entity"; +import { CargoType } from "../../modules/rule-engine/entities/cargo-type.entity"; +import { Rate } from "../../modules/rule-engine/entities/rate.entity"; +import { Contract } from "../../modules/contracts/entities/contract.entity"; +import { ContractRoute } from "../../modules/contracts/entities/contract-route.entity"; +import { ContractCargoScope } from "../../modules/contracts/entities/contract-cargo-scope.entity"; +import { ContractRateSnapshot } from "../../modules/contracts/entities/contract-rate-snapshot.entity"; + +const COMPANY_IDS = [ + "a0000001-0000-4000-8000-000000000001", + "a0000001-0000-4000-8000-000000000002", + "a0000001-0000-4000-8000-000000000003", + "a0000001-0000-4000-8000-000000000004", +]; + +const PROFILE_IDS = [ + "b0000001-0000-4000-8000-000000000001", + "b0000001-0000-4000-8000-000000000002", + "b0000001-0000-4000-8000-000000000003", + "b0000001-0000-4000-8000-000000000004", + "b0000001-0000-4000-8000-000000000005", + "b0000001-0000-4000-8000-000000000006", + "b0000001-0000-4000-8000-000000000007", + "b0000001-0000-4000-8000-000000000008", +]; + +const EXTERNAL_PROFILE_IDS = [ + "c0000001-0000-4000-8000-000000000001", + "c0000001-0000-4000-8000-000000000002", + "c0000001-0000-4000-8000-000000000003", + "c0000001-0000-4000-8000-000000000004", +]; + +function generateRef(index: number): string { + return `TST-CTR-${String(index).padStart(5, "0")}`; +} + +export function registerSeedTestContracts( + vorpal: Vorpal, + ctx: CommandContext, +): void { + vorpal + .command("seed:test-contracts", "Generate test contracts with companies and all deps") + .option("-n, --count ", "Number of contracts to create (default: 4)") + .option("--status ", "Comma-separated contract statuses (default: DRAFT,SUBMITTED,APPROVED,CONTRACT_ACTIVE)") + .option("--freight ", "Freight types: CONTAINER,BULK (default: both)") + .option("--direction ", "Trade directions: IMPORT,EXPORT (default: both)") + .option("--company ", "Only create contracts for company matching name/TIN") + .action(async function (this: any, args: any) { + const { app } = ctx; + const ds = app.get(DataSource); + + const count = Math.max(1, Math.min(20, parseInt(args.options?.count ?? "4", 10))); + const statusList = (args.options?.status ?? "DRAFT,SUBMITTED,APPROVED,CONTRACT_ACTIVE") + .split(",").map((s: string) => s.trim()).filter(Boolean); + const freightList = (args.options?.freight ?? "CONTAINER,BULK") + .split(",").map((s: string) => s.toUpperCase().trim()) + .filter((s: string) => s === "CONTAINER" || s === "BULK"); + const directionList = (args.options?.direction ?? "IMPORT,EXPORT") + .split(",").map((s: string) => s.toUpperCase().trim()) + .filter((s: string) => s === "IMPORT" || s === "EXPORT"); + const companyFilter = args.options?.company as string | undefined; + + if (freightList.length === 0 || directionList.length === 0) { + this.log("error: at least one freight type and trade direction required"); + return; + } + + this.log(`Seeding ${count} contracts (statuses=${statusList.join(",")}, freight=${freightList.join(",")}, dir=${directionList.join(",")})...`); + + const yards = await ds.getRepository(Yard).find({ where: { isActive: true } }); + const yardByCode = new Map(yards.map((y) => [y.code, y])); + const djibouti = yardByCode.get("DJIBOUTI"); + const addis = yardByCode.get("ADDIS_ABABA"); + if (!djibouti || !addis) { + this.log("error: need at least DJIBOUTI and ADDIS_ABABA yards seeded"); + return; + } + + const serviceTypes = await ds + .getRepository(ServiceType) + .find({ where: { isActive: true } }); + const stByCode = new Map(serviceTypes.map((st) => [st.code, st])); + const railContainer = stByCode.get("RAIL_CONTAINER"); + const railBulk = stByCode.get("RAIL_BULK"); + if (!railContainer && !railBulk) { + this.log("error: need at least RAIL_CONTAINER or RAIL_BULK service type seeded"); + return; + } + + const cargoTypes = await ds + .getRepository(CargoType) + .find({ where: { isActive: true } }); + const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c])); + const grain = cargoByCode.get("GRAIN"); + const sugar = cargoByCode.get("SUGAR"); + const fertilizer = cargoByCode.get("FERTILIZER"); + + const rates = await ds.getRepository(Rate).find({ where: { status: "LIVE" } }); + + const companyRepo = ds.getRepository(Company); + let companies = await companyRepo.find({}); + + if (companyFilter) { + companies = companies.filter( + (c) => + c.name.toLowerCase().includes(companyFilter.toLowerCase()) || + c.tin.includes(companyFilter), + ); + } + + if (companies.length === 0) { + this.log("No existing companies found — seeding test companies..."); + companies = await seedTestCompanies(ds, (msg) => this.log(msg)); + } else { + this.log(`Using ${companies.length} existing companies from DB`); + } + + const contractRepo = ds.getRepository(Contract); + let contractCount = 0; + + for (let i = 0; i < count; i++) { + const statusIdx = i % statusList.length; + const ftIdx = i % freightList.length; + const dirIdx = i % directionList.length; + const companyIdx = i % companies.length; + + const status = statusList[statusIdx]; + const freightType = freightList[ftIdx]; + const direction = directionList[dirIdx]; + const company = companies[companyIdx]; + + const profile = await ds.getRepository(CompanyProfile).findOne({ + where: { + companyId: company.id, + type: direction === "IMPORT" ? ProfileType.importer : ProfileType.exporter, + }, + }); + if (!profile) continue; + + const ref = generateRef(i + 1); + const exists = await contractRepo.findOne({ where: { reference: ref } }); + if (exists) { + this.log(` Contract ${ref} already exists — skipping`); + contractCount++; + continue; + } + + const serviceTypeId = + freightType === "BULK" && railBulk + ? railBulk.id + : railContainer + ? railContainer.id + : serviceTypes[0].id; + + const originId = direction === "IMPORT" ? djibouti.id : addis.id; + const destId = direction === "IMPORT" ? addis.id : djibouti.id; + + const contract = contractRepo.create({ + reference: ref, + companyId: company.id, + companyProfileId: profile.id, + contractKind: "ONE_TIME" as const, + tradeDirection: direction, + freightType, + serviceTypeId, + paymentCurrency: "USD", + customsClearingEnabled: false, + equipmentReturn: "without_return", + status, + versionNumber: 1, + }); + + const saved = await contractRepo.save(contract); + + await ds.getRepository(ContractRoute).save( + ds.getRepository(ContractRoute).create({ + contractId: saved.id, + originYardId: originId, + destinationYardId: destId, + sortOrder: 1, + }), + ); + + if (freightType === "CONTAINER") { + for (const size of ["20FT", "40FT"] as const) { + await ds.getRepository(ContractCargoScope).save( + ds.getRepository(ContractCargoScope).create({ + contractId: saved.id, + containerSize: size, + }), + ); + } + } else { + const bulkCargo = grain || sugar || fertilizer; + if (bulkCargo) { + await ds.getRepository(ContractCargoScope).save( + ds.getRepository(ContractCargoScope).create({ + contractId: saved.id, + cargoTypeId: bulkCargo.id, + quantityCap: 10000, + }), + ); + } + } + + const matchingRates = rates.filter((r) => { + if (r.appliesTo === "CONTAINER" && freightType !== "CONTAINER") return false; + if (r.appliesTo === "BULK" && freightType !== "BULK") return false; + if (r.tradeDirection && r.tradeDirection !== direction) return false; + return r.status === "LIVE" && r.trigger === "ALWAYS"; + }); + + const seen = new Set(); + for (const rate of matchingRates.slice(0, 3)) { + const sig = `${rate.rateType}|${rate.currency}|${rate.rateValue}`; + if (seen.has(sig)) continue; + seen.add(sig); + + await ds.getRepository(ContractRateSnapshot).save( + ds.getRepository(ContractRateSnapshot).create({ + contractId: saved.id, + rateId: rate.id, + rateCode: rate.rateType, + unitPrice: Number(rate.rateValue), + unitOfMeasure: rate.rateUnit, + currency: rate.currency ?? "USD", + containerSize: freightType === "CONTAINER" ? "20FT" : null, + isSurcharge: rate.trigger !== "ALWAYS", + conditionalOn: rate.trigger !== "ALWAYS" ? rate.trigger : null, + }), + ); + } + + contractCount++; + this.log(` Created ${status} ${freightType} ${direction} contract: ${ref} (${company.name})`); + } + + this.log(`Done — ${contractCount} contracts ensured`); + }); +} + +interface CompanySeed { + id: string; + name: string; + tin: string; + profiles: Array<{ id: string; type: ProfileType; reference: string }>; + externalProfile: { id: string; userId: string; firstName: string; lastName: string }; +} + +const TEST_COMPANIES: CompanySeed[] = [ + { + id: COMPANY_IDS[0], name: "Test Importer Co.", tin: "TST000001", + profiles: [ + { id: PROFILE_IDS[0], type: ProfileType.importer, reference: "TST-IM-001" }, + { id: PROFILE_IDS[1], type: ProfileType.exporter, reference: "TST-EX-001" }, + ], + externalProfile: { id: EXTERNAL_PROFILE_IDS[0], userId: "00000000-0000-0000-0000-000000000001", firstName: "Abebe", lastName: "Kebede" }, + }, + { + id: COMPANY_IDS[1], name: "Test Exporter Ltd.", tin: "TST000002", + profiles: [ + { id: PROFILE_IDS[2], type: ProfileType.importer, reference: "TST-IM-002" }, + { id: PROFILE_IDS[3], type: ProfileType.exporter, reference: "TST-EX-002" }, + ], + externalProfile: { id: EXTERNAL_PROFILE_IDS[1], userId: "00000000-0000-0000-0000-000000000002", firstName: "Bekele", lastName: "Alemu" }, + }, + { + id: COMPANY_IDS[2], name: "Bulk Commodities PLC", tin: "TST000003", + profiles: [ + { id: PROFILE_IDS[4], type: ProfileType.importer, reference: "TST-IM-003" }, + { id: PROFILE_IDS[5], type: ProfileType.exporter, reference: "TST-EX-003" }, + ], + externalProfile: { id: EXTERNAL_PROFILE_IDS[2], userId: "00000000-0000-0000-0000-000000000003", firstName: "Chala", lastName: "Tesfaye" }, + }, + { + id: COMPANY_IDS[3], name: "Hazardous Logistics Inc.", tin: "TST000004", + profiles: [ + { id: PROFILE_IDS[6], type: ProfileType.importer, reference: "TST-IM-004" }, + { id: PROFILE_IDS[7], type: ProfileType.exporter, reference: "TST-EX-004" }, + ], + externalProfile: { id: EXTERNAL_PROFILE_IDS[3], userId: "00000000-0000-0000-0000-000000000004", firstName: "Desta", lastName: "Hailu" }, + }, +]; + +async function seedTestCompanies(ds: DataSource, log: (msg: string) => void): Promise { + const companyRepo = ds.getRepository(Company); + const profileRepo = ds.getRepository(CompanyProfile); + const extProfileRepo = ds.getRepository(ExternalProfile); + const result: Company[] = []; + + for (const seed of TEST_COMPANIES) { + let company = await companyRepo.findOne({ where: { id: seed.id } }); + if (!company) { + company = await companyRepo.save( + companyRepo.create({ + id: seed.id, + name: seed.name, + type: CompanyType.Customer, + kind: CompanyKind.Commercial, + status: CompanyStatus.Active, + tin: seed.tin, + country: "Ethiopia", + nationality: CompanyNationality.Ethiopian, + email: `info@${seed.name.toLowerCase().replace(/\s+/g, "")}.com`, + phone: "+251911000001", + }), + ); + log(` Created company: ${seed.name}`); + } else { + log(` Company already exists: ${seed.name}`); + } + + for (const p of seed.profiles) { + const existing = await profileRepo.findOne({ where: { id: p.id } }); + if (!existing) { + await profileRepo.save( + profileRepo.create({ + id: p.id, + companyId: seed.id, + type: p.type, + reference: p.reference, + status: ProfileStatus.Active, + }), + ); + log(` Created ${p.type} profile: ${p.reference}`); + } + } + + const ext = seed.externalProfile; + const existingExt = await extProfileRepo.findOne({ where: { id: ext.id } }); + if (!existingExt) { + await extProfileRepo.save( + extProfileRepo.create({ + id: ext.id, + userId: ext.userId, + companyId: seed.id, + firstName: ext.firstName, + lastName: ext.lastName, + isPrimaryContact: true, + onboardingCompleted: true, + }), + ); + log(` Created external profile: ${ext.firstName} ${ext.lastName}`); + } + + result.push(company); + } + + return result; +} diff --git a/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts b/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts new file mode 100644 index 000000000..2d9002555 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts @@ -0,0 +1,298 @@ +import type Vorpal from "vorpal"; +import { DataSource } from "typeorm"; +import { WagonStatus } from "@edr/types"; +import type { CommandContext } from "./types"; +import { Yard } from "../../modules/rule-engine/entities/yard.entity"; +import { Route } from "../../modules/routes/entities/route.entity"; +import { RouteMilestone } from "../../modules/routes/entities/route-milestone.entity"; +import { Locomotive } from "../../modules/locomotives/entities/locomotive.entity"; +import { Wagon } from "../../modules/wagons/entities/wagon.entity"; +import { WagonType } from "../../modules/wagon-types/entities/wagon-type.entity"; +import { TrainSet } from "../../modules/train-sets/entities/train-set.entity"; +import { TrainSetLocomotive } from "../../modules/train-sets/entities/train-set-locomotive.entity"; +import { TrainSetWagon } from "../../modules/train-sets/entities/train-set-wagon.entity"; +import { TrainSchedule } from "../../modules/train-schedules/entities/train-schedule.entity"; +import { TrainScheduleBooking } from "../../modules/train-schedules/entities/train-schedule-booking.entity"; +import { Booking } from "../../modules/bookings/entities/booking.entity"; + +export function registerSeedTestSchedules( + vorpal: Vorpal, + ctx: CommandContext, +): void { + vorpal + .command("seed:test-schedules", "Seed train schedules with routes, wagons, and all deps for booking") + .option("-n, --count ", "Number of schedules to create (default: 3)") + .option("--direction ", "IMPORT,EXPORT (default: both)") + .option("--status ", "DRAFT,SCHEDULED,DISPATCHED (default: SCHEDULED)") + .option("--days-ahead ", "Days from now for departure (default: 3)") + .action(async function (this: any, args: any) { + const { app } = ctx; + const ds = app.get(DataSource); + + const count = Math.max(1, Math.min(10, parseInt(args.options?.count ?? "3", 10))); + const directionList = (args.options?.direction ?? "IMPORT,EXPORT") + .split(",").map((s: string) => s.toUpperCase().trim()) + .filter((s: string) => s === "IMPORT" || s === "EXPORT"); + const statusList = (args.options?.status ?? "SCHEDULED") + .split(",").map((s: string) => s.toUpperCase().trim()) + .filter((s: string) => s === "DRAFT" || s === "SCHEDULED" || s === "DISPATCHED"); + const daysAhead = Math.max(0, parseInt(args.options?.daysAhead ?? "3", 10)); + + if (directionList.length === 0 || statusList.length === 0) { + this.log("error: at least one direction and status required"); + return; + } + + this.log(`Seeding ${count} schedules (direction=${directionList.join(",")}, status=${statusList.join(",")}, days_ahead=${daysAhead})...`); + + const yards = await ds.getRepository(Yard).find({ where: { isActive: true } }); + const yardByCode = new Map(yards.map((y) => [y.code.toUpperCase(), y])); + const djibouti = yardByCode.get("DJIBOUTI") ?? yards.find((y) => y.country === "Djibouti"); + const addis = yardByCode.get("ADDIS_ABABA") ?? yards.find((y) => y.country === "Ethiopia"); + + if (!djibouti || !addis) { + this.log("error: need at least one Djibouti and one Ethiopia yard"); + return; + } + + const wagonTypes = await ds.getRepository(WagonType).find({ where: { isActive: true } }); + if (wagonTypes.length === 0) { + this.log("error: no wagon types found — seed reference data first"); + return; + } + + const route = await ensureTestRoute(ds, yards, djibouti, addis, (msg) => this.log(msg)); + + const scheduleRepo = ds.getRepository(TrainSchedule); + const trainSetRepo = ds.getRepository(TrainSet); + let seeded = 0; + + for (let i = 0; i < count; i++) { + const dir = directionList[i % directionList.length]; + const status = statusList[i % statusList.length]; + const trainNumber = `TST-SCH-${String(i + 1).padStart(3, "0")}`; + + const existing = await scheduleRepo.findOne({ where: { trainNumber } }); + if (existing) { + this.log(` Schedule ${trainNumber} already exists — skipping`); + seeded++; + continue; + } + + const originYard = dir === "IMPORT" ? djibouti : addis; + const destYard = dir === "IMPORT" ? addis : djibouti; + const isDispatched = status === "DISPATCHED"; + + const now = new Date(); + const departure = new Date(now); + departure.setDate(departure.getDate() + daysAhead); + departure.setHours(7, 0, 0, 0); + + const travelHours = dir === "IMPORT" ? 12 : 10; + const arrival = new Date(departure.getTime() + travelHours * 60 * 60 * 1000); + + const locomotive = await ensureTestLocomotive(ds, originYard.id, (msg) => this.log(msg)); + const wagonCount = 4; + const wagonType = wagonTypes[0]; + const wagonCapacity = Number(wagonType.capacityTons) || 70; + const wagonLength = Number(wagonType.lengthMeters) || 14; + const tareWeight = Number(wagonType.tareWeightTons) || 14; + const totalWagonWeight = wagonCount * (tareWeight + 20); + + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: locomotive.id, + totalWeightTons: totalWagonWeight, + totalLengthMeters: wagonLength * wagonCount, + wagonCount, + status: isDispatched ? "DISPATCHED" : status === "DRAFT" ? "DRAFT" : "ASSIGNED", + }), + ); + + await ds.getRepository(TrainSetLocomotive).save( + ds.getRepository(TrainSetLocomotive).create({ + trainSetId: trainSet.id, + locomotiveId: locomotive.id, + sequenceNo: 0, + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + routeId: route.id, + originStationId: originYard.id, + destinationStationId: destYard.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualDepartureAt: isDispatched ? departure : null, + status, + trainNumber, + direction: dir, + maxWagons: 53, + bookingWindowStatus: isDispatched ? "CLOSED" : "OPEN", + }), + ); + + for (let w = 0; w < wagonCount; w++) { + const seq = w + 1; + const wagonNumber = `${trainNumber}-W${String(seq).padStart(2, "0")}`; + + const physicalWagon = await ensureTestWagon(ds, { + wagonNumber, + wagonTypeId: wagonType.id, + yardId: originYard.id, + trainScheduleId: schedule.id, + tareWeight, + capacityTons: wagonCapacity, + dispatched: isDispatched, + }); + + await ds.getRepository(TrainSetWagon).save( + ds.getRepository(TrainSetWagon).create({ + trainSetId: trainSet.id, + wagonTypeId: wagonType.id, + physicalWagonId: physicalWagon.id, + sequenceNo: seq, + capacityTons: wagonCapacity, + lengthMeters: wagonLength, + assignedWeightTons: 20, + status: isDispatched ? "DEPARTED" : "PLANNED", + }), + ); + } + + const scheduleBookings = await ds.getRepository(TrainScheduleBooking).find({ + where: { trainScheduleId: schedule.id }, + }); + if (scheduleBookings.length === 0) { + const booking = await ds.getRepository(Booking).findOne({ where: {}, order: { createdAt: "DESC" } }); + if (booking) { + await ds.getRepository(TrainScheduleBooking).save( + ds.getRepository(TrainScheduleBooking).create({ + trainScheduleId: schedule.id, + bookingId: booking.id, + }), + ); + } + } + + seeded++; + this.log(` Created ${status} ${dir} schedule: ${trainNumber} (${originYard.label} → ${destYard.label})`); + } + + this.log(`Done — ${seeded} train schedules ensured`); + }); +} + +async function ensureTestRoute( + ds: DataSource, + yards: Yard[], + originYard: Yard, + destYard: Yard, + log: (msg: string) => void, +): Promise { + const repo = ds.getRepository(Route); + const name = `TST-RTE-${originYard.code}_${destYard.code}`; + let route = await repo.findOne({ where: { name } }); + if (route) return route; + + route = await repo.save( + repo.create({ + name, + originYardId: originYard.id, + destinationYardId: destYard.id, + isActive: true, + }), + ); + log(` Created route: ${name}`); + + const milestoneRepo = ds.getRepository(RouteMilestone); + const intermediateYards = yards.filter( + (y) => y.id !== originYard.id && y.id !== destYard.id, + ); + + await milestoneRepo.save( + milestoneRepo.create({ + routeId: route.id, + yardId: originYard.id, + sequenceNo: 0, + }), + ); + + for (const [idx, y] of intermediateYards.entries()) { + await milestoneRepo.save( + milestoneRepo.create({ + routeId: route.id, + yardId: y.id, + sequenceNo: (idx + 1) * 2, + }), + ); + } + + await milestoneRepo.save( + milestoneRepo.create({ + routeId: route.id, + yardId: destYard.id, + sequenceNo: (intermediateYards.length + 1) * 2, + }), + ); + + log(` Added ${intermediateYards.length + 2} milestones`); + return route; +} + +async function ensureTestLocomotive( + ds: DataSource, + yardId: string, + log: (msg: string) => void, +): Promise { + const repo = ds.getRepository(Locomotive); + const code = "TST-LOCO-01"; + let loco = await repo.findOne({ where: { code } }); + if (loco) return loco; + + loco = await repo.save( + repo.create({ + code, + name: "Test Locomotive", + locomotiveType: "DIESEL", + maxPullWeightTons: 4200, + maxTrainLengthMeters: 760, + status: "AVAILABLE", + currentYardId: yardId, + }), + ); + log(` Created locomotive: ${code}`); + return loco; +} + +async function ensureTestWagon( + ds: DataSource, + input: { + wagonNumber: string; + wagonTypeId: string; + yardId: string; + trainScheduleId: string; + tareWeight: number; + capacityTons: number; + dispatched: boolean; + }, +): Promise { + const repo = ds.getRepository(Wagon); + const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } }); + if (existing) return existing; + + const wagon = repo.create({ + wagonNumber: input.wagonNumber, + wagonTypeId: input.wagonTypeId, + currentYardId: input.yardId, + currentTrainScheduleId: input.trainScheduleId, + tareWeight: input.tareWeight, + maxPayloadWeight: input.capacityTons, + status: input.dispatched ? WagonStatus.Assigned : WagonStatus.Available, + notes: "Test seed wagon", + }); + const saved = await repo.save(wagon as any); + return Array.isArray(saved) ? saved[0] : saved; +} diff --git a/apps/edr-freight-api/src/scripts/cmds/types.ts b/apps/edr-freight-api/src/scripts/cmds/types.ts new file mode 100644 index 000000000..bd0fbb9c9 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/cmds/types.ts @@ -0,0 +1,6 @@ +import type Vorpal from "vorpal"; +import type { INestApplicationContext } from "@nestjs/common"; + +export type CommandContext = { + app: INestApplicationContext; +}; diff --git a/apps/edr-freight-api/src/scripts/main.ts b/apps/edr-freight-api/src/scripts/main.ts new file mode 100644 index 000000000..94b26347f --- /dev/null +++ b/apps/edr-freight-api/src/scripts/main.ts @@ -0,0 +1,36 @@ +import "reflect-metadata"; +import { config } from "dotenv"; + +config(); + +import Vorpal from "vorpal"; +import { registerCommands } from "./cmds/index"; + +import { NestFactory } from "@nestjs/core"; +import { AppModule } from "../app.module"; + +const vorpal = new Vorpal(); + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: false, + }); + + try { + registerCommands(vorpal, { app }); + + const args = process.argv.slice(2); + if (args.length > 0) { + await vorpal.exec(args.join(" ")); + } else { + vorpal.parse(process.argv); + } + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error("Script failed:", err); + process.exit(1); +}); diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 842276815..07e271cb6 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,7 +1,12 @@ -export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL; + +export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +//export const API_BASE_URL = 'http://localhost:3001'; + + // export const API_BASE_URL = 'http://localhost:3001'; + /** * URL that streams an uploaded file through the API by its UUID. Routes the * bytes through `GET /api/files/:id` (served from MinIO with backend diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index a285a8fd2..07496364b 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1,4 +1,5 @@ -export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL; +export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +//export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the diff --git a/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts b/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts index 43ddecddf..7a450bfaf 100644 --- a/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts +++ b/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts @@ -15,6 +15,10 @@ export class DynamicThrottlerGuard extends ThrottlerGuard { } async canActivate(context: ExecutionContext): Promise { + if (context.getType() !== 'http') { + return true; + } + const [authLimit, authTtl, strictLimit, strictTtl, defaultLimit, defaultTtl] = await Promise.all([ this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_LIMIT), diff --git a/apps/edr-passenger-api/src/config/fayda.config.ts b/apps/edr-passenger-api/src/config/fayda.config.ts index d8c4b1868..a25289159 100644 --- a/apps/edr-passenger-api/src/config/fayda.config.ts +++ b/apps/edr-passenger-api/src/config/fayda.config.ts @@ -23,7 +23,10 @@ export interface FaydaConfig { authorizationEndpoint: string; tokenEndpoint: string; userInfoEndpoint: string; + /** OAuth redirect_uri sent to eSignet for MOBILE clients. */ redirectUri: string; + /** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */ + webRedirectUri: string; privateJwk: FaydaJwk; scope: string; acrValues: string; @@ -73,6 +76,7 @@ export default registerAs('fayda', (): FaydaConfig => { const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am'; const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10); const redirectUri = process.env.FAYDA_REDIRECT_URI ?? ''; + const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri; if (!enabled) { return { enabled: false, @@ -81,6 +85,7 @@ export default registerAs('fayda', (): FaydaConfig => { tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '', userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '', redirectUri, + webRedirectUri, privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, scope, acrValues, @@ -111,6 +116,7 @@ export default registerAs('fayda', (): FaydaConfig => { tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!, userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!, redirectUri, + webRedirectUri, privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!), scope, acrValues, diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 52603e776..4062acddc 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -1,5 +1,5 @@ -import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString } from 'class-validator'; -import { Type } from 'class-transformer'; +import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString, MaxDate } from 'class-validator'; +import { Type, Transform } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Currency, IdDocumentType } from '@prisma/client'; @@ -9,7 +9,11 @@ export class PassengerInputDto { @ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID' }) @IsOptional() @IsString() returnSeatId?: string; @ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) @IsOptional() @IsString() returnLeg2SeatId?: string; @ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string; - @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first free), Age ≥5 = ADULT (full fare)' }) @IsDateString() dateOfBirth: string; + @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD). Must not be a future date.' }) + @IsDateString() + @Transform(({ value }) => value) + @MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' }) + dateOfBirth: string; @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; @ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string; @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string; @@ -38,9 +42,12 @@ export class RoundTripPassengerDto { @ApiProperty({ example: '1990-05-15', - description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first child FREE), Age ≥5 = ADULT (full fare for both legs)' - }) - @IsDateString() dateOfBirth: string; + description: 'Date of birth (YYYY-MM-DD). Must not be a future date.' + }) + @IsDateString() + @Transform(({ value }) => value) + @MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' }) + dateOfBirth: string; @ApiProperty({ example: 'NATIONAL_ID', diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index 0cc75a438..24f9981fc 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -206,6 +206,13 @@ export class FleetController { return this.service.listCoaches(dto); } + @Get('coaches/utilization') + @ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach' }) + @ApiResponse({ status: 200, description: 'Coach utilization data' }) + getCoachUtilization() { + return this.service.getCoachUtilization(); + } + @Get('coaches/:id') @ApiOperation({ summary: 'Get single coach with seat layout' }) @ApiParam({ name: 'id', description: 'Coach UUID' }) diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts index 410db914e..9e1f20c01 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts @@ -1,4 +1,5 @@ import { IsString, IsInt, IsOptional, IsArray, IsBoolean } from 'class-validator'; +import { Transform } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional, PartialType, OmitType } from '@nestjs/swagger'; export class CreateTrainDto { @@ -7,7 +8,7 @@ export class CreateTrainDto { @ApiPropertyOptional({ example: 'EDR', description: 'Operator ID (defaults to op_edr)' }) @IsOptional() @IsString() operatorId?: string; @ApiPropertyOptional({ example: 'Ethiopian-Djibouti Railway' }) @IsOptional() @IsString() operatorName?: string; @ApiPropertyOptional({ example: 'Addis-Djibouti Express' }) @IsOptional() @IsString() description?: string; - @ApiPropertyOptional({ example: true, description: 'Whether the train is active' }) @IsOptional() @IsBoolean() isActive?: boolean; + @ApiPropertyOptional({ example: true, description: 'Whether the train is active' }) @IsOptional() @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) @IsBoolean() isActive?: boolean; } export class CreateCoachDto { diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index dc71dd4b4..df5eea8a1 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -593,6 +593,58 @@ export class FleetService { }; } + async getCoachUtilization() { + const coaches = await this.prisma.coach.findMany({ + include: { + coachType: true, + seats: { select: { id: true, status: true } }, + assignments: { + include: { + schedule: { + select: { id: true, departureAt: true, status: true, _count: { select: { bookings: true } } }, + }, + }, + orderBy: { schedule: { departureAt: 'desc' } }, + take: 10, + }, + }, + orderBy: { sequence: 'asc' }, + }); + + return coaches.map((coach) => { + const totalSeats = coach.seats.length; + const bookedSeats = coach.seats.filter((s) => s.status === 'BOOKED').length; + const blockedSeats = coach.seats.filter((s) => s.status === 'BLOCKED').length; + const maintenanceSeats = coach.seats.filter((s) => (s.status as string) === 'UNDER_MAINTENANCE').length; + const availableSeats = coach.seats.filter((s) => s.status === 'AVAILABLE').length; + const totalAssignments = coach.assignments.length; + const totalBookings = coach.assignments.reduce((sum, a) => sum + ((a.schedule as any)._count?.bookings ?? 0), 0); + const utilizationRate = totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0; + + return { + id: coach.id, + number: coach.number, + sequence: coach.sequence, + coachType: coach.coachType?.name, + status: coach.status, + totalSeats, + availableSeats, + bookedSeats, + blockedSeats, + maintenanceSeats, + utilizationRate, + totalAssignments, + totalBookings, + recentSchedules: coach.assignments.slice(0, 5).map((a) => ({ + scheduleId: a.scheduleId, + departureAt: a.schedule.departureAt, + scheduleStatus: a.schedule.status, + bookings: (a.schedule as any)._count?.bookings ?? 0, + })), + }; + }); + } + async getAnalytics() { const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([ this.prisma.train.count(), diff --git a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts index 291402240..a80c63468 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { PAYMENT_EVENTS_DLX, PAYMENT_EVENTS_EXCHANGE, @@ -19,6 +20,7 @@ export class PaymentEventsConsumer { constructor(private readonly paymentsService: PaymentsService) {} + @IsPublic() @RabbitSubscribe({ exchange: PAYMENT_EVENTS_EXCHANGE, routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER), // payment.passenger.* @@ -29,6 +31,11 @@ export class PaymentEventsConsumer { }, }) async handle(event: PaymentEvent): Promise { + // Logged the instant RabbitMQ delivers the message, before any DB work — proves the + // payment -> passenger broker connection works even if processing later fails/hangs. + this.logger.log( + `RECEIVED ${event.eventType} (${event.eventId}) ref=${event.referenceId} via RabbitMQ`, + ); try { const result = await this.paymentsService.handlePaymentEvent( event as unknown as PaymentEventDto, diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts index 8339bf477..bce25fea5 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -42,4 +42,5 @@ export class UpdateRouteDto { @ApiPropertyOptional() @IsOptional() @IsString() description?: string; @ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean; @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string; + @ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[]; } diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index d8df838ca..2a7254f37 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -81,7 +81,8 @@ export class RoutesService { async updateRoute(id: string, dto: UpdateRouteDto) { const route = await this.prisma.route.findUnique({ where: { id } }); if (!route) throw new NotFoundException('Route not found'); - return this.prisma.route.update({ + + await this.prisma.route.update({ where: { id }, data: { name: dto.name, @@ -89,6 +90,22 @@ export class RoutesService { active: dto.active, effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined, }, + }); + + if (dto.stops && dto.stops.length >= 2) { + await this.prisma.routeStop.deleteMany({ where: { routeId: id } }); + await this.prisma.routeStop.createMany({ + data: dto.stops.map(s => ({ + routeId: id, + stationId: s.stationId, + sequence: s.sequence, + distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, + })), + }); + } + + return this.prisma.route.findUnique({ + where: { id }, include: { stops: { orderBy: { sequence: 'asc' } } }, }); } @@ -128,6 +145,7 @@ export class RoutesService { const station = await this.prisma.station.findUnique({ where: { id: dto.stationId } }); if (!station) throw new NotFoundException(`Station ${dto.stationId} not found`); + if (!station.isOperational) throw new BadRequestException(`Station ${dto.stationId} is not operational`); const existing = await this.prisma.routeStop.findUnique({ where: { routeId_sequence: { routeId, sequence: dto.sequence } }, diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index e14db6b0a..08687d291 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -9,6 +9,30 @@ import { Currency } from '@prisma/client'; const POINTS_TO_MINOR = 10; +// Shape returned by the heavy schedule include used throughout this service +type ScheduleWithIncludes = { + id: string; + routeId: string | null; + departureAt: Date; + arrivalAt: Date; + status: string; + train: any; + originStation: any; + destinationStation: any; + stopTimes: Array<{ stationId: string; sequence: number; plannedArrivalAt: Date | null; plannedDepartureAt: Date | null; station: any }>; + coachAssignments: Array<{ coach: { id: string; seats: any[]; coachType: { id: string; name: string; code: string; seatClasses: any[] } | null } }>; +}; + +const SCHEDULE_INCLUDE = { + train: true, + originStation: true, + destinationStation: true, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + coachAssignments: { + include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + }, +} as const; + @Injectable() export class SearchService { constructor( @@ -124,7 +148,7 @@ export class SearchService { if (windowStart < now) windowStart.setTime(now.getTime()); const windowEnd = new Date(requestedDate); - windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); // exclusive upper bound + windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); const totalPassengers = adultCount + (childCount ?? 0); @@ -139,30 +163,16 @@ export class SearchService { ], stopTimes: { some: { stationId: originStationId } }, }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + include: SCHEDULE_INCLUDE, orderBy: { departureAt: 'asc' }, }); - const results: any[] = []; - for (const schedule of schedules) { - const result = await this.buildScheduleResult( - schedule, - originStationId, - destinationStationId, - totalPassengers, - nationality, - ); - if (result) results.push(result); - } - return results; + const results = await Promise.all( + schedules.map(schedule => + this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) + ) + ); + return results.filter(Boolean); } private async searchSchedules( @@ -185,29 +195,18 @@ export class SearchService { departureAt: { gte: date < now ? now : date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + include: SCHEDULE_INCLUDE, }); - const results: any[] = []; - for (const schedule of schedules) { - const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality); - if (result) results.push(result); - } - return results; + const results = await Promise.all( + schedules.map(schedule => + this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) + ) + ); + return results.filter(Boolean); } // ── Transit search ───────────────────────────────────────────────────────── - // Finds pairs of schedules (leg1: origin→transit, leg2: transit→destination) - // where the passenger has between MIN_CONNECTION and MAX_CONNECTION minutes - // to change trains at the transit station. private readonly MIN_CONNECTION_MINUTES = 30; private readonly MAX_CONNECTION_MINUTES = 360; @@ -219,82 +218,67 @@ export class SearchService { childCount?: number, nationality?: string, ) { - // Find all stations that can serve as transit points: - // they must be a stop after origin on some schedule AND - // a stop before destination on another schedule on the same day. const [y, m, d] = dateStr.split('-').map(Number); const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0); const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + const leg2WindowEnd = new Date(dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000); const totalPassengers = adultCount + (childCount ?? 0); - // Load all schedules on this date that pass through origin - const leg1Schedules = await this.prisma.trainSchedule.findMany({ - where: { - status: { in: ['SCHEDULED', 'BOARDING'] }, - departureAt: { gte: dayStart, lt: dayEnd }, - stopTimes: { some: { stationId: originStationId } }, - }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + // Load leg1 and all potential leg2 candidates in one parallel round-trip + // instead of firing a separate DB query per transit stop. + const [leg1Schedules, allCandidates] = await Promise.all([ + this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + departureAt: { gte: dayStart, lt: dayEnd }, + stopTimes: { some: { stationId: originStationId } }, }, - }, - }); + include: SCHEDULE_INCLUDE, + }), + this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + departureAt: { gte: dayStart, lt: leg2WindowEnd }, + }, + include: SCHEDULE_INCLUDE, + }), + ]); const results: any[] = []; - for (const leg1 of leg1Schedules) { - const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId); + for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) { + const originStop = leg1.stopTimes.find(s => s.stationId === originStationId); if (!originStop) continue; - // Every stop after origin on leg1 is a candidate transit station const candidateTransitStops = leg1.stopTimes.filter( - (s: any) => s.sequence > originStop.sequence, + s => s.sequence > originStop.sequence, ); for (const transitStop of candidateTransitStops) { - // leg1 must NOT already contain the final destination - const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId); - if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules + const leg1HasDest = leg1.stopTimes.some(s => s.stationId === destinationStationId); + if (leg1HasDest) continue; const transitStationId = transitStop.stationId; const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt; - // Find leg2 schedules departing from the transit station within the connection window, - // and reaching the final destination. Search up to the next calendar day to handle - // overnight connections. const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000); const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000); - const leg2Schedules = await this.prisma.trainSchedule.findMany({ - where: { - status: { in: ['SCHEDULED', 'BOARDING'] }, - departureAt: { gte: connWindowStart, lte: connWindowEnd }, - stopTimes: { some: { stationId: transitStationId } }, - }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + // Filter from pre-loaded candidates in memory — no extra DB query + const leg2Schedules = (allCandidates as ScheduleWithIncludes[]).filter(s => { + const dep = new Date(s.departureAt).getTime(); + return dep >= connWindowStart.getTime() + && dep <= connWindowEnd.getTime() + && s.stopTimes.some(st => st.stationId === transitStationId); }); for (const leg2 of leg2Schedules) { - const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId); - const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId); + const leg2TransitStop = leg2.stopTimes.find(s => s.stationId === transitStationId); + const leg2DestStop = leg2.stopTimes.find(s => s.stationId === destinationStationId); if (!leg2TransitStop || !leg2DestStop) continue; if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue; - // Build individual leg result objects (reuse existing per-schedule logic) const [leg1Result, leg2Result] = await Promise.all([ this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality), this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality), @@ -326,7 +310,6 @@ export class SearchService { displayCurrency, combinedMinFareMinor, combinedMinFareDisplay, - // Convenience top-level fields so round-trip filter can read them uniformly departureAt: leg1Result.departureAt, arrivalAt: leg2Result.arrivalAt, totalDurationMinutes: @@ -339,19 +322,37 @@ export class SearchService { return results; } - // Builds the same result shape as searchSchedules for a single schedule+leg, - // extracted so both direct and transit paths share identical output. private async buildScheduleResult( - schedule: any, + schedule: ScheduleWithIncludes, originStationId: string, destinationStationId: string, totalPassengers: number, nationality?: string, ) { - const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId); - const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId); + const originStop = schedule.stopTimes.find(s => s.stationId === originStationId); + const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId); if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null; + // Collect all valid seat IDs upfront for a single batch availability check + const allValidSeatIds = schedule.coachAssignments.flatMap(a => + a.coach.seats + .filter((s: any) => s.status !== 'BLOCKED' && s.seatNumber?.trim()) + .map((s: any) => s.id as string) + ); + + // Run availability batch and fare calculation in parallel + const [freeSeats, faresByClass] = await Promise.all([ + this.segmentsService.getFreeSeatIds( + schedule.id, + allValidSeatIds, + schedule.stopTimes, + originStop.sequence, + destStop.sequence, + ), + this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality), + ]); + + // Compute per-class availability using the pre-computed free seat set const availabilityByClass: Record = {}; for (const assignment of schedule.coachAssignments) { const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard']; @@ -362,8 +363,7 @@ export class SearchService { let count = 0; for (const seat of assignment.coach.seats) { if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; - const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); - if (free) count++; + if (freeSeats.has(seat.id)) count++; } if (count > 0) { const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition)); @@ -374,15 +374,13 @@ export class SearchService { let available = 0; for (const seat of assignment.coach.seats) { if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; - const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); - if (free) available++; + if (freeSeats.has(seat.id)) available++; } for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available; } } - const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality); - const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass); + const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass); const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; @@ -400,8 +398,8 @@ export class SearchService { durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000), status: schedule.status, stops: schedule.stopTimes - .filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) - .map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), + .filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) + .map(st => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), availabilityByClass, hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), displayCurrency, @@ -500,46 +498,31 @@ export class SearchService { } private async calculateFaresForSegment( - schedule: any, + schedule: ScheduleWithIncludes, originStationId: string, destinationStationId: string, nationality?: string, ): Promise> { const displayCurrency = resolveCurrencyFromNationality(nationality); - const seatClassIds: string[] = Array.from( - new Set( - schedule.coachAssignments - .flatMap((a: any) => a.coach.coachType?.seatClasses || []) - .map((sc: any) => sc.id) - .filter((id: any) => id) - ) - ); - - if (seatClassIds.length === 0) { - console.log(`No seat classes assigned to schedule ${schedule.id}`); - return []; + // Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany + const seatClassMap = new Map(); + for (const a of schedule.coachAssignments) { + for (const sc of (a.coach.coachType?.seatClasses ?? [])) { + if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc); + } } + const seatClasses = Array.from(seatClassMap.values()) + .sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor); - const seatClasses = await this.prisma.seatClass.findMany({ - where: { - isActive: true, - id: { in: seatClassIds } - }, - orderBy: { baseFareMinor: 'asc' }, - }); - - if (seatClasses.length === 0) { - console.log(`No active seat classes for schedule ${schedule.id}`); - return []; - } + if (seatClasses.length === 0) return []; if (schedule.routeId) { const results = await Promise.all( seatClasses.map(async (sc) => { try { const fare = await this.fareEngine.calculate({ - routeId: schedule.routeId, + routeId: schedule.routeId!, originStationId, destinationStationId, seatClassId: sc.id, @@ -552,8 +535,7 @@ export class SearchService { displayCurrency: fare.billingCurrency as Currency, displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate), }; - } catch (error) { - console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message); + } catch { return null; } }), @@ -562,36 +544,32 @@ export class SearchService { const validResults = results.filter( (r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null, ); - if (validResults.length > 0) { - return validResults; - } + if (validResults.length > 0) return validResults; } - const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } }); - const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } }); + // Fallback: use station codes from already-loaded stopTimes when available + const originStop = schedule.stopTimes.find(st => st.stationId === originStationId); + const destStop = schedule.stopTimes.find(st => st.stationId === destinationStationId); + const originCode = originStop?.station?.code; + const destCode = destStop?.station?.code; - if (originStation && destStation) { - const segmentRoute = `${originStation.code}-${destStation.code}`; + if (originCode && destCode) { + const segmentRoute = `${originCode}-${destCode}`; const now = new Date(); const fareRules = await this.prisma.fareRule.findMany({ where: { route: segmentRoute, - seatClassId: { in: seatClassIds }, + seatClassId: { in: seatClasses.map((sc: any) => sc.id) }, validFrom: { lte: now }, - OR: [ - { validUntil: null }, - { validUntil: { gte: now } }, - ], + OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, }); if (fareRules.length > 0) { - console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`); - const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name])); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency); return fareRules.map(rule => ({ - seatClassName: seatClassMap[rule.seatClassId] || 'Unknown', + seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown', baseFareMinor: rule.baseFareMinor, displayCurrency, displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate), @@ -599,20 +577,20 @@ export class SearchService { } } - console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`); return []; } - private async buildCoachTypeDetails( - schedule: any, + // buildCoachTypeDetails is pure in-memory — no async needed + private buildCoachTypeDetails( + schedule: ScheduleWithIncludes, faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>, - ): Promise; - }>> { + }> { const coachTypeMap = new Map< string, { coachType: any; classNames: Set; coachId: string } @@ -682,14 +660,6 @@ export class SearchService { return fare.baseFarePerPassengerMinor; } - private getDefaultFareForClass(_className: string): never { - throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead'); - } - - private defaultFare(_seatClassName: string): never { - throw new Error('defaultFare should not be called — use resolveScheduleFare instead'); - } - private selectBestFareRule( candidates: any[], scheduleId: string, diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 690b42a43..a8d5d724c 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -184,6 +184,27 @@ This makes it clear which segment of the route each seat is held for, enabling s return this.service.unblockSeat(seatId); } + // ── Maintenance ─────────────────────────────────────────────────────────── + @Post(":seatId/maintenance") + @UseGuards(IamGuard) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ summary: "Set seat status to Under Maintenance" }) + @ApiParam({ name: "seatId", description: "Seat UUID" }) + @ApiResponse({ status: 200, description: "Seat set to under maintenance" }) + setMaintenance(@Param("seatId") seatId: string, @Body() body: { reason: string }) { + return this.service.setMaintenance(seatId, body.reason); + } + + @Delete(":seatId/maintenance") + @UseGuards(IamGuard) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ summary: "Clear seat maintenance status" }) + @ApiParam({ name: "seatId", description: "Seat UUID" }) + @ApiResponse({ status: 200, description: "Seat cleared from maintenance" }) + clearMaintenance(@Param("seatId") seatId: string) { + return this.service.clearMaintenance(seatId); + } + // ── Remove Seat ──────────────────────────────────────────────────────────── @Patch(":seatId/remove") @UseGuards(IamGuard) diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index fbb8cc552..1562a600b 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -742,6 +742,23 @@ export class SeatsService { return { unblocked: true, seatId }; } + async setMaintenance(seatId: string, reason: string) { + const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); + if (!seat) throw new NotFoundException('Seat not found'); + if (seat.status === 'BOOKED') throw new BadRequestException('Cannot set a booked seat to maintenance'); + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'UNDER_MAINTENANCE' as any } }); + await this.prisma.seatBlock.create({ data: { seatId, reason: `MAINTENANCE: ${reason}`, blockedBy: 'system' } }); + return { maintenance: true, seatId, reason }; + } + + async clearMaintenance(seatId: string) { + const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); + if (!seat) throw new NotFoundException('Seat not found'); + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' as any } }); + await this.prisma.seatBlock.deleteMany({ where: { seatId } }); + return { maintenance: false, seatId }; + } + async removeSeat(seatId: string) { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); diff --git a/apps/edr-passenger-api/src/modules/segments/segments.service.ts b/apps/edr-passenger-api/src/modules/segments/segments.service.ts index 2eef0302e..16b486bbe 100644 --- a/apps/edr-passenger-api/src/modules/segments/segments.service.ts +++ b/apps/edr-passenger-api/src/modules/segments/segments.service.ts @@ -146,6 +146,96 @@ export class SegmentsService { return true; } + /** + * Batch availability check for multiple seats on a single schedule. + * Replaces N×isSeatFreeForLeg calls with 2 queries total. + * Returns a Set of seat IDs that are free for [reqFrom, reqTo). + */ + async getFreeSeatIds( + scheduleId: string, + seatIds: string[], + stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>, + reqFrom: number, + reqTo: number, + ): Promise> { + if (seatIds.length === 0) return new Set(); + + const seqOf = (stationId: string) => + stopTimesForSeqLookup.find(s => s.stationId === stationId)?.sequence; + + const seatIdSet = new Set(seatIds); + const now = new Date(); + + const [allHolds, bookedLegs] = await Promise.all([ + this.prisma.seatHold.findMany({ + where: { scheduleId, expiresAt: { gt: now } }, + select: { seatIds: true, createdBy: true }, + }), + this.prisma.journeySegment.findMany({ + where: { + scheduleId, + seatId: { in: seatIds }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, + }, + select: { seatId: true, journeyId: true, departureStationId: true, arrivalStationId: true }, + }), + ]); + + // Determine which seats are blocked by active holds + const holdBlockedSeats = new Set(); + for (const hold of allHolds) { + let holdFrom: number | undefined; + let holdTo: number | undefined; + try { + if (hold.createdBy) { + const meta = JSON.parse(hold.createdBy as string); + holdFrom = seqOf(meta.originStationId); + holdTo = seqOf(meta.destinationStationId); + } + } catch { /* ignore */ } + + for (const sid of hold.seatIds) { + if (!seatIdSet.has(sid)) continue; + // Conservative block if leg can't be resolved; otherwise check overlap + if (holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo)) { + holdBlockedSeats.add(sid); + } + } + } + + // Build full journey ranges per seat (group multi-leg journeys) + const journeyRangesBySeat = new Map>(); + for (const leg of bookedLegs) { + if (!leg.seatId || !leg.journeyId || !leg.departureStationId || !leg.arrivalStationId) continue; + const depSeq = seqOf(leg.departureStationId); + const arrSeq = seqOf(leg.arrivalStationId); + if (depSeq === undefined || arrSeq === undefined) continue; + + let rangeMap = journeyRangesBySeat.get(leg.seatId); + if (!rangeMap) { rangeMap = new Map(); journeyRangesBySeat.set(leg.seatId, rangeMap); } + + const existing = rangeMap.get(leg.journeyId); + rangeMap.set(leg.journeyId, existing + ? { from: Math.min(existing.from, depSeq), to: Math.max(existing.to, arrSeq) } + : { from: depSeq, to: arrSeq }); + } + + const freeSeats = new Set(); + for (const seatId of seatIds) { + if (holdBlockedSeats.has(seatId)) continue; + let blocked = false; + const rangeMap = journeyRangesBySeat.get(seatId); + if (rangeMap) { + for (const { from, to } of rangeMap.values()) { + if (from < reqTo && reqFrom < to) { blocked = true; break; } + } + } + if (!blocked) freeSeats.add(seatId); + } + + return freeSeats; + } + /** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */ async getOverlappingReservations( scheduleId: string, diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts index 8e5e575e0..26108a452 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts @@ -16,7 +16,7 @@ export class StartVerificationDto { enum: ['WEB', 'MOBILE'], default: 'WEB', description: - 'Client platform. Decides where /callback redirects on completion: a web https URL (WEB) or a custom-scheme deep link the Flutter app intercepts (MOBILE).', + 'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.', }) @IsOptional() @IsIn(['WEB', 'MOBILE']) diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts index ede9aa9e8..0a821f235 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts @@ -35,6 +35,7 @@ function buildConfig(overrides?: Partial): FaydaConfig { tokenEndpoint: 'https://esignet.test/token', userInfoEndpoint: 'https://esignet.test/userinfo', redirectUri: 'http://localhost:4000/fayda/verification/complete', + webRedirectUri: 'http://localhost:5174/fayda/verification/complete', privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, scope: 'openid profile email', acrValues: 'mosip:idp:acr:generated-code', @@ -101,13 +102,14 @@ describe('VerifaydaService (OIDC, client-callback)', () => { expect(parsed.origin + parsed.pathname).toBe('https://esignet.test/authorize'); expect(parsed.searchParams.get('client_id')).toBe('edr-test-client'); expect(parsed.searchParams.get('code_challenge_method')).toBe('S256'); + // Default platform is WEB → webRedirectUri. expect(parsed.searchParams.get('redirect_uri')).toBe( - 'http://localhost:4000/fayda/verification/complete', + 'http://localhost:5174/fayda/verification/complete', ); expect(parsed.searchParams.get('state')).toBe(created.state); }); - it('uses the same single redirect_uri regardless of platform (platform is only recorded)', async () => { + it('sends the MOBILE redirect_uri (base redirectUri) for MOBILE sessions', async () => { prisma.faydaVerificationSession.create.mockResolvedValue({}); const url = await service.startVerification({ @@ -122,6 +124,19 @@ describe('VerifaydaService (OIDC, client-callback)', () => { ); }); + it('sends the WEB redirect_uri (webRedirectUri) for WEB sessions', async () => { + prisma.faydaVerificationSession.create.mockResolvedValue({}); + + const url = await service.startVerification({ + purpose: 'VERIFY', + platform: 'WEB', + }); + + expect(new URL(url).searchParams.get('redirect_uri')).toBe( + 'http://localhost:5174/fayda/verification/complete', + ); + }); + it('throws ServiceUnavailable when fayda integration is disabled', async () => { const disabledService = new VerifaydaService( buildConfigService(buildConfig({ enabled: false })), diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts index e51bf1846..99b1d1d7c 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -161,7 +161,18 @@ export class VerifaydaService { `Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`, ); - return this.buildAuthorizationUrl({ state, codeChallenge }); + return this.buildAuthorizationUrl({ + state, + codeChallenge, + redirectUri: this.redirectUriForPlatform(input.platform ?? 'WEB'), + }); + } + + /** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */ + private redirectUriForPlatform(platform?: FaydaPlatform): string { + return platform === 'MOBILE' + ? this.faydaConfig.redirectUri + : this.faydaConfig.webRedirectUri; } @@ -213,6 +224,7 @@ export class VerifaydaService { const tokens = await this.exchangeCodeForTokens( query.code, session.codeVerifier, + this.redirectUriForPlatform(session.platform as FaydaPlatform), ); const userInfo = await this.fetchUserInfo(tokens.access_token); const normalized = this.normalizeUserInfo(userInfo); @@ -307,11 +319,12 @@ export class VerifaydaService { private buildAuthorizationUrl(args: { state: string; codeChallenge: string; + redirectUri: string; }): string { const params = new URLSearchParams({ client_id: this.faydaConfig.clientId, response_type: 'code', - redirect_uri: this.faydaConfig.redirectUri, + redirect_uri: args.redirectUri, scope: this.faydaConfig.scope, state: args.state, code_challenge: args.codeChallenge, @@ -344,6 +357,7 @@ export class VerifaydaService { private async exchangeCodeForTokens( code: string, codeVerifier: string, + redirectUri: string, ): Promise { const clientAssertion = await generateClientAssertion({ clientId: this.faydaConfig.clientId, @@ -354,7 +368,7 @@ export class VerifaydaService { const body = new URLSearchParams({ grant_type: 'authorization_code', code, - redirect_uri: this.faydaConfig.redirectUri, + redirect_uri: redirectUri, client_id: this.faydaConfig.clientId, client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', diff --git a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx index 0153887ae..4478fce74 100644 --- a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx @@ -131,9 +131,38 @@ export default function AuditLogsPage() { return (
-
-

Audit Logs

-

Track all system activities and changes

+
+
+

Audit Logs

+

Track all system activities and changes

+
+ { + const items = data?.items || []; + if (!items.length) return; + const headers = ['Timestamp', 'Action', 'Entity Type', 'Entity ID', 'User ID', 'IP Address']; + const rows = items.map((l: any) => [ + formatDateTime(l.createdAt), + l.action, + l.entityType, + l.entityId || '', + l.iamUserId || l.userId || '', + l.ipAddress || '', + ]); + const csv = [headers, ...rows].map(r => r.map((v: string) => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + }} + > + Export CSV +
{/* Stats Cards */} diff --git a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx index a808d44f1..db5f362dc 100644 --- a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx @@ -14,32 +14,142 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro const videoRef = useRef(null); const canvasRef = useRef(null); const [isScanning, setIsScanning] = useState(false); + const [isInitializing, setIsInitializing] = useState(false); const [stream, setStream] = useState(null); const [cameraError, setCameraError] = useState(null); const scanIntervalRef = useRef(null); const startCamera = async () => { try { + setIsInitializing(true); setCameraError(null); - const mediaStream = await navigator.mediaDevices.getUserMedia({ - video: { - facingMode: 'environment', // Use back camera - width: { ideal: 1280 }, - height: { ideal: 720 } + + // Check if mediaDevices is supported + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + const errorMsg = 'Camera not supported in this browser. Please use a modern browser like Chrome, Firefox, or Safari.'; + setCameraError(errorMsg); + onError(errorMsg); + setIsInitializing(false); + return; + } + + // First, stop any existing stream + if (stream) { + stream.getTracks().forEach(track => track.stop()); + setStream(null); + } + + // Request camera access with simpler fallback + let mediaStream: MediaStream | null = null; + + try { + // Try with environment (back) camera first + mediaStream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: 'environment', + width: { ideal: 1280 }, + height: { ideal: 720 } + }, + audio: false + }); + } catch { + // Fallback to any available camera with simple constraints + try { + mediaStream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: false + }); + } catch (fallbackErr) { + throw fallbackErr; } + } + + if (!mediaStream) { + throw new Error('Failed to get media stream'); + } + + if (!videoRef.current) { + throw new Error('Video element not found'); + } + + const video = videoRef.current; + video.srcObject = mediaStream; + + // Wait for video to be ready with proper event handling + await new Promise((resolve, reject) => { + let resolved = false; + + const cleanup = () => { + video.removeEventListener('loadedmetadata', onLoadedMetadata); + video.removeEventListener('loadeddata', onLoadedData); + video.removeEventListener('canplay', onCanPlay); + video.removeEventListener('error', onVideoError); + }; + + const finishResolve = () => { + if (!resolved) { + resolved = true; + cleanup(); + resolve(); + } + }; + + const onLoadedMetadata = () => finishResolve(); + const onLoadedData = () => finishResolve(); + const onCanPlay = () => finishResolve(); + + const onVideoError = (_e: Event) => { + cleanup(); + reject(new Error('Video failed to load')); + }; + + // Add multiple event listeners for better compatibility + video.addEventListener('loadedmetadata', onLoadedMetadata); + video.addEventListener('loadeddata', onLoadedData); + video.addEventListener('canplay', onCanPlay); + video.addEventListener('error', onVideoError); + + setTimeout(() => finishResolve(), 2000); }); - if (videoRef.current) { - videoRef.current.srcObject = mediaStream; - await videoRef.current.play(); - setStream(mediaStream); - setIsScanning(true); + try { + await video.play(); + } catch { + await new Promise(resolve => setTimeout(resolve, 100)); + try { await video.play(); } catch { /* continue */ } } + + // Set state to show video + setStream(mediaStream); + setIsScanning(true); + setIsInitializing(false); + } catch (error: any) { - const errorMsg = 'Camera access denied. Please enable camera permissions in browser settings.'; + + let errorMsg = 'Camera access failed. Please check permissions and try again.'; + + if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') { + errorMsg = 'Camera permission denied. Please allow camera access in your browser settings and try again.'; + } else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') { + errorMsg = 'No camera found. Please connect a camera and try again.'; + } else if (error.name === 'NotReadableError' || error.name === 'TrackStartError') { + errorMsg = 'Camera is already in use by another application. Please close other apps using the camera.'; + } else if (error.name === 'OverconstrainedError') { + errorMsg = 'Camera does not meet the requirements. Please try a different camera.'; + } else if (error.name === 'SecurityError') { + errorMsg = 'Camera access blocked due to security settings. Please use HTTPS or check your browser security settings.'; + } + setCameraError(errorMsg); onError(errorMsg); - console.error('Camera error:', error); + + // Clean up on error + if (stream) { + stream.getTracks().forEach(track => track.stop()); + setStream(null); + } + setIsScanning(false); + setIsInitializing(false); } }; @@ -75,21 +185,17 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); try { - // Try to use jsqr if available const jsQR = (window as any).jsQR; if (jsQR) { const code = jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'dontInvert', }); - if (code) { onScan(code.data); stopCamera(); } } - } catch (err) { - console.error('QR scan error:', err); - } + } catch { /* ignore scan errors */ } } }, [isScanning, onScan, stopCamera]); @@ -120,7 +226,43 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro return (
- {!isScanning ? ( + {/* Video viewer - always rendered, visibility controlled by display style */} +
+
+
+ + +
+ + {/* Start button and loading state */} + {!isScanning && !isInitializing && (
)}
- ) : ( + )} + + {/* Loading state */} + {isInitializing && (
-
-
{/* Quick Stats */} diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index f8ee29091..b25a40498 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -9,7 +9,7 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { fleetApi, apiClient } from '@/lib/api'; -type Tab = 'types' | 'coaches'; +type Tab = 'types' | 'coaches' | 'utilization'; const getBedLabel = (bedPosition: string | null): string => { if (bedPosition === 'upper') return 'U'; @@ -163,6 +163,12 @@ export default function CoachesPage() { queryFn: () => fleetApi.getCoaches({}), }); + const { data: utilizationData, isLoading: utilizationLoading } = useQuery({ + queryKey: ['coach-utilization'], + queryFn: () => apiClient.get('/fleet/coaches/utilization'), + enabled: activeTab === 'utilization', + }); + // Coach Type Mutations const createCoachTypeMutation = useMutation({ mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data), @@ -547,6 +553,16 @@ export default function CoachesPage() { > Coaches +
{/* Coach Types Tab */} @@ -594,6 +610,44 @@ export default function CoachesPage() { />
)} + + {/* Utilization Tab */} + {activeTab === 'utilization' && (() => { + const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || []; + return ( +
+ {r.sequence} }, + { key: 'number', label: 'Coach', render: (r: any) => {r.number} }, + { key: 'coachType', label: 'Type', render: (r: any) => {r.coachType || 'N/A'} }, + { key: 'totalSeats', label: 'Total Seats', render: (r: any) => {r.totalSeats} }, + { key: 'availableSeats', label: 'Available', render: (r: any) => {r.availableSeats} }, + { key: 'bookedSeats', label: 'Booked', render: (r: any) => {r.bookedSeats} }, + { key: 'blockedSeats', label: 'Blocked', render: (r: any) => {r.blockedSeats} }, + { key: 'maintenanceSeats', label: 'Maintenance', render: (r: any) => {r.maintenanceSeats} }, + { + key: 'utilizationRate', label: 'Utilization', + render: (r: any) => ( +
+
+
+
+ {r.utilizationRate}% +
+ ), + }, + { key: 'totalAssignments', label: 'Assignments', render: (r: any) => {r.totalAssignments} }, + { key: 'totalBookings', label: 'Total Bookings', render: (r: any) => {r.totalBookings} }, + ]} + data={rows} + actions={[]} + loading={utilizationLoading} + emptyMessage="No coach utilization data available" + /> +
+ ); + })()}
{/* Delete Confirmation */} diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index 41b7abce7..f61282989 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -3,13 +3,13 @@ import { useQuery } from '@tanstack/react-query'; import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PERMS } from '@/lib/permissions'; -import { Ticket, Users, DollarSign, Percent, AlertCircle, TrendingUp, Calendar } from 'lucide-react'; +import { Ticket, Users, DollarSign, AlertCircle, Calendar } from 'lucide-react'; import StatCard from '@/components/dashboard/StatCard'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import { dashboardApi } from '@/lib/api/dashboard'; import { formatCurrency, formatDateTime } from '@/lib/utils'; -import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; +import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'; const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; @@ -18,7 +18,6 @@ const MOCK_STATS = { totalBookings: 1247, totalRevenue: 892450, totalPassengers: 2156, - occupancyRate: 78 }; const MOCK_RECENT_BOOKINGS = [ @@ -50,12 +49,6 @@ function DashboardPageContent() { staleTime: 60000, // 1 minute }); - const { data: revenueData, isLoading: revenueLoading } = useQuery({ - queryKey: ['revenue-chart'], - queryFn: () => dashboardApi.getRevenueChart(30), - retry: 1, - }); - const { data: recentBookingsData, isLoading: bookingsLoading, error: bookingsError } = useQuery({ queryKey: ['recent-bookings'], queryFn: () => dashboardApi.getRecentBookings(10), @@ -68,12 +61,6 @@ function DashboardPageContent() { retry: 1, }); - const { data: occupancyTrend, isLoading: occupancyLoading } = useQuery({ - queryKey: ['occupancy-trend'], - queryFn: () => dashboardApi.getOccupancyTrend(7), - retry: 1, - }); - const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({ queryKey: ['upcoming-trips'], queryFn: () => dashboardApi.getUpcomingTrips(5), @@ -170,7 +157,7 @@ function DashboardPageContent() { )} {/* Primary Metrics */} -
+
- -
- - {/* Charts Row */} -
- {/* Revenue Trend */} -
-

- - Revenue Trend (Last 30 Days) -

- {revenueLoading ? ( -
-
-
- ) : revenueData && revenueData.length > 0 ? ( - - - - - - formatCurrency(value, 'ETB')} /> - - - - ) : ( -
-
- -

No revenue data available

-
-
- )} -
- - {/* Occupancy Trend */} -
-

- - Occupancy Trend (Last 7 Days) -

- {occupancyLoading ? ( -
-
-
- ) : occupancyTrend && occupancyTrend.length > 0 ? ( - - - - - - `${value}%`} /> - - - - ) : ( -
-
- -

No occupancy data available

-
-
- )} -
{/* Payment Methods Distribution */} diff --git a/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx b/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx index 218fbd832..fdd61ef19 100644 --- a/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx @@ -2,187 +2,121 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Settings, Play, Square, Trash2, TestTube, History, Download } from 'lucide-react'; +import { Plus, Edit, Trash2 } 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'; +import { formatDateTime } from '@/lib/utils'; -interface FareConfiguration { - id: string; - name: string; - description?: string; - effective_date: string; - expiry_date?: string; - is_active: boolean; - is_default: boolean; - created_by?: string; - approved_by?: string; - approved_at?: string; - created_at: string; - updated_at: string; - rate_rules_count: number; - components_count: number; - age_rules_count: number; -} - -interface SystemStatus { - configurableFaresEnabled: boolean; - rolloutPercentage: number; - totalConfigurations: number; - activeConfiguration: string | null; - activeConfigurationName: string | null; - systemReady: boolean; -} - -interface FareTestResult { - baseFareMinor: number; - componentsTotal: number; - finalTotalMinor: number; - breakdown?: Array<{ - description: string; - runningTotal: number; - }>; -} - -export default function ConfigurableFarePage() { - const [showCreateModal, setShowCreateModal] = useState(false); - const [showTestModal, setShowTestModal] = useState(false); - const [selectedConfig, setSelectedConfig] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; config: FareConfiguration | null }>({ isOpen: false, config: null }); +export default function FareManagementPage() { + const [filters, setFilters] = useState({ scheduleId: '' }); + const [showModal, setShowModal] = useState(false); + const [editingRule, setEditingRule] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; rule: any | null; error?: string }>({ isOpen: false, rule: null }); + const [formError, setFormError] = useState(null); const queryClient = useQueryClient(); - // Queries - const { data: configurations = [], isLoading: configsLoading } = useQuery({ - queryKey: ['fare-configurations'], - queryFn: () => apiClient.get('/admin/fare-configurations'), - }); - - const { data: systemStatus } = useQuery({ - queryKey: ['fare-system-status'], - queryFn: () => apiClient.get('/admin/fare-migration/status'), - }); - - // Mutations - const activateMutation = useMutation({ - mutationFn: (id: string) => apiClient.post(`/admin/fare-configurations/${id}/activate`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); - queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); + const { data: fareRules, isLoading } = useQuery({ + queryKey: ['fare-rules', filters], + queryFn: async () => { + const params = new URLSearchParams(); + if (filters.scheduleId) params.append('scheduleId', filters.scheduleId); + const res = await apiClient.get(`/schedules/fares?${params}`); + return Array.isArray(res) ? res : (res as any)?.items || (res as any)?.data || []; }, }); + const { data: schedulesData } = useQuery({ + queryKey: ['schedules'], + queryFn: () => apiClient.get('/schedules'), + }); + + const { data: seatClassesData } = useQuery({ + queryKey: ['seat-classes'], + queryFn: () => apiClient.get('/fleet/classes'), + }); + + const schedules = Array.isArray(schedulesData) ? schedulesData : (schedulesData as any)?.items || []; + const seatClasses = Array.isArray(seatClassesData) ? seatClassesData : (seatClassesData as any)?.items || (seatClassesData as any)?.data || []; + + const createMutation = useMutation({ + mutationFn: (data: any) => apiClient.post('/schedules/fares', data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['fare-rules'] }); setShowModal(false); setEditingRule(null); setFormError(null); }, + onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to save fare rule'), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/schedules/fares/${id}`, data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['fare-rules'] }); setShowModal(false); setEditingRule(null); setFormError(null); }, + onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to update fare rule'), + }); + const deleteMutation = useMutation({ - mutationFn: (id: string) => apiClient.delete(`/admin/fare-configurations/${id}`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); - setDeleteConfirm({ isOpen: false, config: null }); - }, + mutationFn: (id: string) => apiClient.delete(`/schedules/fares/${id}`), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['fare-rules'] }); setDeleteConfirm({ isOpen: false, rule: null }); }, + onError: (e: any) => setDeleteConfirm(prev => ({ ...prev, error: e?.response?.data?.message || e?.message || 'Failed to delete' })), }); - const toggleSystemMutation = useMutation({ - mutationFn: (enabled: boolean) => - enabled - ? apiClient.post('/admin/fare-configurations/system/enable-configurable-fares', { rolloutPercentage: 100 }) - : apiClient.post('/admin/fare-configurations/system/disable-configurable-fares'), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); - }, - }); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + const fd = new FormData(e.currentTarget); + const payload: any = { + seatClassId: fd.get('seatClassId') as string, + baseFareMinor: Math.round(parseFloat(fd.get('baseFareMinor') as string) * 100), + validFrom: new Date(fd.get('validFrom') as string).toISOString(), + }; + const scheduleId = fd.get('scheduleId') as string; + const nationality = fd.get('nationality') as string; + const passengerCategory = fd.get('passengerCategory') as string; + const validUntil = fd.get('validUntil') as string; + if (scheduleId) payload.scheduleId = scheduleId; + if (nationality) payload.nationality = nationality; + if (passengerCategory) payload.passengerCategory = passengerCategory; + if (validUntil) payload.validUntil = new Date(validUntil).toISOString(); - const setupSystemMutation = useMutation({ - mutationFn: () => apiClient.post('/admin/fare-migration/complete-setup', { - activateNewFormula: true, - enableFeature: true - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); - queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); - }, - }); - - const handleActivate = async (config: FareConfiguration) => { - await activateMutation.mutateAsync(config.id); - }; - - const handleDelete = (config: FareConfiguration) => { - setDeleteConfirm({ isOpen: true, config }); - }; - - const confirmDelete = async () => { - if (deleteConfirm.config) { - await deleteMutation.mutateAsync(deleteConfirm.config.id); + if (editingRule) { + await updateMutation.mutateAsync({ id: editingRule.id, data: payload }); + } else { + await createMutation.mutateAsync(payload); } }; - const handleTest = (config: FareConfiguration) => { - setSelectedConfig(config); - setShowTestModal(true); - }; - const columns = [ { - key: 'name', - label: 'Configuration Name', - sortable: true, - render: (config: FareConfiguration) => ( -
-
{config.name}
- {config.description && ( -
{config.description}
- )} -
- ), + key: 'seatClass', label: 'Seat Class', + render: (r: any) => {r.seatClass?.name || r.seatClassId}, }, { - key: 'status', - label: 'Status', - render: (config: FareConfiguration) => ( -
- - {config.is_active ? 'Active' : 'Inactive'} - - {config.is_default && ( - Default - )} -
- ), + key: 'schedule', label: 'Schedule', + render: (r: any) => r.trip + ? {r.trip.originStation?.name} → {r.trip.destinationStation?.name}
{r.trip.departureAt ? new Date(r.trip.departureAt).toLocaleDateString() : ''}
+ : All schedules, }, { - key: 'rules', - label: 'Rules Count', - render: (config: FareConfiguration) => ( -
-
{config.rate_rules_count} rate rules
-
{config.components_count} components
-
{config.age_rules_count} age rules
-
- ), + key: 'passengerCategory', label: 'Category', + render: (r: any) => r.passengerCategory + ? {r.passengerCategory} + : All, }, { - key: 'dates', - label: 'Validity Period', - render: (config: FareConfiguration) => ( -
-
From: {new Date(config.effective_date).toLocaleDateString()}
- {config.expiry_date && ( -
Until: {new Date(config.expiry_date).toLocaleDateString()}
- )} -
- ), + key: 'nationality', label: 'Nationality', + render: (r: any) => {r.nationality || 'All'}, }, { - key: 'created_at', - label: 'Created', - sortable: true, - render: (config: FareConfiguration) => ( -
-
{new Date(config.created_at).toLocaleDateString()}
- {config.created_by && ( -
by {config.created_by}
- )} + key: 'baseFareMinor', label: 'Base Fare (ETB)', + render: (r: any) => {(r.baseFareMinor / 100).toFixed(2)}, + }, + { + key: 'validity', label: 'Validity', + render: (r: any) => ( +
+
From: {formatDateTime(r.validFrom)}
+ {r.validUntil &&
Until: {formatDateTime(r.validUntil)}
} + {!r.validUntil &&
No expiry
}
), }, @@ -190,24 +124,12 @@ export default function ConfigurableFarePage() { const actions = [ { - label: 'Activate', - onClick: handleActivate, - variant: 'secondary' as const, - icon: Play, - show: (config: FareConfiguration) => !config.is_active, + label: 'Edit', icon: Edit, variant: 'secondary' as const, + onClick: (r: any) => { setEditingRule(r); setFormError(null); setShowModal(true); }, }, { - label: 'Test', - onClick: handleTest, - variant: 'secondary' as const, - icon: TestTube, - }, - { - label: 'Delete', - onClick: handleDelete, - variant: 'danger' as const, - icon: Trash2, - show: (config: FareConfiguration) => !config.is_active, + label: 'Delete', icon: Trash2, variant: 'danger' as const, + onClick: (r: any) => setDeleteConfirm({ isOpen: true, rule: r }), }, ]; @@ -215,321 +137,107 @@ export default function ConfigurableFarePage() {
-

Configurable Fare Management

-

- Manage dynamic fare configurations with flexible rules, components, and pricing -

-
-
- setupSystemMutation.mutate()} - loading={setupSystemMutation.isPending} - disabled={systemStatus?.systemReady} - > - {systemStatus?.systemReady ? 'System Ready' : 'Setup System'} - - setShowCreateModal(true)} - > - New Configuration - +

Fare Management

+

Configure fare rules by seat class, passenger category, and nationality

+ { setEditingRule(null); setFormError(null); setShowModal(true); }}>Add Fare Rule
- {/* System Status */} -
-
-
-
-
System Status
-
- {systemStatus?.systemReady ? 'Ready' : 'Setup Required'} -
-
- - {systemStatus?.configurableFaresEnabled ? 'Enabled' : 'Disabled'} - -
-
- -
-
Total Configurations
-
{systemStatus?.totalConfigurations || 0}
-
- -
-
Rollout Percentage
-
{systemStatus?.rolloutPercentage || 0}%
-
- -
-
Active Configuration
-
- {systemStatus?.activeConfigurationName || 'None'} -
-
-
- - {/* System Controls */}
-
-
-

System Control

-

- Enable or disable the configurable fare system globally -

-
-
- - {systemStatus?.configurableFaresEnabled ? 'System Enabled' : 'Using Legacy System'} - - toggleSystemMutation.mutate(!systemStatus?.configurableFaresEnabled)} - loading={toggleSystemMutation.isPending} - icon={systemStatus?.configurableFaresEnabled ? Square : Play} - > - {systemStatus?.configurableFaresEnabled ? 'Disable' : 'Enable'} - -
+
+
+
- {/* Configurations Table */} -
-
-

Fare Configurations

-

- Manage fare calculation configurations with custom rates, components, and age-based pricing -

-
- - -
- - {/* Delete Confirmation */} setDeleteConfirm({ isOpen: false, config: null })} - onConfirm={confirmDelete} - title="Delete Configuration" - message={`Are you sure you want to delete "${deleteConfirm.config?.name}"? This action cannot be undone.`} - confirmText="Delete" - isDanger={true} - isLoading={deleteMutation.isPending} - warning="Active configurations cannot be deleted. Deactivate first if needed." + onClose={() => setDeleteConfirm({ isOpen: false, rule: null })} + onConfirm={() => deleteMutation.mutate(deleteConfirm.rule?.id)} + title="Delete Fare Rule" + message={`Delete fare rule for ${deleteConfirm.rule?.seatClass?.name || 'this class'}?`} + confirmText="Delete" isDanger isLoading={deleteMutation.isPending} + error={deleteConfirm.error} /> - {/* Test Modal */} - {showTestModal && selectedConfig && ( - { - setShowTestModal(false); - setSelectedConfig(null); - }} - /> - )} - - {/* Create/Edit Modal */} - {showCreateModal && ( - setShowCreateModal(false)} - onSuccess={() => { - setShowCreateModal(false); - queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); - }} - /> - )} + { setShowModal(false); setEditingRule(null); }} + title={`${editingRule ? 'Edit' : 'Add'} Fare Rule`} size="lg"> +
+ {formError && ( +
{formError}
+ )} +
+
+ + +
+
+ + +
+
+ + +

Leave blank to apply to all passengers

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ { setShowModal(false); setEditingRule(null); }}>Cancel + + {editingRule ? 'Update' : 'Create'} Fare Rule + +
+
+
); } - -// Test Modal Component -function FareTestModal({ - configuration, - isOpen, - onClose -}: { - configuration: FareConfiguration; - isOpen: boolean; - onClose: () => void; -}) { - const [testData, setTestData] = useState({ - distanceKm: 100, - nationality: 'Ethiopian', - coachType: 'REGULAR_SEAT', - bedPosition: '', - adultCount: 2, - childCount: 1, - }); - - const testMutation = useMutation({ - mutationFn: () => apiClient.post(`/admin/fare-configurations/${configuration.id}/test`, testData), - }); - - const handleTest = () => { - testMutation.mutate(); - }; - - return ( - -
-
-
- - setTestData({ ...testData, distanceKm: +e.target.value })} - /> -
-
- - -
-
- - -
- {(testData.coachType === 'ECONOMY_BED' || testData.coachType === 'VIP_BED') && ( -
- - -
- )} -
- - setTestData({ ...testData, adultCount: +e.target.value })} - /> -
-
- - setTestData({ ...testData, childCount: +e.target.value })} - /> -
-
- - - Calculate Fare - - - {testMutation.data && ( -
-

Calculation Result

-
-
- Base Fare: - {(testMutation.data.baseFareMinor / 100).toFixed(2)} ETB -
-
- Components: - {(testMutation.data.componentsTotal / 100).toFixed(2)} ETB -
-
- Total: - {(testMutation.data.finalTotalMinor / 100).toFixed(2)} ETB -
-
- - {testMutation.data.breakdown && ( -
-
Calculation Breakdown:
-
- {testMutation.data.breakdown.map((step: any, index: number) => ( -
- {step.description} - {(step.runningTotal / 100).toFixed(2)} ETB -
- ))} -
-
- )} -
- )} - - {testMutation.error && ( -
- {(testMutation.error as any)?.response?.data?.message || 'Test failed'} -
- )} -
-
- ); -} - -// Create Configuration Form Modal -function ConfigurationFormModal({ - isOpen, - onClose, - onSuccess -}: { - isOpen: boolean; - onClose: () => void; - onSuccess: () => void; -}) { - return ( - -
-

Configuration Form

-

- This would contain a comprehensive form for creating fare configurations with rate rules, components, and age pricing. -

- - Close for Now - -
-
- ); -} \ No newline at end of file diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 18006fc18..4ed841384 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -33,7 +33,6 @@ export default function RoutesPage() { queryKey: ['routes'], queryFn: async () => { const result = await routesApi.getAll(); - console.log('Routes query result:', result); return result; }, }); @@ -71,7 +70,7 @@ export default function RoutesPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); - + if (!originStationId || !destinationStationId) { alert('Please select origin and destination stations'); return; @@ -112,9 +111,7 @@ export default function RoutesPage() { effectiveUntil: formData.get('effectiveUntil') as string || undefined, stops: stopsArray, }; - - console.log('Submitting route data:', JSON.stringify(routeData, null, 2)); - + if (editingRoute) { await updateMutation.mutateAsync({ id: editingRoute.id, data: routeData }); } else { @@ -189,8 +186,8 @@ export default function RoutesPage() { { key: 'code', label: 'Route Code', sortable: true }, { key: 'name', label: 'Route Name', sortable: true }, { key: 'description', label: 'Description', render: (route: any) => route.description || 'N/A' }, - { - key: 'active', + { + key: 'active', label: 'Status', render: (route: any) => ( @@ -220,15 +217,20 @@ export default function RoutesPage() { if (routeStops.length >= 2) { setOriginStationId(routeStops[0].stationId); setDestinationStationId(routeStops[routeStops.length - 1].stationId); - - // Last stop's distanceKm is already cumulative from origin - setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0); - const middleStops = routeStops.slice(1, -1).map((stop: any) => ({ + // Last stop's distanceKm is segment distance from previous stop, so accumulate + let cumulative = 0; + const allStops = routeStops.map((stop: any) => { + cumulative += stop.distanceKm || 0; + return { ...stop, _cumulative: cumulative }; + }); + setDestinationDistance(allStops[allStops.length - 1]._cumulative); + + const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => ({ stationId: stop.stationId, sequence: stop.sequence, distanceKm: stop.distanceKm, - distanceFromOrigin: stop.distanceKm || 0, + distanceFromOrigin: allStops[idx + 1]._cumulative, })); setStops(middleStops); } @@ -392,7 +394,7 @@ export default function RoutesPage() { )}
- +