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),
},
};
}

View File

@@ -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<Language>('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() {
);
})}
</section>
<section className="form-section">
<div className="form-container">
<h2>{t('contact.form')}</h2>
{message && (
<div className={`alert alert-${message.type === 'success' ? 'success' : 'error'}`}>
{message.text}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>{t('contact.name')}</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="form-group">
<label>{t('contact.emailField')}</label>
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</div>
<div className="form-group">
<label>{t('contact.subject')}</label>
<input
type="text"
required
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
/>
</div>
<div className="form-group">
<label>{t('contact.message')}</label>
<textarea
required
rows={5}
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
/>
</div>
<button type="submit" disabled={loading} className="form-submit">
{loading ? (
<>
<Loader size={18} />
{t('contact.sending')}
</>
) : (
<>
<Send size={18} />
{t('contact.send')}
</>
)}
</button>
</form>
</div>
</section>
</main>
<Footer />
</>

View File

@@ -48,13 +48,13 @@ export function Footer() {
<a href="https://web.facebook.com/ethiodjiboutirailwaysc" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Facebook">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/></svg>
</a>
<a href="https://twitter.com/edr" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Twitter">
<a href="/" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Twitter">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
</a>
<a href="https://instagram.com/edr" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Instagram">
<a href="/" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Instagram">
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20" rx="5" ry="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none"/></svg>
</a>
<a href="https://linkedin.com/company/edr" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="LinkedIn">
<a href="/" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="LinkedIn">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6zM2 9h4v12H2z"/><circle cx="4" cy="4" r="2"/></svg>
</a>
</div>