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) =>