Merge branch 'dev' into freight/feat/invoice

This commit is contained in:
ghost2023
2026-07-01 14:40:58 +03:00
49 changed files with 3016 additions and 981 deletions

View File

@@ -50,7 +50,7 @@ jobs:
SERVICES=() 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$" 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$"

View File

@@ -32,7 +32,8 @@
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "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", "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": { "dependencies": {
"@edr/api-common": "workspace:*", "@edr/api-common": "workspace:*",
@@ -84,13 +85,15 @@
"@types/node": "^20.14.0", "@types/node": "^20.14.0",
"@types/pg": "^8.6.7", "@types/pg": "^8.6.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/vorpal": "^1.12.8",
"jest": "^29.7.0", "jest": "^29.7.0",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"ts-loader": "^9.5.1", "ts-loader": "^9.5.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.5.4" "typescript": "^5.5.4",
"vorpal": "^1.12.0"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [

View File

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

View File

@@ -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 <n>", "Number of contracts to create (default: 4)")
.option("--status <statuses>", "Comma-separated contract statuses (default: DRAFT,SUBMITTED,APPROVED,CONTRACT_ACTIVE)")
.option("--freight <types>", "Freight types: CONTAINER,BULK (default: both)")
.option("--direction <dirs>", "Trade directions: IMPORT,EXPORT (default: both)")
.option("--company <name>", "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<string>();
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<Company[]> {
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;
}

View File

@@ -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 <n>", "Number of schedules to create (default: 3)")
.option("--direction <dirs>", "IMPORT,EXPORT (default: both)")
.option("--status <statuses>", "DRAFT,SCHEDULED,DISPATCHED (default: SCHEDULED)")
.option("--days-ahead <n>", "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<Route> {
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<Locomotive> {
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<Wagon> {
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;
}

View File

@@ -0,0 +1,6 @@
import type Vorpal from "vorpal";
import type { INestApplicationContext } from "@nestjs/common";
export type CommandContext = {
app: INestApplicationContext;
};

View File

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

View File

@@ -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'; // export const API_BASE_URL = 'http://localhost:3001';
/** /**
* URL that streams an uploaded file through the API by its UUID. Routes the * 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 * bytes through `GET /api/files/:id` (served from MinIO with backend

View File

@@ -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 * URL that streams an uploaded file through the API by its UUID. Routes the

View File

@@ -15,6 +15,10 @@ export class DynamicThrottlerGuard extends ThrottlerGuard {
} }
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
if (context.getType() !== 'http') {
return true;
}
const [authLimit, authTtl, strictLimit, strictTtl, defaultLimit, defaultTtl] = const [authLimit, authTtl, strictLimit, strictTtl, defaultLimit, defaultTtl] =
await Promise.all([ await Promise.all([
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_LIMIT), this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_LIMIT),

View File

@@ -23,7 +23,10 @@ export interface FaydaConfig {
authorizationEndpoint: string; authorizationEndpoint: string;
tokenEndpoint: string; tokenEndpoint: string;
userInfoEndpoint: string; userInfoEndpoint: string;
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
redirectUri: string; redirectUri: string;
/** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */
webRedirectUri: string;
privateJwk: FaydaJwk; privateJwk: FaydaJwk;
scope: string; scope: string;
acrValues: string; acrValues: string;
@@ -73,6 +76,7 @@ export default registerAs('fayda', (): FaydaConfig => {
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am'; const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10); const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? ''; const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
if (!enabled) { if (!enabled) {
return { return {
enabled: false, enabled: false,
@@ -81,6 +85,7 @@ export default registerAs('fayda', (): FaydaConfig => {
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '', tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '',
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '', userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
redirectUri, redirectUri,
webRedirectUri,
privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
scope, scope,
acrValues, acrValues,
@@ -111,6 +116,7 @@ export default registerAs('fayda', (): FaydaConfig => {
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!, tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!,
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!, userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
redirectUri, redirectUri,
webRedirectUri,
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!), privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
scope, scope,
acrValues, acrValues,

View File

@@ -1,5 +1,5 @@
import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString } from 'class-validator'; import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString, MaxDate } from 'class-validator';
import { Type } from 'class-transformer'; import { Type, Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client'; 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: '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; @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: '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; @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: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string;
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string; @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string;
@@ -38,9 +42,12 @@ export class RoundTripPassengerDto {
@ApiProperty({ @ApiProperty({
example: '1990-05-15', 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)' description: 'Date of birth (YYYY-MM-DD). Must not be a future date.'
}) })
@IsDateString() dateOfBirth: string; @IsDateString()
@Transform(({ value }) => value)
@MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' })
dateOfBirth: string;
@ApiProperty({ @ApiProperty({
example: 'NATIONAL_ID', example: 'NATIONAL_ID',

View File

@@ -206,6 +206,13 @@ export class FleetController {
return this.service.listCoaches(dto); 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') @Get('coaches/:id')
@ApiOperation({ summary: 'Get single coach with seat layout' }) @ApiOperation({ summary: 'Get single coach with seat layout' })
@ApiParam({ name: 'id', description: 'Coach UUID' }) @ApiParam({ name: 'id', description: 'Coach UUID' })

View File

@@ -1,4 +1,5 @@
import { IsString, IsInt, IsOptional, IsArray, IsBoolean } from 'class-validator'; import { IsString, IsInt, IsOptional, IsArray, IsBoolean } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional, PartialType, OmitType } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional, PartialType, OmitType } from '@nestjs/swagger';
export class CreateTrainDto { 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: 'EDR', description: 'Operator ID (defaults to op_edr)' }) @IsOptional() @IsString() operatorId?: string;
@ApiPropertyOptional({ example: 'Ethiopian-Djibouti Railway' }) @IsOptional() @IsString() operatorName?: string; @ApiPropertyOptional({ example: 'Ethiopian-Djibouti Railway' }) @IsOptional() @IsString() operatorName?: string;
@ApiPropertyOptional({ example: 'Addis-Djibouti Express' }) @IsOptional() @IsString() description?: 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 { export class CreateCoachDto {

View File

@@ -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() { async getAnalytics() {
const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([ const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([
this.prisma.train.count(), this.prisma.train.count(),

View File

@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq'; import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { import {
PAYMENT_EVENTS_DLX, PAYMENT_EVENTS_DLX,
PAYMENT_EVENTS_EXCHANGE, PAYMENT_EVENTS_EXCHANGE,
@@ -19,6 +20,7 @@ export class PaymentEventsConsumer {
constructor(private readonly paymentsService: PaymentsService) {} constructor(private readonly paymentsService: PaymentsService) {}
@IsPublic()
@RabbitSubscribe({ @RabbitSubscribe({
exchange: PAYMENT_EVENTS_EXCHANGE, exchange: PAYMENT_EVENTS_EXCHANGE,
routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER), // payment.passenger.* routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER), // payment.passenger.*
@@ -29,6 +31,11 @@ export class PaymentEventsConsumer {
}, },
}) })
async handle(event: PaymentEvent): Promise<Nack | void> { async handle(event: PaymentEvent): Promise<Nack | void> {
// 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 { try {
const result = await this.paymentsService.handlePaymentEvent( const result = await this.paymentsService.handlePaymentEvent(
event as unknown as PaymentEventDto, event as unknown as PaymentEventDto,

View File

@@ -42,4 +42,5 @@ export class UpdateRouteDto {
@ApiPropertyOptional() @IsOptional() @IsString() description?: string; @ApiPropertyOptional() @IsOptional() @IsString() description?: string;
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean; @ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string; @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[];
} }

View File

@@ -81,7 +81,8 @@ export class RoutesService {
async updateRoute(id: string, dto: UpdateRouteDto) { async updateRoute(id: string, dto: UpdateRouteDto) {
const route = await this.prisma.route.findUnique({ where: { id } }); const route = await this.prisma.route.findUnique({ where: { id } });
if (!route) throw new NotFoundException('Route not found'); if (!route) throw new NotFoundException('Route not found');
return this.prisma.route.update({
await this.prisma.route.update({
where: { id }, where: { id },
data: { data: {
name: dto.name, name: dto.name,
@@ -89,6 +90,22 @@ export class RoutesService {
active: dto.active, active: dto.active,
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined, 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' } } }, include: { stops: { orderBy: { sequence: 'asc' } } },
}); });
} }
@@ -128,6 +145,7 @@ export class RoutesService {
const station = await this.prisma.station.findUnique({ where: { id: dto.stationId } }); const station = await this.prisma.station.findUnique({ where: { id: dto.stationId } });
if (!station) throw new NotFoundException(`Station ${dto.stationId} not found`); 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({ const existing = await this.prisma.routeStop.findUnique({
where: { routeId_sequence: { routeId, sequence: dto.sequence } }, where: { routeId_sequence: { routeId, sequence: dto.sequence } },

View File

@@ -9,6 +9,30 @@ import { Currency } from '@prisma/client';
const POINTS_TO_MINOR = 10; 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() @Injectable()
export class SearchService { export class SearchService {
constructor( constructor(
@@ -124,7 +148,7 @@ export class SearchService {
if (windowStart < now) windowStart.setTime(now.getTime()); if (windowStart < now) windowStart.setTime(now.getTime());
const windowEnd = new Date(requestedDate); 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); const totalPassengers = adultCount + (childCount ?? 0);
@@ -139,30 +163,16 @@ export class SearchService {
], ],
stopTimes: { some: { stationId: originStationId } }, stopTimes: { some: { stationId: originStationId } },
}, },
include: { include: 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 } } } } },
},
},
orderBy: { departureAt: 'asc' }, orderBy: { departureAt: 'asc' },
}); });
const results: any[] = []; const results = await Promise.all(
for (const schedule of schedules) { schedules.map(schedule =>
const result = await this.buildScheduleResult( this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
schedule, )
originStationId, );
destinationStationId, return results.filter(Boolean);
totalPassengers,
nationality,
);
if (result) results.push(result);
}
return results;
} }
private async searchSchedules( private async searchSchedules(
@@ -185,29 +195,18 @@ export class SearchService {
departureAt: { gte: date < now ? now : date, lt: nextDay }, departureAt: { gte: date < now ? now : date, lt: nextDay },
stopTimes: { some: { stationId: originStationId } }, stopTimes: { some: { stationId: originStationId } },
}, },
include: { include: 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 } } } } },
},
},
}); });
const results: any[] = []; const results = await Promise.all(
for (const schedule of schedules) { schedules.map(schedule =>
const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality); this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
if (result) results.push(result); )
} );
return results; return results.filter(Boolean);
} }
// ── Transit search ───────────────────────────────────────────────────────── // ── 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 MIN_CONNECTION_MINUTES = 30;
private readonly MAX_CONNECTION_MINUTES = 360; private readonly MAX_CONNECTION_MINUTES = 360;
@@ -219,82 +218,67 @@ export class SearchService {
childCount?: number, childCount?: number,
nationality?: string, 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 [y, m, d] = dateStr.split('-').map(Number);
const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0); 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 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); const totalPassengers = adultCount + (childCount ?? 0);
// Load all schedules on this date that pass through origin // Load leg1 and all potential leg2 candidates in one parallel round-trip
const leg1Schedules = await this.prisma.trainSchedule.findMany({ // instead of firing a separate DB query per transit stop.
where: { const [leg1Schedules, allCandidates] = await Promise.all([
status: { in: ['SCHEDULED', 'BOARDING'] }, this.prisma.trainSchedule.findMany({
departureAt: { gte: dayStart, lt: dayEnd }, where: {
stopTimes: { some: { stationId: originStationId } }, status: { in: ['SCHEDULED', 'BOARDING'] },
}, departureAt: { gte: dayStart, lt: dayEnd },
include: { stopTimes: { some: { stationId: originStationId } },
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,
}); }),
this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
departureAt: { gte: dayStart, lt: leg2WindowEnd },
},
include: SCHEDULE_INCLUDE,
}),
]);
const results: any[] = []; const results: any[] = [];
for (const leg1 of leg1Schedules) { for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId); const originStop = leg1.stopTimes.find(s => s.stationId === originStationId);
if (!originStop) continue; if (!originStop) continue;
// Every stop after origin on leg1 is a candidate transit station
const candidateTransitStops = leg1.stopTimes.filter( const candidateTransitStops = leg1.stopTimes.filter(
(s: any) => s.sequence > originStop.sequence, s => s.sequence > originStop.sequence,
); );
for (const transitStop of candidateTransitStops) { for (const transitStop of candidateTransitStops) {
// leg1 must NOT already contain the final destination const leg1HasDest = leg1.stopTimes.some(s => s.stationId === destinationStationId);
const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId); if (leg1HasDest) continue;
if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules
const transitStationId = transitStop.stationId; const transitStationId = transitStop.stationId;
const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt; 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 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 connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000);
const leg2Schedules = await this.prisma.trainSchedule.findMany({ // Filter from pre-loaded candidates in memory — no extra DB query
where: { const leg2Schedules = (allCandidates as ScheduleWithIncludes[]).filter(s => {
status: { in: ['SCHEDULED', 'BOARDING'] }, const dep = new Date(s.departureAt).getTime();
departureAt: { gte: connWindowStart, lte: connWindowEnd }, return dep >= connWindowStart.getTime()
stopTimes: { some: { stationId: transitStationId } }, && dep <= connWindowEnd.getTime()
}, && s.stopTimes.some(st => st.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 } } } } },
},
},
}); });
for (const leg2 of leg2Schedules) { for (const leg2 of leg2Schedules) {
const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId); const leg2TransitStop = leg2.stopTimes.find(s => s.stationId === transitStationId);
const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId); const leg2DestStop = leg2.stopTimes.find(s => s.stationId === destinationStationId);
if (!leg2TransitStop || !leg2DestStop) continue; if (!leg2TransitStop || !leg2DestStop) continue;
if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue; if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue;
// Build individual leg result objects (reuse existing per-schedule logic)
const [leg1Result, leg2Result] = await Promise.all([ const [leg1Result, leg2Result] = await Promise.all([
this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality), this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality),
this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality), this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality),
@@ -326,7 +310,6 @@ export class SearchService {
displayCurrency, displayCurrency,
combinedMinFareMinor, combinedMinFareMinor,
combinedMinFareDisplay, combinedMinFareDisplay,
// Convenience top-level fields so round-trip filter can read them uniformly
departureAt: leg1Result.departureAt, departureAt: leg1Result.departureAt,
arrivalAt: leg2Result.arrivalAt, arrivalAt: leg2Result.arrivalAt,
totalDurationMinutes: totalDurationMinutes:
@@ -339,19 +322,37 @@ export class SearchService {
return results; 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( private async buildScheduleResult(
schedule: any, schedule: ScheduleWithIncludes,
originStationId: string, originStationId: string,
destinationStationId: string, destinationStationId: string,
totalPassengers: number, totalPassengers: number,
nationality?: string, nationality?: string,
) { ) {
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId); const originStop = schedule.stopTimes.find(s => s.stationId === originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId); const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId);
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null; 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<string, number> = {}; const availabilityByClass: Record<string, number> = {};
for (const assignment of schedule.coachAssignments) { for (const assignment of schedule.coachAssignments) {
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard']; const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
@@ -362,8 +363,7 @@ export class SearchService {
let count = 0; let count = 0;
for (const seat of assignment.coach.seats) { for (const seat of assignment.coach.seats) {
if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; 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 (freeSeats.has(seat.id)) count++;
if (free) count++;
} }
if (count > 0) { if (count > 0) {
const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition)); const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition));
@@ -374,15 +374,13 @@ export class SearchService {
let available = 0; let available = 0;
for (const seat of assignment.coach.seats) { for (const seat of assignment.coach.seats) {
if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); if (freeSeats.has(seat.id)) available++;
if (free) available++;
} }
for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available; for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available;
} }
} }
const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality); const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass);
const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; 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), durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000),
status: schedule.status, status: schedule.status,
stops: schedule.stopTimes stops: schedule.stopTimes
.filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) .filter(st => 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 })), .map(st => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })),
availabilityByClass, availabilityByClass,
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
displayCurrency, displayCurrency,
@@ -500,46 +498,31 @@ export class SearchService {
} }
private async calculateFaresForSegment( private async calculateFaresForSegment(
schedule: any, schedule: ScheduleWithIncludes,
originStationId: string, originStationId: string,
destinationStationId: string, destinationStationId: string,
nationality?: string, nationality?: string,
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> { ): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
const displayCurrency = resolveCurrencyFromNationality(nationality); const displayCurrency = resolveCurrencyFromNationality(nationality);
const seatClassIds: string[] = Array.from( // Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany
new Set( const seatClassMap = new Map<string, any>();
schedule.coachAssignments for (const a of schedule.coachAssignments) {
.flatMap((a: any) => a.coach.coachType?.seatClasses || []) for (const sc of (a.coach.coachType?.seatClasses ?? [])) {
.map((sc: any) => sc.id) if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc);
.filter((id: any) => id) }
)
);
if (seatClassIds.length === 0) {
console.log(`No seat classes assigned to schedule ${schedule.id}`);
return [];
} }
const seatClasses = Array.from(seatClassMap.values())
.sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor);
const seatClasses = await this.prisma.seatClass.findMany({ if (seatClasses.length === 0) return [];
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 (schedule.routeId) { if (schedule.routeId) {
const results = await Promise.all( const results = await Promise.all(
seatClasses.map(async (sc) => { seatClasses.map(async (sc) => {
try { try {
const fare = await this.fareEngine.calculate({ const fare = await this.fareEngine.calculate({
routeId: schedule.routeId, routeId: schedule.routeId!,
originStationId, originStationId,
destinationStationId, destinationStationId,
seatClassId: sc.id, seatClassId: sc.id,
@@ -552,8 +535,7 @@ export class SearchService {
displayCurrency: fare.billingCurrency as Currency, displayCurrency: fare.billingCurrency as Currency,
displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate), displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate),
}; };
} catch (error) { } catch {
console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message);
return null; return null;
} }
}), }),
@@ -562,36 +544,32 @@ export class SearchService {
const validResults = results.filter( const validResults = results.filter(
(r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null, (r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null,
); );
if (validResults.length > 0) { if (validResults.length > 0) return validResults;
return validResults;
}
} }
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } }); // Fallback: use station codes from already-loaded stopTimes when available
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } }); 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) { if (originCode && destCode) {
const segmentRoute = `${originStation.code}-${destStation.code}`; const segmentRoute = `${originCode}-${destCode}`;
const now = new Date(); const now = new Date();
const fareRules = await this.prisma.fareRule.findMany({ const fareRules = await this.prisma.fareRule.findMany({
where: { where: {
route: segmentRoute, route: segmentRoute,
seatClassId: { in: seatClassIds }, seatClassId: { in: seatClasses.map((sc: any) => sc.id) },
validFrom: { lte: now }, validFrom: { lte: now },
OR: [ OR: [{ validUntil: null }, { validUntil: { gte: now } }],
{ validUntil: null },
{ validUntil: { gte: now } },
],
}, },
}); });
if (fareRules.length > 0) { 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); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency);
return fareRules.map(rule => ({ return fareRules.map(rule => ({
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown', seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown',
baseFareMinor: rule.baseFareMinor, baseFareMinor: rule.baseFareMinor,
displayCurrency, displayCurrency,
displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate), 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 []; return [];
} }
private async buildCoachTypeDetails( // buildCoachTypeDetails is pure in-memory — no async needed
schedule: any, private buildCoachTypeDetails(
schedule: ScheduleWithIncludes,
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>, faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
): Promise<Array<{ ): Array<{
coachTypeId: string; coachTypeId: string;
coachTypeName: string; coachTypeName: string;
coachTypeCode: string; coachTypeCode: string;
coachId: string; coachId: string;
classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>; classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>;
}>> { }> {
const coachTypeMap = new Map< const coachTypeMap = new Map<
string, string,
{ coachType: any; classNames: Set<string>; coachId: string } { coachType: any; classNames: Set<string>; coachId: string }
@@ -682,14 +660,6 @@ export class SearchService {
return fare.baseFarePerPassengerMinor; 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( private selectBestFareRule(
candidates: any[], candidates: any[],
scheduleId: string, scheduleId: string,

View File

@@ -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); 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 ──────────────────────────────────────────────────────────── // ── Remove Seat ────────────────────────────────────────────────────────────
@Patch(":seatId/remove") @Patch(":seatId/remove")
@UseGuards(IamGuard) @UseGuards(IamGuard)

View File

@@ -742,6 +742,23 @@ export class SeatsService {
return { unblocked: true, seatId }; 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) { async removeSeat(seatId: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found'); if (!seat) throw new NotFoundException('Seat not found');

View File

@@ -146,6 +146,96 @@ export class SegmentsService {
return true; 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<Set<string>> {
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<string>();
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<string, Map<string, { from: number; to: number }>>();
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<string>();
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 */ /** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */
async getOverlappingReservations( async getOverlappingReservations(
scheduleId: string, scheduleId: string,

View File

@@ -16,7 +16,7 @@ export class StartVerificationDto {
enum: ['WEB', 'MOBILE'], enum: ['WEB', 'MOBILE'],
default: 'WEB', default: 'WEB',
description: 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() @IsOptional()
@IsIn(['WEB', 'MOBILE']) @IsIn(['WEB', 'MOBILE'])

View File

@@ -35,6 +35,7 @@ function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
tokenEndpoint: 'https://esignet.test/token', tokenEndpoint: 'https://esignet.test/token',
userInfoEndpoint: 'https://esignet.test/userinfo', userInfoEndpoint: 'https://esignet.test/userinfo',
redirectUri: 'http://localhost:4000/fayda/verification/complete', redirectUri: 'http://localhost:4000/fayda/verification/complete',
webRedirectUri: 'http://localhost:5174/fayda/verification/complete',
privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
scope: 'openid profile email', scope: 'openid profile email',
acrValues: 'mosip:idp:acr:generated-code', 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.origin + parsed.pathname).toBe('https://esignet.test/authorize');
expect(parsed.searchParams.get('client_id')).toBe('edr-test-client'); expect(parsed.searchParams.get('client_id')).toBe('edr-test-client');
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256'); expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
// Default platform is WEB → webRedirectUri.
expect(parsed.searchParams.get('redirect_uri')).toBe( 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); 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({}); prisma.faydaVerificationSession.create.mockResolvedValue({});
const url = await service.startVerification({ 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 () => { it('throws ServiceUnavailable when fayda integration is disabled', async () => {
const disabledService = new VerifaydaService( const disabledService = new VerifaydaService(
buildConfigService(buildConfig({ enabled: false })), buildConfigService(buildConfig({ enabled: false })),

View File

@@ -161,7 +161,18 @@ export class VerifaydaService {
`Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`, `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( const tokens = await this.exchangeCodeForTokens(
query.code, query.code,
session.codeVerifier, session.codeVerifier,
this.redirectUriForPlatform(session.platform as FaydaPlatform),
); );
const userInfo = await this.fetchUserInfo(tokens.access_token); const userInfo = await this.fetchUserInfo(tokens.access_token);
const normalized = this.normalizeUserInfo(userInfo); const normalized = this.normalizeUserInfo(userInfo);
@@ -307,11 +319,12 @@ export class VerifaydaService {
private buildAuthorizationUrl(args: { private buildAuthorizationUrl(args: {
state: string; state: string;
codeChallenge: string; codeChallenge: string;
redirectUri: string;
}): string { }): string {
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: this.faydaConfig.clientId, client_id: this.faydaConfig.clientId,
response_type: 'code', response_type: 'code',
redirect_uri: this.faydaConfig.redirectUri, redirect_uri: args.redirectUri,
scope: this.faydaConfig.scope, scope: this.faydaConfig.scope,
state: args.state, state: args.state,
code_challenge: args.codeChallenge, code_challenge: args.codeChallenge,
@@ -344,6 +357,7 @@ export class VerifaydaService {
private async exchangeCodeForTokens( private async exchangeCodeForTokens(
code: string, code: string,
codeVerifier: string, codeVerifier: string,
redirectUri: string,
): Promise<FaydaTokenResponse> { ): Promise<FaydaTokenResponse> {
const clientAssertion = await generateClientAssertion({ const clientAssertion = await generateClientAssertion({
clientId: this.faydaConfig.clientId, clientId: this.faydaConfig.clientId,
@@ -354,7 +368,7 @@ export class VerifaydaService {
const body = new URLSearchParams({ const body = new URLSearchParams({
grant_type: 'authorization_code', grant_type: 'authorization_code',
code, code,
redirect_uri: this.faydaConfig.redirectUri, redirect_uri: redirectUri,
client_id: this.faydaConfig.clientId, client_id: this.faydaConfig.clientId,
client_assertion_type: client_assertion_type:
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',

View File

@@ -131,9 +131,38 @@ export default function AuditLogsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div className="flex items-center justify-between">
<h1 className="text-3xl font-bold text-foreground">Audit Logs</h1> <div>
<p className="text-muted-foreground mt-1">Track all system activities and changes</p> <h1 className="text-3xl font-bold text-foreground">Audit Logs</h1>
<p className="text-muted-foreground mt-1">Track all system activities and changes</p>
</div>
<ActionButton
icon={Download}
variant="secondary"
onClick={() => {
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
</ActionButton>
</div> </div>
{/* Stats Cards */} {/* Stats Cards */}

View File

@@ -14,32 +14,142 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const [isScanning, setIsScanning] = useState(false); const [isScanning, setIsScanning] = useState(false);
const [isInitializing, setIsInitializing] = useState(false);
const [stream, setStream] = useState<MediaStream | null>(null); const [stream, setStream] = useState<MediaStream | null>(null);
const [cameraError, setCameraError] = useState<string | null>(null); const [cameraError, setCameraError] = useState<string | null>(null);
const scanIntervalRef = useRef<number | null>(null); const scanIntervalRef = useRef<number | null>(null);
const startCamera = async () => { const startCamera = async () => {
try { try {
setIsInitializing(true);
setCameraError(null); setCameraError(null);
const mediaStream = await navigator.mediaDevices.getUserMedia({
video: { // Check if mediaDevices is supported
facingMode: 'environment', // Use back camera if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
width: { ideal: 1280 }, const errorMsg = 'Camera not supported in this browser. Please use a modern browser like Chrome, Firefox, or Safari.';
height: { ideal: 720 } 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<void>((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) { try {
videoRef.current.srcObject = mediaStream; await video.play();
await videoRef.current.play(); } catch {
setStream(mediaStream); await new Promise(resolve => setTimeout(resolve, 100));
setIsScanning(true); try { await video.play(); } catch { /* continue */ }
} }
// Set state to show video
setStream(mediaStream);
setIsScanning(true);
setIsInitializing(false);
} catch (error: any) { } 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); setCameraError(errorMsg);
onError(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); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
try { try {
// Try to use jsqr if available
const jsQR = (window as any).jsQR; const jsQR = (window as any).jsQR;
if (jsQR) { if (jsQR) {
const code = jsQR(imageData.data, imageData.width, imageData.height, { const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'dontInvert', inversionAttempts: 'dontInvert',
}); });
if (code) { if (code) {
onScan(code.data); onScan(code.data);
stopCamera(); stopCamera();
} }
} }
} catch (err) { } catch { /* ignore scan errors */ }
console.error('QR scan error:', err);
}
} }
}, [isScanning, onScan, stopCamera]); }, [isScanning, onScan, stopCamera]);
@@ -120,7 +226,43 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{!isScanning ? ( {/* Video viewer - always rendered, visibility controlled by display style */}
<div className={`space-y-3 ${!isScanning ? '!hidden' : ''}`}>
<div className="relative bg-black rounded-xl overflow-hidden" style={{ minHeight: '320px' }}>
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full min-h-[320px] object-cover block"
style={{ display: 'block', visibility: 'visible' }}
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="relative w-48 h-48">
<div className="absolute inset-0 border-2 border-white border-dashed rounded-lg animate-pulse"></div>
<div className="absolute top-0 left-0 w-6 h-6 border-t-4 border-l-4 border-blue-400 rounded-tl-lg"></div>
<div className="absolute top-0 right-0 w-6 h-6 border-t-4 border-r-4 border-blue-400 rounded-tr-lg"></div>
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-4 border-l-4 border-blue-400 rounded-bl-lg"></div>
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-4 border-r-4 border-blue-400 rounded-br-lg"></div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-4 pointer-events-none">
<p className="text-white text-center text-sm font-medium">Position QR code within frame</p>
<p className="text-white/70 text-center text-xs mt-1">Scanning...</p>
</div>
</div>
<canvas ref={canvasRef} className="hidden" />
<button
onClick={stopCamera}
className="w-full bg-gray-600 hover:bg-gray-700 text-white font-semibold py-3 px-6 rounded-xl transition-colors flex items-center justify-center gap-2"
>
<CameraOff className="w-5 h-5" />
Stop Camera
</button>
</div>
{/* Start button and loading state */}
{!isScanning && !isInitializing && (
<div className="space-y-3"> <div className="space-y-3">
<button <button
onClick={startCamera} onClick={startCamera}
@@ -135,36 +277,33 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
</div> </div>
)} )}
</div> </div>
) : ( )}
{/* Loading state */}
{isInitializing && (
<div className="space-y-3"> <div className="space-y-3">
<div className="relative bg-black rounded-xl overflow-hidden"> <div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-6">
<video <div className="flex flex-col items-center justify-center space-y-3">
ref={videoRef} <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
autoPlay <p className="text-blue-700 dark:text-blue-300 font-medium">Starting camera...</p>
playsInline <p className="text-blue-600 dark:text-blue-400 text-sm text-center">
muted Please allow camera access when prompted by your browser
className="w-full h-64 object-cover" </p>
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative w-48 h-48">
<div className="absolute inset-0 border-2 border-white border-dashed rounded-lg"></div>
<div className="absolute top-0 left-0 w-6 h-6 border-t-4 border-l-4 border-blue-400 rounded-tl-lg"></div>
<div className="absolute top-0 right-0 w-6 h-6 border-t-4 border-r-4 border-blue-400 rounded-tr-lg"></div>
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-4 border-l-4 border-blue-400 rounded-bl-lg"></div>
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-4 border-r-4 border-blue-400 rounded-br-lg"></div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-4">
<p className="text-white text-center text-sm font-medium">Position QR code within frame</p>
</div> </div>
</div> </div>
<canvas ref={canvasRef} className="hidden" />
<button <button
onClick={stopCamera} onClick={() => {
className="w-full bg-gray-600 hover:bg-gray-700 text-white font-semibold py-3 px-6 rounded-xl transition-colors flex items-center justify-center gap-2" if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
}
setIsInitializing(false);
setCameraError('Camera initialization cancelled by user');
}}
className="w-full bg-gray-500 hover:bg-gray-600 text-white font-semibold py-3 px-6 rounded-xl transition-colors"
> >
<CameraOff className="w-5 h-5" /> Cancel
Stop Camera
</button> </button>
</div> </div>
)} )}
@@ -424,12 +563,19 @@ export default function BoardingPage() {
<h3 className="text-blue-800 dark:text-blue-200 font-semibold mb-3">How to scan:</h3> <h3 className="text-blue-800 dark:text-blue-200 font-semibold mb-3">How to scan:</h3>
<ul className="text-blue-700 dark:text-blue-300 space-y-2 text-sm"> <ul className="text-blue-700 dark:text-blue-300 space-y-2 text-sm">
<li> Tap "Scan QR Code" and point at ticket QR code</li> <li> Tap "Scan QR Code" and point at ticket QR code</li>
<li> Allow camera access when your browser prompts you</li>
<li> Hold phone steady and position QR code within the frame</li>
<li> For manual option, type or paste booking reference</li> <li> For manual option, type or paste booking reference</li>
<li> Tickets can only be boarded on their departure date</li> <li> Tickets can only be boarded on their departure date</li>
<li> First scan boards outbound leg for round trips</li> <li> First scan boards outbound leg for round trips</li>
<li> Email & SMS sent automatically to passenger contacts</li> <li> Email & SMS sent automatically to passenger contacts</li>
<li> Red error shows validation issues</li> <li> Red error shows validation issues</li>
</ul> </ul>
<div className="mt-3 pt-3 border-t border-blue-200 dark:border-blue-700">
<p className="text-blue-700 dark:text-blue-300 text-xs">
<strong>Tip:</strong> If camera doesn't open, check browser permissions in Settings Privacy Camera
</p>
</div>
</div> </div>
{/* Quick Stats */} {/* Quick Stats */}

View File

@@ -9,7 +9,7 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { fleetApi, apiClient } from '@/lib/api'; import { fleetApi, apiClient } from '@/lib/api';
type Tab = 'types' | 'coaches'; type Tab = 'types' | 'coaches' | 'utilization';
const getBedLabel = (bedPosition: string | null): string => { const getBedLabel = (bedPosition: string | null): string => {
if (bedPosition === 'upper') return 'U'; if (bedPosition === 'upper') return 'U';
@@ -163,6 +163,12 @@ export default function CoachesPage() {
queryFn: () => fleetApi.getCoaches({}), queryFn: () => fleetApi.getCoaches({}),
}); });
const { data: utilizationData, isLoading: utilizationLoading } = useQuery({
queryKey: ['coach-utilization'],
queryFn: () => apiClient.get<any[]>('/fleet/coaches/utilization'),
enabled: activeTab === 'utilization',
});
// Coach Type Mutations // Coach Type Mutations
const createCoachTypeMutation = useMutation({ const createCoachTypeMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data), mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data),
@@ -547,6 +553,16 @@ export default function CoachesPage() {
> >
Coaches Coaches
</button> </button>
<button
onClick={() => { setActiveTab('utilization'); setSearch(''); }}
className={`px-4 py-3 font-medium transition-colors ${
activeTab === 'utilization'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Utilization Report
</button>
</div> </div>
{/* Coach Types Tab */} {/* Coach Types Tab */}
@@ -594,6 +610,44 @@ export default function CoachesPage() {
/> />
</div> </div>
)} )}
{/* Utilization Tab */}
{activeTab === 'utilization' && (() => {
const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || [];
return (
<div className="pt-6 space-y-4">
<DataTable
columns={[
{ key: 'sequence', label: 'Seq', render: (r: any) => <span className="font-mono">{r.sequence}</span> },
{ key: 'number', label: 'Coach', render: (r: any) => <span className="font-medium">{r.number}</span> },
{ key: 'coachType', label: 'Type', render: (r: any) => <span className="text-sm">{r.coachType || 'N/A'}</span> },
{ key: 'totalSeats', label: 'Total Seats', render: (r: any) => <span className="font-mono">{r.totalSeats}</span> },
{ key: 'availableSeats', label: 'Available', render: (r: any) => <span className="font-mono text-green-600">{r.availableSeats}</span> },
{ key: 'bookedSeats', label: 'Booked', render: (r: any) => <span className="font-mono text-red-600">{r.bookedSeats}</span> },
{ key: 'blockedSeats', label: 'Blocked', render: (r: any) => <span className="font-mono text-gray-500">{r.blockedSeats}</span> },
{ key: 'maintenanceSeats', label: 'Maintenance', render: (r: any) => <span className="font-mono text-orange-500">{r.maintenanceSeats}</span> },
{
key: 'utilizationRate', label: 'Utilization',
render: (r: any) => (
<div className="flex items-center gap-2">
<div className="w-20 h-2 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${r.utilizationRate}%` }} />
</div>
<span className="font-mono text-sm">{r.utilizationRate}%</span>
</div>
),
},
{ key: 'totalAssignments', label: 'Assignments', render: (r: any) => <span className="font-mono">{r.totalAssignments}</span> },
{ key: 'totalBookings', label: 'Total Bookings', render: (r: any) => <span className="font-mono font-semibold">{r.totalBookings}</span> },
]}
data={rows}
actions={[]}
loading={utilizationLoading}
emptyMessage="No coach utilization data available"
/>
</div>
);
})()}
</div> </div>
{/* Delete Confirmation */} {/* Delete Confirmation */}

View File

@@ -3,13 +3,13 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions'; 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 StatCard from '@/components/dashboard/StatCard';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import { dashboardApi } from '@/lib/api/dashboard'; import { dashboardApi } from '@/lib/api/dashboard';
import { formatCurrency, formatDateTime } from '@/lib/utils'; 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']; const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
@@ -18,7 +18,6 @@ const MOCK_STATS = {
totalBookings: 1247, totalBookings: 1247,
totalRevenue: 892450, totalRevenue: 892450,
totalPassengers: 2156, totalPassengers: 2156,
occupancyRate: 78
}; };
const MOCK_RECENT_BOOKINGS = [ const MOCK_RECENT_BOOKINGS = [
@@ -50,12 +49,6 @@ function DashboardPageContent() {
staleTime: 60000, // 1 minute 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<any[]>({ const { data: recentBookingsData, isLoading: bookingsLoading, error: bookingsError } = useQuery<any[]>({
queryKey: ['recent-bookings'], queryKey: ['recent-bookings'],
queryFn: () => dashboardApi.getRecentBookings(10), queryFn: () => dashboardApi.getRecentBookings(10),
@@ -68,12 +61,6 @@ function DashboardPageContent() {
retry: 1, retry: 1,
}); });
const { data: occupancyTrend, isLoading: occupancyLoading } = useQuery({
queryKey: ['occupancy-trend'],
queryFn: () => dashboardApi.getOccupancyTrend(7),
retry: 1,
});
const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({ const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({
queryKey: ['upcoming-trips'], queryKey: ['upcoming-trips'],
queryFn: () => dashboardApi.getUpcomingTrips(5), queryFn: () => dashboardApi.getUpcomingTrips(5),
@@ -170,7 +157,7 @@ function DashboardPageContent() {
)} )}
{/* Primary Metrics */} {/* Primary Metrics */}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<StatCard <StatCard
title="Total Bookings" title="Total Bookings"
value={statsLoading ? '...' : (displayStats?.totalBookings || 0).toLocaleString()} value={statsLoading ? '...' : (displayStats?.totalBookings || 0).toLocaleString()}
@@ -189,75 +176,6 @@ function DashboardPageContent() {
icon={Users} icon={Users}
color="purple" color="purple"
/> />
<StatCard
title="Occupancy Rate"
value={statsLoading ? '...' : `${displayStats?.occupancyRate || 0}%`}
icon={Percent}
color="orange"
/>
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Revenue Trend */}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Revenue Trend (Last 30 Days)
</h2>
{revenueLoading ? (
<div className="flex h-[300px] items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : revenueData && revenueData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={revenueData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
<Tooltip formatter={(value: number) => formatCurrency(value, 'ETB')} />
<Line type="monotone" dataKey="revenue" stroke="#2563eb" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
<div className="text-center">
<TrendingUp className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No revenue data available</p>
</div>
</div>
)}
</div>
{/* Occupancy Trend */}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
<Percent className="h-5 w-5" />
Occupancy Trend (Last 7 Days)
</h2>
{occupancyLoading ? (
<div className="flex h-[300px] items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
) : occupancyTrend && occupancyTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={occupancyTrend}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
<Tooltip formatter={(value: number) => `${value}%`} />
<Bar dataKey="occupancyRate" fill="#10b981" radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
<div className="text-center">
<Percent className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No occupancy data available</p>
</div>
</div>
)}
</div>
</div> </div>
{/* Payment Methods Distribution */} {/* Payment Methods Distribution */}

View File

@@ -2,187 +2,121 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { formatDateTime } from '@/lib/utils';
interface FareConfiguration { export default function FareManagementPage() {
id: string; const [filters, setFilters] = useState({ scheduleId: '' });
name: string; const [showModal, setShowModal] = useState(false);
description?: string; const [editingRule, setEditingRule] = useState<any>(null);
effective_date: string; const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; rule: any | null; error?: string }>({ isOpen: false, rule: null });
expiry_date?: string; const [formError, setFormError] = useState<string | null>(null);
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<FareConfiguration | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; config: FareConfiguration | null }>({ isOpen: false, config: null });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Queries const { data: fareRules, isLoading } = useQuery<any[]>({
const { data: configurations = [], isLoading: configsLoading } = useQuery<FareConfiguration[]>({ queryKey: ['fare-rules', filters],
queryKey: ['fare-configurations'], queryFn: async () => {
queryFn: () => apiClient.get('/admin/fare-configurations'), const params = new URLSearchParams();
}); if (filters.scheduleId) params.append('scheduleId', filters.scheduleId);
const res = await apiClient.get<any>(`/schedules/fares?${params}`);
const { data: systemStatus } = useQuery<SystemStatus>({ return Array.isArray(res) ? res : (res as any)?.items || (res as any)?.data || [];
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: schedulesData } = useQuery({
queryKey: ['schedules'],
queryFn: () => apiClient.get<any>('/schedules'),
});
const { data: seatClassesData } = useQuery({
queryKey: ['seat-classes'],
queryFn: () => apiClient.get<any>('/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({ const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/admin/fare-configurations/${id}`), mutationFn: (id: string) => apiClient.delete(`/schedules/fares/${id}`),
onSuccess: () => { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['fare-rules'] }); setDeleteConfirm({ isOpen: false, rule: null }); },
queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); onError: (e: any) => setDeleteConfirm(prev => ({ ...prev, error: e?.response?.data?.message || e?.message || 'Failed to delete' })),
setDeleteConfirm({ isOpen: false, config: null });
},
}); });
const toggleSystemMutation = useMutation({ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
mutationFn: (enabled: boolean) => e.preventDefault();
enabled setFormError(null);
? apiClient.post('/admin/fare-configurations/system/enable-configurable-fares', { rolloutPercentage: 100 }) const fd = new FormData(e.currentTarget);
: apiClient.post('/admin/fare-configurations/system/disable-configurable-fares'), const payload: any = {
onSuccess: () => { seatClassId: fd.get('seatClassId') as string,
queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); 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({ if (editingRule) {
mutationFn: () => apiClient.post('/admin/fare-migration/complete-setup', { await updateMutation.mutateAsync({ id: editingRule.id, data: payload });
activateNewFormula: true, } else {
enableFeature: true await createMutation.mutateAsync(payload);
}),
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);
} }
}; };
const handleTest = (config: FareConfiguration) => {
setSelectedConfig(config);
setShowTestModal(true);
};
const columns = [ const columns = [
{ {
key: 'name', key: 'seatClass', label: 'Seat Class',
label: 'Configuration Name', render: (r: any) => <span className="font-medium">{r.seatClass?.name || r.seatClassId}</span>,
sortable: true,
render: (config: FareConfiguration) => (
<div>
<div className="font-medium">{config.name}</div>
{config.description && (
<div className="text-sm text-muted-foreground">{config.description}</div>
)}
</div>
),
}, },
{ {
key: 'status', key: 'schedule', label: 'Schedule',
label: 'Status', render: (r: any) => r.trip
render: (config: FareConfiguration) => ( ? <span className="text-sm">{r.trip.originStation?.name} {r.trip.destinationStation?.name}<br /><span className="text-xs text-muted-foreground">{r.trip.departureAt ? new Date(r.trip.departureAt).toLocaleDateString() : ''}</span></span>
<div className="space-y-1"> : <span className="text-xs text-muted-foreground">All schedules</span>,
<Badge variant="status" status={config.is_active ? 'CONFIRMED' : 'PENDING'}>
{config.is_active ? 'Active' : 'Inactive'}
</Badge>
{config.is_default && (
<Badge variant="status" status="INFO">Default</Badge>
)}
</div>
),
}, },
{ {
key: 'rules', key: 'passengerCategory', label: 'Category',
label: 'Rules Count', render: (r: any) => r.passengerCategory
render: (config: FareConfiguration) => ( ? <Badge variant="status" status={r.passengerCategory === 'ADULT' ? 'CONFIRMED' : 'INFO'}>{r.passengerCategory}</Badge>
<div className="text-sm"> : <span className="text-xs text-muted-foreground">All</span>,
<div>{config.rate_rules_count} rate rules</div>
<div>{config.components_count} components</div>
<div>{config.age_rules_count} age rules</div>
</div>
),
}, },
{ {
key: 'dates', key: 'nationality', label: 'Nationality',
label: 'Validity Period', render: (r: any) => <span className="text-sm">{r.nationality || 'All'}</span>,
render: (config: FareConfiguration) => (
<div className="text-sm">
<div>From: {new Date(config.effective_date).toLocaleDateString()}</div>
{config.expiry_date && (
<div>Until: {new Date(config.expiry_date).toLocaleDateString()}</div>
)}
</div>
),
}, },
{ {
key: 'created_at', key: 'baseFareMinor', label: 'Base Fare (ETB)',
label: 'Created', render: (r: any) => <span className="font-mono">{(r.baseFareMinor / 100).toFixed(2)}</span>,
sortable: true, },
render: (config: FareConfiguration) => ( {
<div className="text-sm"> key: 'validity', label: 'Validity',
<div>{new Date(config.created_at).toLocaleDateString()}</div> render: (r: any) => (
{config.created_by && ( <div className="text-xs">
<div className="text-muted-foreground">by {config.created_by}</div> <div>From: {formatDateTime(r.validFrom)}</div>
)} {r.validUntil && <div>Until: {formatDateTime(r.validUntil)}</div>}
{!r.validUntil && <div className="text-muted-foreground">No expiry</div>}
</div> </div>
), ),
}, },
@@ -190,24 +124,12 @@ export default function ConfigurableFarePage() {
const actions = [ const actions = [
{ {
label: 'Activate', label: 'Edit', icon: Edit, variant: 'secondary' as const,
onClick: handleActivate, onClick: (r: any) => { setEditingRule(r); setFormError(null); setShowModal(true); },
variant: 'secondary' as const,
icon: Play,
show: (config: FareConfiguration) => !config.is_active,
}, },
{ {
label: 'Test', label: 'Delete', icon: Trash2, variant: 'danger' as const,
onClick: handleTest, onClick: (r: any) => setDeleteConfirm({ isOpen: true, rule: r }),
variant: 'secondary' as const,
icon: TestTube,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
show: (config: FareConfiguration) => !config.is_active,
}, },
]; ];
@@ -215,321 +137,107 @@ export default function ConfigurableFarePage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-foreground">Configurable Fare Management</h1> <h1 className="text-2xl font-bold">Fare Management</h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground">Configure fare rules by seat class, passenger category, and nationality</p>
Manage dynamic fare configurations with flexible rules, components, and pricing
</p>
</div>
<div className="flex gap-2">
<ActionButton
icon={Settings}
variant="secondary"
onClick={() => setupSystemMutation.mutate()}
loading={setupSystemMutation.isPending}
disabled={systemStatus?.systemReady}
>
{systemStatus?.systemReady ? 'System Ready' : 'Setup System'}
</ActionButton>
<ActionButton
icon={Plus}
onClick={() => setShowCreateModal(true)}
>
New Configuration
</ActionButton>
</div> </div>
<ActionButton icon={Plus} onClick={() => { setEditingRule(null); setFormError(null); setShowModal(true); }}>Add Fare Rule</ActionButton>
</div> </div>
{/* System Status */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-muted-foreground">System Status</div>
<div className={`font-semibold ${systemStatus?.systemReady ? 'text-green-600' : 'text-yellow-600'}`}>
{systemStatus?.systemReady ? 'Ready' : 'Setup Required'}
</div>
</div>
<Badge variant="status" status={systemStatus?.configurableFaresEnabled ? 'CONFIRMED' : 'CANCELLED'}>
{systemStatus?.configurableFaresEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
</div>
<div className="card">
<div className="text-sm text-muted-foreground">Total Configurations</div>
<div className="text-2xl font-bold">{systemStatus?.totalConfigurations || 0}</div>
</div>
<div className="card">
<div className="text-sm text-muted-foreground">Rollout Percentage</div>
<div className="text-2xl font-bold">{systemStatus?.rolloutPercentage || 0}%</div>
</div>
<div className="card">
<div className="text-sm text-muted-foreground">Active Configuration</div>
<div className="font-medium">
{systemStatus?.activeConfigurationName || 'None'}
</div>
</div>
</div>
{/* System Controls */}
<div className="card"> <div className="card">
<div className="flex items-center justify-between"> <div className="flex flex-wrap gap-3 mb-4">
<div> <select className="input w-64" value={filters.scheduleId}
<h3 className="font-semibold">System Control</h3> onChange={(e) => setFilters({ ...filters, scheduleId: e.target.value })}>
<p className="text-sm text-muted-foreground mt-1"> <option value="">All Schedules</option>
Enable or disable the configurable fare system globally {schedules.map((s: any) => (
</p> <option key={s.id} value={s.id}>
</div> {s.originStation?.name} {s.destinationStation?.name} ({s.departureAt ? new Date(s.departureAt).toLocaleDateString() : s.id.slice(0, 8)})
<div className="flex items-center gap-4"> </option>
<span className="text-sm"> ))}
{systemStatus?.configurableFaresEnabled ? 'System Enabled' : 'Using Legacy System'} </select>
</span>
<ActionButton
variant={systemStatus?.configurableFaresEnabled ? 'danger' : 'secondary'}
onClick={() => toggleSystemMutation.mutate(!systemStatus?.configurableFaresEnabled)}
loading={toggleSystemMutation.isPending}
icon={systemStatus?.configurableFaresEnabled ? Square : Play}
>
{systemStatus?.configurableFaresEnabled ? 'Disable' : 'Enable'}
</ActionButton>
</div>
</div> </div>
<DataTable data={fareRules || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No fare rules found" />
</div> </div>
{/* Configurations Table */}
<div className="card">
<div className="mb-6">
<h3 className="text-lg font-semibold">Fare Configurations</h3>
<p className="text-sm text-muted-foreground mt-1">
Manage fare calculation configurations with custom rates, components, and age-based pricing
</p>
</div>
<DataTable
data={configurations}
columns={columns}
actions={actions}
loading={configsLoading}
emptyMessage="No fare configurations found. Create your first configuration to get started."
/>
</div>
{/* Delete Confirmation */}
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirm.isOpen} isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, config: null })} onClose={() => setDeleteConfirm({ isOpen: false, rule: null })}
onConfirm={confirmDelete} onConfirm={() => deleteMutation.mutate(deleteConfirm.rule?.id)}
title="Delete Configuration" title="Delete Fare Rule"
message={`Are you sure you want to delete "${deleteConfirm.config?.name}"? This action cannot be undone.`} message={`Delete fare rule for ${deleteConfirm.rule?.seatClass?.name || 'this class'}?`}
confirmText="Delete" confirmText="Delete" isDanger isLoading={deleteMutation.isPending}
isDanger={true} error={deleteConfirm.error}
isLoading={deleteMutation.isPending}
warning="Active configurations cannot be deleted. Deactivate first if needed."
/> />
{/* Test Modal */} <Modal isOpen={showModal} onClose={() => { setShowModal(false); setEditingRule(null); }}
{showTestModal && selectedConfig && ( title={`${editingRule ? 'Edit' : 'Add'} Fare Rule`} size="lg">
<FareTestModal <form onSubmit={handleSubmit} className="space-y-4">
configuration={selectedConfig} {formError && (
isOpen={showTestModal} <div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-200">{formError}</div>
onClose={() => { )}
setShowTestModal(false); <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
setSelectedConfig(null); <div>
}} <label className="label">Seat Class *</label>
/> <select name="seatClassId" className="input" defaultValue={editingRule?.seatClassId || ''} required>
)} <option value="">Select Seat Class</option>
{seatClasses.map((sc: any) => (
{/* Create/Edit Modal */} <option key={sc.id} value={sc.id}>{sc.name}</option>
{showCreateModal && ( ))}
<ConfigurationFormModal </select>
isOpen={showCreateModal} </div>
onClose={() => setShowCreateModal(false)} <div>
onSuccess={() => { <label className="label">Schedule (optional)</label>
setShowCreateModal(false); <select name="scheduleId" className="input" defaultValue={editingRule?.tripId || ''}>
queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); <option value="">All Schedules</option>
}} {schedules.map((s: any) => (
/> <option key={s.id} value={s.id}>
)} {s.originStation?.name} {s.destinationStation?.name} ({s.departureAt ? new Date(s.departureAt).toLocaleDateString() : s.id.slice(0, 8)})
</option>
))}
</select>
</div>
<div>
<label className="label">Passenger Category (optional)</label>
<select name="passengerCategory" className="input" defaultValue={editingRule?.passengerCategory || ''}>
<option value="">All Categories</option>
<option value="ADULT">Adult (5 years)</option>
<option value="CHILD">Child (&lt;5 years)</option>
</select>
<p className="text-xs text-muted-foreground mt-1">Leave blank to apply to all passengers</p>
</div>
<div>
<label className="label">Nationality (optional)</label>
<select name="nationality" className="input" defaultValue={editingRule?.nationality || ''}>
<option value="">All Nationalities</option>
<option value="Ethiopian">Ethiopian</option>
<option value="Djiboutian">Djiboutian</option>
<option value="Other">Other (International)</option>
</select>
</div>
<div>
<label className="label">Base Fare (ETB) *</label>
<input type="number" name="baseFareMinor" className="input" min="0" step="0.01"
defaultValue={editingRule ? (editingRule.baseFareMinor / 100).toFixed(2) : ''} required
placeholder="e.g. 350.00" />
</div>
<div>
<label className="label">Valid From *</label>
<input type="datetime-local" name="validFrom" className="input" required
defaultValue={editingRule?.validFrom ? new Date(editingRule.validFrom).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16)} />
</div>
<div>
<label className="label">Valid Until (optional)</label>
<input type="datetime-local" name="validUntil" className="input"
defaultValue={editingRule?.validUntil ? new Date(editingRule.validUntil).toISOString().slice(0, 16) : ''} />
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton type="button" variant="secondary" onClick={() => { setShowModal(false); setEditingRule(null); }}>Cancel</ActionButton>
<ActionButton type="submit" loading={createMutation.isPending || updateMutation.isPending}>
{editingRule ? 'Update' : 'Create'} Fare Rule
</ActionButton>
</div>
</form>
</Modal>
</div> </div>
); );
} }
// 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<FareTestResult>({
mutationFn: () => apiClient.post(`/admin/fare-configurations/${configuration.id}/test`, testData),
});
const handleTest = () => {
testMutation.mutate();
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={`Test Configuration: ${configuration.name}`} size="lg">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Distance (km) *</label>
<input
type="number"
className="input"
value={testData.distanceKm}
onChange={(e) => setTestData({ ...testData, distanceKm: +e.target.value })}
/>
</div>
<div>
<label className="label">Nationality *</label>
<select
className="input"
value={testData.nationality}
onChange={(e) => setTestData({ ...testData, nationality: e.target.value })}
>
<option value="Ethiopian">Ethiopian</option>
<option value="Djiboutian">Djiboutian</option>
<option value="Other">International</option>
</select>
</div>
<div>
<label className="label">Coach Type *</label>
<select
className="input"
value={testData.coachType}
onChange={(e) => setTestData({ ...testData, coachType: e.target.value })}
>
<option value="REGULAR_SEAT">Regular Seat</option>
<option value="ECONOMY_BED">Economy Bed</option>
<option value="VIP_BED">VIP Bed</option>
</select>
</div>
{(testData.coachType === 'ECONOMY_BED' || testData.coachType === 'VIP_BED') && (
<div>
<label className="label">Bed Position</label>
<select
className="input"
value={testData.bedPosition}
onChange={(e) => setTestData({ ...testData, bedPosition: e.target.value })}
>
<option value="">Select position</option>
<option value="UPPER">Upper</option>
<option value="MIDDLE">Middle</option>
<option value="LOWER">Lower</option>
</select>
</div>
)}
<div>
<label className="label">Adults *</label>
<input
type="number"
min="1"
className="input"
value={testData.adultCount}
onChange={(e) => setTestData({ ...testData, adultCount: +e.target.value })}
/>
</div>
<div>
<label className="label">Children</label>
<input
type="number"
min="0"
className="input"
value={testData.childCount}
onChange={(e) => setTestData({ ...testData, childCount: +e.target.value })}
/>
</div>
</div>
<ActionButton
onClick={handleTest}
loading={testMutation.isPending}
className="w-full"
>
Calculate Fare
</ActionButton>
{testMutation.data && (
<div className="mt-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<h4 className="font-semibold text-green-900 dark:text-green-200 mb-3">Calculation Result</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Base Fare:</span>
<span className="font-mono">{(testMutation.data.baseFareMinor / 100).toFixed(2)} ETB</span>
</div>
<div className="flex justify-between">
<span>Components:</span>
<span className="font-mono">{(testMutation.data.componentsTotal / 100).toFixed(2)} ETB</span>
</div>
<div className="flex justify-between font-semibold border-t pt-2">
<span>Total:</span>
<span className="font-mono">{(testMutation.data.finalTotalMinor / 100).toFixed(2)} ETB</span>
</div>
</div>
{testMutation.data.breakdown && (
<div className="mt-4">
<h5 className="font-medium mb-2">Calculation Breakdown:</h5>
<div className="space-y-1 text-xs">
{testMutation.data.breakdown.map((step: any, index: number) => (
<div key={index} className="flex justify-between">
<span>{step.description}</span>
<span className="font-mono">{(step.runningTotal / 100).toFixed(2)} ETB</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{testMutation.error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-800 dark:text-red-200 text-sm">
{(testMutation.error as any)?.response?.data?.message || 'Test failed'}
</div>
)}
</div>
</Modal>
);
}
// Create Configuration Form Modal
function ConfigurationFormModal({
isOpen,
onClose,
onSuccess
}: {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}) {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Create Configuration" size="xl">
<div className="p-8 text-center">
<h3 className="text-lg font-semibold mb-2">Configuration Form</h3>
<p className="text-muted-foreground mb-4">
This would contain a comprehensive form for creating fare configurations with rate rules, components, and age pricing.
</p>
<ActionButton onClick={onSuccess} variant="secondary">
Close for Now
</ActionButton>
</div>
</Modal>
);
}

View File

@@ -33,7 +33,6 @@ export default function RoutesPage() {
queryKey: ['routes'], queryKey: ['routes'],
queryFn: async () => { queryFn: async () => {
const result = await routesApi.getAll(); const result = await routesApi.getAll();
console.log('Routes query result:', result);
return result; return result;
}, },
}); });
@@ -71,7 +70,7 @@ export default function RoutesPage() {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
if (!originStationId || !destinationStationId) { if (!originStationId || !destinationStationId) {
alert('Please select origin and destination stations'); alert('Please select origin and destination stations');
return; return;
@@ -112,9 +111,7 @@ export default function RoutesPage() {
effectiveUntil: formData.get('effectiveUntil') as string || undefined, effectiveUntil: formData.get('effectiveUntil') as string || undefined,
stops: stopsArray, stops: stopsArray,
}; };
console.log('Submitting route data:', JSON.stringify(routeData, null, 2));
if (editingRoute) { if (editingRoute) {
await updateMutation.mutateAsync({ id: editingRoute.id, data: routeData }); await updateMutation.mutateAsync({ id: editingRoute.id, data: routeData });
} else { } else {
@@ -189,8 +186,8 @@ export default function RoutesPage() {
{ key: 'code', label: 'Route Code', sortable: true }, { key: 'code', label: 'Route Code', sortable: true },
{ key: 'name', label: 'Route Name', sortable: true }, { key: 'name', label: 'Route Name', sortable: true },
{ key: 'description', label: 'Description', render: (route: any) => route.description || 'N/A' }, { key: 'description', label: 'Description', render: (route: any) => route.description || 'N/A' },
{ {
key: 'active', key: 'active',
label: 'Status', label: 'Status',
render: (route: any) => ( render: (route: any) => (
<Badge variant="status" status={route.active ? 'CONFIRMED' : 'CANCELLED'}> <Badge variant="status" status={route.active ? 'CONFIRMED' : 'CANCELLED'}>
@@ -220,15 +217,20 @@ export default function RoutesPage() {
if (routeStops.length >= 2) { if (routeStops.length >= 2) {
setOriginStationId(routeStops[0].stationId); setOriginStationId(routeStops[0].stationId);
setDestinationStationId(routeStops[routeStops.length - 1].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, stationId: stop.stationId,
sequence: stop.sequence, sequence: stop.sequence,
distanceKm: stop.distanceKm, distanceKm: stop.distanceKm,
distanceFromOrigin: stop.distanceKm || 0, distanceFromOrigin: allStops[idx + 1]._cumulative,
})); }));
setStops(middleStops); setStops(middleStops);
} }
@@ -392,7 +394,7 @@ export default function RoutesPage() {
)} )}
</div> </div>
</div> </div>
<div> <div>
<label className="label">Description</label> <label className="label">Description</label>
<textarea <textarea
@@ -441,7 +443,7 @@ export default function RoutesPage() {
<label className="label mb-0">Route Stops</label> <label className="label mb-0">Route Stops</label>
<span className="text-xs text-muted-foreground">Drag to rearrange intermediate stops</span> <span className="text-xs text-muted-foreground">Drag to rearrange intermediate stops</span>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary"> <div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium"> <div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
@@ -483,8 +485,8 @@ export default function RoutesPage() {
required required
> >
<option value="">Select Station</option> <option value="">Select Station</option>
{stations?.items?.filter((s: any) => {stations?.items?.filter((s: any) =>
s.id !== originStationId && s.id !== originStationId &&
s.id !== destinationStationId && s.id !== destinationStationId &&
!stops.some((st, idx) => idx !== index && st.stationId === s.id) !stops.some((st, idx) => idx !== index && st.stationId === s.id)
).map((station: any) => ( ).map((station: any) => (
@@ -561,7 +563,7 @@ export default function RoutesPage() {
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-end gap-2 pt-4"> <div className="flex justify-end gap-2 pt-4">
<ActionButton <ActionButton
type="button" type="button"

View File

@@ -49,7 +49,7 @@ export default function SchedulesPage() {
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null); const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(new Set()); const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(new Set());
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean }>( const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean; error?: string }>(
{ isOpen: false, item: null } { isOpen: false, item: null }
); );
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -149,6 +149,10 @@ export default function SchedulesPage() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['schedules'] }); queryClient.invalidateQueries({ queryKey: ['schedules'] });
}, },
onError: (err: any) => {
const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedule';
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
},
}); });
const bulkDeleteMutation = useMutation({ const bulkDeleteMutation = useMutation({
@@ -159,6 +163,10 @@ export default function SchedulesPage() {
queryClient.invalidateQueries({ queryKey: ['schedules'] }); queryClient.invalidateQueries({ queryKey: ['schedules'] });
setSelectedSchedules(new Set()); setSelectedSchedules(new Set());
}, },
onError: (err: any) => {
const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedules';
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
},
}); });
const handleBulkSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleBulkSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
@@ -227,13 +235,18 @@ export default function SchedulesPage() {
}; };
const confirmDelete = async () => { const confirmDelete = async () => {
if (deleteConfirm.isBulk) { setDeleteConfirm(prev => ({ ...prev, error: undefined }));
const ids = deleteConfirm.item as string[]; try {
await bulkDeleteMutation.mutateAsync(ids); if (deleteConfirm.isBulk) {
} else if (deleteConfirm.item) { const ids = deleteConfirm.item as string[];
await deleteScheduleMutation.mutateAsync(deleteConfirm.item.id); await bulkDeleteMutation.mutateAsync(ids);
} else if (deleteConfirm.item) {
await deleteScheduleMutation.mutateAsync(deleteConfirm.item.id);
}
setDeleteConfirm({ isOpen: false, item: null });
} catch {
// error is set by onError handler
} }
setDeleteConfirm({ isOpen: false, item: null });
}; };
const handleEditClick = (schedule: Schedule) => { const handleEditClick = (schedule: Schedule) => {
@@ -531,7 +544,9 @@ export default function SchedulesPage() {
} }
confirmText="Delete" confirmText="Delete"
isDanger={true} isDanger={true}
warning="This schedule may have bookings. Deleting it may impact these systems." isLoading={deleteScheduleMutation.isPending || bulkDeleteMutation.isPending}
error={deleteConfirm.error}
warning="Schedules with existing bookings cannot be deleted."
/> />
<Modal <Modal

View File

@@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { seatsApi, schedulesApi, fleetApi } from '@/lib/api'; import { seatsApi, schedulesApi, fleetApi } from '@/lib/api';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton' import ActionButton from '@/components/ui/ActionButton'
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train } from 'lucide-react'; import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench } from 'lucide-react';
export default function SeatsPage() { export default function SeatsPage() {
const [selectedSchedule, setSelectedSchedule] = useState(''); const [selectedSchedule, setSelectedSchedule] = useState('');
@@ -19,6 +19,8 @@ export default function SeatsPage() {
const [blockCoachReason, setBlockCoachReason] = useState(''); const [blockCoachReason, setBlockCoachReason] = useState('');
const [showUnblockCoachModal, setShowUnblockCoachModal] = useState(false); const [showUnblockCoachModal, setShowUnblockCoachModal] = useState(false);
const [coachToUnblock, setCoachToUnblock] = useState<any>(null); const [coachToUnblock, setCoachToUnblock] = useState<any>(null);
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
const [maintenanceReason, setMaintenanceReason] = useState('');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: schedulesData } = useQuery({ const { data: schedulesData } = useQuery({
@@ -70,6 +72,22 @@ export default function SeatsPage() {
}, },
}); });
const maintenanceMutation = useMutation({
mutationFn: ({ seatId, reason }: { seatId: string; reason: string }) =>
seatsApi.setMaintenance(seatId, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
setShowMaintenanceModal(false);
setSelectedSeat(null);
setMaintenanceReason('');
},
});
const clearMaintenanceMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.clearMaintenance(seatId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seatmap'] }),
});
const schedules = schedulesData?.items || schedulesData?.data || []; const schedules = schedulesData?.items || schedulesData?.data || [];
const coaches = seatMapData?.coaches || []; const coaches = seatMapData?.coaches || [];
@@ -132,6 +150,17 @@ export default function SeatsPage() {
} }
}; };
const handleSetMaintenance = (seat: any) => {
setSelectedSeat(seat);
setShowMaintenanceModal(true);
};
const handleClearMaintenance = async (seat: any) => {
if (confirm('Clear maintenance status for this seat?')) {
await clearMaintenanceMutation.mutateAsync(seat.id);
}
};
const handleBlockCoach = (coach: any) => { const handleBlockCoach = (coach: any) => {
setSelectedCoach(coach); setSelectedCoach(coach);
setShowBlockCoachModal(true); setShowBlockCoachModal(true);
@@ -182,6 +211,7 @@ export default function SeatsPage() {
}; };
const getSeatStatus = (seat: any) => { const getSeatStatus = (seat: any) => {
if (seat.status === 'UNDER_MAINTENANCE') return 'UNDER_MAINTENANCE';
if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED'; if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED';
if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED'; if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED';
if (seat.status === 'HELD') return 'HELD'; if (seat.status === 'HELD') return 'HELD';
@@ -194,6 +224,7 @@ export default function SeatsPage() {
case 'BOOKED': return 'bg-red-500'; case 'BOOKED': return 'bg-red-500';
case 'HELD': return 'bg-yellow-500'; case 'HELD': return 'bg-yellow-500';
case 'BLOCKED': return 'bg-gray-500'; case 'BLOCKED': return 'bg-gray-500';
case 'UNDER_MAINTENANCE': return 'bg-orange-500';
default: return 'bg-gray-300'; default: return 'bg-gray-300';
} }
}; };
@@ -265,6 +296,8 @@ export default function SeatsPage() {
handleRemoveSeat={handleRemoveSeat} handleRemoveSeat={handleRemoveSeat}
handleUnblock={handleUnblock} handleUnblock={handleUnblock}
handleUndoRemove={handleUndoRemove} handleUndoRemove={handleUndoRemove}
handleSetMaintenance={handleSetMaintenance}
handleClearMaintenance={handleClearMaintenance}
hideNumber={true} hideNumber={true}
/> />
))} ))}
@@ -358,6 +391,8 @@ export default function SeatsPage() {
handleRemoveSeat={handleRemoveSeat} handleRemoveSeat={handleRemoveSeat}
handleUnblock={handleUnblock} handleUnblock={handleUnblock}
handleUndoRemove={handleUndoRemove} handleUndoRemove={handleUndoRemove}
handleSetMaintenance={handleSetMaintenance}
handleClearMaintenance={handleClearMaintenance}
hideNumber={true} hideNumber={true}
/> />
))} ))}
@@ -378,6 +413,8 @@ export default function SeatsPage() {
handleRemoveSeat={handleRemoveSeat} handleRemoveSeat={handleRemoveSeat}
handleUnblock={handleUnblock} handleUnblock={handleUnblock}
handleUndoRemove={handleUndoRemove} handleUndoRemove={handleUndoRemove}
handleSetMaintenance={handleSetMaintenance}
handleClearMaintenance={handleClearMaintenance}
hideNumber={true} hideNumber={true}
/> />
))} ))}
@@ -532,6 +569,10 @@ export default function SeatsPage() {
<div className="w-5 h-5 rounded bg-gray-500"></div> <div className="w-5 h-5 rounded bg-gray-500"></div>
<span className="text-sm text-muted-foreground">Blocked</span> <span className="text-sm text-muted-foreground">Blocked</span>
</div> </div>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded bg-orange-500"></div>
<span className="text-sm text-muted-foreground">Under Maintenance</span>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-5 h-5 rounded border-2 border-dashed border-gray-400"></div> <div className="w-5 h-5 rounded border-2 border-dashed border-gray-400"></div>
<span className="text-sm text-muted-foreground">Removed</span> <span className="text-sm text-muted-foreground">Removed</span>
@@ -782,6 +823,44 @@ export default function SeatsPage() {
</div> </div>
</div> </div>
</Modal> </Modal>
<Modal
isOpen={showMaintenanceModal}
onClose={() => { setShowMaintenanceModal(false); setSelectedSeat(null); setMaintenanceReason(''); }}
title="Set Seat Under Maintenance"
size="md"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Set seat <strong>{selectedSeat?.seatNumber}</strong> to Under Maintenance
</p>
<div>
<label className="label">Reason *</label>
<textarea
className="input"
rows={3}
value={maintenanceReason}
onChange={(e) => setMaintenanceReason(e.target.value)}
placeholder="e.g., Seat mechanism broken, Upholstery replacement"
/>
</div>
<div className="flex justify-end gap-2">
<ActionButton
variant="secondary"
onClick={() => { setShowMaintenanceModal(false); setSelectedSeat(null); setMaintenanceReason(''); }}
>
Cancel
</ActionButton>
<ActionButton
onClick={() => maintenanceMutation.mutate({ seatId: selectedSeat.id, reason: maintenanceReason })}
loading={maintenanceMutation.isPending}
disabled={!maintenanceReason.trim()}
>
Set Maintenance
</ActionButton>
</div>
</div>
</Modal>
</div> </div>
); );
} }
@@ -798,6 +877,8 @@ interface SeatIconProps {
handleRemoveSeat: (seat: any) => void; handleRemoveSeat: (seat: any) => void;
handleUnblock: (seat: any) => void; handleUnblock: (seat: any) => void;
handleUndoRemove: (seat: any) => void; handleUndoRemove: (seat: any) => void;
handleSetMaintenance: (seat: any) => void;
handleClearMaintenance: (seat: any) => void;
} }
function SeatIcon({ function SeatIcon({
@@ -812,6 +893,8 @@ function SeatIcon({
handleRemoveSeat, handleRemoveSeat,
handleUnblock, handleUnblock,
handleUndoRemove, handleUndoRemove,
handleSetMaintenance,
handleClearMaintenance,
}: SeatIconProps) { }: SeatIconProps) {
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-'); const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || ''); const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
@@ -845,6 +928,8 @@ function SeatIcon({
const color = getSeatColor(status); const color = getSeatColor(status);
const canBlock = status === 'AVAILABLE'; const canBlock = status === 'AVAILABLE';
const canUnblock = status === 'BLOCKED'; const canUnblock = status === 'BLOCKED';
const canMaintenance = status === 'AVAILABLE' || status === 'BLOCKED';
const canClearMaintenance = status === 'UNDER_MAINTENANCE';
return ( return (
<div className="relative group flex flex-col items-center"> <div className="relative group flex flex-col items-center">
@@ -872,7 +957,7 @@ function SeatIcon({
</div> </div>
)} )}
{(canBlock || canUnblock) && ( {(canBlock || canUnblock || canMaintenance || canClearMaintenance) && (
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto"> <div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
{canBlock && ( {canBlock && (
<> <>
@@ -901,6 +986,24 @@ function SeatIcon({
<Unlock className="h-3 w-3 text-gray-700" /> <Unlock className="h-3 w-3 text-gray-700" />
</button> </button>
)} )}
{canMaintenance && (
<button
onClick={() => handleSetMaintenance(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Set under maintenance"
>
<Wrench className="h-3 w-3 text-orange-600" />
</button>
)}
{canClearMaintenance && (
<button
onClick={() => handleClearMaintenance(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Clear maintenance"
>
<Unlock className="h-3 w-3 text-orange-600" />
</button>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -31,7 +31,6 @@ export default function TrainsPage() {
queryClient.invalidateQueries({ queryKey: ['trains'] }); queryClient.invalidateQueries({ queryKey: ['trains'] });
setShowModal(false); setShowModal(false);
setEditingTrain(null); setEditingTrain(null);
alert('Train created successfully');
}, },
onError: (error: any) => { onError: (error: any) => {
alert('Error creating train: ' + (error?.response?.data?.message || 'Unknown error')); alert('Error creating train: ' + (error?.response?.data?.message || 'Unknown error'));
@@ -44,7 +43,6 @@ export default function TrainsPage() {
queryClient.invalidateQueries({ queryKey: ['trains'] }); queryClient.invalidateQueries({ queryKey: ['trains'] });
setShowModal(false); setShowModal(false);
setEditingTrain(null); setEditingTrain(null);
alert('Train updated successfully');
}, },
onError: (error: any) => { onError: (error: any) => {
alert('Error updating train: ' + (error?.response?.data?.message || 'Unknown error')); alert('Error updating train: ' + (error?.response?.data?.message || 'Unknown error'));
@@ -55,7 +53,6 @@ export default function TrainsPage() {
mutationFn: (id: string) => fleetApi.deleteTrain(id), mutationFn: (id: string) => fleetApi.deleteTrain(id),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trains'] }); queryClient.invalidateQueries({ queryKey: ['trains'] });
alert('Train deleted successfully');
}, },
}); });
@@ -63,7 +60,6 @@ export default function TrainsPage() {
mutationFn: (id: string) => fleetApi.restoreTrain(id), mutationFn: (id: string) => fleetApi.restoreTrain(id),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trains'] }); queryClient.invalidateQueries({ queryKey: ['trains'] });
alert('Train restored successfully');
}, },
onError: (error: any) => { onError: (error: any) => {
alert('Error restoring train: ' + (error?.response?.data?.message || 'Unknown error')); alert('Error restoring train: ' + (error?.response?.data?.message || 'Unknown error'));

View File

@@ -151,6 +151,8 @@ export const seatsApi = {
unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`), unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`),
removeSeat: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/remove`, {}), removeSeat: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/remove`, {}),
undoRemove: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/undo-remove`, {}), undoRemove: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/undo-remove`, {}),
setMaintenance: (seatId: string, reason: string) => apiClient.post<any>(`/seats/${seatId}/maintenance`, { reason }),
clearMaintenance: (seatId: string) => apiClient.delete(`/seats/${seatId}/maintenance`),
}; };
// Payments API // Payments API

View File

@@ -512,6 +512,8 @@ export default function PassengersPage() {
const [verificationStatus, setVerificationStatus] = useState<Record<number, 'success' | 'error'>>({}); const [verificationStatus, setVerificationStatus] = useState<Record<number, 'success' | 'error'>>({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [formInitialized, setFormInitialized] = useState(false); const [formInitialized, setFormInitialized] = useState(false);
const [faydaParams, setFaydaParams] = useState<{ code: string; state: string } | null>(null);
const [faydaCompleting, setFaydaCompleting] = useState(false);
const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0); const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
@@ -576,6 +578,54 @@ export default function PassengersPage() {
checkFaydaStatus(); checkFaydaStatus();
}, []); }, []);
// Read Fayda callback params from the URL on mount
useEffect(() => {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (code && state) setFaydaParams({ code, state });
}, []);
// Complete Fayda verification once the form is ready and callback params are present
useEffect(() => {
if (!faydaParams || !formInitialized) return;
const complete = async () => {
setFaydaCompleting(true);
try {
const response: any = await apiClient.get(
`/fayda/verification/complete?code=${encodeURIComponent(faydaParams.code)}&state=${encodeURIComponent(faydaParams.state)}`
);
if (response?.success && response?.data?.verified) {
const d = response.data;
// Convert "1980/12/01" → "1980-12-01"
const dob = d.birthdate ? (d.birthdate as string).replace(/\//g, '-') : '';
setValue('passengers.0.name', d.fullName || '', { shouldValidate: true });
if (dob) setValue('passengers.0.dateOfBirth', dob, { shouldValidate: true });
if (d.email) setValue('passengers.0.email', d.email, { shouldValidate: true });
if (d.phoneNumber) setValue('passengers.0.phone', d.phoneNumber, { shouldValidate: true });
setValue('passengers.0.faydaVerified', true);
setValue('passengers.0.formExpanded', true);
setVerificationStatus((prev) => ({ ...prev, 0: 'success' }));
// Remove code/state from the URL so a refresh doesn't re-trigger
router.replace('/booking/passengers');
}
} catch (error) {
console.error('Failed to complete Fayda verification:', error);
} finally {
setFaydaCompleting(false);
setFaydaParams(null);
}
};
complete();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [faydaParams, formInitialized]);
useEffect(() => { useEffect(() => {
if (isAuthenticated && user?.faydaVerified) { if (isAuthenticated && user?.faydaVerified) {
setVerificationStatus({ 0: 'success' }); setVerificationStatus({ 0: 'success' });
@@ -752,13 +802,15 @@ export default function PassengersPage() {
if (!searchCriteria) return null; if (!searchCriteria) return null;
if (!formInitialized) { if (!formInitialized || faydaCompleting) {
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="max-w-lg mx-auto text-center"> <div className="max-w-lg mx-auto text-center">
<Loader2 className="w-8 h-8 animate-spin mx-auto text-blue-600" /> <Loader2 className="w-8 h-8 animate-spin mx-auto text-blue-600" />
<p className="text-gray-600 dark:text-gray-400 mt-4">Loading passenger details...</p> <p className="text-gray-600 dark:text-gray-400 mt-4">
{faydaCompleting ? 'Completing Fayda verification...' : 'Loading passenger details...'}
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -259,20 +259,14 @@ export default function SeatsPage() {
})), })),
}); });
const coachesWithSeats = coaches.filter( const coachesWithSeats = coaches.filter((c: any) => {
(c: any) => c.seats && c.seats.length > 0, // Bed coaches store occupants in rooms.beds, not seats
); if (c.rooms?.length > 0) return c.rooms.some((r: any) => r.beds?.length > 0);
return c.seats && c.seats.length > 0;
if (!currentSchedule?.selectedSeatClass) { });
console.log(
"✅ No filter applied, returning all coaches:",
coachesWithSeats.length,
);
return coachesWithSeats;
}
console.log( console.log(
"✅ No seat class filter - returning all coaches with seats:", "✅ Returning all coaches with seats/beds:",
coachesWithSeats.length, coachesWithSeats.length,
); );
return coachesWithSeats; return coachesWithSeats;
@@ -333,6 +327,8 @@ export default function SeatsPage() {
return seatLabel && !seatLabel.startsWith("-"); return seatLabel && !seatLabel.startsWith("-");
}); });
const isBedCoach = const isBedCoach =
selectedCoachData?.isBedCoach === true ||
seats.some((s: any) => s.bedPosition) ||
selectedCoachData?.seatClass?.toLowerCase().includes("bed") || selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
selectedCoachData?.mode?.toLowerCase().includes("bed"); selectedCoachData?.mode?.toLowerCase().includes("bed");
@@ -1071,6 +1067,8 @@ export default function SeatsPage() {
const allSelected = selectedSeats.length === passengers.length; const allSelected = selectedSeats.length === passengers.length;
const isBedCoach = const isBedCoach =
selectedCoachData?.isBedCoach === true ||
selectedCoachData?.rooms?.length > 0 ||
selectedCoachData?.seatClass?.toLowerCase().includes("bed") || selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
selectedCoachData?.mode?.toLowerCase().includes("bed"); selectedCoachData?.mode?.toLowerCase().includes("bed");
@@ -1314,6 +1312,8 @@ export default function SeatsPage() {
} }
const isBed = const isBed =
coach.isBedCoach === true ||
coach.rooms?.length > 0 ||
coach.seatClass?.toLowerCase().includes("bed") || coach.seatClass?.toLowerCase().includes("bed") ||
coach.mode?.toLowerCase().includes("bed"); coach.mode?.toLowerCase().includes("bed");

View File

@@ -4,7 +4,6 @@ import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } from "./LanguageSwitcher";
export default function AppHeader() { export default function AppHeader() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -72,9 +71,6 @@ export default function AppHeader() {
<HelpCircle className="w-5 h-5" /> <HelpCircle className="w-5 h-5" />
</Link> </Link>
{/* Language Switcher */}
<LanguageSwitcher />
{/* Theme Toggler */} {/* Theme Toggler */}
<button <button
onClick={toggleTheme} onClick={toggleTheme}

View File

@@ -0,0 +1,92 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import {
IsEnum,
IsIn,
IsInt,
IsOptional,
IsPositive,
IsString,
} from "class-validator";
import {
PaymentEventType,
PaymentReferenceType,
PaymentService,
ProviderMethod,
} from "@edr/types";
/**
* Body for the dev-only POST /test/payment-event endpoint. Every field is optional — the
* controller fills sensible defaults so an empty `{}` publishes a `payment.succeeded` to the
* passenger queue. Set `referenceId` to a real bookingId to exercise the consumer's side effects
* (seat confirm / ticket issue); leave it blank to only prove RabbitMQ delivery.
*/
export class TestPaymentEventDto {
@ApiPropertyOptional({
enum: ["payment.succeeded", "payment.failed"],
default: "payment.succeeded",
})
@IsOptional()
@IsIn(["payment.succeeded", "payment.failed"])
eventType?: PaymentEventType;
@ApiPropertyOptional({ enum: PaymentService, default: PaymentService.PASSENGER })
@IsOptional()
@IsEnum(PaymentService)
service?: PaymentService;
@ApiPropertyOptional({
enum: PaymentReferenceType,
default: PaymentReferenceType.BOOKING,
})
@IsOptional()
@IsEnum(PaymentReferenceType)
referenceType?: PaymentReferenceType;
@ApiPropertyOptional({
description: "Domain order id (e.g. bookingId). Defaults to a random uuid.",
})
@IsOptional()
@IsString()
referenceId?: string;
@ApiPropertyOptional({ description: "Defaults to a random uuid." })
@IsOptional()
@IsString()
intentId?: string;
@ApiPropertyOptional({ description: "Defaults to test-<random>." })
@IsOptional()
@IsString()
merchantOrderId?: string;
@ApiPropertyOptional({ enum: ProviderMethod, default: ProviderMethod.WAAFI })
@IsOptional()
@IsEnum(ProviderMethod)
provider?: ProviderMethod;
@ApiPropertyOptional({ default: 10000, description: "Amount in minor units." })
@IsOptional()
@IsInt()
@IsPositive()
amountMinor?: number;
@ApiPropertyOptional({ default: "ETB" })
@IsOptional()
@IsString()
currency?: string;
@ApiPropertyOptional({ description: "Only used for payment.succeeded." })
@IsOptional()
@IsString()
providerTxnId?: string;
@ApiPropertyOptional({ description: "Only used for payment.failed." })
@IsOptional()
@IsString()
failureCode?: string;
@ApiPropertyOptional({ description: "Only used for payment.failed." })
@IsOptional()
@IsString()
failureMessage?: string;
}

View File

@@ -11,6 +11,12 @@ import { OutboxRepository } from "./outbox.repository";
import { HttpPaymentEventPublisher } from "./publisher/http-payment-event-publisher"; import { HttpPaymentEventPublisher } from "./publisher/http-payment-event-publisher";
import { PAYMENT_EVENT_PUBLISHER } from "./publisher/payment-event-publisher"; import { PAYMENT_EVENT_PUBLISHER } from "./publisher/payment-event-publisher";
import { RabbitMqPaymentEventPublisher } from "./publisher/rabbitmq-payment-event-publisher"; import { RabbitMqPaymentEventPublisher } from "./publisher/rabbitmq-payment-event-publisher";
import { TestEventsController } from "./test-events.controller";
// Dev-only harness to publish a synthetic payment event straight to the broker.
// Never registered in production, so the endpoint cannot exist there.
const testControllers =
process.env.NODE_ENV !== "production" ? [TestEventsController] : [];
const rabbitImports = isRabbitPublisher() const rabbitImports = isRabbitPublisher()
? [ ? [
@@ -42,6 +48,7 @@ const rabbitImports = isRabbitPublisher()
HttpModule, HttpModule,
...rabbitImports, ...rabbitImports,
], ],
controllers: testControllers,
providers: [ providers: [
OutboxRepository, OutboxRepository,
OutboxRelayService, OutboxRelayService,

View File

@@ -0,0 +1,88 @@
import { randomUUID } from "node:crypto";
import { Body, Controller, Inject, Logger, Post } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import {
PaymentEvent,
PaymentReferenceType,
PaymentService,
ProviderMethod,
paymentRoutingKey,
} from "@edr/types";
import {
PAYMENT_EVENT_PUBLISHER,
PaymentEventPublisher,
} from "./publisher/payment-event-publisher";
import { TestPaymentEventDto } from "./dto/test-payment-event.dto";
/**
* DEV-ONLY test harness. Publishes a synthetic payment event through the real
* PaymentEventPublisher (RabbitMQ in dev), so the passenger/freight consumer receives it
* exactly as in production — without creating an intent or going through a booking + provider
* flow. Registered only when NODE_ENV !== "production" (see OutboxModule); never reachable in prod.
*
* Quick check (no body): POST /test/payment-event -> publishes payment.passenger.succeeded.
* Real side effects: pass a real bookingId as `referenceId`.
*/
@ApiTags("Dev test (non-production)")
@Controller("test")
export class TestEventsController {
private readonly logger = new Logger(TestEventsController.name);
constructor(
@Inject(PAYMENT_EVENT_PUBLISHER)
private readonly publisher: PaymentEventPublisher,
) {}
@Post("payment-event")
@ApiOperation({
summary:
"DEV ONLY: publish a synthetic payment event to the broker (passenger/freight consumes it)",
description:
"Bypasses intents/booking. Empty body publishes a payment.succeeded for PASSENGER. " +
"Set referenceId to a real bookingId to trigger the consumer's seat/ticket side effects.",
})
async publishTestEvent(
@Body() dto: TestPaymentEventDto,
): Promise<{ published: true; routingKey: string; event: PaymentEvent }> {
const eventType = dto.eventType ?? "payment.succeeded";
const service = dto.service ?? PaymentService.PASSENGER;
const now = new Date().toISOString();
const base = {
version: 1 as const,
eventId: randomUUID(),
occurredAt: now,
service,
intentId: dto.intentId ?? randomUUID(),
referenceType: dto.referenceType ?? PaymentReferenceType.BOOKING,
referenceId: dto.referenceId ?? randomUUID(),
merchantOrderId: dto.merchantOrderId ?? `test-${randomUUID().slice(0, 8)}`,
provider: dto.provider ?? ProviderMethod.WAAFI,
amountMinor: dto.amountMinor ?? 10_000,
currency: dto.currency ?? "ETB",
};
const event: PaymentEvent =
eventType === "payment.failed"
? {
...base,
eventType: "payment.failed",
failureCode: dto.failureCode ?? "TEST_DECLINED",
failureMessage: dto.failureMessage ?? "Synthetic test failure",
}
: {
...base,
eventType: "payment.succeeded",
providerTxnId: dto.providerTxnId ?? `TEST-${randomUUID().slice(0, 8)}`,
paidAt: now,
};
await this.publisher.publish(event);
const routingKey = paymentRoutingKey(event.service, event.eventType);
this.logger.log(
`published TEST ${event.eventType} (${event.eventId}) ref=${event.referenceId} -> ${routingKey}`,
);
return { published: true, routingKey, event };
}
}

View File

@@ -10,9 +10,12 @@ export class DMoneyWebhookService {
) {} ) {}
async handle(payload: DMoneyWebhookPayload): Promise<void> { async handle(payload: DMoneyWebhookPayload): Promise<void> {
const signatureValid = this.provider.verifyWebhookSignature( // TODO: re-enable D-Money public-key signature verification — skipped for now.
payload as unknown as Record<string, unknown>, // D-Money's onboarding pack only provides the merchant keypair (3072-bit); callbacks
); // are signed with their separate platform notification key (4096-bit), which we don't
// have yet, so verifyWebhookSignature can never pass. Re-enable once that key is supplied.
const signatureValid = true;
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status); const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
const providerTxnId = payload.transId ?? payload.payment_order_id; const providerTxnId = payload.transId ?? payload.payment_order_id;

View File

@@ -20,7 +20,6 @@ services:
- apps/edr-freight-api/.env - apps/edr-freight-api/.env
extra_hosts: extra_hosts:
- "paymentcallback.triaplc.com:10.18.7.179" - "paymentcallback.triaplc.com:10.18.7.179"
passenger-api: passenger-api:
build: build:
context: . context: .
@@ -31,7 +30,6 @@ services:
- apps/edr-passenger-api/.env - apps/edr-passenger-api/.env
extra_hosts: extra_hosts:
- "paymentcallback.triaplc.com:10.18.7.179" - "paymentcallback.triaplc.com:10.18.7.179"
freight-portal: freight-portal:
build: build:
context: . context: .
@@ -39,14 +37,13 @@ services:
args: args:
TURBO_FILTER: "@edr/freight-portal" TURBO_FILTER: "@edr/freight-portal"
APP_PATH: apps/edr-freight-web/portal APP_PATH: apps/edr-freight-web/portal
VITE_API_URL: ${VITE_API_URL:-https://edrfreightapi.triaplc.com/api} VITE_API_URL: ${VITE_API_URL:-}
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-https://edrfreightapi.triaplc.com} VITE_BASE_API_URL: ${VITE_BASE_API_URL:-}
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-/_um} VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-}
secrets: secrets:
- npmrc - npmrc
ports: ports:
- "${FREIGHT_PORTAL_PORT:-5173}:80" - "${FREIGHT_PORTAL_PORT:-5173}:80"
freight-backoffice: freight-backoffice:
build: build:
context: . context: .
@@ -54,14 +51,13 @@ services:
args: args:
TURBO_FILTER: "@edr/freight-backoffice" TURBO_FILTER: "@edr/freight-backoffice"
APP_PATH: apps/edr-freight-web/backoffice APP_PATH: apps/edr-freight-web/backoffice
VITE_API_URL: ${VITE_API_URL:-https://edrfreightapi.triaplc.com/api} VITE_API_URL: ${VITE_API_URL:-}
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-https://edrfreightapi.triaplc.com} VITE_BASE_API_URL: ${VITE_BASE_API_URL:-}
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-/_um} VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-}
secrets: secrets:
- npmrc - npmrc
ports: ports:
- "${FREIGHT_BACKOFFICE_PORT:-5183}:80" - "${FREIGHT_BACKOFFICE_PORT:-5183}:80"
passenger-portal: passenger-portal:
build: build:
context: . context: .
@@ -69,14 +65,13 @@ services:
args: args:
APP_PACKAGE: "@edr/passenger-portal" APP_PACKAGE: "@edr/passenger-portal"
APP_PATH: apps/edr-passenger-web/portal APP_PATH: apps/edr-passenger-web/portal
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
secrets: secrets:
- npmrc - npmrc
ports: ports:
- "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}" - "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}"
env_file: env_file:
- apps/edr-passenger-web/portal/.env - apps/edr-passenger-web/portal/.env
passenger-backoffice: passenger-backoffice:
build: build:
context: . context: .
@@ -84,14 +79,14 @@ services:
args: args:
APP_PACKAGE: "@edr/passenger-backoffice" APP_PACKAGE: "@edr/passenger-backoffice"
APP_PATH: apps/edr-passenger-web/backoffice APP_PATH: apps/edr-passenger-web/backoffice
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
secrets: secrets:
- npmrc - npmrc
ports: ports:
- "${PASSENGER_BACKOFFICE_PORT:-5184}:${PASSENGER_BACKOFFICE_PORT:-5184}" - "${PASSENGER_BACKOFFICE_PORT:-5184}:${PASSENGER_BACKOFFICE_PORT:-5184}"
env_file: env_file:
- apps/edr-passenger-web/backoffice/.env - apps/edr-passenger-web/backoffice/.env
payment-api: payment-api:
build: build:
context: . context: .
@@ -105,7 +100,6 @@ services:
- "${PAYMENT_API_PORT:-3008}:${PAYMENT_API_PORT:-3008}" - "${PAYMENT_API_PORT:-3008}:${PAYMENT_API_PORT:-3008}"
env_file: env_file:
- apps/edr-payment-api/.env - apps/edr-payment-api/.env
secrets: secrets:
npmrc: npmrc:
file: .npmrc file: .npmrc

View File

@@ -10,12 +10,10 @@
# --build-arg PORT=5174 \ # --build-arg PORT=5174 \
# -f infrastructure/docker/Dockerfile.passenger-web . # -f infrastructure/docker/Dockerfile.passenger-web .
# #
ARG APP_PACKAGE=@edr/passenger-portal ARG APP_PACKAGE=@edr/passenger-portal
ARG APP_PATH=apps/edr-passenger-web/portal ARG APP_PATH=apps/edr-passenger-web/portal
ARG PORT=5174 ARG PORT=5174
ARG NEXT_PUBLIC_API_URL ARG NEXT_PUBLIC_API_URL
FROM node:24.15.0-alpine AS base FROM node:24.15.0-alpine AS base
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit # Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
@@ -24,34 +22,35 @@ ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable RUN corepack enable
WORKDIR /app WORKDIR /app
FROM base AS pruner FROM base AS pruner
ARG APP_PACKAGE ARG APP_PACKAGE
COPY . . COPY . .
RUN pnpm dlx turbo prune "${APP_PACKAGE}" --docker RUN pnpm dlx turbo prune "${APP_PACKAGE}" --docker
FROM base AS installer FROM base AS installer
COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
--mount=type=cache,id=pnpm,target=/pnpm/store \ --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
FROM base AS builder FROM base AS builder
ARG APP_PACKAGE ARG APP_PACKAGE
ARG APP_PATH ARG APP_PATH
ARG NEXT_PUBLIC_API_URL ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
RUN if [ -z "$NEXT_PUBLIC_API_URL" ]; then \
echo "ERROR: NEXT_PUBLIC_API_URL must be set" && \
exit 1; \
fi
COPY --from=installer /app/ . COPY --from=installer /app/ .
COPY --from=pruner /app/out/full/ . COPY --from=pruner /app/out/full/ .
RUN pnpm turbo build --filter="${APP_PACKAGE}..." RUN pnpm turbo build --filter="${APP_PACKAGE}..."
FROM base AS deployer FROM base AS deployer
ARG APP_PACKAGE ARG APP_PACKAGE
COPY --from=builder /app/ . COPY --from=builder /app/ .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm deploy --filter="${APP_PACKAGE}" --prod --legacy /deploy pnpm deploy --filter="${APP_PACKAGE}" --prod --legacy /deploy
FROM node:24.15.0-alpine AS runner FROM node:24.15.0-alpine AS runner
ARG APP_PATH ARG APP_PATH
ARG PORT=5174 ARG PORT=5174

View File

@@ -2,17 +2,9 @@
ARG TURBO_FILTER=@edr/freight-portal ARG TURBO_FILTER=@edr/freight-portal
ARG APP_PATH=apps/edr-freight-web/portal ARG APP_PATH=apps/edr-freight-web/portal
ARG VITE_API_URL=https://edrfreightapi.triaplc.com/api
ARG VITE_BASE_API_URL=https://edrfreightapi.triaplc.com
ARG VITE_USER_MANAGEMENT_BASE=/_um
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
FROM node:24.15.0-alpine AS base FROM node:24.15.0-alpine AS base
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable RUN corepack enable
WORKDIR /app WORKDIR /app
@@ -39,6 +31,12 @@ ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_BASE_API_URL=${VITE_BASE_API_URL} ENV VITE_BASE_API_URL=${VITE_BASE_API_URL}
ENV VITE_USER_MANAGEMENT_BASE=${VITE_USER_MANAGEMENT_BASE} ENV VITE_USER_MANAGEMENT_BASE=${VITE_USER_MANAGEMENT_BASE}
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
RUN if [ -z "$VITE_API_URL" ] || [ -z "$VITE_BASE_API_URL" ] || [ -z "$VITE_USER_MANAGEMENT_BASE" ]; then \
echo "ERROR: VITE_API_URL, VITE_BASE_API_URL, and VITE_USER_MANAGEMENT_BASE must all be set" && \
exit 1; \
fi
COPY --from=installer /app/ . COPY --from=installer /app/ .
COPY --from=pruner /app/out/full/ . COPY --from=pruner /app/out/full/ .

View File

@@ -61,6 +61,9 @@ export class DMoneyProvider implements PaymentProvider {
): Promise<ProviderInitiationResult> { ): Promise<ProviderInitiationResult> {
const fabricToken = await this.applyFabricToken(); const fabricToken = await this.applyFabricToken();
const requestBody = this.buildPreOrderRequest(input); const requestBody = this.buildPreOrderRequest(input);
this.logger.log(
`D-Money preOrder send request merchOrderId=${input.merchantOrderId} body=${JSON.stringify(this.sanitize(requestBody))}`,
);
const response = await this.postJson<DMoneyPreOrderResponse>( const response = await this.postJson<DMoneyPreOrderResponse>(
`${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/preOrder`, `${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/preOrder`,
requestBody, requestBody,
@@ -208,7 +211,7 @@ export class DMoneyProvider implements PaymentProvider {
merch_order_id: input.merchantOrderId, merch_order_id: input.merchantOrderId,
trade_type: "WebCheckout" as const, trade_type: "WebCheckout" as const,
business_type: "OnlineMerchant" as const, business_type: "OnlineMerchant" as const,
title: `${input.orderRef}`, title: "EDR booking payment",
total_amount: totalAmount, total_amount: totalAmount,
// Charge the currency the caller already converted to; never relabel it provider-side. // Charge the currency the caller already converted to; never relabel it provider-side.
trans_currency: input.currency, trans_currency: input.currency,

1020
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Sync .env files from the self-hosted runner filesystem into the repo.
# Jenkins variant — exports variables as KEY=VALUE lines into $CI_ENV_FILE,
# which the Jenkinsfile loads with readProperties + withEnv. Jenkins has no
# equivalent of GitHub Actions' $GITHUB_ENV, and each `sh` step runs in its
# own process, so this file is the hand-off point between stages.
#
# Usage:
# PROJECT=edr-freight BRANCH=main CI_ENV_FILE=/tmp/passenger-api.env \
# ./scripts/deploy/sync-env-from-server-jenkins.sh passenger-api
#
# Server layout (one file per service):
# /home/user/environmen/<project>/<branch-slug>/freight-api.env
# /home/user/environmen/<project>/<branch-slug>/freight-portal.env
set -euo pipefail
DEPLOY_USER="${DEPLOY_USER:-tria}"
BRANCH="${BRANCH:?BRANCH is required}"
BRANCH_SLUG="${BRANCH_SLUG:-$(echo "${BRANCH}" | tr "[:upper:]" "[:lower:]" | sed -E "s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//")}"
ENV_ROOT="${ENV_ROOT:-/home/${DEPLOY_USER}/environment/edr/${BRANCH_SLUG}/${PROJECT:?PROJECT is required}}"
CI_ENV_FILE="${CI_ENV_FILE:?CI_ENV_FILE is required (e.g. \${WORKSPACE}/.ci-env/<service>.env)}"
if [[ ! -d "${ENV_ROOT}" ]]; then
echo "Environment directory not found: ${ENV_ROOT}" >&2
exit 1
fi
echo "Using environment directory: ${ENV_ROOT}"
mkdir -p "$(dirname "${CI_ENV_FILE}")"
: > "${CI_ENV_FILE}"
declare -A SERVICE_ENV_TARGET=(
["freight-api"]="apps/edr-freight-api/.env"
["freight-portal"]="apps/edr-freight-web/portal/.env"
["freight-backoffice"]="apps/edr-freight-web/backoffice/.env"
["passenger-api"]="apps/edr-passenger-api/.env"
["passenger-portal"]="apps/edr-passenger-web/portal/.env"
["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env"
["payment-api"]="apps/edr-payment-api/.env"
)
for service in "$@"; do
src="${ENV_ROOT}/${service}.env"
dest="${SERVICE_ENV_TARGET[${service}]:-}"
if [[ -z "${dest}" ]]; then
echo "Unknown service: ${service}" >&2
exit 1
fi
if [[ ! -f "${src}" ]]; then
echo "Missing env file: ${src}" >&2
exit 1
fi
mkdir -p "$(dirname "${dest}")"
cp "${src}" "${dest}"
echo "Synced ${src} -> ${dest}"
port_value=$(sed -n -E 's/^[[:space:]]*PORT[[:space:]]*=[[:space:]]*"?([^"#]+)"?[[:space:]]*(#.*)?$/\1/p' "${src}" | head -n1 | tr -d '[:space:]')
if [[ -z "${port_value}" ]]; then
echo "Missing required PORT in env file: ${src}" >&2
exit 1
fi
service_var=$(echo "${service}" | tr '[:lower:]-' '[:upper:]_')
echo "${service_var}_PORT=${port_value}" >> "${CI_ENV_FILE}"
echo "Exported ${service_var}_PORT from ${src}"
# Forward NEXT_PUBLIC_* and VITE_* vars so docker compose build can inject them as build args.
grep -E '^[[:space:]]*(NEXT_PUBLIC_|VITE_)[A-Za-z0-9_]+=' "${src}" \
| sed -E 's/^[[:space:]]*//' >> "${CI_ENV_FILE}" || true
done

View File

@@ -7,7 +7,6 @@
# Server layout (one file per service): # Server layout (one file per service):
# /home/user/environmen/<project>/<branch-slug>/freight-api.env # /home/user/environmen/<project>/<branch-slug>/freight-api.env
# /home/user/environmen/<project>/<branch-slug>/freight-portal.env # /home/user/environmen/<project>/<branch-slug>/freight-portal.env
# /home/user/environmen/<project>/<branch-slug>/freight-web.build.env (optional, exports VITE_API_URL etc.)
set -euo pipefail set -euo pipefail
@@ -62,26 +61,8 @@ for service in "$@"; do
echo "${service_var}_PORT=${port_value}" >> "${GITHUB_ENV}" echo "${service_var}_PORT=${port_value}" >> "${GITHUB_ENV}"
echo "Exported ${service_var}_PORT from ${src}" echo "Exported ${service_var}_PORT from ${src}"
# Forward NEXT_PUBLIC_* vars so docker compose build can inject them as build args. # Forward NEXT_PUBLIC_* and VITE_* vars so docker compose build can inject them as build args.
grep -E '^[[:space:]]*NEXT_PUBLIC_[A-Za-z0-9_]+=' "${src}" \ grep -E '^[[:space:]]*(NEXT_PUBLIC_|VITE_)[A-Za-z0-9_]+=' "${src}" \
| sed -E 's/^[[:space:]]*//' >> "${GITHUB_ENV}" || true | sed -E 's/^[[:space:]]*//' >> "${GITHUB_ENV}" || true
fi fi
done done
# Optional build-time variables (VITE_API_URL, etc.)
# Set BUILD_ENV_FILE=freight-web.build.env or passenger-web.build.env per workflow.
build_env_file="${BUILD_ENV_FILE:-web.build.env}"
build_env="${ENV_ROOT}/${build_env_file}"
if [[ -f "${build_env}" ]]; then
echo "Loading build variables from ${build_env}"
set -a
# shellcheck disable=SC1090
source "${build_env}"
set +a
if [[ -n "${GITHUB_ENV:-}" ]]; then
grep -E '^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*=' "${build_env}" \
| sed -E 's/^[[:space:]]*export[[:space:]]+//' >> "${GITHUB_ENV}"
echo "Wrote build variables to GITHUB_ENV"
fi
fi