mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-01 01:57:37 +00:00
feat: ( bookings ) add My Bookings history covering account and same-phone guest bookings
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user