mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 04:48:18 +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 { PrismaService } from '../../common/prisma.service';
|
||||||
import { AuditService } from '../../common/audit.service';
|
import { AuditService } from '../../common/audit.service';
|
||||||
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
||||||
import { hasPassengerPermission, MeLikeUser } from '../../common/passenger-permission.util';
|
import { MeLikeUser } from '../../common/passenger-permission.util';
|
||||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
|
||||||
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||||
|
import { normalizePhone, samePhone } from '../../common/utils/phone.utils';
|
||||||
import { BookingsService } from '../bookings/bookings.service';
|
import { BookingsService } from '../bookings/bookings.service';
|
||||||
import { SeatsService } from '../seats/seats.service';
|
import { SeatsService } from '../seats/seats.service';
|
||||||
import { TicketsService } from '../tickets/tickets.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' });
|
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 = {
|
type LegView = {
|
||||||
leg: number;
|
leg: number;
|
||||||
@@ -443,17 +443,52 @@ export class RescheduleService {
|
|||||||
|
|
||||||
// ── Internals ────────────────────────────────────────────────────────────
|
// ── 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) {
|
private async loadOwnedBooking(bookingRef: string, user: ActingUser) {
|
||||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: bookingInclude });
|
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: bookingInclude });
|
||||||
if (!booking) throw new NotFoundException('Booking not found');
|
if (!booking) throw new NotFoundException('Booking not found');
|
||||||
const iamUserId = user.id ?? user.sub;
|
const iamUserId = user.id ?? user.sub;
|
||||||
if (!iamUserId) throw new ForbiddenException();
|
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 } });
|
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
|
||||||
if (!passenger || passenger.id !== booking.passengerId) throw new ForbiddenException('Not your booking');
|
if (!passenger || passenger.id !== booking.passengerId) throw new ForbiddenException('Not your booking');
|
||||||
return 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[] {
|
private legsOf(booking: any): LegView[] {
|
||||||
const legs: LegView[] = [];
|
const legs: LegView[] = [];
|
||||||
const seatsOf = (n: number) =>
|
const seatsOf = (n: number) =>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Suspense } from "react";
|
|||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { apiClient } from "@/lib/api-client";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
|
import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -78,6 +79,12 @@ function BookingDetailContent() {
|
|||||||
searchParams.get("bookingRef") ||
|
searchParams.get("bookingRef") ||
|
||||||
searchParams.get("pnr");
|
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`
|
// 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.
|
// (used both for lookup and to decide provider-specific redirect handling), not the id.
|
||||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||||
@@ -329,6 +336,16 @@ function BookingDetailContent() {
|
|||||||
const isExpired = booking.status === "EXPIRED";
|
const isExpired = booking.status === "EXPIRED";
|
||||||
const isCancelled = booking.status === "CANCELLED";
|
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 StatusBadge = () => {
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
PENDING_PAYMENT: {
|
PENDING_PAYMENT: {
|
||||||
@@ -1022,13 +1039,29 @@ function BookingDetailContent() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => router.push(`/booking/reschedule?ref=${booking.bookingRef}`)}
|
disabled={!isAuthInitialized}
|
||||||
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"
|
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" />
|
<Clock className="w-4 h-4" />
|
||||||
Reschedule
|
{isAuthInitialized && !isAuthenticated ? "Sign in to reschedule" : "Reschedule"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { AlertCircle, ArrowRight, CheckCircle2, ChevronLeft, Loader2 } from "lucide-react";
|
import { AlertCircle, ArrowRight, CheckCircle2, ChevronLeft, Loader2 } from "lucide-react";
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { apiClient } from "@/lib/api-client";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
import ModernDatePicker from "@/components/ModernDatePicker";
|
import ModernDatePicker from "@/components/ModernDatePicker";
|
||||||
import StationDropdown, {
|
import StationDropdown, {
|
||||||
pushRecentStation,
|
pushRecentStation,
|
||||||
@@ -83,6 +84,20 @@ function ReschedulePageContent() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const ref = searchParams.get("ref") || "";
|
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 [legNo, setLegNo] = useState(1);
|
||||||
const [date, setDate] = useState<Date | undefined>(undefined);
|
const [date, setDate] = useState<Date | undefined>(undefined);
|
||||||
const [originId, setOriginId] = useState("");
|
const [originId, setOriginId] = useState("");
|
||||||
@@ -113,7 +128,8 @@ function ReschedulePageContent() {
|
|||||||
const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery<Options>({
|
const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery<Options>({
|
||||||
queryKey: ["reschedule-options", ref],
|
queryKey: ["reschedule-options", ref],
|
||||||
queryFn: () => apiClient.get<Options>(`/bookings/${ref}/reschedule`),
|
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,
|
retry: false,
|
||||||
});
|
});
|
||||||
const { data: stations = [] } = useQuery<Station[]>({
|
const { data: stations = [] } = useQuery<Station[]>({
|
||||||
@@ -311,6 +327,10 @@ function ReschedulePageContent() {
|
|||||||
const stationName = (id: string | null) => stations.find((s) => s.id === id)?.name ?? id ?? "—";
|
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>;
|
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 (loadingOptions) return <Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>;
|
||||||
if (optionsError || !options || !leg) {
|
if (optionsError || !options || !leg) {
|
||||||
return <Shell><p className="text-red-600">{(optionsError as any)?.response?.data?.message || "This booking cannot be rescheduled."}</p></Shell>;
|
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