Fix look up by phone number

This commit is contained in:
Roba Boru
2026-07-11 12:13:05 +03:00
parent 75871320f9
commit 33a9129e8b
3 changed files with 151 additions and 254 deletions

View File

@@ -53,15 +53,17 @@ function normalizePhoneVariants(raw: string): string[] {
const variants = new Set<string>([stripped]);
if (stripped.startsWith('+251') && digits.length === 12) {
// +251 9XXXXXXXX → 09XXXXXXXX
variants.add('0' + digits.slice(3));
// +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX
variants.add(digits); // 251XXXXXXXXX
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
} else if (stripped.startsWith('251') && digits.length === 12) {
// 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX
variants.add('+' + stripped);
variants.add('0' + digits.slice(3));
variants.add('+' + stripped); // +251XXXXXXXXX
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
} else if (stripped.startsWith('0') && digits.length === 10) {
// 09XXXXXXXX → +251 9XXXXXXXX
variants.add('+251' + digits.slice(1));
// 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +)
variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX
variants.add('251' + digits.slice(1)); // 251XXXXXXXXX
} else if (!stripped.startsWith('+') && digits.length >= 9) {
// bare international digits without +
variants.add('+' + digits);
@@ -183,15 +185,81 @@ 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 }[];
});
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 } }] : []),
],
};
if (status) where.status = status;
const [items, total] = await Promise.all([
// PackageBooking is a separate table with its own contactPhone field —
// must be queried independently or guest package bookings are invisible.
const pkgWhere: any = {
OR: [
{ contactPhone: { in: variants } },
...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []),
],
};
if (status) pkgWhere.status = status;
const [items, total, pkgItems, pkgTotal] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
@@ -205,37 +273,84 @@ export class BookingsService {
},
}),
this.prisma.booking.count({ where }),
this.prisma.packageBooking.findMany({
where: pkgWhere,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
package: {
include: {
outboundSchedule: { include: { originStation: true, destinationStation: true } },
},
},
paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } },
},
}),
this.prisma.packageBooking.count({ where: pkgWhere }),
]);
const mappedBookings = items.map(booking => ({
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
arrivalAt: booking.schedule.arrivalAt,
},
payment: booking.paymentIntent ?? undefined,
seatCount: booking.seats.length,
}));
const mappedPkg = pkgItems.map((b: any) => ({
id: b.id,
bookingRef: b.bookingRef,
status: b.status,
totalMinor: b.totalMinor,
currency: b.currency || 'ETB',
displayCurrency: b.displayCurrency ?? null,
displayTotalMinor: b.displayTotalMinor ?? null,
adultCount: b.adultCount,
childCount: b.childCount,
bookingType: 'PACKAGE',
returnLegStatus: null,
createdAt: b.createdAt,
schedule: b.package?.outboundSchedule
? {
train: null,
originStation: b.package.outboundSchedule.originStation,
destinationStation: b.package.outboundSchedule.destinationStation,
departureAt: b.package.outboundSchedule.departureAt,
arrivalAt: b.package.outboundSchedule.arrivalAt,
}
: null,
payment: b.paymentIntent ?? undefined,
seatCount: b.passengerCount,
}));
const allItems = [...mappedBookings, ...mappedPkg]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, pageSize);
return {
items: items.map(booking => ({
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
arrivalAt: booking.schedule.arrivalAt,
},
payment: booking.paymentIntent ?? undefined,
seatCount: booking.seats.length,
})),
items: allItems,
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
total: total + pkgTotal,
totalPages: Math.ceil((total + pkgTotal) / pageSize),
},
};
}