From cd31f3612cbd4d588de19386d927913b9c42ddf2 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 17 Jun 2026 12:35:29 +0000 Subject: [PATCH] Implement user-based booking access control and enhance booking filtering options --- .../modules/bookings/bookings.controller.ts | 43 +- .../src/modules/bookings/bookings.service.ts | 40 +- .../backoffice/src/constants/apiConfig.ts | 4 +- .../portal/src/constants/apiConfig.ts | 4 +- .../components/PaymentDeadlineCard.tsx | 13 +- .../components/StatusHero.tsx | 86 +++- .../BookingDetailPage/components/pricing.tsx | 4 +- .../portal/src/pages/bookings/MyBookings.tsx | 471 +++++++++++++----- .../portal/src/services/bookings.service.ts | 2 + 9 files changed, 530 insertions(+), 137 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index ba9fffb66..b2ac73f57 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -11,6 +11,7 @@ import { Query, Request, Res, + UnauthorizedException, UploadedFiles, UseInterceptors, } from '@nestjs/common'; @@ -117,8 +118,22 @@ export class BookingsController { @Get() @ApiOperation({ summary: 'List freight bookings (paginated)' }) - findAll(@Query() filter: FilterBookingDto) { - return this.bookingsService.findAll(filter); + async findAll( + @Query() filter: FilterBookingDto, + @CurrentUser() user: TCurrentUser, + ) { + // Staff (backoffice) see every booking. Customers (portal) are always + // force-scoped to their own company, regardless of any companyId they pass. + if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + return this.bookingsService.findAll(filter); + } + const userId = user?.id; + if (!userId) throw new UnauthorizedException('Authentication required'); + const companyId = + await this.bookingsService.resolveCustomerCompanyId(userId); + // No linked company yet → no bookings to show (avoids leaking all bookings). + if (!companyId) return { items: [], total: 0 }; + return this.bookingsService.findAll(filter, companyId); } @Get('list-summary') @@ -166,15 +181,35 @@ export class BookingsController { @Get('by-reference/:reference') @ApiOperation({ summary: 'Get booking by reference' }) - async findByReference(@Param('reference') reference: string) { + async findByReference( + @Param('reference') reference: string, + @CurrentUser() user: TCurrentUser, + ) { const booking = await this.bookingsService.findByReference(reference); + // Staff see any booking; customers only their own company's. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } return this.transitionService.enrichBookingResponse(booking); } @Get(':id') @ApiOperation({ summary: 'Get booking by ID' }) - async findOne(@Param('id', ParseUUIDPipe) id: string) { + async findOne( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { const booking = await this.bookingsService.findById(id); + // Staff see any booking; customers only their own company's. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } return this.transitionService.enrichBookingResponse(booking); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index ebf8273de..70cc9affc 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ConflictException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -575,6 +576,7 @@ export class BookingsService { /** Return a paginated list of bookings matching the filter. */ async findAll( filter: FilterBookingDto, + forceCompanyId?: string, ): Promise<{ items: Booking[]; total: number }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; @@ -587,7 +589,9 @@ export class BookingsService { ...statusFilter, ...schedulingStatusFilter, assignedToSchedule: filter.assignedToSchedule, - companyId: filter.companyId, + // A forced company scope (portal/customer) overrides any caller-provided + // companyId so a customer can only ever see their own company's bookings. + companyId: forceCompanyId ?? filter.companyId, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, @@ -631,6 +635,40 @@ export class BookingsService { }); } + /** + * Resolve the company a customer user belongs to, for scoping their own + * bookings. Returns null when no profile/company is linked yet. + */ + async resolveCustomerCompanyId(userId: string): Promise { + try { + const { company } = + await this.companiesService.getCompanyInfoByUserId(userId); + return company?.id ?? null; + } catch { + return null; + } + } + + /** + * Authorize a customer's access to a single booking. Staff are scoped at the + * controller (they pass `isStaff`); for a customer, the booking must belong + * to the company the authenticated user is linked to — otherwise it is hidden + * behind a NotFound so booking IDs can't be probed. + */ + async assertCustomerCanAccessBooking( + userId: string | undefined, + booking: Booking, + ): Promise { + if (!userId) { + throw new ForbiddenException('Authentication required'); + } + const companyId = await this.resolveCustomerCompanyId(userId); + if (!companyId || booking.companyId !== companyId) { + // Don't reveal that the booking exists for another company. + throw new NotFoundException(`Booking ${booking.id} not found`); + } + } + /** Aggregate metrics and tab counts for the backoffice booking list. */ async getListSummary(filter: FilterBookingDto): Promise { const page = filter.page ?? 1; diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 030b051a1..a7c8670cf 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,3 +1,3 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -// export const API_BASE_URL = 'http://localhost:3001'; +export const API_BASE_URL = 'http://localhost:3001'; diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index dce8aad63..fc9f57a58 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1,3 +1,3 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -// export const API_BASE_URL = 'http://localhost:3001'; +// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +export const API_BASE_URL = 'http://localhost:3001'; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx index 75ed3e652..54f900ab5 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx @@ -69,11 +69,18 @@ export function PaymentDeadlineCard({ return () => clearInterval(interval); }, [deadlineMs]); - const accentBg = remaining.expired ? "#FBEAE7" : "#FDF3E0"; - const accentFg = remaining.expired ? "#C0392B" : "#9A5B00"; + const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6"; + const accentFg = remaining.expired ? "#C0392B" : "#B07D14"; return ( - + Payment deadline + + + + + + + + + ); +} + +function RouteEndpoint({ + label, + value, + alignRight, +}: { + label: string; + value: string; + alignRight?: boolean; +}) { + return ( + + + + + {label} + + + + {value} + + + ); +} + export function StatusHero({ booking, children, @@ -91,6 +171,8 @@ export function StatusHero({ + {!negative && } + {children ?? ( est. diff --git a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx index 0ee19f95e..0be9ef439 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { @@ -8,14 +8,33 @@ import { Card, Group, Menu, + Paper, + Select, + SimpleGrid, Stack, Text, + TextInput, ThemeIcon, Title, } from "@mantine/core"; -import { ArrowUpDown, Download, Filter, MoreVertical, Package, Plus } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { + ArrowRight, + CheckCircle2, + CreditCard, + FileEdit, + LayoutList, + MoreVertical, + Package, + Plus, + Search, + Wallet, + X, +} from "lucide-react"; import { api } from "@/services/api"; +import type { BookingListFilter } from "@/services/bookings.service"; +import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants"; import type { Freight } from "@edr/types"; import { DataTable, @@ -24,25 +43,86 @@ import { usePagination, } from "@edr/ui-common"; -// ── Status badge ────────────────────────────────────────────────────────────── +// ── Status filter options (grouped by lifecycle) ────────────────────────────── -const STATUS_CONFIG: Record = { - DRAFT: { bg: "#F1F4F7", dot: "#94A3B8", color: "#475569", label: "Draft" }, - REVIEWING: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Reviewing" }, - AWAITING_PAYMENT: { bg: "#FDF3E0", dot: "#F2A516", color: "#9A5B00", label: "Awaiting Payment" }, - CONFIRMED: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "Confirmed" }, - IN_TRANSIT: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "In Transit" }, - DELIVERED: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Delivered" }, - CANCELLED: { bg: "#FBEAE7", dot: "#C0392B", color: "#C0392B", label: "Cancelled" }, -}; +const STATUS_FILTERS = [ + { key: "all", label: "All bookings", statuses: undefined as string | undefined }, + { + key: "active", + label: "In progress", + statuses: + "SUBMITTED,CHANGES_REQUESTED,PENDING_APPROVAL,APPROVED_PENDING_SIGNATURE,APPROVED,CONTRACT_READY,SIGNED_CUSTOMER,FULLY_EXECUTED,PENDING_CONSOLIDATION,CONSOLIDATED", + }, + { key: "draft", label: "Drafts", statuses: "DRAFT" }, + { + key: "payment", + label: "Awaiting payment", + statuses: + "SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED", + }, + { key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" }, + { key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" }, + { key: "closed", label: "Cancelled / rejected", statuses: "CANCELLED,REJECTED" }, +] as const; + +type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"]; + +const SELECT_DATA = STATUS_FILTERS.map((f) => ({ value: f.key, label: f.label })); + +// ── Summary stat cards (clickable lifecycle filters) ────────────────────────── + +const STAT_CARDS: Array<{ + key: StatusFilterKey; + label: string; + icon: LucideIcon; + iconBg: string; + iconColor: string; +}> = [ + { + key: "all", + label: "All bookings", + icon: LayoutList, + iconBg: "#ECF6F1", + iconColor: "#0A8A5F", + }, + { + key: "active", + label: "In progress", + icon: Package, + iconBg: "#FDF3E0", + iconColor: "#C77F09", + }, + { + key: "payment", + label: "Awaiting payment", + icon: Wallet, + iconBg: "#FEF6E6", + iconColor: "#F2A516", + }, + { + key: "draft", + label: "Drafts", + icon: FileEdit, + iconBg: "#F1F4F7", + iconColor: "#475569", + }, + { + key: "done", + label: "Completed", + icon: CheckCircle2, + iconBg: "#ECF6F1", + iconColor: "#0A8A5F", + }, +]; + +// ── Status badge (reuses the shared portal status config) ───────────────────── function StatusBadge({ status }: { status: string }) { - const cfg = STATUS_CONFIG[status] ?? { - bg: "#F1F4F7", - dot: "#94A3B8", - color: "#475569", - label: status.replace(/_/g, " "), - }; + const cfg = STATUS_CONFIG[status]; + const label = cfg?.badgeLabel ?? status.replace(/_/g, " "); + const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7"; + const text = cfg ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` : "#475569"; + const dot = cfg ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` : "#94A3B8"; return ( @@ -60,12 +140,12 @@ function StatusBadge({ status }: { status: string }) { width: 6, height: 6, borderRadius: "50%", - backgroundColor: cfg.dot, + backgroundColor: dot, flexShrink: 0, }} /> - - {cfg.label} + + {label} ); @@ -82,6 +162,7 @@ function PrimaryAction({ id: string; onNavigate: (path: string) => void; }) { + const go = () => onNavigate(`/bookings/${id}`); if (status === "DRAFT") { return ( ); } - if (status === "AWAITING_PAYMENT") { + if (status === "SELECTED_FOR_BATCH") { return ( - ); - } - if (status === "IN_TRANSIT") { - return ( - ); } return ( - ); } -// ── Column header label ─────────────────────────────────────────────────────── - function ColHeader({ label }: { label: string }) { return ( void; +}) { + const Icon = card.icon; + return ( + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(); + } + }} + p="md" + radius="lg" + withBorder + style={{ + cursor: "pointer", + transition: "box-shadow 140ms ease, border-color 140ms ease", + borderColor: active ? "#F2A516" : "var(--mantine-color-edr-border-0)", + boxShadow: active ? "0 0 0 1px #F2A516" : "none", + }} + > + + + + + + + {count ?? "—"} + + + {card.label} + + + + + ); +} + export default function MyBookings() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [statusFilter, setStatusFilter] = useState("all"); + const [query, setQuery] = useState(""); - const { data, isLoading, isError } = useQuery(api.bookings.list.queryOptions()); - const bookings = data?.items ?? []; + const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses; - const total = bookings.length; - const pageCount = Math.ceil(total / pagination.pageSize); - const start = pagination.pageIndex * pagination.pageSize; - const end = Math.min(start + pagination.pageSize, total); - const paginatedData = useMemo(() => bookings.slice(start, end), [bookings, start, end]); + const selectFilter = (key: StatusFilterKey) => { + setStatusFilter(key); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }; + + const filter: BookingListFilter = useMemo( + () => ({ + statuses, + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + }), + [statuses, pagination.pageIndex, pagination.pageSize], + ); + + const { data, isLoading, isError } = useQuery( + api.bookings.list.queryOptions({ input: filter }), + ); + + // Per-card lifecycle counts (one cheap query each, total-only). + const allCount = useStatusCount(undefined); + const activeCount = useStatusCount( + STATUS_FILTERS.find((f) => f.key === "active")!.statuses, + ); + const paymentCount = useStatusCount( + STATUS_FILTERS.find((f) => f.key === "payment")!.statuses, + ); + const draftCount = useStatusCount( + STATUS_FILTERS.find((f) => f.key === "draft")!.statuses, + ); + const doneCount = useStatusCount( + STATUS_FILTERS.find((f) => f.key === "done")!.statuses, + ); + const cardCounts: Record = { + all: allCount, + active: activeCount, + payment: paymentCount, + draft: draftCount, + done: doneCount, + transit: undefined, + closed: undefined, + }; + + const allItems = data?.items ?? []; + const total = data?.total ?? allItems.length; + + // Server handles status + pagination; reference search is applied on the page. + const rows = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return allItems; + return allItems.filter((b) => + [b.reference, b.originYard?.label, b.destinationYard?.label] + .filter(Boolean) + .some((v) => String(v).toLowerCase().includes(q)), + ); + }, [allItems, query]); + + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success"; + const showEmpty = + !isLoading && !isError && rows.length === 0; const columns: ColumnDef[] = [ { @@ -178,8 +379,7 @@ export default function MyBookings() { header: () => , cell: ({ row }) => { const b = row.original; - const cargoLabel = - b.freightType === "BULK" ? "Bulk Cargo" : "Cargo"; + const cargoLabel = b.freightType === "BULK" ? "Bulk cargo" : "Container"; return ( @@ -245,7 +445,10 @@ export default function MyBookings() { meta: hMeta, header: () => , cell: ({ row }) => { - const b = row.original as Freight.IBooking & { totalAmount?: number; amount?: number }; + const b = row.original as Freight.IBooking & { + totalAmount?: number; + amount?: number; + }; const amount = b.totalAmount ?? b.amount ?? null; if (!amount) { return ( @@ -272,18 +475,13 @@ export default function MyBookings() { - + navigate(`/bookings/${booking.id}`)}> - View Details + View details @@ -293,8 +491,6 @@ export default function MyBookings() { }, ]; - const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success"; - return ( @@ -305,81 +501,112 @@ export default function MyBookings() { Bookings - Manage every cargo booking — from draft to delivery. + Track every cargo booking — from draft to delivery. - - - - + + {/* ── Summary stat cards ──────────────────────────────────────── */} + + {STAT_CARDS.map((card) => ( + selectFilter(card.key)} + /> + ))} + + {/* ── Bookings table card ──────────────────────────────────────── */} - {/* Toolbar */} - - + + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + rightSection={ + query ? ( + setQuery("")} + > + + + ) : null + } + radius="md" + style={{ flex: 1, minWidth: 200, maxWidth: 340 }} + /> +