mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 08:18:20 +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,
|
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
|
* Every Booking-level condition that means "this booking belongs to the person who
|
||||||
// account that has never booked, or a staff account. findUniqueOrThrow raised P2025
|
* owns `variants`". Shared by findByPhone (public guest retrieval) and
|
||||||
// here, which surfaced as a 500 on the portal's "My bookings" page. Empty page instead.
|
* findByIamUserId (the portal's own history) so the two can never disagree about
|
||||||
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
|
* what a phone number owns.
|
||||||
if (!passenger) {
|
*
|
||||||
const page = filters.page ?? 1;
|
* Each sub-lookup is independently catch-and-warn: a phone match is a best-effort
|
||||||
const pageSize = filters.pageSize ?? 20;
|
* widening, and one unavailable source must not fail the whole listing.
|
||||||
return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
|
*/
|
||||||
}
|
private async buildPhoneOwnershipClauses(
|
||||||
return this.findByPassengerId(passenger.id, filters);
|
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).
|
* 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
|
* `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
|
* 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
|
* `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
|
* 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.
|
* 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 { search, status, scope = 'all', page = 1, pageSize = 20 } = filters;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
const where: any = { passengerId };
|
const and: any[] = [ownerClause];
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
and.push({
|
||||||
{ bookingRef: { contains: search, mode: 'insensitive' } },
|
OR: [
|
||||||
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
|
{ bookingRef: { contains: search, mode: 'insensitive' } },
|
||||||
{ schedule: { destinationStation: { name: { 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
|
// `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.
|
// validation error (a 500) rather than being ignored. Only accept real enum members.
|
||||||
if (status && (Object.values(BookingStatus) as string[]).includes(status)) {
|
if (status && (Object.values(BookingStatus) as string[]).includes(status)) {
|
||||||
where.status = status;
|
and.push({ status });
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
let orderBy: any = { createdAt: 'desc' };
|
let orderBy: any = { createdAt: 'desc' };
|
||||||
if (scope === 'cancelled') {
|
if (scope === 'cancelled') {
|
||||||
where.status = { in: CLOSED_BOOKING_STATUSES };
|
and.push({ status: { in: CLOSED_BOOKING_STATUSES } });
|
||||||
} else if (scope === 'upcoming' || scope === 'past') {
|
} else if (scope === 'upcoming' || scope === 'past') {
|
||||||
// Don't clobber an explicit `status` filter — intersect with it.
|
and.push({ status: { notIn: CLOSED_BOOKING_STATUSES } });
|
||||||
if (!where.status) where.status = { notIn: CLOSED_BOOKING_STATUSES };
|
and.push({ schedule: { departureAt: scope === 'upcoming' ? { gte: now } : { lt: now } } });
|
||||||
where.schedule = {
|
|
||||||
...(where.schedule ?? {}),
|
|
||||||
departureAt: scope === 'upcoming' ? { gte: now } : { lt: now },
|
|
||||||
};
|
|
||||||
orderBy = { schedule: { departureAt: scope === 'upcoming' ? 'asc' : 'desc' } };
|
orderBy = { schedule: { departureAt: scope === 'upcoming' ? 'asc' : 'desc' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const where: any = { AND: and };
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.booking.findMany({
|
this.prisma.booking.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -227,68 +344,11 @@ export class BookingsService {
|
|||||||
const { status, page = 1, pageSize = 20 } = filters;
|
const { status, page = 1, pageSize = 20 } = filters;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
// Authenticated-user bookings don't store contactPhone — their phone lives in
|
// Same ownership resolution the authenticated history uses, so a customer sees the
|
||||||
// iam.users.phone_number linked via passenger.iamUserId. Mirror the same lookup
|
// same set here and on "My bookings".
|
||||||
// that findAll uses for the search field.
|
const ownership = await this.buildPhoneOwnershipClauses(variants);
|
||||||
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
|
const where: any = { OR: ownership.clauses };
|
||||||
? (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 } }] : []),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
if (status) where.status = status;
|
if (status) where.status = status;
|
||||||
|
|
||||||
// PackageBooking is a separate table with its own contactPhone field —
|
// PackageBooking is a separate table with its own contactPhone field —
|
||||||
@@ -296,7 +356,7 @@ export class BookingsService {
|
|||||||
const pkgWhere: any = {
|
const pkgWhere: any = {
|
||||||
OR: [
|
OR: [
|
||||||
{ contactPhone: { in: variants } },
|
{ contactPhone: { in: variants } },
|
||||||
...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []),
|
...(ownership.passengerIds.length > 0 ? [{ passengerId: { in: ownership.passengerIds } }] : []),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
if (status) pkgWhere.status = status;
|
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))
|
else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType))
|
||||||
rescheduleBlocker = 'Transit bookings cannot be rescheduled online';
|
rescheduleBlocker = 'Transit bookings cannot be rescheduled online';
|
||||||
else if (b.outboundBoardedAt) rescheduleBlocker = 'This trip has already been boarded';
|
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))
|
else if (b.contactPhone && !samePhone(userPhone, b.contactPhone))
|
||||||
rescheduleBlocker = 'Only the person who made this booking can reschedule it';
|
rescheduleBlocker = 'Only the person who made this booking can reschedule it';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user