diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index ecd3b4043..36f2b7250 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -22,7 +22,7 @@ import { ApiBody, } from "@nestjs/swagger"; import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator"; -import { BookingsService } from "./bookings.service"; +import { BookingsService, BookingScope } from "./bookings.service"; import { GuestBookingService } from "./guest-booking.service"; import { CreateBookingDto, @@ -38,6 +38,8 @@ import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../comm import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { SeatsService } from "../seats/seats.service"; +const BOOKING_SCOPES: BookingScope[] = ["upcoming", "past", "cancelled", "all"]; + @ApiTags("Booking") @Controller("bookings") // @Throttle({ strict: { limit: 20, ttl: 60_000 } }) @@ -66,6 +68,13 @@ export class BookingsController { required: false, description: "Filter by booking status", }) + @ApiQuery({ + name: "scope", + required: false, + enum: ["upcoming", "past", "cancelled", "all"], + description: + "Which slice of the history to return. 'upcoming' and 'past' split on the schedule's departure and exclude cancelled/refunded bookings; 'cancelled' returns only those. Defaults to 'all'.", + }) @ApiQuery({ name: "page", required: false, description: "Page number" }) @ApiQuery({ name: "pageSize", @@ -80,6 +89,7 @@ export class BookingsController { @Req() req: any, @Query("search") search?: string, @Query("status") status?: string, + @Query("scope") scope?: BookingScope, @Query("page") page?: string, @Query("pageSize") pageSize?: string, ) { @@ -88,6 +98,7 @@ export class BookingsController { return this.service.findByIamUserId(iamUserId, { search, status, + scope: BOOKING_SCOPES.includes(scope as BookingScope) ? scope : "all", page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 20, }); diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 7a447690a..b627d64ba 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -15,7 +15,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service'; import { PaymentsService } from '../payments/payments.service'; import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils'; import { normalizePhoneVariants } from '../../common/utils/phone.utils'; -import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; +import { BookingStatus, Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; import { JourneyDirection } from '../seats/seats.dto'; import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; @@ -67,8 +67,15 @@ interface BookingFilters { dateTo?: string; page?: number; pageSize?: number; + /** Portal "My bookings" tabs. Only honoured by findByPassengerId. */ + scope?: BookingScope; } +export type BookingScope = 'upcoming' | 'past' | 'cancelled' | 'all'; + +/** Statuses that mean the reservation is off — used by the `cancelled` scope. */ +const CLOSED_BOOKING_STATUSES: BookingStatus[] = ['CANCELLED', 'REFUNDED']; + @Injectable() export class BookingsService { private readonly logger = new Logger(BookingsService.name); @@ -87,16 +94,34 @@ export class BookingsService { ) {} async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) { - const passenger = await this.prisma.passenger.findUniqueOrThrow({ where: { iamUserId }, select: { id: true } }); + // An IAM user with no Passenger row is normal, not an error: a freshly registered + // account that has never booked, or a staff account. findUniqueOrThrow raised P2025 + // here, which surfaced as a 500 on the portal's "My bookings" page. Empty page instead. + const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } }); + if (!passenger) { + const page = filters.page ?? 1; + const pageSize = filters.pageSize ?? 20; + return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } }; + } return this.findByPassengerId(passenger.id, filters); } + /** + * The portal's authenticated "My bookings" history (GET /bookings/my). + * + * `scope` drives the Upcoming / Past / Cancelled tabs server-side so each tab paginates + * correctly, rather than the client filtering one page at a time. Note it filters on + * `schedule.departureAt` — the schedule's own origin departure — while each item's + * displayed `departureAt` comes from resolveBookingSegment, i.e. the passenger's own + * boarding stop. They differ by the run time to that stop; that is close enough for a + * tab filter and avoids a correlated stopTimes query per row. + */ async findByPassengerId(passengerId: string, filters: BookingFilters = {}) { - const { search, status, page = 1, pageSize = 20 } = filters; + const { search, status, scope = 'all', page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - + const where: any = { passengerId }; - + if (search) { where.OR = [ { bookingRef: { contains: search, mode: 'insensitive' } }, @@ -104,22 +129,41 @@ export class BookingsService { { schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } }, ]; } - - if (status) { + + // `status` used to be forwarded raw, so an unrecognised value threw a Prisma + // validation error (a 500) rather than being ignored. Only accept real enum members. + if (status && (Object.values(BookingStatus) as string[]).includes(status)) { where.status = status; } - + + const now = new Date(); + let orderBy: any = { createdAt: 'desc' }; + if (scope === 'cancelled') { + where.status = { in: CLOSED_BOOKING_STATUSES }; + } else if (scope === 'upcoming' || scope === 'past') { + // Don't clobber an explicit `status` filter — intersect with it. + if (!where.status) where.status = { notIn: CLOSED_BOOKING_STATUSES }; + where.schedule = { + ...(where.schedule ?? {}), + departureAt: scope === 'upcoming' ? { gte: now } : { lt: now }, + }; + orderBy = { schedule: { departureAt: scope === 'upcoming' ? 'asc' : 'desc' } }; + } + const [items, total] = await Promise.all([ this.prisma.booking.findMany({ where, skip, take: pageSize, - orderBy: { createdAt: 'desc' }, + orderBy, include: { schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: true, - seats: { include: { seat: true } }, + seats: { include: { seat: { include: { coach: { select: { number: true } } } } } }, priceTier: { select: { priceMinor: true } }, + // A rescheduled booking stays CONFIRMED — there is no RESCHEDULED status — so the + // portal needs this to show a "Rescheduled" chip alongside the real status. + reschedules: { where: { status: 'APPLIED' }, select: { id: true } }, }, }), this.prisma.booking.count({ where }), @@ -150,6 +194,21 @@ export class BookingsService { }, paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, + // Seat/coach per passenger, so the history table can show a Seat / Coach column + // without a round trip to GET /bookings/:ref for every row. `leg` disambiguates + // outbound (1) from return (2) on a round trip. + seats: booking.seats.map((bs: any) => ({ + leg: bs.leg ?? 1, + passengerName: bs.passengerName, + seatNumber: bs.seat?.seatNumber ?? null, + coachNumber: bs.seat?.coach?.number ?? null, + })), + rescheduled: ((booking as any).reschedules?.length ?? 0) > 0, + // These three let the portal apply the same coarse reschedule gate the booking + // detail page uses, without fetching each booking in full. + outboundBoardedAt: (booking as any).outboundBoardedAt ?? null, + isPackageBooking: !!(booking as any).packageId, + contactPhone: (booking as any).contactPhone ?? null, }; }), meta: { diff --git a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx index 123cd5710..1fbe4080e 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import { apiClient } from "@/lib/api-client"; import { format } from "date-fns"; import { toZonedDate } from "@/utils/format"; +import { STATUS_LABELS } from "@/lib/api/bookings"; type SearchMode = "pnr" | "phone"; @@ -30,15 +31,6 @@ interface BookingListItem { seatCount: number; } -const STATUS_LABELS: Record = { - CONFIRMED: { label: "Confirmed", className: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300" }, - PENDING_PAYMENT: { label: "Pending Payment", className: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" }, - CANCELLED: { label: "Cancelled", className: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300" }, - BOARDED: { label: "Boarded", className: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" }, - NO_SHOW: { label: "No Show", className: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300" }, - REFUNDED: { label: "Refunded", className: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300" }, -}; - export default function BookingLookupPage() { const router = useRouter(); const [mode, setMode] = useState("pnr"); diff --git a/apps/edr-passenger-web/portal/src/app/bookings/page.tsx b/apps/edr-passenger-web/portal/src/app/bookings/page.tsx new file mode 100644 index 000000000..6092e4cb2 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/bookings/page.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Loader2, Search } from 'lucide-react'; +import { useAuthStore } from '@/lib/auth-store'; +import MyBookingsTable from '@/components/MyBookingsTable'; + +/** + * "My Bookings" for a signed-in customer — every booking on the account, without the + * BRN / phone lookup a guest has to go through at /booking/lookup. + * + * The portal's middleware does no auth gating (it only sets the CSP nonce), so pages + * self-check. Same shape as /profile and /booking/reschedule. + */ +export default function MyBookingsPage() { + const router = useRouter(); + const { isAuthenticated, isInitialized, initialize } = useAuthStore(); + + useEffect(() => { + initialize(); + }, [initialize]); + + useEffect(() => { + if (isInitialized && !isAuthenticated) { + router.push('/login?redirect=/bookings'); + } + }, [isInitialized, isAuthenticated, router]); + + if (!isInitialized || !isAuthenticated) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

My Bookings

+

+ Every trip booked on this account. +

+
+ {/* A customer can still hold a booking made under a different phone number as a + guest — that one is only reachable by reference, so keep the door open. */} + + + Look up another booking + +
+ + +
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/profile/page.tsx b/apps/edr-passenger-web/portal/src/app/profile/page.tsx index 37ffdabfb..ef21631b1 100644 --- a/apps/edr-passenger-web/portal/src/app/profile/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/profile/page.tsx @@ -4,32 +4,19 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; import { useTheme } from '@/components/ThemeProvider'; -import { - User, Settings, Ticket, Calendar, MapPin, - Download, Trash2, Lock, Bell, CreditCard, +import { + User, Settings, Ticket, + Download, Trash2, Lock, Bell, MapPinned, Palette, CheckCircle, - Eye, Edit, LogOut, X + Edit, LogOut, X } from 'lucide-react'; import { apiClient } from '@/lib/api-client'; -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import CustomModal from '@/components/CustomModal'; +import MyBookingsTable from '@/components/MyBookingsTable'; type Tab = 'bookings' | 'profile' | 'settings'; -interface Booking { - id: string; - pnr: string; - status: string; - totalMinor: number; - createdAt: string; - trip?: { - trainNumber: string; - departureAt: string; - origin?: { name: string }; - destination?: { name: string }; - }; -} - export default function ProfilePage() { const router = useRouter(); const { user, isAuthenticated, logout, initialize, updateUser, isInitialized, fetchProfile } = useAuthStore(); @@ -82,18 +69,6 @@ export default function ProfilePage() { } }, [isInitialized, isAuthenticated, user, router, fetchProfile]); - const { data: bookings, isLoading: loadingBookings } = useQuery({ - queryKey: ['user-bookings'], - queryFn: async () => { - try { - return await apiClient.get('/bookings/my-bookings'); - } catch { - return []; - } - }, - enabled: isAuthenticated && activeTab === 'bookings', - }); - const updateProfileMutation = useMutation({ mutationFn: (data: any) => apiClient.patch('/auth/profile', data), onSuccess: (response) => { @@ -229,16 +204,6 @@ export default function ProfilePage() { setShowModal(true); }; - const getStatusBadge = (status: string) => { - const styles = { - CONFIRMED: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300', - PENDING: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300', - CANCELLED: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300', - COMPLETED: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300', - }; - return styles[status as keyof typeof styles] || styles.PENDING; - }; - if (!isInitialized || !user) { return (
@@ -322,71 +287,11 @@ export default function ProfilePage() { {activeTab === 'bookings' && (

Bookings

- - {loadingBookings ? ( -
-
-

Loading bookings...

-
- ) : bookings && Array.isArray(bookings) && bookings.length > 0 ? ( - bookings.map((booking: Booking) => ( -
-
-
-
- - {booking.status} - - - PNR: {booking.pnr} - -
- -
-
- - - {booking.trip?.departureAt - ? new Date(booking.trip.departureAt).toLocaleDateString('en-US', { timeZone: 'Africa/Addis_Ababa' }) - : 'N/A'} - -
-
- - - {booking.trip?.origin?.name} → {booking.trip?.destination?.name} - -
-
- - - ETB {((booking.totalMinor || 0) / 100).toFixed(2)} - -
-
-
- -
- -
-
-
- )) - ) : ( -
- -

No bookings yet

- -
- )} + {/* Same component as /bookings, so the two never drift. It replaces a card + list that called GET /bookings/my-bookings — a route that does not exist + (the real one is GET /bookings/my), whose 404 was swallowed, so this tab + always read "No bookings yet". */} +
)} diff --git a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx index a621c5d83..7565712ba 100644 --- a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx @@ -39,9 +39,12 @@ const BOOKING_STEP_MAP: Record = { '/booking/confirmation': 'confirmation', }; -const NAV_LINKS = [ +// "My Bookings" resolves differently by session: a signed-in customer gets their own +// account history at /bookings, a guest gets the BRN / phone lookup form. Same label +// either way, because it is the same intent. +const navLinks = (isAuthenticated: boolean) => [ { href: '/', label: 'Home', icon: Home }, - { href: '/booking/lookup', label: 'My Bookings', icon: Ticket }, + { href: isAuthenticated ? '/bookings' : '/booking/lookup', label: 'My Bookings', icon: Ticket }, { href: '/contact', label: 'Contact', icon: Phone }, { href: '/help', label: 'Help', icon: HelpCircle }, ]; @@ -83,7 +86,7 @@ export default function AppSidebar() {