feat: (reschedule) drop the staff override so only the booker can reschedule

This commit is contained in:
Abubeker Yasin
2026-08-26 09:19:53 +03:00
parent 2c9453f87d
commit 46fa17b341
4 changed files with 122 additions and 9 deletions

View File

@@ -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;
}

View File

@@ -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<string | null> {
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) =>

View File

@@ -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<string | null>(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() {
</>
)}
</button>
{!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 && (
<button
onClick={() => router.push(`/booking/reschedule?ref=${booking.bookingRef}`)}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2"
disabled={!isAuthInitialized}
onClick={() =>
router.push(
isAuthenticated
? reschedulePath
: `/login?redirect=${encodeURIComponent(reschedulePath)}`,
)
}
title={
isAuthenticated
? "Change the date, train or seats on this booking"
: "Rescheduling needs an account — sign in to continue"
}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Clock className="w-4 h-4" />
Reschedule
{isAuthInitialized && !isAuthenticated ? "Sign in to reschedule" : "Reschedule"}
</button>
)}
</>

View File

@@ -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<Date | undefined>(undefined);
const [originId, setOriginId] = useState("");
@@ -113,7 +128,8 @@ function ReschedulePageContent() {
const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery<Options>({
queryKey: ["reschedule-options", ref],
queryFn: () => apiClient.get<Options>(`/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<Station[]>({
@@ -311,6 +327,10 @@ function ReschedulePageContent() {
const stationName = (id: string | null) => stations.find((s) => s.id === id)?.name ?? id ?? "—";
if (!ref) return <Shell><p className="text-gray-600">Missing booking reference.</p></Shell>;
// Hold the spinner through the redirect rather than flashing the booking's error state.
if (!isAuthInitialized || needsLogin) {
return <Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>;
}
if (loadingOptions) return <Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>;
if (optionsError || !options || !leg) {
return <Shell><p className="text-red-600">{(optionsError as any)?.response?.data?.message || "This booking cannot be rescheduled."}</p></Shell>;