From a60bcf81630d428e1de566b5fc1884ed109bed1e Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 31 Aug 2026 13:34:10 +0300 Subject: [PATCH] feat: ( bookings ) add My Bookings history covering account and same-phone guest bookings --- .../src/modules/bookings/bookings.service.ts | 236 +++++++++++------- .../portal/src/components/MyBookingsTable.tsx | 4 + 2 files changed, 152 insertions(+), 88 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index b627d64ba..a3931ef1f 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -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; diff --git a/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx b/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx index 2d3cf7d64..96ecd0d3e 100644 --- a/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx +++ b/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx @@ -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';