diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fcd560a95..72ad6de66 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -170,13 +170,36 @@ jobs: - name: Build ${{ matrix.service }} run: | set -euo pipefail + IMAGE_TAG="${COMPOSE_PROJECT_NAME}-${{ matrix.service }}:${GITHUB_SHA::8}" docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}" + # Tag with git SHA for rollback capability + CONTAINER_NAME=$(docker compose --project-name "${COMPOSE_PROJECT_NAME}" config --services | grep "${{ matrix.service }}" | head -1) + docker tag "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}" "${IMAGE_TAG}" 2>/dev/null || true + echo "IMAGE_TAG=${IMAGE_TAG}" >> "${GITHUB_ENV}" - name: Deploy ${{ matrix.service }} run: | set -euo pipefail docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate + - name: Verify deployment health + if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + run: | + set -euo pipefail + PORT=$(grep '^PORT=' "${SERVICE_ENV_FILE}" | cut -d= -f2) + echo "Waiting for service to become healthy on port ${PORT}..." + for i in $(seq 1 12); do + if wget -qO- "http://localhost:${PORT}/health/ready" 2>/dev/null | grep -q '"status":"ok"'; then + echo "Service is healthy." + exit 0 + fi + echo "Attempt ${i}/12 — not ready yet, waiting 10s..." + sleep 10 + done + echo "Service failed health check after 120s — rolling back" + docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate || true + exit 1 + - name: Remove npm credentials from workspace if: always() run: rm -f .npmrc .npmrc_temp diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 2818dcd97..463cea535 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -121,13 +121,59 @@ This ensures `docker ps` shows `0.0.0.0:->/tcp` with matching ports. ### Runtime -The final image runs: +The final image uses Next.js `output: 'standalone'` and runs: ```bash -npx next start +node server.js ``` -Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose) to determine which port to listen on. +Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose). The standalone output bundles only the required `node_modules`, producing a significantly smaller image than a full `pnpm deploy`. + +## Rollback Procedure + +Each build is tagged with the short git SHA (`${COMPOSE_PROJECT_NAME}-:`). + +### Rollback a single service + +```bash +# 1. Find the last known-good image tag +docker images | grep passenger-api + +# 2. Re-tag it as the current image +docker tag edr-passenger-main-passenger-api: edr-passenger-main-passenger-api:latest + +# 3. Restart the container from the previous image +docker compose --project-name edr-passenger-main up -d passenger-api --force-recreate +``` + +### Rollback via re-run + +Alternatively, trigger a `workflow_dispatch` on the last known-good commit SHA from the GitHub Actions UI — this rebuilds and redeploys that exact commit. + +## Production Security Checklist + +Before deploying to production, verify: + +- [ ] `JWT_SECRET`, `JWT_ACCESS_TOKEN_SECRET`, `JWT_REFRESH_TOKEN_SECRET` are set to random 32+ char strings (`openssl rand -hex 32`) +- [ ] `DATABASE_URL` includes `?sslmode=require&connection_limit=10` +- [ ] `WAAFI_INSECURE_TLS` is `false` (app will refuse to start if `true` in production) +- [ ] `NODE_ENV=production` is set +- [ ] `GITHUB_PACKAGE_TOKEN` is a scoped read-only token, not a personal admin token +- [ ] No `.env` files are committed to the repository (`git status` should show none) + +## Data Retention Policy + +The `TasksService` runs a daily purge cron at 02:00 EAT that automatically deletes: + +| Table | Retention | +|---|---| +| `OtpCode` | 1 hour after expiry or verification | +| `FaydaVerificationSession` | 1 hour after expiry or completion | +| `AuditLog` | 365 days | +| `PaymentWebhookEvent` | 90 days | +| `GateValidationLog` | 180 days | + +No manual intervention is required. Monitor the `TasksService` log output for purge counts. ## GitHub Actions Deployment Flow @@ -147,9 +193,10 @@ For each service: - Computes branch slug and sets: - `COMPOSE_PROJECT_NAME=-` - Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`. -- Runs: - - `docker compose --project-name "$COMPOSE_PROJECT_NAME" build ` - - `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d ` +- For `passenger-api` and `payment-api`: builds and runs the migration image as a gated step before the app image. +- Builds the service image and tags it with the short git SHA. +- Runs `docker compose up -d --force-recreate`. +- For API services: polls `GET /health/ready` every 10s for up to 120s. Fails the job if the service does not become healthy. - Cleans `.npmrc`/`.npmrc_temp`. ## Branch/Environment Isolation diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 7da5e2d66..b9f0a74c0 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,21 +1,31 @@ -import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Query, + Res, +} from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; -import { FreightAdmin } from "../../common/booking-guards"; +import { BookingView } from "../../common/booking-guards"; import { BillingService } from "./billing.service"; +import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @ApiTags("billing") @Controller("billing") -@FreightAdmin() +@BookingView() @ApiBearerAuth() export class BillingController { - constructor(private readonly billingService: BillingService) { } + constructor(private readonly billingService: BillingService) {} @Get("invoices") - @ApiOperation({ summary: "List all invoices" }) - findAll() { - return this.billingService.findAll(); + @ApiOperation({ + summary: "List invoices (paginated, filterable by company/status/search)", + }) + findAll(@Query() query: FilterInvoiceDto) { + return this.billingService.findAllPaginated(query); } @Get("invoices/:id") diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 82a47fa29..536129122 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -125,7 +125,7 @@ export class BillingService { private readonly payment: PaymentService, private readonly companies: CompaniesService, private readonly invoiceDocuments: InvoiceDocumentService, - ) { } + ) {} // ── Reads ────────────────────────────────────────────────────────────────── @@ -134,9 +134,56 @@ export class BillingService { return this.invoices.findAll({ order: { issuedAt: "DESC" } }); } + /** + * Paginated invoice list for the backoffice — optionally narrowed to a + * company (customer detail "Invoices" tab) and/or status/search (global + * invoices page). + */ + async findAllPaginated( + filter: { + companyId?: string; + status?: Freight.InvoiceStatus; + search?: string; + page?: number; + pageSize?: number; + } = {}, + ): Promise<{ items: Invoice[]; total: number }> { + const page = filter.page && filter.page > 0 ? filter.page : 1; + const pageSize = + filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; + + const qb = this.dataSource + .getRepository(Invoice) + .createQueryBuilder("invoice") + .leftJoinAndSelect("invoice.company", "company") + .orderBy("invoice.issuedAt", "DESC") + .skip((page - 1) * pageSize) + .take(pageSize); + + if (filter.companyId) { + qb.andWhere("invoice.companyId = :companyId", { + companyId: filter.companyId, + }); + } + if (filter.status) { + qb.andWhere("invoice.status = :status", { status: filter.status }); + } + if (filter.search) { + qb.andWhere( + "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", + { search: `%${filter.search}%` }, + ); + } + + const [items, total] = await qb.getManyAndCount(); + return { items, total }; + } + /** Invoice header plus its line items. */ async findById(id: string): Promise { - const invoice = await this.invoices.findById(id); + const invoice = await this.invoices.findById(id, { + relations: { company: true, companyProfile: true }, + }); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); const lines = await this.invoiceLines.findAll({ where: { invoiceId: id }, @@ -375,7 +422,7 @@ export class BillingService { input.dueAt ?? new Date( Date.now() + - (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, + (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, ); const invoiceNumber = await this.nextInvoiceNumber(mg); diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts new file mode 100644 index 000000000..e8942d586 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -0,0 +1,42 @@ +import { Freight } from "@edr/types"; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { + IsIn, + IsInt, + IsOptional, + IsString, + IsUUID, + Min, +} from "class-validator"; + +export class FilterInvoiceDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + pageSize?: number = 20; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + companyId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ enum: Freight.InvoiceStatus }) + @IsOptional() + @IsIn(Object.values(Freight.InvoiceStatus)) + status?: Freight.InvoiceStatus; +} diff --git a/apps/edr-freight-api/src/modules/payment/payment.repository.ts b/apps/edr-freight-api/src/modules/payment/payment.repository.ts index 25c3bdd6b..96a4994ea 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.repository.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.repository.ts @@ -93,9 +93,8 @@ export class PaymentRepository { p.paid_at, p.created_at FROM freight.payments p - JOIN freight.bookings b ON b.id = p.ref_id + JOIN freight.bookings b ON b.id = p.ref_id::uuid WHERE b.company_id = $1 - AND p.deleted_at IS NULL AND b.deleted_at IS NULL ORDER BY p.created_at DESC`, [companyId], diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 8c2453e4d..b90dd95f5 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -12,6 +12,7 @@ import { PackageCheck, PackageOpen, Paperclip, + Receipt, Send, Settings, ShieldCheck, @@ -32,7 +33,11 @@ import { useParams, } from "react-router-dom"; -import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; +import { + FreightDashboardLayout, + type SidebarItem, + type SidebarSection, +} from "@/components/layout"; import { useAuth } from "./auth/useAuth"; import LoadingScreen from "./components/LoadingScreen"; import LoginPage from "./pages/auth/LoginPage"; @@ -53,6 +58,8 @@ import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; +import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; +import InvoicesPage from "./pages/invoices/InvoicesPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; @@ -61,7 +68,10 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage import PaymentsPage from "./pages/payments/PaymentsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import { RequirePermission } from "./components/auth/RequirePermission"; -import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; +import { + FREIGHT_PERMS, + hasPermission as hasFreightPermission, +} from "./lib/permissions"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; @@ -144,6 +154,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.bookings.view, }, + { + label: "Invoices", + href: "/dashboard/invoices", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, ...demoItems, ], }, @@ -506,7 +522,10 @@ const App = () => { } /> } /> } /> - } /> + } + /> }> } /> } /> @@ -522,8 +541,27 @@ const App = () => { /> } /> } /> + + + + } + /> + + + + } + /> } /> - } /> + } + /> } @@ -536,7 +574,9 @@ const App = () => { + } @@ -570,7 +610,9 @@ const App = () => { + } @@ -578,7 +620,9 @@ const App = () => { + } @@ -586,7 +630,9 @@ const App = () => { + } @@ -618,12 +664,20 @@ const App = () => { } /> - } /> - } /> + } + /> + } + /> + } @@ -631,7 +685,9 @@ const App = () => { + } @@ -644,7 +700,9 @@ const App = () => { + } @@ -655,155 +713,174 @@ const App = () => { /> } /> } /> - } /> + } + /> } /> } /> } /> } /> } /> } /> - } /> - } /> + } + /> + } + /> } /> } /> - } /> - } /> + } + /> + } + /> - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> } + element={ + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + } /> { {/* Legacy embedded user management routes */} } /> } /> - } /> + } + /> {/* } /> */} - } /> + } + /> } /> { } + element={ + + } /> { } /> } /> - } /> - } /> + } + /> + } + /> { } + element={ + + } + /> + } /> - } /> } /> } /> @@ -1026,9 +1122,7 @@ const App = () => { /** Redirect removed milestones page to document clearance. */ function BookingMilestonesRedirect() { const { id } = useParams(); - return ( - - ); + return ; } /** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */ diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index e108d1b1f..31c7efc52 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -1,3 +1,4 @@ +import type { Freight } from "@edr/types"; import { Badge, Button, Group, Tooltip } from "@mantine/core"; import { useMutation } from "@tanstack/react-query"; import { api } from "@/services/api"; @@ -88,7 +89,13 @@ export function ProfileChips({ }) { if (!profiles.length) { return ( - + No profiles ); @@ -118,7 +125,13 @@ export function ProfileChips({ ))} {extra > 0 ? ( - + +{extra} ) : null} @@ -169,7 +182,11 @@ const BOOKING_STATUS_COLOR: Record = { CANCELLED: "red", }; -export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) { +export function BookingStatusBadge({ + status, +}: { + status: CustomerBookingStatus; +}) { return ( = { refunded: "grape", }; -export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) { +export function PaymentStatusBadge({ + status, +}: { + status: CustomerPaymentStatus; +}) { return ( = { + DRAFT: "gray", + ISSUED: "cyan", + PENDING: "yellow", + PARTIALLY_PAID: "orange", + PAID: "edr-green", + OVERDUE: "red", + CANCELLED: "gray", + REFUNDED: "grape", + EXPIRED: "red", +}; + +export function InvoiceStatusBadge({ + status, +}: { + status: Freight.InvoiceStatus; +}) { + return ( + + {humanize(status)} + + ); +} + /** * Inline approval action buttons for a profile row. * Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate @@ -225,8 +278,7 @@ export function ProfileApprovalActions({ api.customers.setProfileStatus.mutationOptions(), ); - const act = (next: ProfileStatus) => - mutate({ profileId, status: next }); + const act = (next: ProfileStatus) => mutate({ profileId, status: next }); if (status === "pending") { return ( diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 61b75767a..620b5ec35 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -2,6 +2,7 @@ export { BookingStatusBadge, CompanyStatusBadge, CompanyTypeBadge, + InvoiceStatusBadge, PaymentStatusBadge, ProfileApprovalActions, ProfileChips, diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index eecee533b..70a134396 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -3,6 +3,7 @@ import type { BookingListFilter } from "@/services/bookings.service"; import type { ContractListFilter } from "@/services/contracts.service"; import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service"; import type { CompanyListFilter } from "@/types/customer"; +import type { InvoiceListFilter } from "@/types/invoice"; import type { RuleEngineResourceSlug } from "@/types/rule-engine"; import type { TrainScheduleFilters } from "@/types/trainScheduling"; @@ -16,7 +17,8 @@ export const QUERY_KEYS = { ROOT: ["file-upload-settings"] as const, list: () => ["file-upload-settings", "list"] as const, byId: (id: string) => ["file-upload-settings", "detail", id] as const, - byCode: (code: string) => ["file-upload-settings", "by-code", code] as const, + byCode: (code: string) => + ["file-upload-settings", "by-code", code] as const, }, DROPDOWN_SETTINGS: { @@ -33,10 +35,18 @@ export const QUERY_KEYS = { ["customers", "list", filter ?? {}] as const, byId: (id: string) => ["customers", "detail", id] as const, bookings: (id: string) => ["customers", "detail", id, "bookings"] as const, - documents: (id: string) => ["customers", "detail", id, "documents"] as const, + documents: (id: string) => + ["customers", "detail", id, "documents"] as const, payments: (id: string) => ["customers", "detail", id, "payments"] as const, }, + INVOICES: { + ROOT: ["invoices"] as const, + list: (filter?: InvoiceListFilter) => + ["invoices", "list", filter ?? {}] as const, + byId: (id: string) => ["invoices", "detail", id] as const, + }, + BOOKINGS: { ROOT: ["bookings"] as const, list: (filter?: BookingListFilter) => @@ -80,7 +90,12 @@ export const QUERY_KEYS = { TRAIN_SCHEDULING: { ROOT: ["train-scheduling"] as const, eligible: (freightType?: string, filters?: TrainScheduleFilters) => - ["train-scheduling", "eligible-bookings", freightType ?? "CONTAINER", filters ?? {}] as const, + [ + "train-scheduling", + "eligible-bookings", + freightType ?? "CONTAINER", + filters ?? {}, + ] as const, locomotives: (routeId?: string) => ["train-scheduling", "locomotives", routeId ?? "all"] as const, stations: () => ["train-scheduling", "stations"] as const, @@ -98,31 +113,37 @@ export const QUERY_KEYS = { FLEET: { ROOT: ["fleet"] as const, - list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const, + list: (resource: FleetResourceSlug | string) => + ["fleet", "list", resource] as const, }, VEHICLES: { ROOT: ["vehicles"] as const, - list: (filter?: Record) => ["vehicles", "list", filter ?? {}] as const, + list: (filter?: Record) => + ["vehicles", "list", filter ?? {}] as const, byId: (id: string) => ["vehicles", "detail", id] as const, }, FIRST_MILE: { ROOT: ["first-mile"] as const, - list: (filter?: Record) => ["first-mile", "list", filter ?? {}] as const, + list: (filter?: Record) => + ["first-mile", "list", filter ?? {}] as const, byId: (id: string) => ["first-mile", "detail", id] as const, }, LAST_MILE: { ROOT: ["last-mile"] as const, - list: (filter?: Record) => ["last-mile", "list", filter ?? {}] as const, + list: (filter?: Record) => + ["last-mile", "list", filter ?? {}] as const, byId: (id: string) => ["last-mile", "detail", id] as const, }, RULE_ENGINE: { ROOT: ["rule-engine"] as const, - list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) => - ["rule-engine", "list", resource, params ?? {}] as const, + list: ( + resource: RuleEngineResourceSlug | string, + params?: RuleEngineListParams, + ) => ["rule-engine", "list", resource, params ?? {}] as const, detail: (resource: RuleEngineResourceSlug | string, id: string) => ["rule-engine", "detail", resource, id] as const, chain: ["rule-engine", "approval-rules", "chain"] as const, @@ -136,27 +157,39 @@ export const QUERY_KEYS = { OVERVIEW: { ROOT: ["overview"] as const, - dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const, - bookingsTab: (range?: string) => ["overview", "bookings", range ?? "30d"] as const, - contractsTab: (range?: string) => ["overview", "contracts", range ?? "30d"] as const, - billingTab: (range?: string) => ["overview", "billing", range ?? "30d"] as const, + dashboard: (range?: string) => + ["overview", "dashboard", range ?? "30d"] as const, + bookingsTab: (range?: string) => + ["overview", "bookings", range ?? "30d"] as const, + contractsTab: (range?: string) => + ["overview", "contracts", range ?? "30d"] as const, + billingTab: (range?: string) => + ["overview", "billing", range ?? "30d"] as const, operationsTab: () => ["overview", "operations"] as const, - customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const, - staffTab: (range?: string) => ["overview", "staff", range ?? "30d"] as const, + customersTab: (range?: string) => + ["overview", "customers", range ?? "30d"] as const, + staffTab: (range?: string) => + ["overview", "staff", range ?? "30d"] as const, }, FUEL: { ROOT: ["fuel"] as const, - purchases: (vehicleId?: string) => ["fuel", "purchases", vehicleId ?? "all"] as const, - stats: (vehicleId?: string) => ["fuel", "stats", vehicleId ?? "all"] as const, + purchases: (vehicleId?: string) => + ["fuel", "purchases", vehicleId ?? "all"] as const, + stats: (vehicleId?: string) => + ["fuel", "stats", vehicleId ?? "all"] as const, }, MAINTENANCE: { ROOT: ["maintenance"] as const, - schedules: (vehicleId?: string) => ["maintenance", "schedules", vehicleId ?? "all"] as const, - upcoming: (vehicleId?: string) => ["maintenance", "upcoming", vehicleId ?? "all"] as const, - history: (vehicleId?: string) => ["maintenance", "history", vehicleId ?? "all"] as const, - stats: (vehicleId?: string) => ["maintenance", "stats", vehicleId ?? "all"] as const, + schedules: (vehicleId?: string) => + ["maintenance", "schedules", vehicleId ?? "all"] as const, + upcoming: (vehicleId?: string) => + ["maintenance", "upcoming", vehicleId ?? "all"] as const, + history: (vehicleId?: string) => + ["maintenance", "history", vehicleId ?? "all"] as const, + stats: (vehicleId?: string) => + ["maintenance", "stats", vehicleId ?? "all"] as const, }, FINANCIAL_REPORTS: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index c59be5d7d..083dd0efc 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -13,7 +13,7 @@ export const URL_CONSTANTS = { BASE: "/users", BY_ID: (id: string | number) => `/users/${id}`, SET_PASSWORD: "/api/auth/set-password", - ME: "/api/auth/me" + ME: "/api/auth/me", }, ROLES: { @@ -74,9 +74,18 @@ export const URL_CONSTANTS = { STATS: "/companies/stats", BY_ID: (id: string | number) => `/companies/${id}`, DOCUMENTS: (id: string) => `/companies/${id}/documents`, - PROFILE_STATUS: (profileId: string) => `/companies/company-profiles/${profileId}/status`, - BOOKINGS_CUSTOMER_VIEW: (id: string) => `/bookings/by-company/${id}/customer-view`, - PAYMENTS_CUSTOMER_VIEW: (id: string) => `/payments/by-company/${id}/customer-view`, + PROFILE_STATUS: (profileId: string) => + `/companies/company-profiles/${profileId}/status`, + BOOKINGS_CUSTOMER_VIEW: (id: string) => + `/bookings/by-company/${id}/customer-view`, + PAYMENTS_CUSTOMER_VIEW: (id: string) => + `/payments/by-company/${id}/customer-view`, + }, + + BILLING: { + INVOICES: "/billing/invoices", + INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`, + INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`, }, CUSTOMERS_API: { @@ -124,15 +133,21 @@ export const URL_CONSTANTS = { CANCEL: (id: string) => `/bookings/${id}/cancel`, CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`, CLEARANCE: (id: string) => `/bookings/${id}/clearance`, - CLEARANCE_DECLARATION: (id: string) => `/bookings/${id}/clearance/declaration`, + CLEARANCE_DECLARATION: (id: string) => + `/bookings/${id}/clearance/declaration`, CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`, CLEARANCE_FINALIZE_PRE: (id: string) => `/bookings/${id}/clearance/finalize-pre-clearance`, - CLEARANCE_TRANSIT_PERMIT: (id: string) => `/bookings/${id}/clearance/transit-permit`, - CLEARANCE_DELIVERY_ORDER: (id: string) => `/bookings/${id}/clearance/delivery-order`, - CLEARANCE_RELEASE_ORDER: (id: string) => `/bookings/${id}/clearance/release-order`, - CLEARANCE_RO_AMENDMENT: (id: string) => `/bookings/${id}/clearance/ro-amendment`, - CLEARANCE_EXPORT_RELEASE: (id: string) => `/bookings/${id}/clearance/export-release`, + CLEARANCE_TRANSIT_PERMIT: (id: string) => + `/bookings/${id}/clearance/transit-permit`, + CLEARANCE_DELIVERY_ORDER: (id: string) => + `/bookings/${id}/clearance/delivery-order`, + CLEARANCE_RELEASE_ORDER: (id: string) => + `/bookings/${id}/clearance/release-order`, + CLEARANCE_RO_AMENDMENT: (id: string) => + `/bookings/${id}/clearance/ro-amendment`, + CLEARANCE_EXPORT_RELEASE: (id: string) => + `/bookings/${id}/clearance/export-release`, CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue", CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue", }, @@ -157,7 +172,8 @@ export const URL_CONSTANTS = { CLEARANCE_OUTPUT_DOCUMENTS: (id: string) => `/contracts/${id}/clearance/output-documents`, CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`, - CLEARANCE_DECLARATION: (id: string) => `/contracts/${id}/clearance/declaration`, + CLEARANCE_DECLARATION: (id: string) => + `/contracts/${id}/clearance/declaration`, CLEARANCE_DUTY: (id: string) => `/contracts/${id}/clearance/duty`, CLEARANCE_DUTY_SLIP: (id: string) => `/contracts/${id}/clearance/duty-slip`, CLEARANCE_FINALIZE_PRE: (id: string) => @@ -247,7 +263,7 @@ export const URL_CONSTANTS = { }, ROUTES: { - BASE: '/routes', + BASE: "/routes", BY_ID: (id: string) => `/routes/${id}`, }, @@ -261,12 +277,15 @@ export const URL_CONSTANTS = { BATCH_BOARD_DETAIL: (scheduleId: string) => `/train-scheduling/batch-board/${scheduleId}`, RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`, + RUN_ALLOCATION: (id: string) => + `/train-scheduling/schedules/${id}/run-allocation`, DOC_REVIEW_COMPLETE: (id: string) => `/train-scheduling/schedules/${id}/doc-review-complete`, RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`, ASSIGN_UNASSIGNED_BOOKING: (id: string) => `/train-scheduling/schedules/${id}/assign-unassigned-booking`, - BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`, + BOOKING_WINDOW: (id: string) => + `/train-scheduling/schedules/${id}/booking-window`, MARK_BOOKING_PAID: (bookingId: string) => `/train-scheduling/bookings/${bookingId}/mark-paid`, EXPIRE_BOOKING: (bookingId: string) => @@ -275,12 +294,14 @@ export const URL_CONSTANTS = { `/train-scheduling/bookings/${bookingId}/move-schedule`, GLOBAL_RULES: "/train-scheduling/global-rules", PREVIEW: "/train-scheduling/preview", - ASSIGN_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/assign-bookings`, + ASSIGN_BOOKINGS: (id: string) => + `/train-scheduling/schedules/${id}/assign-bookings`, CONTAINER: { ELIGIBLE_BOOKINGS: "/train-scheduling/container/eligible-bookings", PREVIEW: "/train-scheduling/container/preview", SCHEDULES: "/train-scheduling/container/schedules", - SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`, + SCHEDULE_BY_ID: (id: string) => + `/train-scheduling/container/schedules/${id}`, ASSIGN_BOOKINGS: (id: string) => `/train-scheduling/container/schedules/${id}/assign-bookings`, CANCEL_SCHEDULE: (id: string) => @@ -319,15 +340,18 @@ export const URL_CONSTANTS = { `/train-scheduling/schedules/${id}/import-djibouti/load-list/document`, EXPORT_LOAD_LIST_DOCUMENT: (id: string) => `/train-scheduling/schedules/${id}/export/load-list/document`, - CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`, + CHECKPOINTS: (id: string) => + `/train-scheduling/schedules/${id}/checkpoints`, ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`, RESCHEDULE_PREVIEW: (id: string) => `/train-scheduling/schedules/${id}/reschedule/preview`, RESCHEDULE_EXECUTE: (id: string) => `/train-scheduling/schedules/${id}/reschedule/execute`, - MAINTENANCE: (id: string) => `/train-scheduling/schedules/${id}/maintenance`, + MAINTENANCE: (id: string) => + `/train-scheduling/schedules/${id}/maintenance`, SCHEDULES: "/train-scheduling/container/schedules", - SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`, + SCHEDULE_BY_ID: (id: string) => + `/train-scheduling/container/schedules/${id}`, CANCEL_SCHEDULE: (id: string) => `/train-scheduling/container/schedules/${id}/cancel`, REMOVE_WAGON_SLOT: (scheduleId: string, wagonId: string) => @@ -375,175 +399,193 @@ export const URL_CONSTANTS = { APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`, APPROVAL_RULES_CHAIN: "/approval-rules/chain", }, - RATE_MATRIX: { - BASE: '/api/rate-matrices', - DRAFT: '/api/rate-matrices/draft', + RATE_MATRIX: { + BASE: "/api/rate-matrices", + DRAFT: "/api/rate-matrices/draft", SUBMIT: (id: string) => `/api/rate-matrices/${id}/submit`, AUTHORIZE: (id: string) => `/api/rate-matrices/${id}/authorize`, - LIST: '/api/rate-matrices', + LIST: "/api/rate-matrices", DETAIL: (id: string) => `/api/rate-matrices/${id}`, }, REFERENCE: { - PORTS: '/api/reference/ports', - CITIES: '/api/reference/cities', - CONTAINER_TYPES: '/api/reference/container-types', - CURRENCIES: '/api/reference/currencies', + PORTS: "/api/reference/ports", + CITIES: "/api/reference/cities", + CONTAINER_TYPES: "/api/reference/container-types", + CURRENCIES: "/api/reference/currencies", }, FACILITIES: { - BASE: '/facilities', + BASE: "/facilities", BY_ID: (id: string) => `/facilities/${id}`, }, WAREHOUSES: { - BASE: '/warehouses', - DASHBOARD: '/warehouses/dashboard', + BASE: "/warehouses", + DASHBOARD: "/warehouses/dashboard", BY_ID: (id: string) => `/warehouses/${id}`, YARDS: (warehouseId: string) => `/warehouses/${warehouseId}/yards`, }, WAREHOUSE_YARDS: { - BASE: '/warehouse-yards', + BASE: "/warehouse-yards", BY_ID: (id: string) => `/warehouse-yards/${id}`, ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`, }, WAREHOUSE_ZONES: { - BASE: '/warehouse-zones', + BASE: "/warehouse-zones", BY_ID: (id: string) => `/warehouse-zones/${id}`, }, WAREHOUSE_INVENTORY: { - BASE: '/warehouse-inventory', - RECEIVE: '/warehouse-inventory/receive', - DASHBOARD_SUMMARY: '/warehouse-inventory/dashboard/summary', - READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading', - INQUIRY: '/warehouse-inventory/inquiry', + BASE: "/warehouse-inventory", + RECEIVE: "/warehouse-inventory/receive", + DASHBOARD_SUMMARY: "/warehouse-inventory/dashboard/summary", + READY_FOR_LOADING: "/warehouse-inventory/ready-for-loading", + INQUIRY: "/warehouse-inventory/inquiry", STORE: (id: string) => `/warehouse-inventory/${id}/store`, INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`, MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`, LOAD: (id: string) => `/warehouse-inventory/${id}/load`, DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`, MOVE: (id: string) => `/warehouse-inventory/${id}/move`, - RESERVE: '/warehouse-inventory/reserve', - ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue', - AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived', - AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready', - UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`, - INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`, - LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons', - BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`, + RESERVE: "/warehouse-inventory/reserve", + ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue", + AUTO_UNLOAD_ARRIVED: "/warehouse-inventory/auto-unload-arrived", + AUTO_LOAD_READY: "/warehouse-inventory/auto-load-ready", + UNLOAD_BOOKING: (bookingId: string) => + `/warehouse-inventory/bookings/${bookingId}/unload`, + INSPECTION_REPORTS: (inventoryId: string) => + `/warehouse-inventory/${inventoryId}/inspection-reports`, + LOADABLE_WAGONS: "/warehouse-inventory/loadable-wagons", + BOOKING_SCHEDULE: (bookingId: string) => + `/warehouse-inventory/booking/${bookingId}/schedule`, MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`, ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`, LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`, // Import branch - MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`, + MARK_READY_PICKUP: (id: string) => + `/warehouse-inventory/${id}/ready-for-pickup`, RELEASE: (id: string) => `/warehouse-inventory/${id}/release`, - RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`, + RELEASE_DOCUMENT: (id: string) => + `/warehouse-inventory/${id}/release-document`, GRN_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/grn-document`, - HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`, + HANDOVER_DOCUMENT: (id: string) => + `/warehouse-inventory/${id}/handover-document`, DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`, // Receive (Import/Export bulk) ELIGIBLE_BOOKINGS: (direction?: string) => direction ? `/warehouse-inventory/eligible-bookings?direction=${direction}` : `/warehouse-inventory/eligible-bookings`, - RECEIVE_BULK: '/warehouse-inventory/receive-bulk', - LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export', - BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected', - RECEIVED_EXPORT: '/warehouse-inventory/received-export', - READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export', - LOADED_EXPORT: '/warehouse-inventory/loaded-export', - BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export', - IMPORT_ARRIVE_QUEUE: '/warehouse-inventory/import/arrive-queue', + RECEIVE_BULK: "/warehouse-inventory/receive-bulk", + LOAD_PASSED_EXPORT: "/warehouse-inventory/load-passed-export", + BULK_MARK_INSPECTED: "/warehouse-inventory/bulk-mark-inspected", + RECEIVED_EXPORT: "/warehouse-inventory/received-export", + READY_TO_LOAD_EXPORT: "/warehouse-inventory/ready-to-load-export", + LOADED_EXPORT: "/warehouse-inventory/loaded-export", + BULK_DISPATCH_EXPORT: "/warehouse-inventory/bulk-dispatch-export", + IMPORT_ARRIVE_QUEUE: "/warehouse-inventory/import/arrive-queue", IMPORT_TRAIN_ITEMS: (scheduleId: string) => `/warehouse-inventory/import/trains/${scheduleId}/items`, - IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings', - IMPORT_UNLOADED_QUEUE: '/warehouse-inventory/import/unloaded-queue', - IMPORT_PICKUP_READY_QUEUE: '/warehouse-inventory/import/pickup-ready-queue', - EXPORT_DJIBOUTI_ARRIVAL_QUEUE: '/warehouse-inventory/export/djibouti-arrival-queue', + IMPORT_AUTO_UNLOAD_ARRIVED: + "/warehouse-inventory/import/auto-unload-arrived-bookings", + IMPORT_UNLOADED_QUEUE: "/warehouse-inventory/import/unloaded-queue", + IMPORT_PICKUP_READY_QUEUE: "/warehouse-inventory/import/pickup-ready-queue", + EXPORT_DJIBOUTI_ARRIVAL_QUEUE: + "/warehouse-inventory/export/djibouti-arrival-queue", EXPORT_DJIBOUTI_TRAIN_ITEMS: (scheduleId: string) => `/warehouse-inventory/export/djibouti-trains/${scheduleId}/items`, - EXPORT_AUTO_UNLOAD_AT_DJIBOUTI: '/warehouse-inventory/export/auto-unload-at-djibouti', + EXPORT_AUTO_UNLOAD_AT_DJIBOUTI: + "/warehouse-inventory/export/auto-unload-at-djibouti", }, WAREHOUSE_LOADINGS: { - BASE: '/warehouse-loadings', + BASE: "/warehouse-loadings", }, WAREHOUSE_INSPECTION: { BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`, - ATTACHMENTS: (id: string) => `/warehouse-inspection-reports/${id}/attachments`, + ATTACHMENTS: (id: string) => + `/warehouse-inspection-reports/${id}/attachments`, }, WAREHOUSE_RULES: { - ALLOCATION: '/warehouse-allocation-rules', + ALLOCATION: "/warehouse-allocation-rules", ALLOCATION_BY_ID: (id: string) => `/warehouse-allocation-rules/${id}`, - ALLOCATION_PREVIEW: '/warehouse-allocation/preview', - FEES: '/warehouse-fee-rules', + ALLOCATION_PREVIEW: "/warehouse-allocation/preview", + FEES: "/warehouse-fee-rules", FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`, - FEE_PREVIEW: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-preview`, + FEE_PREVIEW: (inventoryId: string) => + `/warehouse-inventory/${inventoryId}/fee-preview`, }, WAREHOUSE_INVOICES: { - BASE: '/warehouse-fee-invoices', + BASE: "/warehouse-fee-invoices", BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`, DOCUMENT: (id: string) => `/warehouse-fee-invoices/${id}/document`, RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`, CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`, PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`, PAY_ONLINE: (id: string) => `/warehouse-fee-invoices/${id}/pay-online`, - GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`, - FOR_INVENTORY: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-invoices`, - FOR_BOOKING: (bookingId: string) => `/bookings/${bookingId}/warehouse-fee-invoices`, - GATE_CLEARANCE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/gate-clearance`, + GENERATE: (inventoryId: string) => + `/warehouse-inventory/${inventoryId}/generate-fee-invoice`, + FOR_INVENTORY: (inventoryId: string) => + `/warehouse-inventory/${inventoryId}/fee-invoices`, + FOR_BOOKING: (bookingId: string) => + `/bookings/${bookingId}/warehouse-fee-invoices`, + GATE_CLEARANCE: (inventoryId: string) => + `/warehouse-inventory/${inventoryId}/gate-clearance`, }, INTERCHANGE_DOCUMENTS: { - BASE: '/interchange-documents', + BASE: "/interchange-documents", BY_ID: (id: string) => `/interchange-documents/${id}`, - GENERATE_FROM_SCHEDULE: '/interchange-documents/generate-from-schedule', + GENERATE_FROM_SCHEDULE: "/interchange-documents/generate-from-schedule", ACKNOWLEDGE: (id: string) => `/interchange-documents/${id}/acknowledge`, DISPUTE: (id: string) => `/interchange-documents/${id}/dispute`, CANCEL: (id: string) => `/interchange-documents/${id}/cancel`, }, IMPORT_OPERATIONS: { - DJIBOUTI_INCIDENTS: '/import-operations/djibouti-incidents', + DJIBOUTI_INCIDENTS: "/import-operations/djibouti-incidents", CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`, - CUSTOMS_DOCUMENTS: (bookingId: string) => `/import-operations/customs/${bookingId}/documents`, - CUSTOMS_DECLARATION: (bookingId: string) => `/import-operations/customs/${bookingId}/declaration`, + CUSTOMS_DOCUMENTS: (bookingId: string) => + `/import-operations/customs/${bookingId}/documents`, + CUSTOMS_DECLARATION: (bookingId: string) => + `/import-operations/customs/${bookingId}/declaration`, CUSTOMS_NOTIFY_DUTIES_TAXES: (bookingId: string) => `/import-operations/customs/${bookingId}/notify-duties-taxes`, CUSTOMS_DUTIES_TAXES_PAID: (bookingId: string) => `/import-operations/customs/${bookingId}/duties-taxes-paid`, - CUSTOMS_RISK: (bookingId: string) => `/import-operations/customs/${bookingId}/risk`, + CUSTOMS_RISK: (bookingId: string) => + `/import-operations/customs/${bookingId}/risk`, CUSTOMS_RELEASE_PERMITTED: (bookingId: string) => `/import-operations/customs/${bookingId}/release-permitted`, - EMPTY_CONTAINER_RETURNS: '/import-operations/empty-container-returns', + EMPTY_CONTAINER_RETURNS: "/import-operations/empty-container-returns", EMPTY_CONTAINER_RETURN_STATUS: (id: string) => `/import-operations/empty-container-returns/${id}/status`, }, VEHICLES: { - BASE: '/vehicles', + BASE: "/vehicles", BY_ID: (id: string) => `/vehicles/${id}`, }, FIRST_MILE: { - BASE: '/first-mile', + BASE: "/first-mile", BY_ID: (id: string) => `/first-mile/${id}`, ACCEPT: (reference: string) => `/first-mile/accept/${reference}`, }, LAST_MILE: { - BASE: '/last-mile', + BASE: "/last-mile", BY_ID: (id: string) => `/last-mile/${id}`, ACCEPT: (reference: string) => `/last-mile/accept/${reference}`, }, DRIVERS: { - BASE: '/drivers', + BASE: "/drivers", BY_ID: (id: string) => `/drivers/${id}`, }, }; diff --git a/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx b/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx index 4ca24476c..c00777ef7 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx +++ b/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx @@ -1,24 +1,3 @@ -import { useCallback, useState } from "react"; -import { FileViewerModal, type ViewableFile } from "@edr/ui-common"; - -/** - * Drives a single shared {@link FileViewerModal} for a page. Call `view(file)` - * from any file row to open the document inline (pdf / image / video / office / - * text); render `viewer` once near the page root. - * - * const { view, viewer } = useFileViewer(); - * - * {viewer} - */ -export function useFileViewer() { - const [file, setFile] = useState(null); - - const view = useCallback((f: ViewableFile) => setFile(f), []); - const close = useCallback(() => setFile(null), []); - - const viewer = ( - - ); - - return { view, close, viewer }; -} +// Re-export of the shared hook, now living in @edr/ui-common. Kept so existing +// `@/hooks/useFileViewer` imports keep working. +export { useFileViewer } from "@edr/ui-common"; diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 5689186cd..7f62ea3f5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -1,5 +1,6 @@ import { ActionIcon, + Anchor, Box, Button, Card, @@ -17,10 +18,13 @@ import { ArrowRight, Banknote, Download, + Eye, FileText, IdCard, LayoutGrid, Package, + Paperclip, + Receipt, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; @@ -30,6 +34,7 @@ import { BookingStatusBadge, CompanyStatusBadge, CompanyTypeBadge, + InvoiceStatusBadge, PaymentStatusBadge, ProfileApprovalActions, ProfileChips, @@ -50,7 +55,13 @@ import type { CustomerDocument, CustomerPayment, } from "@/types/customer"; -import { DataTable, type ColumnDef } from "@edr/ui-common"; +import type { Invoice } from "@/types/invoice"; +import { + DataTable, + useFileViewer, + usePagination, + type ColumnDef, +} from "@edr/ui-common"; function InfoField({ label, value }: { label: string; value?: string | null }) { return ( @@ -78,6 +89,7 @@ function tableStatus(query: { isLoading: boolean; isError: boolean }) { export default function CustomerDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const { view, viewer } = useFileViewer(); const { data: company, isLoading } = useQuery( api.customers.getById.queryOptions({ @@ -104,9 +116,34 @@ export default function CustomerDetailPage() { }), ); + const { pagination: invoicePagination, setPagination: setInvoicePagination } = + usePagination({ + pageSize: 10, + }); + const invoiceFilter = useMemo( + () => ({ + companyId: id ?? "", + page: invoicePagination.pageIndex + 1, + pageSize: invoicePagination.pageSize, + }), + [id, invoicePagination.pageIndex, invoicePagination.pageSize], + ); + const invoicesQuery = useQuery( + api.invoices.list.queryOptions({ + input: { filter: invoiceFilter }, + enabled: Boolean(id), + }), + ); + const bookings = bookingsQuery.data ?? []; const documents = documentsQuery.data ?? []; const payments = paymentsQuery.data ?? []; + const invoices = invoicesQuery.data?.items ?? []; + const invoiceTotal = invoicesQuery.data?.total ?? 0; + const invoicePageCount = Math.max( + 1, + Math.ceil(invoiceTotal / invoicePagination.pageSize), + ); const totalPaid = useMemo( () => @@ -285,20 +322,37 @@ export default function CustomerDetailPage() { header: "", meta: { headerClassName: "text-right", cellClassName: "text-right" }, cell: ({ row }) => ( - - - + + + view({ + name: row.original.name, + url: fileViewUrl(row.original.id), + mimeType: row.original.mimeType, + }) + } + > + + + + + + ), }, ], - [], + [view], ); const paymentColumns: ColumnDef[] = useMemo( @@ -358,6 +412,59 @@ export default function CustomerDetailPage() { [], ); + const invoiceColumns: ColumnDef[] = useMemo( + () => [ + { + id: "invoiceNumber", + header: "Invoice", + cell: ({ row }) => ( + + {row.original.invoiceNumber} + + ), + }, + { + id: "source", + header: "Source", + cell: ({ row }) => ( + + {humanize(row.original.source)} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "amount", + header: "Amount", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatMoney(row.original.totalAmount, row.original.currency)} + + ), + }, + { + id: "dueAt", + header: "Due", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.dueAt)} + + ), + }, + ], + [], + ); + + const licenseProfiles = (company?.companyProfiles ?? []).filter( + (p) => p.licenseFiles && p.licenseFiles.length > 0, + ); + if (isLoading) { return (
@@ -392,8 +499,9 @@ export default function CustomerDetailPage() { ]} backTo="/dashboard/customers" title={company.name} - subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : "" - }`} + subtitle={`TIN ${company.tin}${ + company.country ? ` · ${company.country}` : "" + }`} meta={ @@ -416,6 +524,9 @@ export default function CustomerDetailPage() { }> Payments + }> + Invoices + {/* OVERVIEW */} @@ -528,9 +639,9 @@ export default function CustomerDetailPage() { error={ bookingsQuery.isError ? { - message: "Failed to load bookings.", - onRetry: () => void bookingsQuery.refetch(), - } + message: "Failed to load bookings.", + onRetry: () => void bookingsQuery.refetch(), + } : undefined } /> @@ -539,23 +650,63 @@ export default function CustomerDetailPage() { {/* DOCUMENTS */} - - void documentsQuery.refetch(), - } - : undefined - } - /> - + + + void documentsQuery.refetch(), + } + : undefined + } + /> + + + {licenseProfiles.length > 0 && ( + + + + Business licenses + + + {licenseProfiles.map((p) => ( + + + {humanize(p.type)} · {p.reference} + + {(p.licenseFiles ?? []).map((f) => ( + + + + view({ + name: f.name, + url: f.url, + mimeType: f.mimeType, + }) + } + size="xs" + > + {f.name} + + + ))} + + ))} + + + + )} + {/* PAYMENTS */} @@ -570,15 +721,53 @@ export default function CustomerDetailPage() { error={ paymentsQuery.isError ? { - message: "Failed to load payments.", - onRetry: () => void paymentsQuery.refetch(), - } + message: "Failed to load payments.", + onRetry: () => void paymentsQuery.refetch(), + } : undefined } /> + + {/* INVOICES */} + + + + navigate(`/dashboard/invoices/${row.id}`)} + emptyMessage="No invoices for this customer." + containerClassName="border-0 shadow-none bg-transparent" + error={ + invoicesQuery.isError + ? { + message: "Failed to load invoices.", + onRetry: () => void invoicesQuery.refetch(), + } + : undefined + } + pagination={{ + pageIndex: invoicePagination.pageIndex, + pageSize: invoicePagination.pageSize, + pageCount: invoicePageCount, + totalCount: invoiceTotal, + }} + tableOptions={{ + state: { pagination: invoicePagination }, + onPaginationChange: setInvoicePagination, + manualPagination: true, + pageCount: invoicePageCount, + }} + /> + + + + + {viewer} ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx new file mode 100644 index 000000000..7bafc093e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx @@ -0,0 +1,254 @@ +import { + ActionIcon, + Button, + Card, + Center, + Container, + Group, + Loader, + SimpleGrid, + Stack, + Table, + Text, +} from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowLeft, Download } from "lucide-react"; +import { useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import { + InvoiceStatusBadge, + formatDate, + formatMoney, + humanize, +} from "@/components/customers"; +import { PageContainer, PageHeader } from "@/components/page"; +import { api } from "@/services/api"; +import { invoicesService } from "@/services/invoices.service"; + +function openPdfBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const opened = window.open(url, "_blank"); + if (!opened) { + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + } + setTimeout(() => URL.revokeObjectURL(url), 60_000); +} + +function InfoField({ label, value }: { label: string; value?: string | null }) { + return ( + + + {label} + + + {value && value.trim() ? value : "—"} + + + ); +} + +export default function InvoiceDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [downloading, setDownloading] = useState(false); + + const { data: invoice, isLoading } = useQuery( + api.invoices.getById.queryOptions({ + input: { id: id ?? "" }, + enabled: Boolean(id), + }), + ); + + const downloadDocument = async () => { + if (!id) return; + setDownloading(true); + try { + const { data } = await invoicesService.downloadDocument(id); + openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`); + } finally { + setDownloading(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!invoice) { + return ( + + + Invoice not found + + + + ); + } + + return ( + + } + action={ + void downloadDocument()} + > + + + } + /> + + + + + + Summary + + + + + + + + + + + + + + + + + + Line items + + + + + Description + Charge type + Quantity + Unit rate + Amount + + + + {(invoice.lines ?? []).map((line) => ( + + {line.description ?? line.chargeType} + + + {humanize(line.chargeType)} + + + {line.quantity} + + {formatMoney(line.unitRate, line.currency)} + + + {formatMoney(line.amount, line.currency)} + + + ))} + {(invoice.lines ?? []).length === 0 && ( + + + + No line items. + + + + )} + +
+ + + + + Subtotal + + + {formatMoney(invoice.subtotalAmount, invoice.currency)} + + + + + Tax + + + {formatMoney(invoice.taxAmount, invoice.currency)} + + + + + Paid + + + {formatMoney(invoice.paidAmount, invoice.currency)} + + + + + Total + + + {formatMoney(invoice.totalAmount, invoice.currency)} + + + +
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx new file mode 100644 index 000000000..7eca77ddf --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -0,0 +1,237 @@ +import type { Freight } from "@edr/types"; +import { + ActionIcon, + Box, + Card, + Group, + SegmentedControl, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { useQuery } from "@tanstack/react-query"; +import { RefreshCw, Search, X } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { + InvoiceStatusBadge, + formatDate, + formatMoney, + humanize, +} from "@/components/customers"; +import { PageContainer, PageHeader } from "@/components/page"; +import { api } from "@/services/api"; +import type { Invoice } from "@/types/invoice"; +import { + DataTable, + DataTableFooter, + usePagination, + type ColumnDef, +} from "@edr/ui-common"; + +export default function InvoicesPage() { + const navigate = useNavigate(); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [query, setQuery] = useState(""); + const [debouncedQuery] = useDebouncedValue(query, 300); + const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>( + "", + ); + + const filter = useMemo( + () => ({ + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + search: debouncedQuery, + status: statusFilter || undefined, + }), + [pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter], + ); + + const { data, isLoading, isError, refetch, isFetching } = useQuery( + api.invoices.list.queryOptions({ input: { filter } }), + ); + + const rows = data?.items ?? []; + const total = data?.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + const columns: ColumnDef[] = useMemo( + () => [ + { + id: "invoiceNumber", + header: "Invoice", + cell: ({ row }) => ( + + {row.original.invoiceNumber} + + ), + }, + { + id: "billedTo", + header: "Billed to", + cell: ({ row }) => ( + + {row.original.company?.name ?? "—"} + + ), + }, + { + id: "source", + header: "Source", + cell: ({ row }) => ( + + {humanize(row.original.source)} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "amount", + header: "Amount", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatMoney(row.original.totalAmount, row.original.currency)} + + ), + }, + { + id: "balance", + header: "Balance", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatMoney(row.original.balanceAmount, row.original.currency)} + + ), + }, + { + id: "dueAt", + header: "Due", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.dueAt)} + + ), + }, + ], + [], + ); + + return ( + + void refetch()} + > + + + } + /> + + + + + + } + value={query} + onChange={(e) => setQuery(e.target.value)} + rightSection={ + query ? ( + setQuery("")} + > + + + ) : null + } + style={{ flex: 1, minWidth: "240px" }} + radius="lg" + /> + { + setStatusFilter( + v === "all" ? "" : (v as Freight.InvoiceStatus), + ); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }} + data={[ + { label: "All", value: "all" }, + { label: "Pending", value: "PENDING" }, + { label: "Paid", value: "PAID" }, + { label: "Overdue", value: "OVERDUE" }, + ]} + /> + + {total} record{total !== 1 ? "s" : ""} + + + + + + + navigate(`/dashboard/invoices/${row.id}`)} + emptyMessage={ + debouncedQuery + ? "No invoices match your search." + : "No invoices yet." + } + error={ + isError + ? { + message: "Failed to load invoices.", + onRetry: () => void refetch(), + } + : undefined + } + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + manualPagination: true, + pageCount, + }} + containerClassName="border-0 shadow-none bg-transparent" + footer={DataTableFooter} + /> + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index a239b7ee4..0edb591ac 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -28,6 +28,11 @@ import type { UpdateFileUploadFieldDto, UpdateFileUploadSettingDto, } from "@/types/fileUploadSettings"; +import type { + Invoice, + InvoiceListFilter, + PaginatedInvoices, +} from "@/types/invoice"; import type { IOverviewDashboard, OverviewRange } from "@/types/overview"; import { RuleEngineListResult, @@ -128,6 +133,7 @@ import { import { containerTypesService } from "./container-types.service"; import { containerService, type Container } from "./containerService"; import { customersService } from "./customers.service"; +import { invoicesService } from "./invoices.service"; import { dropdownSettingsService } from "./dropdownSettings.service"; import { fileUploadSettingsService } from "./fileUploadSettings.service"; import { @@ -197,7 +203,10 @@ const TRAIN_SCHEDULING_INVALIDATIONS: ReadonlyArray = [ export const api = { trainScheduling: { // ── Queries ──────────────────────────────────────────────────────────── - scheduleList: endpoint<{ freightType?: FreightType }, TrainScheduleListItem[]>( + scheduleList: endpoint< + { freightType?: FreightType }, + TrainScheduleListItem[] + >( "train-scheduling", "schedules", ({ freightType }) => trainSchedulingService.listSchedules(freightType), @@ -211,11 +220,16 @@ export const api = { () => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), ), - batchBoardDetail: endpoint<{ scheduleId: string }, BatchBoardScheduleDetail>( + batchBoardDetail: endpoint< + { scheduleId: string }, + BatchBoardScheduleDetail + >( "train-scheduling", "batch-board-detail", - ({ scheduleId }) => trainSchedulingService.getBatchBoardDetail(scheduleId), - ({ scheduleId }) => QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId), + ({ scheduleId }) => + trainSchedulingService.getBatchBoardDetail(scheduleId), + ({ scheduleId }) => + QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId), ), scheduleDetail: endpoint< @@ -344,7 +358,10 @@ export const api = { ), // ── Mutations ────────────────────────────────────────────────────────── - runAllocation: endpoint<{ scheduleId: string }, WagonAllocationAttemptResult>( + runAllocation: endpoint< + { scheduleId: string }, + WagonAllocationAttemptResult + >( "train-scheduling", "run-allocation", ({ scheduleId }) => trainSchedulingService.runAllocation(scheduleId), @@ -462,7 +479,10 @@ export const api = { () => TRAIN_SCHEDULING_INVALIDATIONS, ), - pinWagons: endpoint<{ id: string; payload: PinWagonsPayload }, TrainScheduleDetail>( + pinWagons: endpoint< + { id: string; payload: PinWagonsPayload }, + TrainScheduleDetail + >( "train-scheduling", "pin-wagons", ({ id, payload }) => trainSchedulingService.pinWagons(id, payload), @@ -558,8 +578,10 @@ export const api = { ({ id }) => warehouseService.getById(id).then((r) => r.data), ), - dashboard: endpoint("warehouses", "dashboard", () => - warehouseService.dashboard().then((r) => r.data), + dashboard: endpoint( + "warehouses", + "dashboard", + () => warehouseService.dashboard().then((r) => r.data), ), create: endpoint( @@ -650,10 +672,8 @@ export const api = { listInventory: endpoint< { filter?: InventoryFilter }, WarehouseInventoryItem[] - >( - "warehouse-inventory", - "list", - ({ filter }) => warehouseService.listInventory(filter).then((r) => r.data), + >("warehouse-inventory", "list", ({ filter }) => + warehouseService.listInventory(filter).then((r) => r.data), ), inquiry: endpoint< @@ -666,11 +686,19 @@ export const api = { ({ filter }) => ["warehouse-inventory", "inquiry", filter], ), - eligibleBookings: endpoint<{ direction?: 'IMPORT' | 'EXPORT' } | void, EligibleBooking[]>( + eligibleBookings: endpoint< + { direction?: "IMPORT" | "EXPORT" } | void, + EligibleBooking[] + >( "warehouse-inventory", "eligible-bookings", - (input) => warehouseService.eligibleBookings(input?.direction).then((r) => r.data), - (input) => ["warehouse-inventory", "eligible-bookings", input?.direction ?? "ALL"], + (input) => + warehouseService.eligibleBookings(input?.direction).then((r) => r.data), + (input) => [ + "warehouse-inventory", + "eligible-bookings", + input?.direction ?? "ALL", + ], ), readyToLoadExport: endpoint( @@ -706,7 +734,11 @@ export const api = { "import-train-items", ({ scheduleId }) => warehouseService.importTrainItems(scheduleId).then((r) => r.data), - ({ scheduleId }) => ["warehouse-inventory", "import-train-items", scheduleId], + ({ scheduleId }) => [ + "warehouse-inventory", + "import-train-items", + scheduleId, + ], ), importUnloadedQueue: endpoint( @@ -774,8 +806,11 @@ export const api = { "inspection-reports", ({ inventoryId }) => warehouseService.listInspectionReports(inventoryId).then((r) => r.data), - ({ inventoryId }) => - ["warehouse-inventory", inventoryId, "inspection-reports"], + ({ inventoryId }) => [ + "warehouse-inventory", + inventoryId, + "inspection-reports", + ], ), allocationRules: endpoint( @@ -792,15 +827,28 @@ export const api = { () => ["warehouse-fee-rules"], ), - feePreview: endpoint<{ inventoryId: string; billingCurrency?: 'ETB' | 'USD' }, FeePreview[]>( + feePreview: endpoint< + { inventoryId: string; billingCurrency?: "ETB" | "USD" }, + FeePreview[] + >( "warehouse-inventory", "fee-preview", ({ inventoryId, billingCurrency }) => - warehouseService.feePreview(inventoryId, billingCurrency).then((r) => r.data), - ({ inventoryId, billingCurrency }) => ["warehouse-inventory", inventoryId, "fee-preview", billingCurrency ?? 'USD'], + warehouseService + .feePreview(inventoryId, billingCurrency) + .then((r) => r.data), + ({ inventoryId, billingCurrency }) => [ + "warehouse-inventory", + inventoryId, + "fee-preview", + billingCurrency ?? "USD", + ], ), - invoices: endpoint<{ filter?: WarehouseInvoiceFilter }, WarehouseFeeInvoice[]>( + invoices: endpoint< + { filter?: WarehouseInvoiceFilter }, + WarehouseFeeInvoice[] + >( "warehouse-fee-invoices", "list", ({ filter }) => warehouseService.listInvoices(filter).then((r) => r.data), @@ -829,7 +877,8 @@ export const api = { receiveInventory: endpoint( "warehouse-inventory", "receive", - (payload) => warehouseService.receiveInventory(payload).then((r) => r.data), + (payload) => + warehouseService.receiveInventory(payload).then((r) => r.data), undefined, () => [["warehouse-inventory"], ["warehouses"]], ), @@ -864,7 +913,8 @@ export const api = { >( "warehouse-inventory", "load", - ({ id, payload }) => warehouseService.load(id, payload).then((r) => r.data), + ({ id, payload }) => + warehouseService.load(id, payload).then((r) => r.data), undefined, () => INVENTORY_INVALIDATIONS, ), @@ -883,7 +933,8 @@ export const api = { >( "warehouse-inventory", "move", - ({ id, payload }) => warehouseService.move(id, payload).then((r) => r.data), + ({ id, payload }) => + warehouseService.move(id, payload).then((r) => r.data), undefined, () => INVENTORY_INVALIDATIONS, ), @@ -939,7 +990,8 @@ export const api = { bulkMarkInspected: endpoint( "warehouse-inventory", "bulk-mark-inspected", - (payload) => warehouseService.bulkMarkInspected(payload).then((r) => r.data), + (payload) => + warehouseService.bulkMarkInspected(payload).then((r) => r.data), undefined, () => INVENTORY_INVALIDATIONS, ), @@ -957,7 +1009,12 @@ export const api = { { scheduleId: string; warehouseId?: string; - assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[]; + assignments?: { + bookingId: string; + warehouseId: string; + yardId: string; + zoneId: string; + }[]; }, AutoUnloadArrivedResult >( @@ -1029,11 +1086,11 @@ export const api = { ), // ── Allocation + fee rules ───────────────────────────────────────────── - previewAllocation: endpoint( - "warehouse-allocation-rules", - "preview", - (criteria) => - warehouseService.previewAllocation(criteria).then((r) => r.data), + previewAllocation: endpoint< + AllocationCriteria, + AllocationPreviewResult | null + >("warehouse-allocation-rules", "preview", (criteria) => + warehouseService.previewAllocation(criteria).then((r) => r.data), ), createAllocationRule: endpoint( @@ -1095,7 +1152,11 @@ export const api = { // ── Invoices ─────────────────────────────────────────────────────────── generateInvoice: endpoint< - { inventoryId: string; confirmZero?: boolean; billingCurrency?: 'ETB' | 'USD' }, + { + inventoryId: string; + confirmZero?: boolean; + billingCurrency?: "ETB" | "USD"; + }, WarehouseFeeInvoice >( "warehouse-fee-invoices", @@ -1151,7 +1212,10 @@ export const api = { }, routes: { - list: endpoint<{ status?: import("./routes.service").RouteStatus } | void, RouteRecord[]>( + list: endpoint< + { status?: import("./routes.service").RouteStatus } | void, + RouteRecord[] + >( "routes", "list", (input) => @@ -1176,7 +1240,10 @@ export const api = { () => [["routes"]], ), - update: endpoint<{ id: string; data: Partial }, RouteRecord>( + update: endpoint< + { id: string; data: Partial }, + RouteRecord + >( "routes", "update", ({ id, data }) => routesService.update(id, data).then((r) => r.data), @@ -1289,10 +1356,8 @@ export const api = { ({ trainId }) => ["wagons", "train", trainId], ), - getById: endpoint<{ id: string }, Wagon>( - "wagons", - "getById", - ({ id }) => wagonService.getById(id).then((r) => r.data), + getById: endpoint<{ id: string }, Wagon>("wagons", "getById", ({ id }) => + wagonService.getById(id).then((r) => r.data), ), assignToTrain: endpoint< @@ -1671,7 +1736,8 @@ export const api = { >( "file-upload-settings", "addField", - ({ settingId, dto }) => fileUploadSettingsService.addField(settingId, dto), + ({ settingId, dto }) => + fileUploadSettingsService.addField(settingId, dto), undefined, () => [["file-upload-settings"]], ), @@ -1770,7 +1836,8 @@ export const api = { >( "dropdown-settings", "updateOption", - ({ optionId, dto }) => dropdownSettingsService.updateOption(optionId, dto), + ({ optionId, dto }) => + dropdownSettingsService.updateOption(optionId, dto), undefined, () => [["dropdown-settings"]], ), @@ -1823,11 +1890,10 @@ export const api = { ruleEngineService.update(resource, id, payload), ), - remove: endpoint< - { resource: RuleEngineResourceSlug; id: string }, - void - >("rule-engine", "remove", ({ resource, id }) => - ruleEngineService.remove(resource, id), + remove: endpoint<{ resource: RuleEngineResourceSlug; id: string }, void>( + "rule-engine", + "remove", + ({ resource, id }) => ruleEngineService.remove(resource, id), ), submitRate: endpoint<{ id: string }, RuleEngineRecord>( @@ -1850,14 +1916,21 @@ export const api = { ), reorder: endpoint< - { resource: RuleEngineResourceSlug; payload: { ids: string[]; requiresDirectorApproval?: boolean } }, + { + resource: RuleEngineResourceSlug; + payload: { ids: string[]; requiresDirectorApproval?: boolean }; + }, void >("rule-engine", "reorder", ({ resource, payload }) => ruleEngineService.reorder(resource, payload), ), moveOrder: endpoint< - { resource: RuleEngineResourceSlug; id: string; direction: "up" | "down" }, + { + resource: RuleEngineResourceSlug; + id: string; + direction: "up" | "down"; + }, void >("rule-engine", "moveOrder", ({ resource, id, direction }) => ruleEngineService.moveOrder(resource, id, direction), @@ -1879,10 +1952,8 @@ export const api = { ({ id }) => QUERY_KEYS.BOOKINGS.byId(id), ), - remove: endpoint<{ id: string }, void>( - "bookings", - "remove", - ({ id }) => bookingsService.remove(id), + remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) => + bookingsService.remove(id), ), staffAccept: endpoint<{ id: string; validityDays: number }, BookingDetail>( @@ -1932,10 +2003,11 @@ export const api = { ({ id }) => bookingsService.generateContract(id), ), - getContractView: endpoint<{ id: string }, import("./bookings.service").ContractView>( - "bookings", - "getContractView", - ({ id }) => bookingsService.getContractView(id), + getContractView: endpoint< + { id: string }, + import("./bookings.service").ContractView + >("bookings", "getContractView", ({ id }) => + bookingsService.getContractView(id), ), signContract: endpoint< @@ -2019,7 +2091,8 @@ export const api = { >( "customers", "setProfileStatus", - ({ profileId, status }) => customersService.setProfileStatus(profileId, status), + ({ profileId, status }) => + customersService.setProfileStatus(profileId, status), undefined, (_input, data) => [ QUERY_KEYS.CUSTOMERS.byId(data.companyId), @@ -2040,6 +2113,22 @@ export const api = { ), }, + invoices: { + list: endpoint<{ filter: InvoiceListFilter }, PaginatedInvoices>( + "invoices", + "list", + ({ filter }) => invoicesService.list(filter), + ({ filter }) => QUERY_KEYS.INVOICES.list(filter), + ), + + getById: endpoint<{ id: string }, Invoice>( + "invoices", + "getById", + ({ id }) => invoicesService.getById(id), + ({ id }) => QUERY_KEYS.INVOICES.byId(id), + ), + }, + overview: { get: endpoint<{ range?: OverviewRange }, IOverviewDashboard>( "overview", diff --git a/apps/edr-freight-web/backoffice/src/services/invoices.service.ts b/apps/edr-freight-web/backoffice/src/services/invoices.service.ts new file mode 100644 index 000000000..a4abc6ea2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/invoices.service.ts @@ -0,0 +1,36 @@ +import { api as apiClient } from "@/auth/http"; +import { URL_CONSTANTS } from "@/constants/URLS"; +import type { + Invoice, + InvoiceListFilter, + PaginatedInvoices, +} from "@/types/invoice"; + +const cleanParams = (params: object) => + Object.fromEntries( + Object.entries(params).filter( + ([, value]) => value !== undefined && value !== "" && value !== null, + ), + ); + +export const invoicesService = { + list(filter: InvoiceListFilter): Promise { + return apiClient + .get(URL_CONSTANTS.BILLING.INVOICES, { + params: cleanParams(filter), + }) + .then((r) => r.data); + }, + + getById(id: string): Promise { + return apiClient + .get(URL_CONSTANTS.BILLING.INVOICE_BY_ID(id)) + .then((r) => r.data); + }, + + downloadDocument(id: string) { + return apiClient.get(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), { + responseType: "blob", + }); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 638ab98cd..a67f1732f 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -32,6 +32,14 @@ export type ProfileType = /** Mirrors backend `ProfileStatus`. */ export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted"; +/** A business-license document uploaded for a company profile. */ +export interface LicenseFile { + name: string; + url: string; + size: number; + mimeType?: string; +} + /** A single role a company is registered for, with its reference code. */ export interface CompanyProfile { id: string; @@ -39,7 +47,10 @@ export interface CompanyProfile { type: ProfileType; reference: string; status: ProfileStatus; + /** @deprecated Superseded by licenseFiles (file model). */ businessLicense?: string | null; + /** Business-license documents uploaded for this profile. */ + licenseFiles?: LicenseFile[]; attributes?: Record | null; createdAt: string; updatedAt: string; diff --git a/apps/edr-freight-web/backoffice/src/types/invoice.ts b/apps/edr-freight-web/backoffice/src/types/invoice.ts new file mode 100644 index 000000000..26ecfcafa --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/types/invoice.ts @@ -0,0 +1,22 @@ +import type { Freight } from "@edr/types"; + +/** Mirrors backend `Invoice` (the shared `Freight.IInvoice` omits a couple of raw entity columns). */ +export interface Invoice extends Freight.IInvoice { + subtotalAmount: number; + taxAmount: number; +} + +/** Query parameters for the invoice list. */ +export interface InvoiceListFilter { + page: number; + pageSize: number; + companyId?: string; + status?: Freight.InvoiceStatus; + search?: string; +} + +/** Standard paginated list envelope (matches the customers/bookings service shape). */ +export interface PaginatedInvoices { + items: Invoice[]; + total: number; +} diff --git a/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx b/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx index 4ca24476c..c00777ef7 100644 --- a/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx +++ b/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx @@ -1,24 +1,3 @@ -import { useCallback, useState } from "react"; -import { FileViewerModal, type ViewableFile } from "@edr/ui-common"; - -/** - * Drives a single shared {@link FileViewerModal} for a page. Call `view(file)` - * from any file row to open the document inline (pdf / image / video / office / - * text); render `viewer` once near the page root. - * - * const { view, viewer } = useFileViewer(); - * - * {viewer} - */ -export function useFileViewer() { - const [file, setFile] = useState(null); - - const view = useCallback((f: ViewableFile) => setFile(f), []); - const close = useCallback(() => setFile(null), []); - - const viewer = ( - - ); - - return { view, close, viewer }; -} +// Re-export of the shared hook, now living in @edr/ui-common. Kept so existing +// `@/hooks/useFileViewer` imports keep working. +export { useFileViewer } from "@edr/ui-common"; diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx index 4d5c4b759..b904d7eba 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx @@ -1,13 +1,16 @@ +import { fileViewUrl } from "@/constants/apiConfig"; import { api } from "@/services/api"; import { companiesService } from "@/services/companies.service"; import { getMinFiles } from "@/types/fileUploadSettings"; import type { ProfileResponse } from "@/types/profile"; -import { SmartFileInput } from "@edr/ui-common"; +import { SmartFileInput, useFileViewer } from "@edr/ui-common"; import { + Anchor, Button, Card, Center, Group, + Stack, Text, Title, } from "@mantine/core"; @@ -17,10 +20,19 @@ import { CheckCircle2, FileCheck, Loader2, + Paperclip, UploadCloud, XCircle, } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; + +const ROLE_LABELS: Record = { + importer: "Importer", + exporter: "Exporter", + freight_forwarder: "Freight Forwarder", + dj_freight_forwarder: "DJ Freight Forwarder", + transporter: "Transporter", +}; interface TabDocumentsProps { profile: ProfileResponse; @@ -28,21 +40,63 @@ interface TabDocumentsProps { onContinue?: () => void; } -export default function TabDocuments({ profile, mode = "edit", onContinue }: TabDocumentsProps) { +function documentSettingCode(nationality: string | null | undefined): string { + return nationality === "foreign" + ? "company_onboarding_documents_foreign" + : "company_onboarding_documents_ethiopian"; +} + +export default function TabDocuments({ + profile, + mode = "edit", + onContinue, +}: TabDocumentsProps) { const queryClient = useQueryClient(); - const [documentFiles, setDocumentFiles] = useState>({}); + const { view, viewer } = useFileViewer(); + const [documentFiles, setDocumentFiles] = useState< + Record + >({}); const docSettingQuery = useQuery( api.fileUploadSettings.getByCode.queryOptions({ - input: { code: "customer_file_documents" }, + input: { code: documentSettingCode(profile.nationality) }, }), ); + const docsQuery = useQuery( + api.companies.documents.queryOptions({ + input: { companyId: profile.companyId }, + }), + ); + + const uploadedKeys = useMemo( + () => (docsQuery.data ?? []).map((d) => d.code), + [docsQuery.data], + ); + + const existingFilesByKey = useMemo(() => { + const map: Record< + string, + { name: string; url: string; size?: number; mimeType?: string | null }[] + > = {}; + for (const doc of docsQuery.data ?? []) { + (map[doc.code] ??= []).push({ + name: doc.name, + url: fileViewUrl(doc.id), + size: doc.size, + mimeType: doc.mimeType, + }); + } + return map; + }, [docsQuery.data]); + const docUploadMutation = useMutation({ mutationFn: (files: Record) => companiesService.uploadDocuments(profile.companyId, files), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); }, }); @@ -73,6 +127,7 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab for (const field of docSettingQuery.data?.fields ?? []) { const min = getMinFiles(field); if (min <= 0) continue; + if (uploadedKeys.includes(field.fileKey)) continue; const v = documentFiles[field.fileKey]; const count = Array.isArray(v) ? v.length : v ? 1 : 0; if (count < min) { @@ -82,94 +137,139 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab return errs; }; + const licenseProfiles = profile.companyProfiles.filter( + (p) => p.licenseFiles && p.licenseFiles.length > 0, + ); + return ( - - - - Documents - - - Upload and manage required business documents - - - {docSettingQuery.isLoading ? ( -
- -
- ) : !docSettingQuery.data ? ( - - No document requirements configured for your account. + <> + + + + Documents + + + Upload and manage required business documents - ) : ( - - )} - {docSettingQuery.data && ( - - - {docUploadMutation.isSuccess && ( - - - - {mode === "onboarding" ? "Saved successfully" : "Documents uploaded successfully"} - - - )} - {docUploadMutation.isError && ( - - - Upload failed - + {docSettingQuery.isLoading ? ( +
+ +
+ ) : !docSettingQuery.data ? ( + + No document requirements configured for your account. + + ) : ( + + )} + + {docSettingQuery.data && ( + + + {docUploadMutation.isSuccess && ( + + + + {mode === "onboarding" + ? "Saved successfully" + : "Documents uploaded successfully"} + + + )} + {docUploadMutation.isError && ( + + + + Upload failed + + + )} + + {mode === "onboarding" ? ( + + ) : ( + )} - {mode === "onboarding" ? ( - - ) : ( - - )} -
+ )} +
+ + {licenseProfiles.length > 0 && ( + + + + Business licenses + + + License documents uploaded per operational profile + + + + {licenseProfiles.map((p) => ( + + + {ROLE_LABELS[p.type] ?? p.type} · {p.reference} + + {p.licenseFiles.map((f) => ( + + + + {f.name} + + + ))} + + ))} + + )} -
+ + {viewer} + ); } diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index a09bd4b4e..1ba83ec30 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -53,6 +53,7 @@ import { UpdateDropdownSettingDto, } from "@/types/dropdownSettings"; import type { + CompanyDocument, CompanyInfoResponse, CompanyNationality, CompanyProfileResponse, @@ -158,7 +159,11 @@ export const api = { createCompanyProfile: endpoint< { type: ProfileTypeValue; businessLicense?: string }, CompanyProfileResponse - >("companies", "createCompanyProfile", companiesService.createCompanyProfile), + >( + "companies", + "createCompanyProfile", + companiesService.createCompanyProfile, + ), startOnboarding: endpoint< { @@ -192,6 +197,12 @@ export const api = { "onboardingRequirements", companiesService.getOnboardingRequirements, ), + + documents: endpoint<{ companyId: string }, CompanyDocument[]>( + "companies", + "documents", + ({ companyId }) => companiesService.getDocuments(companyId), + ), }, bookings: { @@ -228,7 +239,8 @@ export const api = { downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>( "bookings", "downloadHandoverDocument", - ({ inventoryId }) => bookingsService.downloadHandoverDocument(inventoryId), + ({ inventoryId }) => + bookingsService.downloadHandoverDocument(inventoryId), ), create: endpoint< @@ -312,11 +324,8 @@ export const api = { proceedToOperation: endpoint< { id: string; scheduledDate: string }, Freight.IBooking - >( - "bookings", - "proceedToOperation", - ({ id, scheduledDate }) => - bookingsService.proceedToOperation(id, scheduledDate), + >("bookings", "proceedToOperation", ({ id, scheduledDate }) => + bookingsService.proceedToOperation(id, scheduledDate), ), checkPayment: endpoint<{ orderId: string }, { status: string }>( @@ -354,10 +363,11 @@ export const api = { bookingsService.getAvailableDays({ originYardId, destinationYardId }), ), - getAvailableDaysForCargo: endpoint( - "train-scheduling", - "availableDaysForCargo", - (input) => bookingsService.getAvailableDaysForCargo(input), + getAvailableDaysForCargo: endpoint< + Freight.AvailableDaysForCargoQuery, + string[] + >("train-scheduling", "availableDaysForCargo", (input) => + bookingsService.getAvailableDaysForCargo(input), ), getMyBookingWindows: endpoint( diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index d326811b8..d3f584170 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -82,6 +82,18 @@ export interface CompanyInfoResponse { company: CompanyResponse; } +/** A single company-level document uploaded against a `file_upload_settings` field. */ +export interface CompanyDocument { + id: string; + name: string; + /** The `fileKey` of the setting field it was uploaded against. */ + code: string; + mimeType: string; + size: number; + uploadedAt: string; + url: string; +} + /** A single onboarding document field, as resolved and described by the backend. */ export interface OnboardingDocumentField { fileKey: string; @@ -124,7 +136,12 @@ export interface OnboardingRequirements { } export interface CompanyProfileInput { - type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter"; + type: + | "importer" + | "exporter" + | "freight_forwarder" + | "dj_freight_forwarder" + | "transporter"; businessLicense?: string; } @@ -180,7 +197,9 @@ export const companiesService = { } }, - create: async (payload: CreateCompanyPayload): Promise => { + create: async ( + payload: CreateCompanyPayload, + ): Promise => { const response = await client.post>( URL_CONSTANTS.COMPANIES_API.CREATE, payload, @@ -195,7 +214,9 @@ export const companiesService = { return unwrap(response.data); }, - updateProfile: async (payload: UpdateProfilePayload): Promise => { + updateProfile: async ( + payload: UpdateProfilePayload, + ): Promise => { const response = await client.patch>( URL_CONSTANTS.COMPANIES_API.PROFILE, payload, @@ -293,7 +314,18 @@ export const companiesService = { formData.append(fieldName, fileOrFiles); } } - await client.post(URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), formData); + await client.post( + URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), + formData, + ); + }, + + /** List documents already uploaded for a company (settings-driven, by fileKey). */ + getDocuments: async (companyId: string): Promise => { + const response = await client.get>( + URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), + ); + return unwrap(response.data); }, /** Upload business-license document(s) for a company profile (multi-file). */ diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index d21dab37f..45a52dd8f 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -48,6 +48,7 @@ "class-validator": "^0.14.0", "dotenv": "^17.4.2", "express": "^4.18.2", + "helmet": "^8.0.0", "jose": "^5.10.0", "pg": "^8.21.0", "qrcode": "^1.5.3", diff --git a/apps/edr-passenger-api/prisma/migrations/20260702163828_add_route_coach_template/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260702163828_add_route_coach_template/migration.sql new file mode 100644 index 000000000..232f9b956 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260702163828_add_route_coach_template/migration.sql @@ -0,0 +1,25 @@ +-- AlterTable: change distanceKm from Decimal to Double Precision on RouteStop +ALTER TABLE "RouteStop" ALTER COLUMN "distanceKm" TYPE DOUBLE PRECISION; + +-- CreateTable +CREATE TABLE "RouteCoachTemplate" ( + "id" TEXT NOT NULL, + "routeId" TEXT NOT NULL, + "coachId" TEXT NOT NULL, + "positionNumber" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RouteCoachTemplate_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "RouteCoachTemplate_routeId_idx" ON "RouteCoachTemplate"("routeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "RouteCoachTemplate_routeId_positionNumber_key" ON "RouteCoachTemplate"("routeId", "positionNumber"); + +-- AddForeignKey +ALTER TABLE "RouteCoachTemplate" ADD CONSTRAINT "RouteCoachTemplate_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RouteCoachTemplate" ADD CONSTRAINT "RouteCoachTemplate_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260702165853_add_seat_class_nationality_bed_position/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260702165853_add_seat_class_nationality_bed_position/migration.sql new file mode 100644 index 000000000..b70865401 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260702165853_add_seat_class_nationality_bed_position/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "SeatClass" ADD COLUMN "bedPosition" TEXT, +ADD COLUMN "nationalityType" TEXT; + +-- CreateIndex +CREATE INDEX "SeatClass_coachTypeId_nationalityType_bedPosition_idx" ON "SeatClass"("coachTypeId", "nationalityType", "bedPosition"); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 82cd15f89..68b3ac234 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -88,7 +88,9 @@ model SeatClass { coachTypeId String name String description String? - baseFareMinor Int @default(0) // per-km rate + nationalityType String? // 'LOCAL' | 'INTERNATIONAL' + bedPosition String? // 'UPPER' | 'MIDDLE' | 'LOWER' | null for regular seat + baseFareMinor Int @default(0) // per-km rate (tariff decimal × 100000) premiumMinor Int @default(0) // flat fee per passenger insuranceFeeMinor Int @default(0) // flat fee per passenger isActive Boolean @default(true) @@ -100,6 +102,7 @@ model SeatClass { segmentFares SegmentFareRule[] @@unique([coachTypeId, name]) @@index([coachTypeId]) + @@index([coachTypeId, nationalityType, bedPosition]) @@schema("passenger") } @@ -420,9 +423,10 @@ model Coach { status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE' createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - coachType CoachType @relation(fields: [coachTypeId], references: [id]) - seats Seat[] - assignments CoachAssignment[] + coachType CoachType @relation(fields: [coachTypeId], references: [id]) + seats Seat[] + assignments CoachAssignment[] + routeTemplates RouteCoachTemplate[] @@index([coachTypeId]) @@index([sequence]) @@schema("passenger") @@ -998,6 +1002,7 @@ model Route { fareRules RouteFareRule[] segmentFares SegmentFareRule[] schedules TrainSchedule[] + coachTemplates RouteCoachTemplate[] @@schema("passenger") } @@ -1015,6 +1020,20 @@ model RouteStop { @@schema("passenger") } +model RouteCoachTemplate { + id String @id @default(uuid()) + routeId String + coachId String + positionNumber Int + createdAt DateTime @default(now()) + route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) + coach Coach @relation(fields: [coachId], references: [id]) + + @@unique([routeId, positionNumber]) + @@index([routeId]) + @@schema("passenger") +} + model RouteFareRule { id String @id @default(uuid()) routeId String diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index c898e8231..0e9f85079 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -166,21 +166,41 @@ async function seedCoachTypesAndClasses() { }); } + // Tariff rates: baseFareMinor = tariff_decimal × 100000 + // Formula: fare = km × (baseFareMinor / 100000) × 1.02 × exchangeRate + // LOCAL = Ethiopian or Djiboutian nationals + // INTERNATIONAL = all other nationalities const seatClasses = [ - { name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900, premiumMinor: 50, insuranceFeeMinor: 25 }, - { name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800, premiumMinor: 45, insuranceFeeMinor: 20 }, - { name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600, premiumMinor: 30, insuranceFeeMinor: 15 }, - { name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550, premiumMinor: 28, insuranceFeeMinor: 14 }, - { name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500, premiumMinor: 25, insuranceFeeMinor: 12 }, - { name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250, premiumMinor: 12, insuranceFeeMinor: 6 }, + // LOCAL rates + { name: 'Economy Regular (Local)', coachCode: 'HSC', nationalityType: 'LOCAL', bedPosition: null, baseFareMinor: 3000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Upper (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 4000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Middle (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'MIDDLE',baseFareMinor: 5500, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Lower (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'VIP Bed Upper (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 7500, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'VIP Bed Lower (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 }, + // INTERNATIONAL rates + { name: 'Economy Regular (Intl)', coachCode: 'HSC', nationalityType: 'INTERNATIONAL', bedPosition: null, baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Upper (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Middle (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'MIDDLE',baseFareMinor: 11000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Lower (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 12000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'VIP Bed Upper (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 15000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'VIP Bed Lower (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 16000, premiumMinor: 0, insuranceFeeMinor: 0 }, ]; for (const sc of seatClasses) { const ct = await prisma.coachType.findUnique({ where: { id: sc.coachCode } }); await prisma.seatClass.upsert({ where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } }, - update: {}, - create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor, premiumMinor: sc.premiumMinor, insuranceFeeMinor: sc.insuranceFeeMinor }, + update: { nationalityType: sc.nationalityType, bedPosition: sc.bedPosition, baseFareMinor: sc.baseFareMinor }, + create: { + coachTypeId: ct!.id, + name: sc.name, + nationalityType: sc.nationalityType, + bedPosition: sc.bedPosition, + baseFareMinor: sc.baseFareMinor, + premiumMinor: sc.premiumMinor, + insuranceFeeMinor: sc.insuranceFeeMinor, + }, }); } console.log(` ✅ ${coachTypes.length} coach types, ${seatClasses.length} seat classes created`); @@ -238,6 +258,58 @@ async function seedRoute() { }); } console.log(` ✅ Route with ${returnStationCodes.length} stops created`); + + // Full cross-border route: Sebeta → Nagad (all 15 stations) + const fullRoute = await prisma.route.upsert({ + where: { code: 'Route-201' }, + update: {}, + create: { + code: 'Route-201', + name: 'Sebeta - Nagad (Full Cross-Border)', + description: 'Full Ethio-Djibouti cross-border route from Sebeta to Nagad', + effectiveFrom: new Date('2026-01-01'), + effectiveUntil: new Date('2034-12-31'), + active: true, + }, + }); + + // Cumulative distances from Sebeta (km) for all 15 stations + const fullStationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE', 'ADG', 'AYS', 'DAW', 'ALS', 'HOL', 'NAG']; + const fullDistancesKm = [0, 11.5, 67.2, 89.9, 106.7, 180.2, 231.6, 293.6, 413.0, 453.0, 498.0, 531.0, 601.0, 632.0, 656.0]; + for (let i = 0; i < fullStationCodes.length; i++) { + const station = await prisma.station.findUnique({ where: { code: fullStationCodes[i] } }); + await prisma.routeStop.upsert({ + where: { routeId_sequence: { routeId: fullRoute.id, sequence: i + 1 } }, + update: { distanceKm: fullDistancesKm[i] }, + create: { routeId: fullRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullDistancesKm[i] }, + }); + } + + // Full cross-border return route: Nagad → Sebeta + const fullReturnRoute = await prisma.route.upsert({ + where: { code: 'Route-202' }, + update: {}, + create: { + code: 'Route-202', + name: 'Nagad - Sebeta (Full Cross-Border Return)', + description: 'Full Ethio-Djibouti cross-border return route from Nagad to Sebeta', + effectiveFrom: new Date('2026-01-01'), + effectiveUntil: new Date('2034-12-31'), + active: true, + }, + }); + + const fullReturnStationCodes = ['NAG', 'HOL', 'ALS', 'DAW', 'AYS', 'ADG', 'DRE', 'BIK', 'MIS', 'MTE', 'ADM', 'MOJ', 'BSH', 'LEB', 'SBT']; + const fullReturnDistancesKm = [0, 24.0, 55.0, 125.0, 158.0, 203.0, 243.0, 362.4, 424.4, 475.8, 549.3, 566.1, 588.8, 644.5, 656.0]; + for (let i = 0; i < fullReturnStationCodes.length; i++) { + const station = await prisma.station.findUnique({ where: { code: fullReturnStationCodes[i] } }); + await prisma.routeStop.upsert({ + where: { routeId_sequence: { routeId: fullReturnRoute.id, sequence: i + 1 } }, + update: { distanceKm: fullReturnDistancesKm[i] }, + create: { routeId: fullReturnRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullReturnDistancesKm[i] }, + }); + } + console.log(` ✅ Full cross-border routes (Route-201, Route-202) with 15 stops each created`); } async function seedCoaches() { @@ -431,34 +503,61 @@ async function seedTrips() { async function seedFareRules() { console.log('\n💰 Seeding fare rules...'); const route = await prisma.route.findUnique({ where: { code: 'Route-101' } }); + const returnRoute = await prisma.route.findUnique({ where: { code: 'Route-102' } }); const seatClasses = await prisma.seatClass.findMany(); const validFrom = new Date('2024-01-01'); - const fareRules = []; - for (const sc of seatClasses) { - fareRules.push({ - routeId: route!.id, - seatClassId: sc.id, - passengerCategory: 'ADULT' as const, - baseFareMinor: sc.baseFareMinor, - currency: 'ETB', - validFrom, - }); - fareRules.push({ - routeId: route!.id, - seatClassId: sc.id, - passengerCategory: 'CHILD' as const, - baseFareMinor: Math.floor(sc.baseFareMinor * 0.5), - discountPercent: 10, - currency: 'ETB', - validFrom, - }); + // Delete existing FareRule rows so re-seed is idempotent + await prisma.fareRule.deleteMany({}); + + const fareRules: any[] = []; + for (const route of [{ code: 'Route-101' }, { code: 'Route-102' }, { code: 'Route-201' }, { code: 'Route-202' }]) { + for (const sc of seatClasses) { + fareRules.push({ + route: route.code, + seatClassId: sc.id, + baseFareMinor: sc.baseFareMinor, + currency: 'ETB', + validFrom, + }); + } } await Promise.all( - fareRules.map(fr => prisma.routeFareRule.create({ data: fr })) + fareRules.map(fr => prisma.fareRule.create({ data: fr })) ); - console.log(` ✅ ${fareRules.length} fare rules for ADULT/CHILD categories created`); + console.log(` ✅ ${fareRules.length} fare rules created in FareRule table`); + + const allRoutes = await prisma.route.findMany({ + where: { code: { in: ['Route-101', 'Route-102', 'Route-201', 'Route-202'] } }, + }); + const routeFareRules: any[] = []; + for (const r of allRoutes) { + for (const sc of seatClasses) { + routeFareRules.push({ + routeId: r.id, + seatClassId: sc.id, + passengerCategory: 'ADULT' as const, + baseFareMinor: sc.baseFareMinor, + currency: 'ETB', + validFrom, + }); + // CHILD: same per-km rate as ADULT — age-based free/paid logic is handled + // at booking time (first child free, subsequent children full fare). + routeFareRules.push({ + routeId: r.id, + seatClassId: sc.id, + passengerCategory: 'CHILD' as const, + baseFareMinor: sc.baseFareMinor, + currency: 'ETB', + validFrom, + }); + } + } + await Promise.all( + routeFareRules.map(fr => prisma.routeFareRule.create({ data: fr }).catch(() => {})) + ); + console.log(` ✅ ${routeFareRules.length} route fare rules for ADULT/CHILD categories created`); } async function seedCurrency() { @@ -758,7 +857,23 @@ async function runStep(name: string, step: () => Promise): Promise Promise]> = [ + const steps: Array<[string, () => Promise]> = [ + ['System Users', seedSystemUsers], + ['Stations', seedStations], + ['Coach Types & Classes', seedCoachTypesAndClasses], + ['Route', seedRoute], + ['Coaches', seedCoaches], + ['Trips', seedTrips], + ['Fare Rules', seedFareRules], + ['Currency', seedCurrency], + ['Payment Methods', seedPaymentMethods], + ['Segment Fares', seedSegmentFares], + ['Notification Templates', seedNotificationTemplates], + ['Menu & Food', seedMenuAndFood], + ['Promotions', seedPromotions], + ['FAQ', seedFAQ], + ['Fraud Rules', seedFraudRules], + ['Kulubbi Package', seedKulubbiPackage], ]; let failed = 0; diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index f4bac0f0b..ba7a18086 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -1,9 +1,4 @@ -import { - MiddlewareConsumer, - Module, - NestModule, - OnApplicationBootstrap, -} from '@nestjs/common'; +import {Logger, Module, OnApplicationBootstrap} from '@nestjs/common'; import { ThrottlerModule } from '@nestjs/throttler'; import { DynamicThrottlerGuard } from './common/dynamic-throttler.guard'; import { APP_GUARD, APP_FILTER } from '@nestjs/core'; @@ -66,7 +61,6 @@ import { PackagesModule } from './modules/packages/packages.module'; import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module'; import { HealthModule } from './modules/health/health.module'; import { TasksModule } from './modules/tasks/tasks.module'; -import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module'; @Module({ imports: [ @@ -136,7 +130,6 @@ import { ConfigurableFareModule } from './modules/configurable-fare/configurable ExcessBaggageModule, HealthModule, TasksModule, - ConfigurableFareModule, ], providers: [ { provide: APP_GUARD, useClass: DynamicThrottlerGuard }, @@ -147,6 +140,7 @@ import { ConfigurableFareModule } from './modules/configurable-fare/configurable ], }) export class AppModule implements OnApplicationBootstrap { + private readonly logger = new Logger(AppModule.name); constructor( private readonly seeder: DataSeeder, private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder, @@ -157,17 +151,17 @@ export class AppModule implements OnApplicationBootstrap { try { await this.seeder.run(); } catch (err) { - console.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message); + this.logger.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message); } try { await this.edrPassengerOrgSeeder.run(); } catch (err) { - console.error('[EdrPassengerOrgSeeder] Seed failed (non-fatal):', (err as Error).message); + this.logger.error('[EdrPassengerOrgSeeder] Seed failed (non-fatal):', (err as Error).message); } try { await this.passengerStaffUsersSeeder.run(); } catch (err) { - console.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message); + this.logger.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message); } } } diff --git a/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts b/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts index 7a450bfaf..5ad8232ec 100644 --- a/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts +++ b/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts @@ -3,6 +3,17 @@ import { Reflector } from '@nestjs/core'; import { ThrottlerGuard, ThrottlerStorage, getOptionsToken, getStorageToken } from '@nestjs/throttler'; import { SystemConfigService, CONFIG_KEYS } from '../modules/system-config/system-config.service'; +// Route-prefix → throttler tier mapping. +// Evaluated in order; first match wins. +const ROUTE_TIERS: Array<{ prefix: string; tier: 'auth' | 'strict' | 'default' }> = [ + { prefix: '/auth', tier: 'auth' }, + { prefix: '/fayda/verification',tier: 'auth' }, + { prefix: '/bookings', tier: 'strict' }, + { prefix: '/passengers', tier: 'strict' }, + { prefix: '/payments', tier: 'strict' }, + { prefix: '/wallet', tier: 'strict' }, +]; + @Injectable() export class DynamicThrottlerGuard extends ThrottlerGuard { constructor( @@ -29,9 +40,17 @@ export class DynamicThrottlerGuard extends ThrottlerGuard { this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_DEFAULT_TTL_MS), ]); - this.throttlers = [ - { name: 'default', ttl: defaultTtl, limit: defaultLimit }, - ]; + const url: string = context.switchToHttp().getRequest<{ url: string }>().url ?? ''; + const matched = ROUTE_TIERS.find(({ prefix }) => url.startsWith(prefix)); + const tier = matched?.tier ?? 'default'; + + if (tier === 'auth') { + this.throttlers = [{ name: 'auth', ttl: authTtl, limit: authLimit }]; + } else if (tier === 'strict') { + this.throttlers = [{ name: 'strict', ttl: strictTtl, limit: strictLimit }]; + } else { + this.throttlers = [{ name: 'default', ttl: defaultTtl, limit: defaultLimit }]; + } return super.canActivate(context); } diff --git a/apps/edr-passenger-api/src/common/prisma.service.ts b/apps/edr-passenger-api/src/common/prisma.service.ts index 75d4eaa24..9789791ad 100644 --- a/apps/edr-passenger-api/src/common/prisma.service.ts +++ b/apps/edr-passenger-api/src/common/prisma.service.ts @@ -1,8 +1,29 @@ -import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(PrismaService.name); + + constructor() { + super({ + // In production set connection_limit and pool_timeout in DATABASE_URL: + // ?connection_limit=10&pool_timeout=20&sslmode=require + log: + process.env.NODE_ENV === 'development' + ? [{ emit: 'event', level: 'query' }, { emit: 'stdout', level: 'warn' }, { emit: 'stdout', level: 'error' }] + : [{ emit: 'stdout', level: 'warn' }, { emit: 'stdout', level: 'error' }], + }); + + if (process.env.NODE_ENV === 'development') { + (this as any).$on('query', (e: { query: string; duration: number }) => { + if (e.duration > 500) { + this.logger.warn(`Slow query (${e.duration}ms): ${e.query}`); + } + }); + } + } + async onModuleInit() { await this.$connect(); } async onModuleDestroy() { await this.$disconnect(); } } diff --git a/apps/edr-passenger-api/src/config/app.config.ts b/apps/edr-passenger-api/src/config/app.config.ts index 9203a9d7b..5ed75a2b3 100644 --- a/apps/edr-passenger-api/src/config/app.config.ts +++ b/apps/edr-passenger-api/src/config/app.config.ts @@ -1,9 +1,23 @@ import { registerAs } from '@nestjs/config'; -export default registerAs('app', () => ({ - port: parseInt(process.env.PORT ?? '4000', 10), - jwtSecret: process.env.JWT_SECRET ?? 'dev-secret', - jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d', - frontendUrl: process.env.PORTAL_URL ?? 'http://localhost:3000', - portalUrl: process.env.BACK_OFFICE_URL ?? 'http://localhost:3001', -})); +export default registerAs('app', () => { + const isProd = process.env.NODE_ENV === 'production'; + + if (isProd && !process.env.JWT_SECRET) { + throw new Error('JWT_SECRET environment variable is required in production'); + } + if (isProd && !process.env.JWT_ACCESS_TOKEN_SECRET) { + throw new Error('JWT_ACCESS_TOKEN_SECRET environment variable is required in production'); + } + if (isProd && !process.env.JWT_REFRESH_TOKEN_SECRET) { + throw new Error('JWT_REFRESH_TOKEN_SECRET environment variable is required in production'); + } + + return { + port: parseInt(process.env.PORT ?? '4000', 10), + jwtSecret: process.env.JWT_SECRET ?? 'dev-secret', + jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d', + frontendUrl: process.env.PORTAL_URL ?? 'http://localhost:3000', + portalUrl: process.env.BACK_OFFICE_URL ?? 'http://localhost:3001', + }; +}); diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 004ef1336..cbdae37e3 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -1,11 +1,12 @@ -// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM +// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM // modules read process.env at module-load time (e.g. MinioModule.register reads MINIO_ENDPOINT), // which happens before ConfigModule.forRoot() would populate it. Must be the very first import. import "dotenv/config"; import "reflect-metadata"; import { NestFactory } from "@nestjs/core"; -import { ValidationPipe, VersioningType } from "@nestjs/common"; +import { Logger, ValidationPipe, VersioningType } from "@nestjs/common"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; +import helmet from "helmet"; import { AppModule } from "./app.module"; import { HttpExceptionFilter } from "./common/filters/http-exception.filter"; import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor"; @@ -14,21 +15,32 @@ import { SessionActivityInterceptor } from "./common/interceptors/session-activi // Set timezone to Africa/Addis_Ababa (EAT - UTC+3) for Ethiopian Railway operations process.env.TZ = 'Africa/Addis_Ababa'; +// Safety guard: prevent insecure TLS from being enabled in production +if (process.env.NODE_ENV === 'production' && process.env.WAAFI_INSECURE_TLS === 'true') { + throw new Error('WAAFI_INSECURE_TLS=true is not allowed in production'); +} + async function bootstrap() { // rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers // (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed. const app = await NestFactory.create(AppModule, { rawBody: true }); + // Security headers + app.use(helmet()); + // URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under // `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay - // version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend. + // version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend. app.enableVersioning({ type: VersioningType.URI }); app.enableCors({ origin: [ process.env.PORTAL_URL ?? "http://localhost:5174", - process.env.BACK_OFFICE_URL ?? "http://localhost:5184", + process.env.BACK_OFFICE_URL ?? "http://localhost:5184", ], + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'Accept-Language', 'X-Request-ID'], + credentials: true, }); app.useGlobalFilters(new HttpExceptionFilter()); @@ -38,6 +50,7 @@ async function bootstrap() { ); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidUnknownValues: false })); + if (process.env.NODE_ENV !== 'production') { const config = new DocumentBuilder() .setTitle("EDR Passenger API") .setDescription( @@ -130,7 +143,7 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m - Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps) - Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets - Complete audit trail per leg for compliance and reporting -- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode +- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode ### Booking Type Matrix @@ -240,28 +253,28 @@ For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`ret ### Step 3: Passenger Information & Verification **For Ethiopian Passengers:** -\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years) +\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years) **For International Passengers:** -\`POST /passengers/register-international\` — Passport information collection +\`POST /passengers/register-international\` — Passport information collection ### Step 4: View Seat Map -\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats. +\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats. For round-trips, call this twice: once for outbound scheduleId, once for return scheduleId. ### Step 5: Hold Seats \`POST /seats/hold\` to reserve seats for 15 minutes. -- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\` -- TRANSIT leg-2: second hold call → \`leg2HoldId\` -- ROUND_TRIP return: second hold call → \`returnHoldId\` -- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\` +- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\` +- TRANSIT leg-2: second hold call → \`leg2HoldId\` +- ROUND_TRIP return: second hold call → \`returnHoldId\` +- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\` ### Step 6: Create Booking Choose the right endpoint and bookingType: -- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\` -- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\` -- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\` -- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\` +- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\` +- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\` +- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\` +- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\` ### Step 7: Process Payment \`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian) @@ -406,10 +419,12 @@ Payment providers send notifications to: operationsSorter: "alpha", }, }); + } // end if (NODE_ENV !== 'production') const port = process.env.PORT ?? 4000; await app.listen(port); - console.log(`🚀 EDR Passenger API running on port ${port}`); - console.log(`📚 Swagger: http://localhost:${port}/api-docs`); + const logger = new Logger('Bootstrap'); + logger.log(`EDR Passenger API running on port ${port}`); + logger.log(`Swagger: http://localhost:${port}/api-docs`); } bootstrap(); diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts index 4dd131f3e..0213bb8f6 100644 --- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts @@ -180,6 +180,9 @@ export class PassengerAuthService { return { iamUserId, + // Top-level passengerId keeps the profile shape consistent with the login + // response so the web User object always carries it (the JWT does not). + passengerId: passenger.id, email: iam?.email ?? null, phone: iam?.phone_number ?? null, fullName: iam?.name?.en ?? iam?.name?.am ?? null, diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts index 909452144..0adb94656 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts @@ -2,9 +2,11 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; import { CurrenciesController } from './currencies.controller'; import { CurrenciesService } from './currencies.service'; +import { CurrencyModule } from '../currency/currency.module'; +import { PrismaModule } from '../../common/prisma.module'; @Module({ - imports: [HttpModule], + imports: [HttpModule, PrismaModule, CurrencyModule], controllers: [CurrenciesController], providers: [CurrenciesService], exports: [CurrenciesService], diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts index ee96ee9cd..2ccc79b70 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts @@ -1,10 +1,14 @@ import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; +import { CurrencyService } from '../currency/currency.service'; import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto'; @Injectable() export class CurrenciesService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private currencyService: CurrencyService, + ) {} async getAllCurrencies() { const rates = await this.prisma.currencyExchangeRate.findMany({ @@ -108,7 +112,12 @@ export class CurrenciesService { } async syncExchangeRates() { - return { message: 'Exchange rates synced successfully', synced: 0 }; + await this.currencyService.syncExchangeRates(); + const rates = await this.prisma.currencyExchangeRate.findMany({ + orderBy: { effectiveDate: 'desc' }, + take: 10, + }); + return { message: 'Exchange rates synced successfully', synced: rates.length }; } private getCurrencyName(code: string): string { diff --git a/apps/edr-passenger-api/src/modules/currency/currency.module.ts b/apps/edr-passenger-api/src/modules/currency/currency.module.ts index 445f31e05..635ab74b0 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.module.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; import { CurrencyService } from './currency.service'; import { PrismaModule } from '../../common/prisma.module'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, HttpModule], providers: [CurrencyService], exports: [CurrencyService], }) diff --git a/apps/edr-passenger-api/src/modules/currency/currency.service.ts b/apps/edr-passenger-api/src/modules/currency/currency.service.ts index 8ac2056ab..4666d4aaa 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.service.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts @@ -4,6 +4,9 @@ import { NotFoundException, BadRequestException, } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; import { PrismaService } from '../../common/prisma.service'; import { Currency } from '@prisma/client'; @@ -21,7 +24,11 @@ const CHARGE_CURRENCY_DECIMALS: Record = { export class CurrencyService { private readonly logger = new Logger(CurrencyService.name); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) {} async convertEtbMinorToChargeMajor( amountMinorEtb: number, @@ -81,14 +88,11 @@ export class CurrencyService { fromCurrency: Currency, toCurrency: Currency, ): Promise { + if (fromCurrency === toCurrency) return 1; + const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({ - where: { - fromCurrency, - toCurrency, - }, - orderBy: { - effectiveDate: 'desc', - }, + where: { fromCurrency, toCurrency }, + orderBy: { effectiveDate: 'desc' }, }); if (!exchangeRate) { @@ -98,26 +102,61 @@ export class CurrencyService { return 1.0; } + const ageMs = Date.now() - exchangeRate.effectiveDate.getTime(); + if (ageMs > 2 * 24 * 60 * 60 * 1000) { + this.logger.warn( + `Stale exchange rate for ${fromCurrency}->${toCurrency}: last updated ${exchangeRate.effectiveDate.toISOString()}`, + ); + } + return Number(exchangeRate.rate); } async syncExchangeRates(): Promise { - this.logger.log('Syncing exchange rates from external provider'); + this.logger.log('Syncing exchange rates from central bank API'); const today = this.todayUtc(); - const rates = [ - { from: 'ETB', to: 'ETB', rate: 1.0 }, - { from: 'ETB', to: 'DJF', rate: 3.25 }, - { from: 'ETB', to: 'USD', rate: 0.018 }, - { from: 'DJF', to: 'ETB', rate: 0.3077 }, - { from: 'USD', to: 'ETB', rate: 55.56 }, + // Fallback rates used when the API is unreachable + const fallbackRates = [ + { from: Currency.ETB, to: Currency.ETB, rate: 1.0 }, + { from: Currency.ETB, to: Currency.DJF, rate: 3.25 }, + { from: Currency.ETB, to: Currency.USD, rate: 0.018 }, + { from: Currency.DJF, to: Currency.ETB, rate: 0.3077 }, + { from: Currency.USD, to: Currency.ETB, rate: 55.56 }, ]; - for (const { from, to, rate } of rates) { - await this.upsertRate(from as Currency, to as Currency, rate, today, 'EXTERNAL_API'); + const apiUrl = this.configService.get('EXCHANGE_RATE_API_URL'); + if (apiUrl) { + try { + const response = await firstValueFrom( + this.httpService.get>(apiUrl, { timeout: 5000 }), + ); + // Expected response shape: { "ETB_DJF": 3.25, "ETB_USD": 0.018, ... } + const data = response.data; + const apiRates = [ + { from: Currency.ETB, to: Currency.ETB, rate: 1.0 }, + { from: Currency.ETB, to: Currency.DJF, rate: data['ETB_DJF'] ?? fallbackRates[1].rate }, + { from: Currency.ETB, to: Currency.USD, rate: data['ETB_USD'] ?? fallbackRates[2].rate }, + { from: Currency.DJF, to: Currency.ETB, rate: data['DJF_ETB'] ?? fallbackRates[3].rate }, + { from: Currency.USD, to: Currency.ETB, rate: data['USD_ETB'] ?? fallbackRates[4].rate }, + ]; + for (const { from, to, rate } of apiRates) { + await this.upsertRate(from, to, rate, today, 'CENTRAL_BANK_API'); + } + this.logger.log('Exchange rates synced from central bank API'); + return; + } catch (err) { + this.logger.warn( + `Central bank API unreachable (${(err as Error).message}), falling back to configured rates`, + ); + } } - this.logger.log('Exchange rates synced successfully'); + // Fallback: persist the static rates so the DB always has a current row + for (const { from, to, rate } of fallbackRates) { + await this.upsertRate(from, to, rate, today, 'FALLBACK'); + } + this.logger.log('Exchange rates synced using fallback values'); } async listRates() { diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index 847164551..592aadacd 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -4,8 +4,6 @@ import { CurrencyService } from '../currency/currency.service'; import { FareCalculateDto, resolveCurrencyFromNationality } from './fare-engine.dto'; import { Currency } from '@prisma/client'; -const TAX_RATE = 0.05; - @Injectable() export class FareEngineService { constructor( @@ -32,6 +30,22 @@ export class FareEngineService { if (!seatClass) throw new NotFoundException('Seat class not found'); if (!seatClass.isActive) throw new BadRequestException('Seat class is not active'); + // Resolve nationality type: Ethiopian and Djiboutian are LOCAL, everyone else INTERNATIONAL + const nationalityUpper = (dto.nationality ?? '').toUpperCase(); + const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') + ? 'LOCAL' : 'INTERNATIONAL'; + + // Find the nationality-specific seat class for the same coach type and bed position. + // Falls back to the requested seatClass if no nationality-specific one exists. + const nationalitySeatClass = await this.prisma.seatClass.findFirst({ + where: { + coachTypeId: seatClass.coachTypeId, + nationalityType, + bedPosition: seatClass.bedPosition ?? null, + isActive: true, + }, + }) ?? seatClass; + // Calculate distance: distanceKm represents cumulative distance from route origin // For a segment, distance = destination.distanceKm - origin.distanceKm const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!; @@ -68,16 +82,45 @@ export class FareEngineService { let ratePerKmMinor: number; let fareSource: string; - if (fareRule) { - // Flat fare from FareRule — distance is informational only + // 1. Segment override: exact origin→destination stop pair on this route + const segmentOverride = await this.prisma.segmentFareRule.findFirst({ + where: { + routeId: route.id, + seatClassId: dto.seatClassId, + originStopSequence: originStop.sequence, + destinationStopSequence: destStop.sequence, + validFrom: { lte: now }, + OR: [{ validUntil: null }, { validUntil: { gte: now } }], + nationality: dto.nationality ?? null, + }, + }) ?? await this.prisma.segmentFareRule.findFirst({ + where: { + routeId: route.id, + seatClassId: dto.seatClassId, + originStopSequence: originStop.sequence, + destinationStopSequence: destStop.sequence, + validFrom: { lte: now }, + OR: [{ validUntil: null }, { validUntil: { gte: now } }], + nationality: null, + }, + }); + + if (segmentOverride) { + // Flat override for this exact segment — baseFareMinor is the total base, not a per-km rate + baseFarePerPassengerMinor = segmentOverride.baseFareMinor; + ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; + fareSource = 'SEGMENT_FARE_RULE'; + } else if (fareRule?.tripId) { + // Schedule-scoped flat override baseFarePerPassengerMinor = fareRule.baseFareMinor; ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; - fareSource = fareRule.tripId ? 'SCHEDULE_FARE_RULE' : 'ROUTE_FARE_RULE'; + fareSource = 'SCHEDULE_FARE_RULE'; } else { - // Distance × rate fallback - ratePerKmMinor = seatClass.baseFareMinor; - baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor; - fareSource = 'DISTANCE_RATE'; + // Default: distance-based using tariff formula: km × rate × 1.02 + // baseFareMinor stores the per-km rate (tariff decimal × 100000) + ratePerKmMinor = nationalitySeatClass.baseFareMinor; + baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm * 1.02); + fareSource = 'SEAT_CLASS_BASE_FARE'; } // Premium and insurance fees applied per passenger @@ -110,8 +153,7 @@ export class FareEngineService { } const afterDiscountMinor = subtotalMinor - discountMinor; - const taxMinor = Math.round(afterDiscountMinor * TAX_RATE); - const totalEtbMinor = afterDiscountMinor + taxMinor; + const totalEtbMinor = afterDiscountMinor; const billingCurrency = resolveCurrencyFromNationality(dto.nationality); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); @@ -119,8 +161,9 @@ export class FareEngineService { const calculation = [ `Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`, - `Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`, - `Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} = ${baseFarePerPassengerMinor} ETB minor`, + `Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType} → ${nationalitySeatClass.name}`, + `Rate per km: ${ratePerKmMinor} ETB minor (${nationalitySeatClass.name})`, + `Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} × 1.02 = ${baseFarePerPassengerMinor} ETB minor`, `Premium/pax: ${premiumPerPassenger} ETB minor`, `Insurance/pax: ${insurancePerPassenger} ETB minor`, `Total fare/pax: ${farePerPassengerMinor} ETB minor`, @@ -132,10 +175,8 @@ export class FareEngineService { ``, `Subtotal: ${subtotalMinor} ETB minor`, `Discount: ${promoLabel} → -${discountMinor} ETB minor`, - `Tax (5%): +${taxMinor} ETB minor`, `Total (ETB): ${totalEtbMinor} ETB minor`, ``, - `Nationality: ${dto.nationality ?? 'unspecified'} → ${billingCurrency}`, `Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`, `Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`, `Fare source: ${fareSource}`, @@ -146,7 +187,8 @@ export class FareEngineService { routeCode: route.code, originName: originStation?.name ?? dto.originStationId, destinationName: destStation?.name ?? dto.destinationStationId, - seatClassName: seatClass.name, + seatClassId: nationalitySeatClass.id, + seatClassName: nationalitySeatClass.name, totalDistanceKm, ratePerKmMinor, baseFarePerPassengerMinor, @@ -159,7 +201,6 @@ export class FareEngineService { paidChildrenCount, subtotalMinor, discountMinor, - taxMinor, totalMinor: totalEtbMinor, billingCurrency, totalInBillingCurrency, @@ -284,16 +325,13 @@ export class FareEngineService { const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); return fareRules.map(rule => { const seatClassId = rule.seatClassId; - const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE); - const totalMinor = rule.baseFareMinor + taxMinor; return { seatClassId, seatClassName: 'Unknown', baseFareMinor: rule.baseFareMinor, - taxMinor, - totalMinor, + totalMinor: rule.baseFareMinor, billingCurrency, - totalInBillingCurrency: Math.round(totalMinor * exchangeRate), + totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), exchangeRate, source: 'FARE_RULE', }; diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts index 2f2a76f48..f7527020b 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts @@ -79,11 +79,10 @@ export class UpdateClassDto { @ApiPropertyOptional({ example: 'coach-type-uuid' }) @IsOptional() @IsString() coachTypeId?: string; @ApiPropertyOptional({ example: 'Economy' }) @IsOptional() @IsString() name?: string; @ApiPropertyOptional() @IsOptional() @IsString() description?: string; - @ApiPropertyOptional({ example: 500 }) @IsOptional() @IsInt() baseFareMinor?: number; - @ApiPropertyOptional({ example: true }) - @IsOptional() - @IsBoolean() - isActive?: boolean; + @ApiPropertyOptional({ example: 50 }) @IsOptional() @IsInt() baseFareMinor?: number; + @ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() premiumMinor?: number; + @ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() insuranceFeeMinor?: number; + @ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isActive?: boolean; } export class GenerateSeatMapDto { diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index ebec606c6..a725b9cd4 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -258,6 +258,8 @@ export class FleetService { name: dto.name, description: dto.description, baseFareMinor: dto.baseFareMinor, + premiumMinor: dto.premiumMinor, + insuranceFeeMinor: dto.insuranceFeeMinor, }; if (dto.isActive !== undefined) { diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts index 72cbbbb47..1cd382862 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts @@ -1,7 +1,7 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { RoutesService } from './routes.service'; -import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto'; +import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; import { JwtGuard } from '../../common/jwt.guard'; @ApiTags('Routes') @@ -93,4 +93,35 @@ Route stops carry distanceKm for fare-by-distance calculations.`, @ApiResponse({ status: 200, description: 'Schedules with train and terminal station details' }) @ApiResponse({ status: 404, description: 'Route not found' }) getSchedules(@Param('id') id: string) { return this.service.getSchedulesForRoute(id); } + + // ── Route Coach Template ─────────────────────────────────────────────────── + + @Get(':id/coaches') + @ApiOperation({ summary: 'Get the default coach lineup for this route' }) + @ApiParam({ name: 'id', description: 'Route UUID' }) + @ApiResponse({ status: 200, description: 'Ordered coach template with coach and coach type details' }) + @ApiResponse({ status: 404, description: 'Route not found' }) + getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); } + + @Put(':id/coaches') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Set the default coach lineup for this route', + description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.', + }) + @ApiParam({ name: 'id', description: 'Route UUID' }) + @ApiResponse({ status: 200, description: 'Updated coach template' }) + @ApiResponse({ status: 400, description: 'Duplicate positions or inactive coach' }) + @ApiResponse({ status: 404, description: 'Route or coach not found' }) + setCoachTemplate(@Param('id') id: string, @Body() dto: SetRouteCoachTemplateDto) { + return this.service.setRouteCoachTemplate(id, dto); + } + + @Delete(':id/coaches') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Clear the default coach lineup for this route' }) + @ApiParam({ name: 'id', description: 'Route UUID' }) + @ApiResponse({ status: 200, description: 'Template cleared' }) + @ApiResponse({ status: 404, description: 'Route not found' }) + clearCoachTemplate(@Param('id') id: string) { return this.service.removeRouteCoachTemplate(id); } } diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts index bce25fea5..bcd4dfcee 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -44,3 +44,14 @@ export class UpdateRouteDto { @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string; @ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[]; } + +export class RouteCoachTemplateItemDto { + @ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string; + @ApiProperty({ example: 1, description: 'Position in the train consist (1 = first coach)' }) @IsInt() @Min(1) positionNumber: number; +} + +export class SetRouteCoachTemplateDto { + @ApiProperty({ type: [RouteCoachTemplateItemDto], description: 'Ordered list of coaches for this route. Replaces the existing template.' }) + @IsArray() @ValidateNested({ each: true }) @Type(() => RouteCoachTemplateItemDto) + coaches: RouteCoachTemplateItemDto[]; +} diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index 2a7254f37..479f6b0ca 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; -import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto'; +import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; @Injectable() @@ -202,6 +202,44 @@ export class RoutesService { }); } + async getRouteCoachTemplate(routeId: string) { + const route = await this.prisma.route.findUnique({ where: { id: routeId } }); + if (!route) throw new NotFoundException('Route not found'); + return this.prisma.routeCoachTemplate.findMany({ + where: { routeId }, + include: { coach: { include: { coachType: true } } }, + orderBy: { positionNumber: 'asc' }, + }); + } + + async setRouteCoachTemplate(routeId: string, dto: SetRouteCoachTemplateDto) { + const route = await this.prisma.route.findUnique({ where: { id: routeId } }); + if (!route) throw new NotFoundException('Route not found'); + + const coachIds = dto.coaches.map(c => c.coachId); + const coaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } }); + if (coaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found'); + const inactive = coaches.find(c => c.status !== 'ACTIVE'); + if (inactive) throw new BadRequestException(`Coach ${inactive.number} is not active`); + + const positions = dto.coaches.map(c => c.positionNumber); + if (new Set(positions).size !== positions.length) throw new BadRequestException('Duplicate positionNumber values'); + + await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } }); + await this.prisma.routeCoachTemplate.createMany({ + data: dto.coaches.map(c => ({ routeId, coachId: c.coachId, positionNumber: c.positionNumber })), + }); + + return this.getRouteCoachTemplate(routeId); + } + + async removeRouteCoachTemplate(routeId: string) { + const route = await this.prisma.route.findUnique({ where: { id: routeId } }); + if (!route) throw new NotFoundException('Route not found'); + await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } }); + return { deleted: true, routeId }; + } + // ── Used by SchedulesService ─────────────────────────────────────────────── /** diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 6d1091719..1a5547d48 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SchedulesService } from './schedules.service'; @@ -132,6 +132,23 @@ export class SchedulesController { @Body() dto: UpdateStopTimeDto, ) { return this.service.updateStop(id, sequence, dto); } + @Put(':scheduleId/fares/:seatClassId') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Override fare for a specific seat class on a schedule', + description: 'Upserts a schedule-scoped FareRule. Expires any existing active rule for the same schedule+seatClass and creates a new one.', + }) + @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) + @ApiParam({ name: 'seatClassId', description: 'SeatClass UUID' }) + @ApiResponse({ status: 200, description: 'Fare rule upserted' }) + upsertScheduleFare( + @Param('scheduleId') scheduleId: string, + @Param('seatClassId') seatClassId: string, + @Body() dto: { baseFareMinor: number; validFrom?: string; validUntil?: string }, + ) { + return this.service.upsertScheduleFare(scheduleId, seatClassId, dto); + } + @Get(':scheduleId/fares/stored') @ApiOperation({ summary: 'Get stored fare rules for a schedule' }) @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index b6363e085..044768e90 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -119,7 +119,7 @@ export class BulkCreateSchedulesDto { @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) plannedTimes?: PlannedStopTimeDto[]; - @ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' }) + @ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule. Overrides the route coach template if provided.' }) @IsOptional() @IsArray() @IsString({ each: true }) coachIds?: string[]; } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 66e43d6e9..2d194fd7b 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -46,6 +46,8 @@ export class SchedulesService { const schedule = await this.createSchedule(createDto); scheduleIds.push(schedule.id); + // createSchedule already auto-applies the route coach template; + // only override if explicit coachIds are provided if (dto.coachIds && dto.coachIds.length > 0) { await this.assignCoaches( schedule.id, @@ -177,6 +179,18 @@ export class SchedulesService { const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap); + // Auto-apply route coach template if one is defined + const coachTemplates = await this.prisma.routeCoachTemplate.findMany({ + where: { routeId: dto.routeId }, + orderBy: { positionNumber: 'asc' }, + }); + if (coachTemplates.length > 0) { + await this.assignCoaches( + schedule.id, + coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })), + ); + } + return this.getSchedule(schedule.id); } @@ -402,6 +416,34 @@ export class SchedulesService { }); } + async upsertScheduleFare( + scheduleId: string, + seatClassId: string, + dto: { baseFareMinor: number; validFrom?: string; validUntil?: string }, + ) { + const [schedule, seatClass] = await Promise.all([ + this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }), + this.prisma.seatClass.findUnique({ where: { id: seatClassId } }), + ]); + if (!schedule) throw new NotFoundException('Schedule not found'); + if (!seatClass) throw new NotFoundException('Seat class not found'); + + const now = new Date(); + const validFrom = dto.validFrom ? parseEthiopianTime(dto.validFrom) : now; + const validUntil = dto.validUntil ? parseEthiopianTime(dto.validUntil) : null; + + return this.prisma.$transaction(async (tx) => { + await tx.fareRule.updateMany({ + where: { tripId: scheduleId, seatClassId, validUntil: null }, + data: { validUntil: now }, + }); + return tx.fareRule.create({ + data: { tripId: scheduleId, seatClassId, baseFareMinor: dto.baseFareMinor, currency: 'ETB', validFrom, validUntil }, + include: { seatClass: true }, + }); + }); + } + createFareRule(dto: CreateFareRuleDto) { const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto; return this.prisma.fareRule.create({ @@ -555,10 +597,10 @@ export class SchedulesService { await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } }); - const data = coaches.map((c, idx) => ({ + const data = coaches.map((c) => ({ scheduleId, coachId: c.coachId, - positionNumber: idx + 1, + positionNumber: c.positionNumber, isOperational: true, })); diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 08687d291..6f3bc5a67 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -418,6 +418,7 @@ export class SearchService { }, }); if (!schedule) throw new NotFoundException('Schedule not found'); + if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation'); const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId); const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId); @@ -426,56 +427,25 @@ export class SearchService { } const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } }); + if (!seatClass) throw new NotFoundException(`Seat class '${dto.seatClassName}' not found`); - const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; - const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; - const now = new Date(); - const nationality = dto.nationality; - - const candidates = await this.prisma.fareRule.findMany({ - where: { - seatClassId: seatClass?.id, - validFrom: { lte: now }, - OR: [ - { validUntil: null }, - { validUntil: { gte: now } }, - ], - }, + const fare = await this.fareEngine.calculate({ + routeId: schedule.routeId, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + seatClassId: seatClass.id, + nationality: dto.nationality, + scheduleId: dto.scheduleId, + adultCount: dto.adultCount, + childCount: dto.childCount ?? 0, + promoCode: dto.promoCode, }); - const bestMatch = this.selectBestFareRule( - candidates, - dto.scheduleId, - segmentRoute, - fullRoute, - nationality, - ); - - const baseFareMinor = bestMatch?.baseFareMinor - ?? await this.resolveScheduleFare(dto.scheduleId, seatClass?.id, dto.seatClassName); - - const adultCount = dto.adultCount; - const childCount = dto.childCount ?? 0; - const adultFareMinor = baseFareMinor * adultCount; - const paidChildrenCount = Math.max(0, childCount - 1); - const childFareMinor = baseFareMinor * paidChildrenCount; - const totalBaseFareMinor = adultFareMinor + childFareMinor; - - let discountMinor = 0; - if (dto.promoCode) { - const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); - if (promo?.active && promo.validUntil > now) { - discountMinor = promo.percentOff - ? Math.round(totalBaseFareMinor * promo.percentOff / 100) - : (promo.amountOffMinor ?? 0); - } - } - const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR; - const taxesMinor = 0; - const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor); + const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor); - const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality); + const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; + const displayCurrency = dto.displayCurrency ?? (fare.billingCurrency as Currency); const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; @@ -487,13 +457,23 @@ export class SearchService { segmentRoute, seatClassName: dto.seatClassName, nationality: dto.nationality, - adultCount, childCount, - baseFareMinor, adultFareMinor, childFareMinor, - freeChildrenCount: Math.min(childCount, 1), - paidChildrenCount, totalBaseFareMinor, - discountMinor, loyaltyRedemptionMinor: loyaltyMinor, - taxesFeesMinor: taxesMinor, totalMinor, - currency: 'ETB', displayCurrency, displayTotalMinor, + adultCount: fare.adultCount, + childCount: fare.childCount, + baseFareMinor: fare.baseFarePerPassengerMinor, + adultFareMinor: fare.adultCount * fare.farePerPassengerMinor, + childFareMinor: fare.paidChildrenCount * fare.farePerPassengerMinor, + freeChildrenCount: fare.freeChildrenCount, + paidChildrenCount: fare.paidChildrenCount, + premiumMinor: fare.premiumPerPassenger, + insuranceFeeMinor: fare.insurancePerPassenger, + totalBaseFareMinor: fare.subtotalMinor, + discountMinor: fare.discountMinor, + taxesFeesMinor: 0, + loyaltyRedemptionMinor: loyaltyMinor, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor, }; } @@ -505,15 +485,19 @@ export class SearchService { ): Promise> { const displayCurrency = resolveCurrencyFromNationality(nationality); - // Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany - const seatClassMap = new Map(); + // Collect seat class IDs from the schedule include for the ID set, + // but fetch fresh records from DB so updated baseFareMinor is always current + const seatClassIdSet = new Set(); 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); + if (sc.isActive) seatClassIdSet.add(sc.id); } } - const seatClasses = Array.from(seatClassMap.values()) - .sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor); + const freshSeatClasses = await this.prisma.seatClass.findMany({ + where: { id: { in: Array.from(seatClassIdSet) }, isActive: true }, + }); + const seatClassMap = new Map(freshSeatClasses.map(sc => [sc.id, sc])); + const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor); if (seatClasses.length === 0) return []; @@ -531,9 +515,9 @@ export class SearchService { }); return { seatClassName: fare.seatClassName, - baseFareMinor: fare.baseFarePerPassengerMinor, + baseFareMinor: fare.totalMinor, displayCurrency: fare.billingCurrency as Currency, - displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate), + displayAmountMinor: fare.totalInBillingCurrency, }; } catch { return null; @@ -568,12 +552,16 @@ export class SearchService { if (fareRules.length > 0) { const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency); - return fareRules.map(rule => ({ - seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown', - baseFareMinor: rule.baseFareMinor, - displayCurrency, - displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate), - })); + const TAX_RATE = 0.05; + return fareRules.map(rule => { + const totalMinor = rule.baseFareMinor + Math.round(rule.baseFareMinor * TAX_RATE); + return { + seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown', + baseFareMinor: totalMinor, + displayCurrency, + displayAmountMinor: Math.round(totalMinor * exchangeRate), + }; + }); } } @@ -644,54 +632,4 @@ export class SearchService { }); } - private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise { - if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`); - const schedule = await this.prisma.trainSchedule.findUnique({ - where: { id: scheduleId }, - select: { routeId: true, originStationId: true, destinationStationId: true }, - }); - if (!schedule?.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation'); - const fare = await this.fareEngine.calculate({ - routeId: schedule.routeId, - originStationId: schedule.originStationId, - destinationStationId: schedule.destinationStationId, - seatClassId, - }); - return fare.baseFarePerPassengerMinor; - } - - private selectBestFareRule( - candidates: any[], - scheduleId: string, - segmentRoute: string, - fullRoute: string, - nationality?: string, - ): any | null { - const priorities = [ - { tripId: scheduleId, route: segmentRoute, nationality }, - { tripId: scheduleId, route: segmentRoute, nationality: null }, - { tripId: scheduleId, route: fullRoute, nationality }, - { tripId: scheduleId, route: fullRoute, nationality: null }, - { tripId: scheduleId, route: null, nationality }, - { tripId: scheduleId, route: null, nationality: null }, - { tripId: null, route: segmentRoute, nationality }, - { tripId: null, route: segmentRoute, nationality: null }, - { tripId: null, route: fullRoute, nationality }, - { tripId: null, route: fullRoute, nationality: null }, - { tripId: null, route: null, nationality }, - { tripId: null, route: null, nationality: null }, - ]; - - for (const priority of priorities) { - const match = candidates.find( - (c) => - c.tripId === priority.tripId && - c.route === priority.route && - c.nationality === priority.nationality, - ); - if (match) return match; - } - - return null; - } } diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts index 10fcfe0cf..79151bdc9 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts @@ -19,21 +19,31 @@ export class SeatClassesService { return sc; } + async updateSeatClass(id: string, dto: any) { + const sc = await this.prisma.seatClass.findUnique({ where: { id } }); + if (!sc) throw new NotFoundException('SeatClass not found'); + const { basePrice, ...rest } = dto; + const data = { + ...rest, + ...(basePrice !== undefined && { baseFareMinor: basePrice }), + }; + return this.prisma.seatClass.update({ where: { id }, data }); + } + async createSeatClass(dto: any) { try { - return await this.prisma.seatClass.create({ data: dto }); + const { basePrice, ...rest } = dto; + const data = { + ...rest, + ...(basePrice !== undefined && { baseFareMinor: basePrice }), + }; + return await this.prisma.seatClass.create({ data }); } catch (e: any) { if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`); throw e; } } - async updateSeatClass(id: string, dto: any) { - const sc = await this.prisma.seatClass.findUnique({ where: { id } }); - if (!sc) throw new NotFoundException('SeatClass not found'); - return this.prisma.seatClass.update({ where: { id }, data: dto }); - } - async deleteSeatClass(id: string) { const sc = await this.prisma.seatClass.findUnique({ where: { id } }); if (!sc) throw new NotFoundException('SeatClass not found'); diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts index 9759ff297..fb8a29a6e 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../../common/prisma.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { CurrencyModule } from '../currency/currency.module'; import { TasksService } from './tasks.service'; @Module({ - imports: [PrismaModule, NotificationsModule], + imports: [PrismaModule, NotificationsModule, CurrencyModule], providers: [TasksService], }) export class TasksModule {} diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index bc52cc613..401a37ddb 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -2,12 +2,20 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; +import { CurrencyService } from '../currency/currency.service'; /** Maximum time (hours) a passenger has to pay after booking. */ const MAX_PAYMENT_HOURS = 2; /** Minutes before departure: cutoff for new bookings and payment deadline. */ const CUTOFF_MINUTES = 30; +// Retention windows +const OTP_RETENTION_HOURS = 1; +const FAYDA_SESSION_RETENTION_HOURS = 1; +const AUDIT_LOG_RETENTION_DAYS = 365; +const WEBHOOK_EVENT_RETENTION_DAYS = 90; +const GATE_LOG_RETENTION_DAYS = 180; + /** * payment_deadline = MIN(booking_time + 2h, departure_time - 30min) */ @@ -32,6 +40,7 @@ export class TasksService { constructor( private readonly prisma: PrismaService, private readonly sms: SmsClientService, + private readonly currencyService: CurrencyService, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -243,4 +252,47 @@ export class TasksService { this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`); } } + + // ───────────────────────────────────────────────────────────────────────── + // Daily at 01:00 EAT: fetch mid-market rates from central bank API. + // ───────────────────────────────────────────────────────────────────────── + @Cron('0 1 * * *', { timeZone: 'Africa/Addis_Ababa' }) + async syncExchangeRates() { + try { + await this.currencyService.syncExchangeRates(); + } catch (err) { + this.logger.error(`Exchange rate sync failed: ${(err as Error).message}`); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Daily at 02:00 EAT: purge expired/stale records to enforce data retention. + // ───────────────────────────────────────────────────────────────────────── + @Cron('0 2 * * *') + async purgeExpiredData() { + const now = new Date(); + + const otpCutoff = new Date(now.getTime() - OTP_RETENTION_HOURS * 60 * 60 * 1000); + const faydaCutoff = new Date(now.getTime() - FAYDA_SESSION_RETENTION_HOURS * 60 * 60 * 1000); + const auditCutoff = new Date(now.getTime() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000); + const webhookCutoff = new Date(now.getTime() - WEBHOOK_EVENT_RETENTION_DAYS * 24 * 60 * 60 * 1000); + const gateCutoff = new Date(now.getTime() - GATE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000); + + const [otps, faydaSessions, auditLogs, webhookEvents, gateLogs] = await Promise.all([ + this.prisma.otpCode.deleteMany({ + where: { OR: [{ expiresAt: { lte: otpCutoff } }, { verified: true, createdAt: { lte: otpCutoff } }] }, + }), + this.prisma.faydaVerificationSession.deleteMany({ + where: { OR: [{ expiresAt: { lte: faydaCutoff } }, { status: { in: ['COMPLETED', 'FAILED'] }, createdAt: { lte: faydaCutoff } }] }, + }), + this.prisma.auditLog.deleteMany({ where: { createdAt: { lte: auditCutoff } } }), + this.prisma.paymentWebhookEvent.deleteMany({ where: { receivedAt: { lte: webhookCutoff } } }), + this.prisma.gateValidationLog.deleteMany({ where: { validatedAt: { lte: gateCutoff } } }), + ]); + + this.logger.log( + `Data retention purge: ${otps.count} OTPs, ${faydaSessions.count} Fayda sessions, ` + + `${auditLogs.count} audit logs, ${webhookEvents.count} webhook events, ${gateLogs.count} gate logs deleted`, + ); + } } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 797adbb55..e482189d2 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -159,6 +159,8 @@ export class TicketsService { } : null, status: t.status, validatedAt: t.validatedAt, + boardedAt: t.validatedAt, + qrCode: t.qrPayload ?? null, createdAt: t.issuedAt, }; }), @@ -247,9 +249,12 @@ export class TicketsService { // Use first seat for primary data const primarySeat = passengerSeats[0]; - // Build passenger QR data with all legs included - const qrData = JSON.stringify({ + const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`; + + // Re-encode QR with ticketNumber included + const qrDataWithTicket = JSON.stringify({ ref: booking.bookingRef, + ticketNumber: barcodePayload, type: booking.bookingType, passenger: passengerName, seats: passengerSeats.map(ps => ({ @@ -259,8 +264,7 @@ export class TicketsService { scheduleId: ps.scheduleId || booking.scheduleId, })), }); - const qrPayload = await QRCode.toDataURL(qrData); - const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`; + const qrPayloadFinal = await QRCode.toDataURL(qrDataWithTicket); const ticket = await this.prisma.ticket.create({ data: { @@ -270,7 +274,7 @@ export class TicketsService { seatId: primarySeat.seatId, leg: primarySeat.leg || 1, scheduleId: primarySeat.scheduleId || booking.scheduleId, - qrPayload, + qrPayload: qrPayloadFinal, barcodePayload, } as any, }); @@ -371,15 +375,13 @@ export class TicketsService { let bookingRef = qrCodeOrRef; try { const qrData = JSON.parse(qrCodeOrRef); - if (qrData.ref) { - bookingRef = qrData.ref; - } + if (qrData.ref) bookingRef = qrData.ref; } catch { - // Not JSON, treat as booking reference + // Not JSON, treat as booking reference or ticket number } // Get booking and ticket info - const booking = await this.prisma.booking.findUnique({ + let booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, @@ -389,6 +391,23 @@ export class TicketsService { }, }); + if (!booking) { + // Input may be a ticket number (barcodePayload) — look it up + const ticket = await this.prisma.ticket.findFirst({ where: { barcodePayload: bookingRef } }); + if (ticket) { + booking = await this.prisma.booking.findUnique({ + where: { bookingRef: ticket.bookingRef }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + returnSchedule: { include: { originStation: true, destinationStation: true } }, + tickets: true, + seats: { include: { seat: { include: { coach: true } } } }, + }, + }); + if (booking) bookingRef = (booking as any).bookingRef; + } + } + if (!booking) { throw new NotFoundException('Ticket not found'); } @@ -443,6 +462,7 @@ export class TicketsService { message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`, boarding: { ticketId: ticket.id, + ticketNumber: ticket.barcodePayload, bookingRef: booking.bookingRef, passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A', route: `${(booking as any).schedule?.originStation?.name || 'N/A'} → ${(booking as any).schedule?.destinationStation?.name || 'N/A'}`, diff --git a/apps/edr-passenger-web/backoffice/next.config.js b/apps/edr-passenger-web/backoffice/next.config.js index 5690be409..ba1deb613 100644 --- a/apps/edr-passenger-web/backoffice/next.config.js +++ b/apps/edr-passenger-web/backoffice/next.config.js @@ -1,12 +1,13 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + output: 'standalone', reactStrictMode: true, transpilePackages: ['@edr/types', '@edr/ui-common'], env: { NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000', }, images: { - unoptimized: true, + unoptimized: false, }, }; diff --git a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx index db5f362dc..75f85cc90 100644 --- a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx @@ -15,7 +15,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro const canvasRef = useRef(null); const [isScanning, setIsScanning] = useState(false); const [isInitializing, setIsInitializing] = useState(false); - const [stream, setStream] = useState(null); + const streamRef = useRef(null); const [cameraError, setCameraError] = useState(null); const scanIntervalRef = useRef(null); @@ -34,9 +34,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro } // First, stop any existing stream - if (stream) { - stream.getTracks().forEach(track => track.stop()); - setStream(null); + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + streamRef.current = null; } // Request camera access with simpler fallback @@ -120,7 +120,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro } // Set state to show video - setStream(mediaStream); + streamRef.current = mediaStream; setIsScanning(true); setIsInitializing(false); @@ -144,9 +144,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro onError(errorMsg); // Clean up on error - if (stream) { - stream.getTracks().forEach(track => track.stop()); - setStream(null); + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + streamRef.current = null; } setIsScanning(false); setIsInitializing(false); @@ -158,16 +158,16 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro clearInterval(scanIntervalRef.current); scanIntervalRef.current = null; } - if (stream) { - stream.getTracks().forEach(track => track.stop()); - setStream(null); + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + streamRef.current = null; } if (videoRef.current) { videoRef.current.srcObject = null; } setIsScanning(false); setCameraError(null); - }, [stream]); + }, []); // QR code scanning with jsqr const scanFrame = useCallback(() => { @@ -201,15 +201,19 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro useEffect(() => { if (isScanning) { - scanIntervalRef.current = window.setInterval(scanFrame, 100); // Scan every 100ms - } - return () => { + scanIntervalRef.current = window.setInterval(scanFrame, 100); + } else { if (scanIntervalRef.current) { clearInterval(scanIntervalRef.current); + scanIntervalRef.current = null; } - stopCamera(); - }; - }, [isScanning, scanFrame, stopCamera]); + } + }, [isScanning, scanFrame]); + + // Cleanup on unmount only + useEffect(() => { + return () => stopCamera(); + }, [stopCamera]); // Load jsqr from CDN useEffect(() => { @@ -294,9 +298,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro