feat: ( bookings ) add My Bookings history covering account and same-phone guest bookings

This commit is contained in:
Abubeker Yasin
2026-08-31 13:34:10 +03:00
parent 60a41a578c
commit a60bcf8163
2 changed files with 152 additions and 88 deletions

View File

@@ -93,22 +93,139 @@ export class BookingsService {
private readonly paymentsService: PaymentsService,
) {}
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
// An IAM user with no Passenger row is normal, not an error: a freshly registered
// account that has never booked, or a staff account. findUniqueOrThrow raised P2025
// here, which surfaced as a 500 on the portal's "My bookings" page. Empty page instead.
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
if (!passenger) {
const page = filters.page ?? 1;
const pageSize = filters.pageSize ?? 20;
return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
}
return this.findByPassengerId(passenger.id, filters);
/**
* Every Booking-level condition that means "this booking belongs to the person who
* owns `variants`". Shared by findByPhone (public guest retrieval) and
* findByIamUserId (the portal's own history) so the two can never disagree about
* what a phone number owns.
*
* Each sub-lookup is independently catch-and-warn: a phone match is a best-effort
* widening, and one unavailable source must not fail the whole listing.
*/
private async buildPhoneOwnershipClauses(
variants: string[],
): Promise<{ clauses: any[]; passengerIds: string[] }> {
if (variants.length === 0) return { clauses: [], passengerIds: [] };
// Authenticated-user bookings don't store contactPhone — their phone lives in
// iam.users.phone_number, linked through passenger.iamUserId.
const iamRows = await this.dataSource
.query<{ id: string }[]>(
`SELECT u.id FROM iam.users u WHERE u.phone_number = ANY($1::text[])`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`IAM phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { id: string }[];
});
const iamPassengerIds = iamRows.length > 0
? (await this.prisma.passenger.findMany({
where: { iamUserId: { in: iamRows.map(r => r.id) } },
select: { id: true },
})).map(p => p.id)
: [];
// Guest bookings store phone in TravelerProfile.notes JSON (created for every guest
// booking). Catches cases where contactPhone was null but the profile recorded it.
const travelerRows = await this.dataSource
.query<{ passengerId: string }[]>(
`SELECT DISTINCT passenger_id AS "passengerId"
FROM passenger.traveler_profiles
WHERE notes IS NOT NULL
AND (notes::jsonb->>'phone') = ANY($1::text[])`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`TravelerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { passengerId: string }[];
});
// Guests who saved their profile (savePassengerDetails:true) have a
// SavedPassengerProfile row with phone + deviceId; guest bookings stash that
// deviceId in Booking.userAgent.
const savedProfileRows = await this.dataSource
.query<{ deviceId: string }[]>(
`SELECT DISTINCT device_id AS "deviceId"
FROM passenger.saved_passenger_profiles
WHERE phone = ANY($1::text[]) AND device_id IS NOT NULL`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`SavedPassengerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { deviceId: string }[];
});
const allPassengerIds = [...new Set([...iamPassengerIds, ...travelerRows.map(r => r.passengerId)])];
const guestDeviceIds = savedProfileRows.map(r => r.deviceId);
return {
clauses: [
{ contactPhone: { in: variants } },
{ passenger: { user: { phone: { in: variants } } } },
...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []),
...(guestDeviceIds.length > 0 ? [{ userAgent: { in: guestDeviceIds } }] : []),
],
passengerIds: allPassengerIds,
};
}
/**
* The portal's authenticated "My bookings" history (GET /bookings/my).
*
* Returns bookings made **while signed in** (they hang off the Passenger row linked
* to this IAM user) *and* bookings made as a **guest with the same phone number**.
* The second half matters: resolveGuestPassenger (guest-booking.service.ts) creates a
* fresh, unlinked `Passenger` for every guest booking and never looks the phone up, so
* a customer's guest history is scattered across orphan rows that a passengerId-only
* filter cannot see. On the dev database one account had 4 visible bookings out of 30
* carrying its own phone number.
*
* Privacy note: the widened set is exactly what `GET /bookings/by-phone` already
* returns to *anonymous* callers, so showing it to the verified owner of that number
* exposes nothing that was not already public. The phone comes from iam.users, not
* from the request.
*/
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
// An IAM user with no Passenger row is normal, not an error: a freshly registered
// account that has never booked, or a staff account. findUniqueOrThrow raised P2025
// here, which surfaced as a 500 on the portal's "My bookings" page.
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
const iamRows = await this.dataSource
.query<{ phone_number: string | null }[]>(
`SELECT phone_number FROM iam.users WHERE id = $1 LIMIT 1`,
[iamUserId],
)
.catch((err: unknown) => {
this.logger.warn(`IAM self phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { phone_number: string | null }[];
});
const variants = normalizePhoneVariants(iamRows[0]?.phone_number ?? '');
const ownership: any[] = [
...(passenger ? [{ passengerId: passenger.id }] : []),
...(await this.buildPhoneOwnershipClauses(variants)).clauses,
];
if (ownership.length === 0) {
const page = filters.page ?? 1;
const pageSize = filters.pageSize ?? 20;
return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
}
return this.findBookingsForOwner({ OR: ownership }, filters);
}
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
return this.findBookingsForOwner({ passengerId }, filters);
}
/**
* One page of a customer's own bookings. `ownerClause` says whose they are (a single
* passengerId, or the OR of every phone-ownership clause) and is ANDed with the
* search / status / scope filters, so none of them can clobber another's `OR`.
*
* `scope` drives the Upcoming / Past / Cancelled tabs server-side so each tab paginates
* correctly, rather than the client filtering one page at a time. Note it filters on
* `schedule.departureAt` — the schedule's own origin departure — while each item's
@@ -116,40 +233,40 @@ export class BookingsService {
* boarding stop. They differ by the run time to that stop; that is close enough for a
* tab filter and avoids a correlated stopTimes query per row.
*/
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
private async findBookingsForOwner(ownerClause: any, filters: BookingFilters = {}) {
const { search, status, scope = 'all', page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = { passengerId };
const and: any[] = [ownerClause];
if (search) {
where.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
];
and.push({
OR: [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
],
});
}
// `status` used to be forwarded raw, so an unrecognised value threw a Prisma
// validation error (a 500) rather than being ignored. Only accept real enum members.
if (status && (Object.values(BookingStatus) as string[]).includes(status)) {
where.status = status;
and.push({ status });
}
const now = new Date();
let orderBy: any = { createdAt: 'desc' };
if (scope === 'cancelled') {
where.status = { in: CLOSED_BOOKING_STATUSES };
and.push({ status: { in: CLOSED_BOOKING_STATUSES } });
} else if (scope === 'upcoming' || scope === 'past') {
// Don't clobber an explicit `status` filter — intersect with it.
if (!where.status) where.status = { notIn: CLOSED_BOOKING_STATUSES };
where.schedule = {
...(where.schedule ?? {}),
departureAt: scope === 'upcoming' ? { gte: now } : { lt: now },
};
and.push({ status: { notIn: CLOSED_BOOKING_STATUSES } });
and.push({ schedule: { departureAt: scope === 'upcoming' ? { gte: now } : { lt: now } } });
orderBy = { schedule: { departureAt: scope === 'upcoming' ? 'asc' : 'desc' } };
}
const where: any = { AND: and };
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
@@ -227,68 +344,11 @@ export class BookingsService {
const { status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
// Authenticated-user bookings don't store contactPhone — their phone lives in
// iam.users.phone_number linked via passenger.iamUserId. Mirror the same lookup
// that findAll uses for the search field.
const iamRows = await this.dataSource
.query<{ id: string }[]>(
`SELECT u.id FROM iam.users u WHERE u.phone_number = ANY($1::text[])`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`IAM phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { id: string }[];
});
// Same ownership resolution the authenticated history uses, so a customer sees the
// same set here and on "My bookings".
const ownership = await this.buildPhoneOwnershipClauses(variants);
const iamPassengerIds = iamRows.length > 0
? (await this.prisma.passenger.findMany({
where: { iamUserId: { in: iamRows.map(r => r.id) } },
select: { id: true },
})).map(p => p.id)
: [];
// Guest bookings store phone in TravelerProfile.notes JSON (created for every guest booking).
// This catches cases where contactPhone was null but the phone was still recorded in the profile.
const travelerRows = await this.dataSource
.query<{ passengerId: string }[]>(
`SELECT DISTINCT passenger_id AS "passengerId"
FROM passenger.traveler_profiles
WHERE notes IS NOT NULL
AND (notes::jsonb->>'phone') = ANY($1::text[])`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`TravelerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { passengerId: string }[];
});
const travelerPassengerIds = travelerRows.map(r => r.passengerId);
// Guests who saved their profile (savePassengerDetails:true) have a SavedPassengerProfile
// row with phone + deviceId. Guest bookings store the deviceId in Booking.userAgent.
const savedProfileRows = await this.dataSource
.query<{ deviceId: string }[]>(
`SELECT DISTINCT device_id AS "deviceId"
FROM passenger.saved_passenger_profiles
WHERE phone = ANY($1::text[]) AND device_id IS NOT NULL`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`SavedPassengerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { deviceId: string }[];
});
const guestDeviceIds = savedProfileRows.map(r => r.deviceId);
// Merge all passenger IDs from every source
const allPassengerIds = [...new Set([...iamPassengerIds, ...travelerPassengerIds])];
const where: any = {
OR: [
{ contactPhone: { in: variants } },
{ passenger: { user: { phone: { in: variants } } } },
...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []),
...(guestDeviceIds.length > 0 ? [{ userAgent: { in: guestDeviceIds } }] : []),
],
};
const where: any = { OR: ownership.clauses };
if (status) where.status = status;
// PackageBooking is a separate table with its own contactPhone field —
@@ -296,7 +356,7 @@ export class BookingsService {
const pkgWhere: any = {
OR: [
{ contactPhone: { in: variants } },
...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []),
...(ownership.passengerIds.length > 0 ? [{ passengerId: { in: ownership.passengerIds } }] : []),
],
};
if (status) pkgWhere.status = status;

View File

@@ -82,6 +82,10 @@ function resolveActions(b: MyBookingItem, userPhone?: string): RowActions {
else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType))
rescheduleBlocker = 'Transit bookings cannot be rescheduled online';
else if (b.outboundBoardedAt) rescheduleBlocker = 'This trip has already been boarded';
// The API applies policy.cutoffMinutes to the old leg's departure, so a departed trip
// is always rejected. Say so here instead of sending them to a page that refuses.
else if (new Date(b.schedule.departureAt).getTime() <= Date.now())
rescheduleBlocker = 'This trip has already departed';
else if (b.contactPhone && !samePhone(userPhone, b.contactPhone))
rescheduleBlocker = 'Only the person who made this booking can reschedule it';