mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Fix look up by phone number
This commit is contained in:
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user