mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
Add booking lookup by phone number
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user