diff --git a/apps/edr-passenger-api/src/common/utils/phone.utils.ts b/apps/edr-passenger-api/src/common/utils/phone.utils.ts new file mode 100644 index 000000000..808f8df22 --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/phone.utils.ts @@ -0,0 +1,25 @@ +/** + * Phone numbers reach us in every shape the UI allows — `+251912345678`, `0912345678`, + * `912345678`, and the same again with spaces or dashes. Comparing two of them as raw strings + * is a coin flip, so anything that decides access on a phone number must normalise first. + * + * Mirrors `PassengerAuthService.standardizePhone`, plus the bare-9-digit case the passenger + * form produces (its input sits behind a fixed `+251` prefix control). + */ +export function normalizePhone(phone?: string | null): string | null { + if (!phone) return null; + const digits = phone.replace(/\D/g, ''); + if (!digits) return null; + if (digits.startsWith('251')) return `+${digits}`; + if (digits.startsWith('0')) return `+251${digits.slice(1)}`; + // A bare local subscriber number, e.g. "912345678" from the +251-prefixed input. + if (digits.length === 9) return `+251${digits}`; + return `+${digits}`; +} + +/** True only when both numbers are present and resolve to the same E.164 form. */ +export function samePhone(a?: string | null, b?: string | null): boolean { + const left = normalizePhone(a); + const right = normalizePhone(b); + return !!left && !!right && left === right; +} diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts index a0a8670f6..fdf10ca0a 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts @@ -11,9 +11,9 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma.service'; import { AuditService } from '../../common/audit.service'; import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; -import { hasPassengerPermission, MeLikeUser } from '../../common/passenger-permission.util'; -import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; +import { MeLikeUser } from '../../common/passenger-permission.util'; import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; +import { normalizePhone, samePhone } from '../../common/utils/phone.utils'; import { BookingsService } from '../bookings/bookings.service'; import { SeatsService } from '../seats/seats.service'; import { TicketsService } from '../tickets/tickets.service'; @@ -75,7 +75,7 @@ export function addisDay(d: Date): string { return d.toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' }); } -type ActingUser = MeLikeUser & { id?: string; sub?: string }; +type ActingUser = MeLikeUser & { id?: string; sub?: string; phoneNumber?: string }; type LegView = { leg: number; @@ -443,17 +443,52 @@ export class RescheduleService { // ── Internals ──────────────────────────────────────────────────────────── + /** + * Who may act on this booking: only the person who made it, proven by their account's phone + * number matching the booking's `contactPhone`. Being merely *named* on the booking is not + * enough — a passenger travelling on someone else's booking cannot move it. + * + * There is deliberately no staff override. The `bookings:reschedule` permission still exists in + * the registry (and on the stationMaster preset) but is not honoured here, so a station master + * cannot reschedule on a customer's behalf yet. To restore it, re-import + * `hasPassengerPermission` / `PASSENGER_PERMS` and return the booking early when the caller + * holds `PASSENGER_PERMS.bookings.reschedule`. + */ private async loadOwnedBooking(bookingRef: string, user: ActingUser) { const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: bookingInclude }); if (!booking) throw new NotFoundException('Booking not found'); const iamUserId = user.id ?? user.sub; if (!iamUserId) throw new ForbiddenException(); - if (hasPassengerPermission(user, PASSENGER_PERMS.bookings.reschedule)) return booking; + + if (booking.contactPhone) { + const callerPhone = await this.resolveUserPhone(iamUserId, user); + if (samePhone(callerPhone, booking.contactPhone)) return booking; + throw new ForbiddenException( + 'Only the person who made this booking can reschedule it. Sign in with the phone number used to book.', + ); + } + + // ~0.3% of bookings (72 of 24.7k on dev) carry no contactPhone at all, so there is nothing to + // match against. Fall back to the account link rather than locking their owner out entirely. const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } }); if (!passenger || passenger.id !== booking.passengerId) throw new ForbiddenException('Not your booking'); return booking; } + /** + * The signed-in user's phone. The session snapshot (`userInfo.phoneNumber`) is frequently an + * empty string, so `iam.users` is the source of truth — and reading it live also means a user + * who changed their number does not have to sign out before the new one counts. + */ + private async resolveUserPhone(iamUserId: string, user: ActingUser): Promise { + const fromSession = normalizePhone(user.phoneNumber); + if (fromSession) return fromSession; + const rows = await this.prisma.$queryRaw<{ phone_number: string | null }[]>` + SELECT phone_number FROM iam.users WHERE id = ${iamUserId}::uuid LIMIT 1 + `; + return normalizePhone(rows[0]?.phone_number); + } + private legsOf(booking: any): LegView[] { const legs: LegView[] = []; const seatsOf = (n: number) => diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index 34f5b59ec..58372ca60 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -4,6 +4,7 @@ import { Suspense } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; +import { useAuthStore } from "@/lib/auth-store"; import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect"; import { useEffect, useState } from "react"; import { @@ -78,6 +79,12 @@ function BookingDetailContent() { searchParams.get("bookingRef") || searchParams.get("pnr"); + // `isInitialized` gates on the auth store having read localStorage. Without it a signed-in + // user watches the Reschedule button appear a beat after the page, because the store starts + // every render as logged-out. AppSidebar calls initialize() from the root layout. + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const isAuthInitialized = useAuthStore((s) => s.isInitialized); + // Mirrors /booking/payment's state shape: selectedMethod is the PaymentMethod `type` // (used both for lookup and to decide provider-specific redirect handling), not the id. const [selectedMethod, setSelectedMethod] = useState(null); @@ -329,6 +336,16 @@ function BookingDetailContent() { const isExpired = booking.status === "EXPIRED"; const isCancelled = booking.status === "CANCELLED"; + // Whether this booking is the kind that can be rescheduled at all. The per-leg rules + // (fare-class policy, cutoff, already-boarded) are the API's call and are shown on the + // reschedule page itself; this is only the coarse shape test. + const bookingSupportsReschedule = + !booking.isPackageBooking && + ["ONE_WAY", "ROUND_TRIP"].includes(booking.bookingType) && + !booking.outboundBoardedAt; + + const reschedulePath = `/booking/reschedule?ref=${booking.bookingRef}`; + const StatusBadge = () => { const statusConfig = { PENDING_PAYMENT: { @@ -1022,13 +1039,29 @@ function BookingDetailContent() { )} - {!booking.isPackageBooking && ["ONE_WAY", "ROUND_TRIP"].includes(booking.bookingType) && !booking.outboundBoardedAt && ( + {/* Rescheduling is account-only: every /bookings/:ref/reschedule route sits behind + JwtGuard and resolves ownership from the signed-in IAM user. A guest who got + here through booking lookup (ref + phone) has no session, so instead of hiding + the option we name the blocker and send them somewhere that fixes it. */} + {bookingSupportsReschedule && ( )} diff --git a/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx index 350df552a..8e58b8585 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx @@ -6,6 +6,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { format } from "date-fns"; import { AlertCircle, ArrowRight, CheckCircle2, ChevronLeft, Loader2 } from "lucide-react"; import { apiClient } from "@/lib/api-client"; +import { useAuthStore } from "@/lib/auth-store"; import ModernDatePicker from "@/components/ModernDatePicker"; import StationDropdown, { pushRecentStation, @@ -83,6 +84,20 @@ function ReschedulePageContent() { const searchParams = useSearchParams(); const ref = searchParams.get("ref") || ""; + // Every endpoint this page calls is behind JwtGuard, so a guest who deep-links here would + // otherwise watch the options request 401 and land on "This booking cannot be rescheduled" — + // which blames the booking for what is really a missing session. Send them to sign in and + // bring them straight back instead. Waits for isInitialized: the store starts logged-out. + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const isAuthInitialized = useAuthStore((s) => s.isInitialized); + const needsLogin = isAuthInitialized && !isAuthenticated; + + useEffect(() => { + if (!needsLogin) return; + const back = ref ? `/booking/reschedule?ref=${ref}` : "/booking/lookup"; + router.replace(`/login?redirect=${encodeURIComponent(back)}`); + }, [needsLogin, ref, router]); + const [legNo, setLegNo] = useState(1); const [date, setDate] = useState(undefined); const [originId, setOriginId] = useState(""); @@ -113,7 +128,8 @@ function ReschedulePageContent() { const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery({ queryKey: ["reschedule-options", ref], queryFn: () => apiClient.get(`/bookings/${ref}/reschedule`), - enabled: !!ref, + // Never fire before the session is known — an unauthenticated call only 401s. + enabled: !!ref && isAuthInitialized && isAuthenticated, retry: false, }); const { data: stations = [] } = useQuery({ @@ -311,6 +327,10 @@ function ReschedulePageContent() { const stationName = (id: string | null) => stations.find((s) => s.id === id)?.name ?? id ?? "—"; if (!ref) return

Missing booking reference.

; + // Hold the spinner through the redirect rather than flashing the booking's error state. + if (!isAuthInitialized || needsLogin) { + return ; + } if (loadingOptions) return ; if (optionsError || !options || !leg) { return

{(optionsError as any)?.response?.data?.message || "This booking cannot be rescheduled."}

;