mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 14:45:04 +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) =>
|
||||
|
||||
Reference in New Issue
Block a user