Add booking lookup by phone number

This commit is contained in:
Roba Boru
2026-07-11 11:12:16 +03:00
parent 75d3e6c761
commit 122c57debb
8 changed files with 430 additions and 88 deletions

View File

@@ -441,6 +441,36 @@ export class BookingsController {
return this.service.checkBookingUsage(id);
}
@Get('by-phone')
@SetMetadata('isPublic', true)
@ApiOperation({
summary: 'Find bookings by phone number (no auth required)',
description: `Returns all bookings where the contact phone matches the provided number.
Accepts Ethiopian local format (09XXXXXXXX) and international format (+251XXXXXXXXX).
Results are ordered most-recent first. Use the returned \`bookingRef\` to open booking detail.`
})
@ApiQuery({ name: 'phone', required: true, description: 'Phone number in local (09…) or international (+251…) format' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiResponse({ status: 200, description: 'Paginated list of bookings for this phone number' })
@ApiResponse({ status: 400, description: 'Phone number missing or invalid' })
findByPhone(
@Query('phone') phone?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
if (!phone?.trim()) throw new BadRequestException('Phone number is required');
const digits = phone.replace(/[^\d]/g, '');
if (digits.length < 7) throw new BadRequestException('Phone number is too short');
return this.service.findByPhone(phone.trim(), {
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20,
});
}
@Get(':bookingRef')
@SetMetadata('isPublic', true)
@ApiOperation({

View File

@@ -39,6 +39,37 @@ function resolvePackageRoundTripTotal(
return adultCount * adultFareMinor + paidChildren * adultFareMinor;
}
/**
* Returns all plausible normalised variants of a raw phone string so that the
* DB query matches regardless of how the number was stored (local 09… vs international +251…).
* Returns an empty array when the input is clearly invalid (< 7 digits).
*/
function normalizePhoneVariants(raw: string): string[] {
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
const stripped = raw.replace(/[^\d+]/g, '');
const digits = stripped.replace(/^\+/, '');
if (digits.length < 7) return [];
const variants = new Set<string>([stripped]);
if (stripped.startsWith('+251') && digits.length === 12) {
// +251 9XXXXXXXX → 09XXXXXXXX
variants.add('0' + digits.slice(3));
} else if (stripped.startsWith('251') && digits.length === 12) {
// 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX
variants.add('+' + stripped);
variants.add('0' + digits.slice(3));
} else if (stripped.startsWith('0') && digits.length === 10) {
// 09XXXXXXXX → +251 9XXXXXXXX
variants.add('+251' + digits.slice(1));
} else if (!stripped.startsWith('+') && digits.length >= 9) {
// bare international digits without +
variants.add('+' + digits);
}
return [...variants];
}
function calculateAge(dateOfBirth: Date): number {
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
@@ -145,6 +176,70 @@ export class BookingsService {
};
}
async findByPhone(rawPhone: string, filters: BookingFilters = {}) {
const variants = normalizePhoneVariants(rawPhone);
if (variants.length === 0) return { items: [], meta: { page: 1, pageSize: 20, total: 0, totalPages: 0 } };
const { status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {
OR: [
{ contactPhone: { in: variants } },
{ passenger: { user: { phone: { in: variants } } } },
],
};
if (status) where.status = status;
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } },
seats: { select: { id: true } },
priceTier: { select: { priceMinor: true } },
},
}),
this.prisma.booking.count({ where }),
]);
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,
})),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async findByDeviceId(deviceId: string, filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
@@ -1516,7 +1611,12 @@ export class BookingsService {
seat: null,
})),
payment: (pkgBooking as any).paymentIntent
? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status }
? {
method: (pkgBooking as any).paymentIntent.method,
status: (pkgBooking as any).paymentIntent.status,
amountMinor: (pkgBooking as any).paymentIntent.amountMinor,
currency: (pkgBooking as any).paymentIntent.currency,
}
: undefined,
tickets: [],
};
@@ -1556,7 +1656,14 @@ export class BookingsService {
seatClass: bs.seat.coach.coachType?.seatClasses?.[0]?.name ?? null,
},
})),
payment: (booking as any).paymentIntent ? { method: (booking as any).paymentIntent.method, status: (booking as any).paymentIntent.status } : undefined,
payment: (booking as any).paymentIntent
? {
method: (booking as any).paymentIntent.method,
status: (booking as any).paymentIntent.status,
amountMinor: (booking as any).paymentIntent.amountMinor,
currency: (booking as any).paymentIntent.currency,
}
: undefined,
// One ticket per passenger — matched on the frontend by passengerName, not array
// position, since tickets are grouped/created independently of the passengers array.
tickets: (booking as any).tickets?.map((t: any) => ({