From 2bbb37207e5313a67006407d19db962a9936a1c8 Mon Sep 17 00:00:00 2001 From: yonastewabe Date: Tue, 30 Jun 2026 15:16:47 +0300 Subject: [PATCH 01/18] feat: implement automated environment synchronization and conditional CI/CD deployment workflows --- .github/workflows/deploy.yml | 2 +- docker-compose.yaml | 24 +++--- infrastructure/docker/Dockerfile.web | 4 - .../deploy/sync-env-from-server-jenkins.sh | 74 +++++++++++++++++++ scripts/deploy/sync-env-from-server.sh | 25 +------ 5 files changed, 90 insertions(+), 39 deletions(-) create mode 100644 scripts/deploy/sync-env-from-server-jenkins.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 78c15cba1..fcd560a95 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -50,7 +50,7 @@ jobs: SERVICES=() - NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$" + NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$|^scripts/deploy/sync-env-from-server-jenkins[.]sh$" GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$" diff --git a/docker-compose.yaml b/docker-compose.yaml index a045125bb..5ea74843b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -39,14 +39,14 @@ services: args: TURBO_FILTER: "@edr/freight-portal" APP_PATH: apps/edr-freight-web/portal - VITE_API_URL: ${VITE_API_URL:-https://edrfreightapi.triaplc.com/api} - VITE_BASE_API_URL: ${VITE_BASE_API_URL:-https://edrfreightapi.triaplc.com} - VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-/_um} + VITE_API_URL: ${VITE_API_URL:?VITE_API_URL must be set} + VITE_BASE_API_URL: ${VITE_BASE_API_URL:?VITE_BASE_API_URL must be set} + VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:?VITE_USER_MANAGEMENT_BASE must be set} secrets: - npmrc ports: - "${FREIGHT_PORTAL_PORT:-5173}:80" - + freight-backoffice: build: context: . @@ -54,14 +54,14 @@ services: args: TURBO_FILTER: "@edr/freight-backoffice" APP_PATH: apps/edr-freight-web/backoffice - VITE_API_URL: ${VITE_API_URL:-https://edrfreightapi.triaplc.com/api} - VITE_BASE_API_URL: ${VITE_BASE_API_URL:-https://edrfreightapi.triaplc.com} - VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-/_um} + VITE_API_URL: ${VITE_API_URL:?VITE_API_URL must be set} + VITE_BASE_API_URL: ${VITE_BASE_API_URL:?VITE_BASE_API_URL must be set} + VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:?VITE_USER_MANAGEMENT_BASE must be set} secrets: - npmrc ports: - "${FREIGHT_BACKOFFICE_PORT:-5183}:80" - + passenger-portal: build: context: . @@ -69,14 +69,14 @@ services: args: APP_PACKAGE: "@edr/passenger-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:?NEXT_PUBLIC_API_URL must be set} secrets: - npmrc ports: - "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}" env_file: - apps/edr-passenger-web/portal/.env - + passenger-backoffice: build: context: . @@ -84,14 +84,14 @@ services: args: APP_PACKAGE: "@edr/passenger-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:?NEXT_PUBLIC_API_URL must be set} secrets: - npmrc ports: - "${PASSENGER_BACKOFFICE_PORT:-5184}:${PASSENGER_BACKOFFICE_PORT:-5184}" env_file: - apps/edr-passenger-web/backoffice/.env - + payment-api: build: context: . diff --git a/infrastructure/docker/Dockerfile.web b/infrastructure/docker/Dockerfile.web index 1ccdeb81f..65c26a694 100644 --- a/infrastructure/docker/Dockerfile.web +++ b/infrastructure/docker/Dockerfile.web @@ -2,10 +2,6 @@ ARG TURBO_FILTER=@edr/freight-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 RUN apk add --no-cache libc6-compat diff --git a/scripts/deploy/sync-env-from-server-jenkins.sh b/scripts/deploy/sync-env-from-server-jenkins.sh new file mode 100644 index 000000000..74b9a3f13 --- /dev/null +++ b/scripts/deploy/sync-env-from-server-jenkins.sh @@ -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///freight-api.env +# /home/user/environmen///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/.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 \ No newline at end of file diff --git a/scripts/deploy/sync-env-from-server.sh b/scripts/deploy/sync-env-from-server.sh index 795ab25b2..025c518b5 100644 --- a/scripts/deploy/sync-env-from-server.sh +++ b/scripts/deploy/sync-env-from-server.sh @@ -7,7 +7,6 @@ # Server layout (one file per service): # /home/user/environmen///freight-api.env # /home/user/environmen///freight-portal.env -# /home/user/environmen///freight-web.build.env (optional, exports VITE_API_URL etc.) set -euo pipefail @@ -62,26 +61,8 @@ for service in "$@"; do echo "${service_var}_PORT=${port_value}" >> "${GITHUB_ENV}" echo "Exported ${service_var}_PORT from ${src}" - # Forward NEXT_PUBLIC_* vars so docker compose build can inject them as build args. - grep -E '^[[:space:]]*NEXT_PUBLIC_[A-Za-z0-9_]+=' "${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:]]*//' >> "${GITHUB_ENV}" || true fi -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 +done \ No newline at end of file From 18e18bd15e0a1354b31c9f69c1123e8cbacf54d6 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 30 Jun 2026 15:24:09 +0300 Subject: [PATCH 02/18] Update Dockerfile.web --- infrastructure/docker/Dockerfile.web | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/infrastructure/docker/Dockerfile.web b/infrastructure/docker/Dockerfile.web index fc5e9ab7e..d5f77061e 100644 --- a/infrastructure/docker/Dockerfile.web +++ b/infrastructure/docker/Dockerfile.web @@ -5,10 +5,6 @@ ARG APP_PATH=apps/edr-freight-web/portal FROM node:24.15.0-alpine AS base RUN apk add --no-cache libc6-compat -# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit -# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds. -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable WORKDIR /app @@ -35,6 +31,12 @@ ENV VITE_API_URL=${VITE_API_URL} ENV VITE_BASE_API_URL=${VITE_BASE_API_URL} ENV VITE_USER_MANAGEMENT_BASE=${VITE_USER_MANAGEMENT_BASE} 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=pruner /app/out/full/ . From 90f200fdc10b82ac4c445248fd5f8a98a0da271a Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 30 Jun 2026 15:26:23 +0300 Subject: [PATCH 03/18] Update Dockerfile.passenger-web --- infrastructure/docker/Dockerfile.passenger-web | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/infrastructure/docker/Dockerfile.passenger-web b/infrastructure/docker/Dockerfile.passenger-web index 9d9adac08..61b58736b 100644 --- a/infrastructure/docker/Dockerfile.passenger-web +++ b/infrastructure/docker/Dockerfile.passenger-web @@ -10,12 +10,10 @@ # --build-arg PORT=5174 \ # -f infrastructure/docker/Dockerfile.passenger-web . # - ARG APP_PACKAGE=@edr/passenger-portal ARG APP_PATH=apps/edr-passenger-web/portal ARG PORT=5174 ARG NEXT_PUBLIC_API_URL - FROM node:24.15.0-alpine AS base RUN apk add --no-cache libc6-compat # Store pnpm's content-addressable store under PNPM_HOME so the BuildKit @@ -24,34 +22,35 @@ ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable WORKDIR /app - FROM base AS pruner ARG APP_PACKAGE COPY . . RUN pnpm dlx turbo prune "${APP_PACKAGE}" --docker - FROM base AS installer COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile - FROM base AS builder ARG APP_PACKAGE ARG APP_PATH ARG 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=pruner /app/out/full/ . RUN pnpm turbo build --filter="${APP_PACKAGE}..." - FROM base AS deployer ARG APP_PACKAGE COPY --from=builder /app/ . RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm deploy --filter="${APP_PACKAGE}" --prod --legacy /deploy - FROM node:24.15.0-alpine AS runner ARG APP_PATH ARG PORT=5174 From 9f2f1b5138a910e1331b81037cb91de7d5da2ba7 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 30 Jun 2026 15:28:39 +0300 Subject: [PATCH 04/18] Update docker-compose.yaml --- docker-compose.yaml | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 5ea74843b..db3da060a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -20,7 +20,6 @@ services: - apps/edr-freight-api/.env extra_hosts: - "paymentcallback.triaplc.com:10.18.7.179" - passenger-api: build: context: . @@ -31,7 +30,6 @@ services: - apps/edr-passenger-api/.env extra_hosts: - "paymentcallback.triaplc.com:10.18.7.179" - freight-portal: build: context: . @@ -39,14 +37,13 @@ services: args: TURBO_FILTER: "@edr/freight-portal" APP_PATH: apps/edr-freight-web/portal - VITE_API_URL: ${VITE_API_URL:?VITE_API_URL must be set} - VITE_BASE_API_URL: ${VITE_BASE_API_URL:?VITE_BASE_API_URL must be set} - VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:?VITE_USER_MANAGEMENT_BASE must be set} + VITE_API_URL: ${VITE_API_URL:-} + VITE_BASE_API_URL: ${VITE_BASE_API_URL:-} + VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-} secrets: - npmrc ports: - "${FREIGHT_PORTAL_PORT:-5173}:80" - freight-backoffice: build: context: . @@ -54,14 +51,13 @@ services: args: TURBO_FILTER: "@edr/freight-backoffice" APP_PATH: apps/edr-freight-web/backoffice - VITE_API_URL: ${VITE_API_URL:?VITE_API_URL must be set} - VITE_BASE_API_URL: ${VITE_BASE_API_URL:?VITE_BASE_API_URL must be set} - VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:?VITE_USER_MANAGEMENT_BASE must be set} + VITE_API_URL: ${VITE_API_URL:-} + VITE_BASE_API_URL: ${VITE_BASE_API_URL:-} + VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-} secrets: - npmrc ports: - "${FREIGHT_BACKOFFICE_PORT:-5183}:80" - passenger-portal: build: context: . @@ -69,14 +65,13 @@ services: args: APP_PACKAGE: "@edr/passenger-portal" APP_PATH: apps/edr-passenger-web/portal - NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?NEXT_PUBLIC_API_URL must be set} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-} secrets: - npmrc ports: - "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}" env_file: - apps/edr-passenger-web/portal/.env - passenger-backoffice: build: context: . @@ -84,7 +79,7 @@ services: args: APP_PACKAGE: "@edr/passenger-backoffice" APP_PATH: apps/edr-passenger-web/backoffice - NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?NEXT_PUBLIC_API_URL must be set} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-} secrets: - npmrc ports: @@ -105,7 +100,6 @@ services: - "${PAYMENT_API_PORT:-3008}:${PAYMENT_API_PORT:-3008}" env_file: - apps/edr-payment-api/.env - secrets: npmrc: file: .npmrc From a667f5b2df910706de6558b7bf96c522720fb495 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 30 Jun 2026 15:51:47 +0300 Subject: [PATCH 05/18] add test payment event --- .../payments/payment-events.consumer.ts | 5 + .../outbox/dto/test-payment-event.dto.ts | 92 +++++++++++++++++++ .../src/modules/outbox/outbox.module.ts | 7 ++ .../modules/outbox/test-events.controller.ts | 88 ++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts create mode 100644 apps/edr-payment-api/src/modules/outbox/test-events.controller.ts diff --git a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts index 291402240..f13191c48 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts @@ -29,6 +29,11 @@ export class PaymentEventsConsumer { }, }) async handle(event: PaymentEvent): Promise { + // Logged the instant RabbitMQ delivers the message, before any DB work — proves the + // payment -> passenger broker connection works even if processing later fails/hangs. + this.logger.log( + `RECEIVED ${event.eventType} (${event.eventId}) ref=${event.referenceId} via RabbitMQ`, + ); try { const result = await this.paymentsService.handlePaymentEvent( event as unknown as PaymentEventDto, diff --git a/apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts b/apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts new file mode 100644 index 000000000..700d64717 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts @@ -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-." }) + @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; +} diff --git a/apps/edr-payment-api/src/modules/outbox/outbox.module.ts b/apps/edr-payment-api/src/modules/outbox/outbox.module.ts index eae5515e8..5d8d0cc8a 100644 --- a/apps/edr-payment-api/src/modules/outbox/outbox.module.ts +++ b/apps/edr-payment-api/src/modules/outbox/outbox.module.ts @@ -11,6 +11,12 @@ import { OutboxRepository } from "./outbox.repository"; import { HttpPaymentEventPublisher } from "./publisher/http-payment-event-publisher"; import { PAYMENT_EVENT_PUBLISHER } from "./publisher/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() ? [ @@ -42,6 +48,7 @@ const rabbitImports = isRabbitPublisher() HttpModule, ...rabbitImports, ], + controllers: testControllers, providers: [ OutboxRepository, OutboxRelayService, diff --git a/apps/edr-payment-api/src/modules/outbox/test-events.controller.ts b/apps/edr-payment-api/src/modules/outbox/test-events.controller.ts new file mode 100644 index 000000000..d4396a754 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/test-events.controller.ts @@ -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 }; + } +} From 6e81090f8c3e2f91688e6343c5e693042fdcad13 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 30 Jun 2026 16:45:51 +0300 Subject: [PATCH 06/18] Added optimization for search result and fix sea map --- .../src/modules/search/search.service.ts | 284 ++++++++---------- .../src/modules/segments/segments.service.ts | 90 ++++++ .../portal/src/app/booking/seats/page.tsx | 24 +- .../portal/src/components/AppHeader.tsx | 4 - 4 files changed, 229 insertions(+), 173 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index e14db6b0a..08687d291 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -9,6 +9,30 @@ import { Currency } from '@prisma/client'; const POINTS_TO_MINOR = 10; +// Shape returned by the heavy schedule include used throughout this service +type ScheduleWithIncludes = { + id: string; + routeId: string | null; + departureAt: Date; + arrivalAt: Date; + status: string; + train: any; + originStation: any; + destinationStation: any; + stopTimes: Array<{ stationId: string; sequence: number; plannedArrivalAt: Date | null; plannedDepartureAt: Date | null; station: any }>; + coachAssignments: Array<{ coach: { id: string; seats: any[]; coachType: { id: string; name: string; code: string; seatClasses: any[] } | null } }>; +}; + +const SCHEDULE_INCLUDE = { + train: true, + originStation: true, + destinationStation: true, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + coachAssignments: { + include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + }, +} as const; + @Injectable() export class SearchService { constructor( @@ -124,7 +148,7 @@ export class SearchService { if (windowStart < now) windowStart.setTime(now.getTime()); const windowEnd = new Date(requestedDate); - windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); // exclusive upper bound + windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); const totalPassengers = adultCount + (childCount ?? 0); @@ -139,30 +163,16 @@ export class SearchService { ], stopTimes: { some: { stationId: originStationId } }, }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + include: SCHEDULE_INCLUDE, orderBy: { departureAt: 'asc' }, }); - const results: any[] = []; - for (const schedule of schedules) { - const result = await this.buildScheduleResult( - schedule, - originStationId, - destinationStationId, - totalPassengers, - nationality, - ); - if (result) results.push(result); - } - return results; + const results = await Promise.all( + schedules.map(schedule => + this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) + ) + ); + return results.filter(Boolean); } private async searchSchedules( @@ -185,29 +195,18 @@ export class SearchService { departureAt: { gte: date < now ? now : date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + include: SCHEDULE_INCLUDE, }); - const results: any[] = []; - for (const schedule of schedules) { - const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality); - if (result) results.push(result); - } - return results; + const results = await Promise.all( + schedules.map(schedule => + this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) + ) + ); + return results.filter(Boolean); } // ── Transit search ───────────────────────────────────────────────────────── - // Finds pairs of schedules (leg1: origin→transit, leg2: transit→destination) - // where the passenger has between MIN_CONNECTION and MAX_CONNECTION minutes - // to change trains at the transit station. private readonly MIN_CONNECTION_MINUTES = 30; private readonly MAX_CONNECTION_MINUTES = 360; @@ -219,82 +218,67 @@ export class SearchService { childCount?: number, nationality?: string, ) { - // Find all stations that can serve as transit points: - // they must be a stop after origin on some schedule AND - // a stop before destination on another schedule on the same day. const [y, m, d] = dateStr.split('-').map(Number); const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0); const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + const leg2WindowEnd = new Date(dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000); const totalPassengers = adultCount + (childCount ?? 0); - // Load all schedules on this date that pass through origin - const leg1Schedules = await this.prisma.trainSchedule.findMany({ - where: { - status: { in: ['SCHEDULED', 'BOARDING'] }, - departureAt: { gte: dayStart, lt: dayEnd }, - stopTimes: { some: { stationId: originStationId } }, - }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + // Load leg1 and all potential leg2 candidates in one parallel round-trip + // instead of firing a separate DB query per transit stop. + const [leg1Schedules, allCandidates] = await Promise.all([ + this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + departureAt: { gte: dayStart, lt: dayEnd }, + stopTimes: { some: { stationId: originStationId } }, }, - }, - }); + include: SCHEDULE_INCLUDE, + }), + this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + departureAt: { gte: dayStart, lt: leg2WindowEnd }, + }, + include: SCHEDULE_INCLUDE, + }), + ]); const results: any[] = []; - for (const leg1 of leg1Schedules) { - const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId); + for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) { + const originStop = leg1.stopTimes.find(s => s.stationId === originStationId); if (!originStop) continue; - // Every stop after origin on leg1 is a candidate transit station const candidateTransitStops = leg1.stopTimes.filter( - (s: any) => s.sequence > originStop.sequence, + s => s.sequence > originStop.sequence, ); for (const transitStop of candidateTransitStops) { - // leg1 must NOT already contain the final destination - const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId); - if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules + const leg1HasDest = leg1.stopTimes.some(s => s.stationId === destinationStationId); + if (leg1HasDest) continue; const transitStationId = transitStop.stationId; const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt; - // Find leg2 schedules departing from the transit station within the connection window, - // and reaching the final destination. Search up to the next calendar day to handle - // overnight connections. const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000); const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000); - const leg2Schedules = await this.prisma.trainSchedule.findMany({ - where: { - status: { in: ['SCHEDULED', 'BOARDING'] }, - departureAt: { gte: connWindowStart, lte: connWindowEnd }, - stopTimes: { some: { stationId: transitStationId } }, - }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + // Filter from pre-loaded candidates in memory — no extra DB query + const leg2Schedules = (allCandidates as ScheduleWithIncludes[]).filter(s => { + const dep = new Date(s.departureAt).getTime(); + return dep >= connWindowStart.getTime() + && dep <= connWindowEnd.getTime() + && s.stopTimes.some(st => st.stationId === transitStationId); }); for (const leg2 of leg2Schedules) { - const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId); - const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId); + const leg2TransitStop = leg2.stopTimes.find(s => s.stationId === transitStationId); + const leg2DestStop = leg2.stopTimes.find(s => s.stationId === destinationStationId); if (!leg2TransitStop || !leg2DestStop) continue; if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue; - // Build individual leg result objects (reuse existing per-schedule logic) const [leg1Result, leg2Result] = await Promise.all([ this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality), this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality), @@ -326,7 +310,6 @@ export class SearchService { displayCurrency, combinedMinFareMinor, combinedMinFareDisplay, - // Convenience top-level fields so round-trip filter can read them uniformly departureAt: leg1Result.departureAt, arrivalAt: leg2Result.arrivalAt, totalDurationMinutes: @@ -339,19 +322,37 @@ export class SearchService { return results; } - // Builds the same result shape as searchSchedules for a single schedule+leg, - // extracted so both direct and transit paths share identical output. private async buildScheduleResult( - schedule: any, + schedule: ScheduleWithIncludes, originStationId: string, destinationStationId: string, totalPassengers: number, nationality?: string, ) { - const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId); - const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId); + const originStop = schedule.stopTimes.find(s => s.stationId === originStationId); + const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId); if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null; + // Collect all valid seat IDs upfront for a single batch availability check + const allValidSeatIds = schedule.coachAssignments.flatMap(a => + a.coach.seats + .filter((s: any) => s.status !== 'BLOCKED' && s.seatNumber?.trim()) + .map((s: any) => s.id as string) + ); + + // Run availability batch and fare calculation in parallel + const [freeSeats, faresByClass] = await Promise.all([ + this.segmentsService.getFreeSeatIds( + schedule.id, + allValidSeatIds, + schedule.stopTimes, + originStop.sequence, + destStop.sequence, + ), + this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality), + ]); + + // Compute per-class availability using the pre-computed free seat set const availabilityByClass: Record = {}; for (const assignment of schedule.coachAssignments) { const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard']; @@ -362,8 +363,7 @@ export class SearchService { let count = 0; for (const seat of assignment.coach.seats) { if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; - const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); - if (free) count++; + if (freeSeats.has(seat.id)) count++; } if (count > 0) { const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition)); @@ -374,15 +374,13 @@ export class SearchService { let available = 0; for (const seat of assignment.coach.seats) { if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; - const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); - if (free) available++; + if (freeSeats.has(seat.id)) available++; } for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available; } } - const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality); - const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass); + const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass); const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; @@ -400,8 +398,8 @@ export class SearchService { durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000), status: schedule.status, stops: schedule.stopTimes - .filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) - .map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), + .filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) + .map(st => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), availabilityByClass, hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), displayCurrency, @@ -500,46 +498,31 @@ export class SearchService { } private async calculateFaresForSegment( - schedule: any, + schedule: ScheduleWithIncludes, originStationId: string, destinationStationId: string, nationality?: string, ): Promise> { const displayCurrency = resolveCurrencyFromNationality(nationality); - const seatClassIds: string[] = Array.from( - new Set( - schedule.coachAssignments - .flatMap((a: any) => a.coach.coachType?.seatClasses || []) - .map((sc: any) => sc.id) - .filter((id: any) => id) - ) - ); - - if (seatClassIds.length === 0) { - console.log(`No seat classes assigned to schedule ${schedule.id}`); - return []; + // Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany + const seatClassMap = new Map(); + for (const a of schedule.coachAssignments) { + for (const sc of (a.coach.coachType?.seatClasses ?? [])) { + if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc); + } } + const seatClasses = Array.from(seatClassMap.values()) + .sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor); - const seatClasses = await this.prisma.seatClass.findMany({ - where: { - isActive: true, - id: { in: seatClassIds } - }, - orderBy: { baseFareMinor: 'asc' }, - }); - - if (seatClasses.length === 0) { - console.log(`No active seat classes for schedule ${schedule.id}`); - return []; - } + if (seatClasses.length === 0) return []; if (schedule.routeId) { const results = await Promise.all( seatClasses.map(async (sc) => { try { const fare = await this.fareEngine.calculate({ - routeId: schedule.routeId, + routeId: schedule.routeId!, originStationId, destinationStationId, seatClassId: sc.id, @@ -552,8 +535,7 @@ export class SearchService { displayCurrency: fare.billingCurrency as Currency, displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate), }; - } catch (error) { - console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message); + } catch { return null; } }), @@ -562,36 +544,32 @@ export class SearchService { const validResults = results.filter( (r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null, ); - if (validResults.length > 0) { - return validResults; - } + if (validResults.length > 0) return validResults; } - const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } }); - const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } }); + // Fallback: use station codes from already-loaded stopTimes when available + const originStop = schedule.stopTimes.find(st => st.stationId === originStationId); + const destStop = schedule.stopTimes.find(st => st.stationId === destinationStationId); + const originCode = originStop?.station?.code; + const destCode = destStop?.station?.code; - if (originStation && destStation) { - const segmentRoute = `${originStation.code}-${destStation.code}`; + if (originCode && destCode) { + const segmentRoute = `${originCode}-${destCode}`; const now = new Date(); const fareRules = await this.prisma.fareRule.findMany({ where: { route: segmentRoute, - seatClassId: { in: seatClassIds }, + seatClassId: { in: seatClasses.map((sc: any) => sc.id) }, validFrom: { lte: now }, - OR: [ - { validUntil: null }, - { validUntil: { gte: now } }, - ], + OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, }); if (fareRules.length > 0) { - console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`); - const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name])); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency); return fareRules.map(rule => ({ - seatClassName: seatClassMap[rule.seatClassId] || 'Unknown', + seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown', baseFareMinor: rule.baseFareMinor, displayCurrency, displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate), @@ -599,20 +577,20 @@ export class SearchService { } } - console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`); return []; } - private async buildCoachTypeDetails( - schedule: any, + // buildCoachTypeDetails is pure in-memory — no async needed + private buildCoachTypeDetails( + schedule: ScheduleWithIncludes, faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>, - ): Promise; - }>> { + }> { const coachTypeMap = new Map< string, { coachType: any; classNames: Set; coachId: string } @@ -682,14 +660,6 @@ export class SearchService { return fare.baseFarePerPassengerMinor; } - private getDefaultFareForClass(_className: string): never { - throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead'); - } - - private defaultFare(_seatClassName: string): never { - throw new Error('defaultFare should not be called — use resolveScheduleFare instead'); - } - private selectBestFareRule( candidates: any[], scheduleId: string, diff --git a/apps/edr-passenger-api/src/modules/segments/segments.service.ts b/apps/edr-passenger-api/src/modules/segments/segments.service.ts index 2eef0302e..16b486bbe 100644 --- a/apps/edr-passenger-api/src/modules/segments/segments.service.ts +++ b/apps/edr-passenger-api/src/modules/segments/segments.service.ts @@ -146,6 +146,96 @@ export class SegmentsService { return true; } + /** + * Batch availability check for multiple seats on a single schedule. + * Replaces N×isSeatFreeForLeg calls with 2 queries total. + * Returns a Set of seat IDs that are free for [reqFrom, reqTo). + */ + async getFreeSeatIds( + scheduleId: string, + seatIds: string[], + stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>, + reqFrom: number, + reqTo: number, + ): Promise> { + if (seatIds.length === 0) return new Set(); + + const seqOf = (stationId: string) => + stopTimesForSeqLookup.find(s => s.stationId === stationId)?.sequence; + + const seatIdSet = new Set(seatIds); + const now = new Date(); + + const [allHolds, bookedLegs] = await Promise.all([ + this.prisma.seatHold.findMany({ + where: { scheduleId, expiresAt: { gt: now } }, + select: { seatIds: true, createdBy: true }, + }), + this.prisma.journeySegment.findMany({ + where: { + scheduleId, + seatId: { in: seatIds }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, + }, + select: { seatId: true, journeyId: true, departureStationId: true, arrivalStationId: true }, + }), + ]); + + // Determine which seats are blocked by active holds + const holdBlockedSeats = new Set(); + for (const hold of allHolds) { + let holdFrom: number | undefined; + let holdTo: number | undefined; + try { + if (hold.createdBy) { + const meta = JSON.parse(hold.createdBy as string); + holdFrom = seqOf(meta.originStationId); + holdTo = seqOf(meta.destinationStationId); + } + } catch { /* ignore */ } + + for (const sid of hold.seatIds) { + if (!seatIdSet.has(sid)) continue; + // Conservative block if leg can't be resolved; otherwise check overlap + if (holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo)) { + holdBlockedSeats.add(sid); + } + } + } + + // Build full journey ranges per seat (group multi-leg journeys) + const journeyRangesBySeat = new Map>(); + for (const leg of bookedLegs) { + if (!leg.seatId || !leg.journeyId || !leg.departureStationId || !leg.arrivalStationId) continue; + const depSeq = seqOf(leg.departureStationId); + const arrSeq = seqOf(leg.arrivalStationId); + if (depSeq === undefined || arrSeq === undefined) continue; + + let rangeMap = journeyRangesBySeat.get(leg.seatId); + if (!rangeMap) { rangeMap = new Map(); journeyRangesBySeat.set(leg.seatId, rangeMap); } + + const existing = rangeMap.get(leg.journeyId); + rangeMap.set(leg.journeyId, existing + ? { from: Math.min(existing.from, depSeq), to: Math.max(existing.to, arrSeq) } + : { from: depSeq, to: arrSeq }); + } + + const freeSeats = new Set(); + for (const seatId of seatIds) { + if (holdBlockedSeats.has(seatId)) continue; + let blocked = false; + const rangeMap = journeyRangesBySeat.get(seatId); + if (rangeMap) { + for (const { from, to } of rangeMap.values()) { + if (from < reqTo && reqFrom < to) { blocked = true; break; } + } + } + if (!blocked) freeSeats.add(seatId); + } + + return freeSeats; + } + /** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */ async getOverlappingReservations( scheduleId: string, diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index 696dcb45b..a02f00eec 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -259,20 +259,14 @@ export default function SeatsPage() { })), }); - const coachesWithSeats = coaches.filter( - (c: any) => c.seats && c.seats.length > 0, - ); - - if (!currentSchedule?.selectedSeatClass) { - console.log( - "✅ No filter applied, returning all coaches:", - coachesWithSeats.length, - ); - return coachesWithSeats; - } + const coachesWithSeats = coaches.filter((c: any) => { + // 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; + }); console.log( - "✅ No seat class filter - returning all coaches with seats:", + "✅ Returning all coaches with seats/beds:", coachesWithSeats.length, ); return coachesWithSeats; @@ -333,6 +327,8 @@ export default function SeatsPage() { return seatLabel && !seatLabel.startsWith("-"); }); const isBedCoach = + selectedCoachData?.isBedCoach === true || + seats.some((s: any) => s.bedPosition) || selectedCoachData?.seatClass?.toLowerCase().includes("bed") || selectedCoachData?.mode?.toLowerCase().includes("bed"); @@ -1071,6 +1067,8 @@ export default function SeatsPage() { const allSelected = selectedSeats.length === passengers.length; const isBedCoach = + selectedCoachData?.isBedCoach === true || + selectedCoachData?.rooms?.length > 0 || selectedCoachData?.seatClass?.toLowerCase().includes("bed") || selectedCoachData?.mode?.toLowerCase().includes("bed"); @@ -1314,6 +1312,8 @@ export default function SeatsPage() { } const isBed = + coach.isBedCoach === true || + coach.rooms?.length > 0 || coach.seatClass?.toLowerCase().includes("bed") || coach.mode?.toLowerCase().includes("bed"); diff --git a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx index 3b6c19336..6422e12fa 100644 --- a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx @@ -4,7 +4,6 @@ import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; import { useEffect, useState } from "react"; -import { LanguageSwitcher } from "./LanguageSwitcher"; export default function AppHeader() { const [isOpen, setIsOpen] = useState(false); @@ -72,9 +71,6 @@ export default function AppHeader() { - {/* Language Switcher */} - - {/* Theme Toggler */} + + + {/* Start button and loading state */} + {!isScanning && !isInitializing && (
)} - ) : ( + )} + + {/* Loading state */} + {isInitializing && (
-
-
{/* Quick Stats */} diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index f8ee29091..b25a40498 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -9,7 +9,7 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { fleetApi, apiClient } from '@/lib/api'; -type Tab = 'types' | 'coaches'; +type Tab = 'types' | 'coaches' | 'utilization'; const getBedLabel = (bedPosition: string | null): string => { if (bedPosition === 'upper') return 'U'; @@ -163,6 +163,12 @@ export default function CoachesPage() { queryFn: () => fleetApi.getCoaches({}), }); + const { data: utilizationData, isLoading: utilizationLoading } = useQuery({ + queryKey: ['coach-utilization'], + queryFn: () => apiClient.get('/fleet/coaches/utilization'), + enabled: activeTab === 'utilization', + }); + // Coach Type Mutations const createCoachTypeMutation = useMutation({ mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data), @@ -547,6 +553,16 @@ export default function CoachesPage() { > Coaches +
{/* Coach Types Tab */} @@ -594,6 +610,44 @@ export default function CoachesPage() { /> )} + + {/* Utilization Tab */} + {activeTab === 'utilization' && (() => { + const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || []; + return ( +
+ {r.sequence} }, + { key: 'number', label: 'Coach', render: (r: any) => {r.number} }, + { key: 'coachType', label: 'Type', render: (r: any) => {r.coachType || 'N/A'} }, + { key: 'totalSeats', label: 'Total Seats', render: (r: any) => {r.totalSeats} }, + { key: 'availableSeats', label: 'Available', render: (r: any) => {r.availableSeats} }, + { key: 'bookedSeats', label: 'Booked', render: (r: any) => {r.bookedSeats} }, + { key: 'blockedSeats', label: 'Blocked', render: (r: any) => {r.blockedSeats} }, + { key: 'maintenanceSeats', label: 'Maintenance', render: (r: any) => {r.maintenanceSeats} }, + { + key: 'utilizationRate', label: 'Utilization', + render: (r: any) => ( +
+
+
+
+ {r.utilizationRate}% +
+ ), + }, + { key: 'totalAssignments', label: 'Assignments', render: (r: any) => {r.totalAssignments} }, + { key: 'totalBookings', label: 'Total Bookings', render: (r: any) => {r.totalBookings} }, + ]} + data={rows} + actions={[]} + loading={utilizationLoading} + emptyMessage="No coach utilization data available" + /> +
+ ); + })()}
{/* Delete Confirmation */} diff --git a/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx b/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx index 218fbd832..fdd61ef19 100644 --- a/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx @@ -2,187 +2,121 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Settings, Play, Square, Trash2, TestTube, History, Download } from 'lucide-react'; +import { Plus, Edit, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { apiClient } from '@/lib/api-client'; +import { formatDateTime } from '@/lib/utils'; -interface FareConfiguration { - id: string; - name: string; - description?: string; - effective_date: string; - expiry_date?: string; - is_active: boolean; - is_default: boolean; - created_by?: string; - approved_by?: string; - approved_at?: string; - created_at: string; - updated_at: string; - rate_rules_count: number; - components_count: number; - age_rules_count: number; -} - -interface SystemStatus { - configurableFaresEnabled: boolean; - rolloutPercentage: number; - totalConfigurations: number; - activeConfiguration: string | null; - activeConfigurationName: string | null; - systemReady: boolean; -} - -interface FareTestResult { - baseFareMinor: number; - componentsTotal: number; - finalTotalMinor: number; - breakdown?: Array<{ - description: string; - runningTotal: number; - }>; -} - -export default function ConfigurableFarePage() { - const [showCreateModal, setShowCreateModal] = useState(false); - const [showTestModal, setShowTestModal] = useState(false); - const [selectedConfig, setSelectedConfig] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; config: FareConfiguration | null }>({ isOpen: false, config: null }); +export default function FareManagementPage() { + const [filters, setFilters] = useState({ scheduleId: '' }); + const [showModal, setShowModal] = useState(false); + const [editingRule, setEditingRule] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; rule: any | null; error?: string }>({ isOpen: false, rule: null }); + const [formError, setFormError] = useState(null); const queryClient = useQueryClient(); - // Queries - const { data: configurations = [], isLoading: configsLoading } = useQuery({ - queryKey: ['fare-configurations'], - queryFn: () => apiClient.get('/admin/fare-configurations'), - }); - - const { data: systemStatus } = useQuery({ - queryKey: ['fare-system-status'], - queryFn: () => apiClient.get('/admin/fare-migration/status'), - }); - - // Mutations - const activateMutation = useMutation({ - mutationFn: (id: string) => apiClient.post(`/admin/fare-configurations/${id}/activate`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); - queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); + const { data: fareRules, isLoading } = useQuery({ + queryKey: ['fare-rules', filters], + queryFn: async () => { + const params = new URLSearchParams(); + if (filters.scheduleId) params.append('scheduleId', filters.scheduleId); + const res = await apiClient.get(`/schedules/fares?${params}`); + return Array.isArray(res) ? res : (res as any)?.items || (res as any)?.data || []; }, }); + const { data: schedulesData } = useQuery({ + queryKey: ['schedules'], + queryFn: () => apiClient.get('/schedules'), + }); + + const { data: seatClassesData } = useQuery({ + queryKey: ['seat-classes'], + queryFn: () => apiClient.get('/fleet/classes'), + }); + + const schedules = Array.isArray(schedulesData) ? schedulesData : (schedulesData as any)?.items || []; + const seatClasses = Array.isArray(seatClassesData) ? seatClassesData : (seatClassesData as any)?.items || (seatClassesData as any)?.data || []; + + const createMutation = useMutation({ + mutationFn: (data: any) => apiClient.post('/schedules/fares', data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['fare-rules'] }); setShowModal(false); setEditingRule(null); setFormError(null); }, + onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to save fare rule'), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/schedules/fares/${id}`, data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['fare-rules'] }); setShowModal(false); setEditingRule(null); setFormError(null); }, + onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to update fare rule'), + }); + const deleteMutation = useMutation({ - mutationFn: (id: string) => apiClient.delete(`/admin/fare-configurations/${id}`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); - setDeleteConfirm({ isOpen: false, config: null }); - }, + mutationFn: (id: string) => apiClient.delete(`/schedules/fares/${id}`), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['fare-rules'] }); setDeleteConfirm({ isOpen: false, rule: null }); }, + onError: (e: any) => setDeleteConfirm(prev => ({ ...prev, error: e?.response?.data?.message || e?.message || 'Failed to delete' })), }); - const toggleSystemMutation = useMutation({ - mutationFn: (enabled: boolean) => - enabled - ? apiClient.post('/admin/fare-configurations/system/enable-configurable-fares', { rolloutPercentage: 100 }) - : apiClient.post('/admin/fare-configurations/system/disable-configurable-fares'), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); - }, - }); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + const fd = new FormData(e.currentTarget); + const payload: any = { + seatClassId: fd.get('seatClassId') as string, + baseFareMinor: Math.round(parseFloat(fd.get('baseFareMinor') as string) * 100), + validFrom: new Date(fd.get('validFrom') as string).toISOString(), + }; + const scheduleId = fd.get('scheduleId') as string; + const nationality = fd.get('nationality') as string; + const passengerCategory = fd.get('passengerCategory') as string; + const validUntil = fd.get('validUntil') as string; + if (scheduleId) payload.scheduleId = scheduleId; + if (nationality) payload.nationality = nationality; + if (passengerCategory) payload.passengerCategory = passengerCategory; + if (validUntil) payload.validUntil = new Date(validUntil).toISOString(); - const setupSystemMutation = useMutation({ - mutationFn: () => apiClient.post('/admin/fare-migration/complete-setup', { - activateNewFormula: true, - enableFeature: true - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); - queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); - }, - }); - - const handleActivate = async (config: FareConfiguration) => { - await activateMutation.mutateAsync(config.id); - }; - - const handleDelete = (config: FareConfiguration) => { - setDeleteConfirm({ isOpen: true, config }); - }; - - const confirmDelete = async () => { - if (deleteConfirm.config) { - await deleteMutation.mutateAsync(deleteConfirm.config.id); + if (editingRule) { + await updateMutation.mutateAsync({ id: editingRule.id, data: payload }); + } else { + await createMutation.mutateAsync(payload); } }; - const handleTest = (config: FareConfiguration) => { - setSelectedConfig(config); - setShowTestModal(true); - }; - const columns = [ { - key: 'name', - label: 'Configuration Name', - sortable: true, - render: (config: FareConfiguration) => ( -
-
{config.name}
- {config.description && ( -
{config.description}
- )} -
- ), + key: 'seatClass', label: 'Seat Class', + render: (r: any) => {r.seatClass?.name || r.seatClassId}, }, { - key: 'status', - label: 'Status', - render: (config: FareConfiguration) => ( -
- - {config.is_active ? 'Active' : 'Inactive'} - - {config.is_default && ( - Default - )} -
- ), + key: 'schedule', label: 'Schedule', + render: (r: any) => r.trip + ? {r.trip.originStation?.name} → {r.trip.destinationStation?.name}
{r.trip.departureAt ? new Date(r.trip.departureAt).toLocaleDateString() : ''}
+ : All schedules, }, { - key: 'rules', - label: 'Rules Count', - render: (config: FareConfiguration) => ( -
-
{config.rate_rules_count} rate rules
-
{config.components_count} components
-
{config.age_rules_count} age rules
-
- ), + key: 'passengerCategory', label: 'Category', + render: (r: any) => r.passengerCategory + ? {r.passengerCategory} + : All, }, { - key: 'dates', - label: 'Validity Period', - render: (config: FareConfiguration) => ( -
-
From: {new Date(config.effective_date).toLocaleDateString()}
- {config.expiry_date && ( -
Until: {new Date(config.expiry_date).toLocaleDateString()}
- )} -
- ), + key: 'nationality', label: 'Nationality', + render: (r: any) => {r.nationality || 'All'}, }, { - key: 'created_at', - label: 'Created', - sortable: true, - render: (config: FareConfiguration) => ( -
-
{new Date(config.created_at).toLocaleDateString()}
- {config.created_by && ( -
by {config.created_by}
- )} + key: 'baseFareMinor', label: 'Base Fare (ETB)', + render: (r: any) => {(r.baseFareMinor / 100).toFixed(2)}, + }, + { + key: 'validity', label: 'Validity', + render: (r: any) => ( +
+
From: {formatDateTime(r.validFrom)}
+ {r.validUntil &&
Until: {formatDateTime(r.validUntil)}
} + {!r.validUntil &&
No expiry
}
), }, @@ -190,24 +124,12 @@ export default function ConfigurableFarePage() { const actions = [ { - label: 'Activate', - onClick: handleActivate, - variant: 'secondary' as const, - icon: Play, - show: (config: FareConfiguration) => !config.is_active, + label: 'Edit', icon: Edit, variant: 'secondary' as const, + onClick: (r: any) => { setEditingRule(r); setFormError(null); setShowModal(true); }, }, { - label: 'Test', - onClick: handleTest, - variant: 'secondary' as const, - icon: TestTube, - }, - { - label: 'Delete', - onClick: handleDelete, - variant: 'danger' as const, - icon: Trash2, - show: (config: FareConfiguration) => !config.is_active, + label: 'Delete', icon: Trash2, variant: 'danger' as const, + onClick: (r: any) => setDeleteConfirm({ isOpen: true, rule: r }), }, ]; @@ -215,321 +137,107 @@ export default function ConfigurableFarePage() {
-

Configurable Fare Management

-

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

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

Fare Management

+

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

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

System Control

-

- Enable or disable the configurable fare system globally -

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

Fare Configurations

-

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

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

Leave blank to apply to all passengers

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

Calculation Result

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

Configuration Form

-

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

- - Close for Now - -
-
- ); -} \ No newline at end of file diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index d06d9e98e..32efb080d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -49,7 +49,7 @@ export default function SchedulesPage() { const [showEditModal, setShowEditModal] = useState(false); const [editingSchedule, setEditingSchedule] = useState(null); const [selectedSchedules, setSelectedSchedules] = useState>(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 } ); const [error, setError] = useState(null); @@ -149,6 +149,10 @@ export default function SchedulesPage() { onSuccess: () => { 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({ @@ -159,6 +163,10 @@ export default function SchedulesPage() { queryClient.invalidateQueries({ queryKey: ['schedules'] }); 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) => { @@ -227,13 +235,18 @@ export default function SchedulesPage() { }; const confirmDelete = async () => { - if (deleteConfirm.isBulk) { - const ids = deleteConfirm.item as string[]; - await bulkDeleteMutation.mutateAsync(ids); - } else if (deleteConfirm.item) { - await deleteScheduleMutation.mutateAsync(deleteConfirm.item.id); + setDeleteConfirm(prev => ({ ...prev, error: undefined })); + try { + if (deleteConfirm.isBulk) { + const ids = deleteConfirm.item as string[]; + 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) => { @@ -531,7 +544,9 @@ export default function SchedulesPage() { } confirmText="Delete" 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." /> (null); + const [showMaintenanceModal, setShowMaintenanceModal] = useState(false); + const [maintenanceReason, setMaintenanceReason] = useState(''); const queryClient = useQueryClient(); 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 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) => { setSelectedCoach(coach); setShowBlockCoachModal(true); @@ -182,6 +211,7 @@ export default function SeatsPage() { }; const getSeatStatus = (seat: any) => { + if (seat.status === 'UNDER_MAINTENANCE') return 'UNDER_MAINTENANCE'; if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED'; if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED'; if (seat.status === 'HELD') return 'HELD'; @@ -194,6 +224,7 @@ export default function SeatsPage() { case 'BOOKED': return 'bg-red-500'; case 'HELD': return 'bg-yellow-500'; case 'BLOCKED': return 'bg-gray-500'; + case 'UNDER_MAINTENANCE': return 'bg-orange-500'; default: return 'bg-gray-300'; } }; @@ -265,6 +296,8 @@ export default function SeatsPage() { handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} handleUndoRemove={handleUndoRemove} + handleSetMaintenance={handleSetMaintenance} + handleClearMaintenance={handleClearMaintenance} hideNumber={true} /> ))} @@ -358,6 +391,8 @@ export default function SeatsPage() { handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} handleUndoRemove={handleUndoRemove} + handleSetMaintenance={handleSetMaintenance} + handleClearMaintenance={handleClearMaintenance} hideNumber={true} /> ))} @@ -378,6 +413,8 @@ export default function SeatsPage() { handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} handleUndoRemove={handleUndoRemove} + handleSetMaintenance={handleSetMaintenance} + handleClearMaintenance={handleClearMaintenance} hideNumber={true} /> ))} @@ -532,6 +569,10 @@ export default function SeatsPage() {
Blocked
+
+
+ Under Maintenance +
Removed @@ -782,6 +823,44 @@ export default function SeatsPage() {
+ + { setShowMaintenanceModal(false); setSelectedSeat(null); setMaintenanceReason(''); }} + title="Set Seat Under Maintenance" + size="md" + > +
+

+ Set seat {selectedSeat?.seatNumber} to Under Maintenance +

+
+ +