From 33a9129e8be0b4c7d10e604313aaef66d7c0c1be Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Sat, 11 Jul 2026 12:13:05 +0300 Subject: [PATCH 1/4] Fix look up by phone number --- .../src/modules/bookings/bookings.service.ts | 179 +++++++++++--- .../portal/src/app/contact/page.tsx | 220 +----------------- .../portal/src/components/Footer.tsx | 6 +- 3 files changed, 151 insertions(+), 254 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index d9c393ee7..74ba9f509 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -53,15 +53,17 @@ function normalizePhoneVariants(raw: string): string[] { const variants = new Set([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), }, }; } diff --git a/apps/edr-passenger-web/portal/src/app/contact/page.tsx b/apps/edr-passenger-web/portal/src/app/contact/page.tsx index 9f0d2d474..90aa54bd0 100644 --- a/apps/edr-passenger-web/portal/src/app/contact/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/contact/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { getTranslation, Language, useLanguage } from '@/lib/i18n'; -import { Phone, Mail, MapPin, Send, Loader } from 'lucide-react'; +import { Phone, Mail, MapPin } from 'lucide-react'; import { Footer } from '@/components/Footer'; const styles = ` @@ -110,145 +110,10 @@ const styles = ` .contact-card a:hover { color: rgb(20, 113, 76); } - - .form-section { - padding: 60px 20px; - background-color: #f9fafb; - } - - .dark .form-section { - background-color: #0f1117; - } - - .form-container { - max-width: 42rem; - margin: 0 auto; - background: white; - border-radius: 18px; - padding: 32px; - border: 1px solid #e5e7eb; - } - - .dark .form-container { - background: #1f2937; - border-color: #374151; - } - - .form-container h2 { - font-size: 1.5rem; - font-weight: 700; - margin-bottom: 24px; - color: #111827; - } - - .dark .form-container h2 { - color: #f3f4f6; - } - - .form-group { - margin-bottom: 20px; - } - - .form-group label { - display: block; - font-size: 0.875rem; - font-weight: 500; - color: #374151; - margin-bottom: 8px; - } - - .dark .form-group label { - color: #d1d5db; - } - - .form-group input, - .form-group textarea { - width: 100%; - padding: 12px 16px; - border: 2px solid #e5e7eb; - border-radius: 12px; - font-size: 1rem; - font-family: inherit; - transition: all 0.2s; - box-sizing: border-box; - background: white; - color: #111827; - } - - .dark .form-group input, - .dark .form-group textarea { - background: #111827; - color: #f3f4f6; - border-color: #374151; - } - - .form-group input:focus, - .form-group textarea:focus { - outline: none; - border-color: rgb(20, 113, 76); - box-shadow: 0 0 0 3px rgba(20, 113, 76, 0.1); - } - - .form-submit { - width: 100%; - padding: 14px 20px; - background-color: rgb(20, 113, 76); - color: white; - border: none; - border-radius: 12px; - font-weight: 700; - cursor: pointer; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - margin-top: 8px; - } - - .form-submit:hover { - background-color: rgb(16, 89, 60); - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); - } - - .form-submit:disabled { - opacity: 0.6; - cursor: not-allowed; - } - - .alert { - padding: 12px 16px; - border-radius: 8px; - margin-bottom: 16px; - font-size: 0.875rem; - } - - .alert-success { - background-color: #dbeafe; - color: #1e40af; - } - - .dark .alert-success { - background-color: rgba(20, 113, 76, 0.1); - color: #a7f3d0; - } - - .alert-error { - background-color: #fee2e2; - color: #991b1b; - } - - .dark .alert-error { - background-color: rgba(239, 68, 68, 0.1); - color: #fca5a5; - } `; export default function Contact() { const [lang, setLang] = useState('en'); - const [formData, setFormData] = useState({ name: '', email: '', subject: '', message: '' }); - const [loading, setLoading] = useState(false); - const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const { getLang } = useLanguage(); const t = (key: string) => getTranslation(lang, key); @@ -259,21 +124,6 @@ export default function Contact() { return () => window.removeEventListener('languageChange', handleLanguageChange); }, [getLang]); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - - try { - await new Promise(resolve => setTimeout(resolve, 1500)); - setMessage({ type: 'success', text: t('contact.success') }); - setFormData({ name: '', email: '', subject: '', message: '' }); - } catch (error) { - setMessage({ type: 'error', text: t('contact.error') }); - } finally { - setLoading(false); - } - }; - const contactInfo = [ { icon: Phone, title: t('contact.phone'), value: '9546', link: 'tel:9546' }, { icon: Mail, title: t('contact.email'), value: 'edr_@edrsc.com', link: 'mailto:edr_@edrsc.com' }, @@ -303,74 +153,6 @@ export default function Contact() { ); })} - -
-
-

{t('contact.form')}

- - {message && ( -
- {message.text} -
- )} - -
-
- - setFormData({ ...formData, name: e.target.value })} - /> -
- -
- - setFormData({ ...formData, email: e.target.value })} - /> -
- -
- - setFormData({ ...formData, subject: e.target.value })} - /> -
- -
- -