mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: (reschedule) drop the staff override so only the booker can reschedule
This commit is contained in:
25
apps/edr-passenger-api/src/common/utils/phone.utils.ts
Normal file
25
apps/edr-passenger-api/src/common/utils/phone.utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user