From 543b044734fcde2094bdcd7ec299cfe08ba61271 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 7 Jul 2026 14:26:37 +0300 Subject: [PATCH 01/18] Set email field optional in passenger information --- .../portal/src/app/booking/passengers/page.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index e443ccf07..814a5009e 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -568,9 +568,8 @@ function createFormSchema(adultCount: number) { // Contact fields are only collected from — and validated against — adults. // Children's phone/email are inherited from the primary adult, not user-entered. if (isAdult) { - if (!p.email || p.email.trim().length === 0) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Email is required', path: ['passengers', i, 'email'] }); - } else { + // Email is optional — only validate its format when the user actually provides one. + if (p.email && p.email.trim().length > 0) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(p.email)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['passengers', i, 'email'] }); @@ -1191,7 +1190,7 @@ export default function PassengersPage() { {/* Email */}
- + - + Date: Tue, 7 Jul 2026 15:17:25 +0300 Subject: [PATCH 02/18] Package seat selection updates, round trip alternative trips updates --- .../src/modules/search/search.service.ts | 36 +- .../src/modules/seats/seats.service.ts | 2 + .../portal/src/app/booking/results/page.tsx | 85 ++++- .../portal/src/app/booking/seats/page.tsx | 328 +++++++++++++++--- .../portal/src/app/packages/[id]/page.tsx | 18 +- 5 files changed, 394 insertions(+), 75 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 2942625ca..355446544 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -64,7 +64,7 @@ export class SearchService { const outbound = [...direct, ...transit]; - if (outbound.length === 0) { + if (outbound.length === 0 && dto.journeyType !== 'ROUND_TRIP') { const alternativesOutbound = await this.searchAlternatives( dto.originStationId, dto.destinationStationId, @@ -74,7 +74,7 @@ export class SearchService { dto.nationality, ); return { - journeyType: dto.journeyType === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY', + journeyType: 'ONE_WAY', outbound: [], alternativeOutbound: alternativesOutbound, requestedDate: dto.date, @@ -110,19 +110,29 @@ export class SearchService { new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival ); - if (inbound.length === 0) { - const alternativeInbound = await this.searchAlternatives( - dto.destinationStationId, - dto.originStationId, - dto.returnDate ?? dto.date, - dto.adultCount, - dto.childCount, - dto.nationality, - ); - return { journeyType: 'ROUND_TRIP', outbound, inbound: [], alternativeInbound }; + const returnDate = dto.returnDate ?? dto.date; + + if (outbound.length === 0 || inbound.length === 0) { + const [alternativeOutbound, alternativeInbound] = await Promise.all([ + outbound.length === 0 + ? this.searchAlternatives(dto.originStationId, dto.destinationStationId, dto.date, dto.adultCount, dto.childCount, dto.nationality) + : Promise.resolve([]), + inbound.length === 0 + ? this.searchAlternatives(dto.destinationStationId, dto.originStationId, returnDate, dto.adultCount, dto.childCount, dto.nationality) + : Promise.resolve([]), + ]); + return { + journeyType: 'ROUND_TRIP', + outbound, + inbound, + alternativeOutbound, + alternativeInbound, + requestedDate: dto.date, + requestedReturnDate: returnDate, + }; } - return { journeyType: 'ROUND_TRIP', outbound, inbound }; + return { journeyType: 'ROUND_TRIP', outbound, inbound, requestedDate: dto.date, requestedReturnDate: returnDate }; } return { journeyType: 'ONE_WAY', outbound }; diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 495c394ee..b26dcb763 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -90,6 +90,7 @@ export class SeatsService { label: a.coach.number, mode: a.coach.status, name: `Coach ${a.coach.number}`, + coachTypeId: a.coach.coachType?.id ?? null, coachTypeName, isBedCoach, bedCategory, @@ -665,6 +666,7 @@ export class SeatsService { return { coachId: a.coach.id, + coachTypeId: a.coach.coachType?.id ?? null, coachNumber: a.coach.number, positionNumber: a.positionNumber, coachTypeName: a.coach.coachType?.name ?? '', diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index b94b9ee14..77a18380f 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -143,17 +143,18 @@ export default function ResultsPage() { } } - // For one-way, check if outbound has results - // For round-trip, check if BOTH outbound and inbound have results - const hasResults = isRoundTrip - ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) - : outboundSchedules.length > 0; - - // One-way searches that come back with an empty outbound list may still include - // date-shifted alternatives from the API — surface those instead of a dead end. - const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0; - const alternativeOutbound: Schedule[] = isOneWayNoOutbound ? (results.alternativeOutbound || []) : []; + // Alternatives are surfaced whenever a leg returns no exact-date results. + const alternativeOutbound: Schedule[] = (!!results && outboundSchedules.length === 0) ? (results?.alternativeOutbound || []) : []; + const alternativeInbound: Schedule[] = (isRoundTrip && !!results && inboundSchedules.length === 0) ? (results?.alternativeInbound || []) : []; const requestedDate: string = (results && results.requestedDate) || searchData.date; + const requestedReturnDate: string = (results && results.requestedReturnDate) || searchData.returnDate || ''; + + const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0; + // Round-trip: show results view if either leg has exact results OR alternatives. + // One-way: need at least one outbound result. + const hasResults = isRoundTrip + ? (outboundSchedules.length > 0 || alternativeOutbound.length > 0) || (inboundSchedules.length > 0 || alternativeInbound.length > 0) + : outboundSchedules.length > 0; const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => { setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } })); @@ -687,8 +688,28 @@ export default function ResultsPage() { } if (!hasResults) { - // ONE_WAY search with an explicit empty outbound list — surface any date-shifted - // alternatives the API suggests instead of a dead-end "no trains found" screen. + const isRoundTripNoResults = isRoundTrip && !!results && outboundSchedules.length === 0 && inboundSchedules.length === 0 && alternativeOutbound.length === 0 && alternativeInbound.length === 0; + if (isRoundTripNoResults) { + return ( +
+
+
+
+
+ +
+

No trains found

+

+ We couldn't find any trains for your round trip. Try adjusting your dates or route. +

+ +
+
+
+
+ ); + } + if (isOneWayNoOutbound) { const requestedDateLabel = requestedDate ? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy') @@ -843,6 +864,26 @@ export default function ResultsPage() {
{outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))}
+ {outboundSchedules.length === 0 && alternativeOutbound.length > 0 && ( +
+
+
+ +
+

No trains available

+

+ No trains are available on {requestedDate ? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy') : 'your selected date'}. This may be due to no scheduled service or full capacity. Please check the alternative options below or try a different date. +

+ +
+
+

Alternative Outbound Options

+
+
+ {alternativeOutbound.map((schedule: Schedule) => renderScheduleCard(schedule, true, true))} +
+
+ )}
) : (
@@ -891,6 +932,26 @@ export default function ResultsPage() {
{inboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, false))}
+ {inboundSchedules.length === 0 && alternativeInbound.length > 0 && ( +
+
+
+ +
+

No trains available

+

+ No trains are available on {requestedReturnDate ? format(new Date(`${requestedReturnDate}T00:00:00`), 'EEEE, MMMM d, yyyy') : 'your selected return date'}. This may be due to no scheduled service or full capacity. Please check the alternative options below or try a different date. +

+ +
+
+

Alternative Return Options

+
+
+ {alternativeInbound.map((schedule: Schedule) => renderScheduleCard(schedule, false, true))} +
+
+ )}
) ) : ( diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index b0a7e8fd8..ddf115084 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -172,6 +172,11 @@ export default function SeatsPage() { searchCriteria, bookingId, packageName, + packageId, + priceTierId, + packageDepartureStationId, + packageDepartureStationName, + setPackageContext, } = useBookingStore(); // Maps passenger index -> assigned seat id. A passenger can only get a seat while // they are the "active" passenger, which prevents bulk/batch selection across passengers. @@ -197,9 +202,12 @@ export default function SeatsPage() { type: "info" as "warning" | "error" | "success" | "info", onConfirm: undefined as (() => void) | undefined, showCancel: false, + confirmText: "OK", }); + const [autoAssigningReturn, setAutoAssigningReturn] = useState(false); const isRoundTrip = searchCriteria?.tripType === "ROUND_TRIP"; + const isPackageBooking = !!packageName; const currentSchedule = isRoundTrip && currentJourneyType === "inbound" ? inboundSchedule @@ -306,8 +314,10 @@ export default function SeatsPage() { return raw .map((c: any, idx: number) => ({ id: c.id || c.coachId || String(idx), + coachId: c.coachId || c.id || null, label: c.label || c.coachNumber || c.name || c.coachTypeName || `Coach ${idx + 1}`, type: String(c.type || c.coachType || c.category || c.coachTypeCode || c.coachTypeName || ""), + coachTypeName: String(c.coachTypeName || c.type || c.coachType || c.category || ""), typeName: c.coachTypeName || c.coachType || c.category || c.type || "", coachTypeId: c.coachTypeId ?? c.typeId ?? null, remainingSeats: c.remainingSeats ?? c.availableSeats ?? c.available ?? null, @@ -316,6 +326,22 @@ export default function SeatsPage() { .sort((a: any, b: any) => a.sequence - b.sequence); }, [trainCoachesData]); + // Resolve the CoachType UUID for a preview-list coach. The preview API now returns + // coachTypeId directly; fall back to cross-referencing the seatmap data for older + // API versions that may not include it. + const resolveCoachTypeIdFromSeatmap = useCallback((previewCoach: any): string | null => { + if (previewCoach.coachTypeId) return previewCoach.coachTypeId; + // Fallback: match by label against the current seatmap coaches + const previewLabel = previewCoach.label || previewCoach.coachNumber || ""; + const allSeatmapCoaches: any[] = (seatMapData as any)?.coaches || (seatMapData as any)?.data?.coaches || []; + const match = allSeatmapCoaches.find( + (c: any) => (c.label || c.coachNumber || "") === previewLabel || + c.id === previewCoach.coachId || + c.id === previewCoach.id + ); + return match?.coachTypeId ?? null; + }, [seatMapData]); + const isDiningCoachType = (type: string) => /dining|dpc/i.test(type); // Looks up a coach type's lowest per-adult fare from the coach-type/fare data captured @@ -363,17 +389,15 @@ export default function SeatsPage() { const applyCoachTypeSwitch = (coach: any, matchedType: any) => { const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId); const firstClass = matchedType.classes?.[0]; + const newCoachTypeId = matchedType.coachTypeId || matchedType.coachId; const updatedSchedule = { ...(currentSchedule as any), - selectedCoachTypeId: matchedType.coachTypeId || matchedType.coachId, - selectedCoachTypeCode: matchedType.coachTypeCode, - selectedCoachTypeName: matchedType.coachTypeName, - selectedSeatClass: firstClass?.name || matchedType.coachTypeName, - selectedSeatClassName: firstClass?.name || matchedType.coachTypeName, - // review/page.tsx's fare-breakdown request reads THIS field (not - // selectedSeatClassName) to resolve the seat class — must stay in sync or the - // review page keeps pricing against the coach type the user switched away from. - seatClassName: firstClass?.name || matchedType.coachTypeName, + selectedCoachTypeId: newCoachTypeId, + selectedCoachTypeCode: matchedType.coachTypeCode || coach.type || "", + selectedCoachTypeName: matchedType.coachTypeName || coach.coachTypeName || coach.typeName || "", + selectedSeatClass: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "", + selectedSeatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "", + seatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "", baseFareAdult: newFare ?? (currentSchedule as any)?.baseFareAdult, baseFareChild: newFare ?? (currentSchedule as any)?.baseFareChild, }; @@ -382,6 +406,11 @@ export default function SeatsPage() { setInboundSchedule(updatedSchedule); } else if (isRoundTrip) { setOutboundSchedule(updatedSchedule); + // For package bookings both legs always use the same coach type — mirror the + // switch to the inbound schedule so the auto-assign fetches the right seatmap. + if (isPackageBooking && inboundSchedule) { + setInboundSchedule({ ...(inboundSchedule as any), ...updatedSchedule, id: inboundSchedule.id }); + } } else { setSelectedSchedule(updatedSchedule); } @@ -391,6 +420,19 @@ export default function SeatsPage() { setSelectedCoach(null); setPendingCoachLabel(coach.label); setShowCoachPreview(false); + + // For package bookings, sync the stored tier price with the new coach type's fare + // so the review page totals reflect the switched coach type. + if (isPackageBooking && packageId && newFare != null) { + setPackageContext( + packageId, + priceTierId ?? '', + newFare, + packageName ?? undefined, + packageDepartureStationId ?? undefined, + packageDepartureStationName ?? undefined, + ); + } }; // Same coach type as the one already loaded — no refetch needed, just bring this @@ -401,12 +443,6 @@ export default function SeatsPage() { setShowCoachPreview(false); }; - // Coach card click handler for the Train Coach Preview: validates availability, then - // immediately loads that coach's seat map (switching coach type if needed) — no price - // confirmation here. Individual seats within a coach type/bed coach can still be priced - // differently (e.g. Upper/Middle/Lower berths), so the fare confirmation instead happens - // at the point of actually picking a seat (see handleSeatClick), once real seat data is - // in view. const handlePreviewCoachSelect = (coach: any) => { if (coach.remainingSeats != null && coach.remainingSeats <= 0) { setModalState({ @@ -416,31 +452,71 @@ export default function SeatsPage() { type: "warning", onConfirm: undefined, showCancel: false, + confirmText: "OK", }); return; } - const types = (currentSchedule as any)?.coachTypes || []; - const matchedType = types.find( - (ct: any) => - ct.coachTypeId === coach.coachTypeId || - ct.coachId === coach.coachTypeId || - ct.coachTypeCode === coach.type || - ct.coachTypeName === coach.type, - ); - const isSameType = - matchedType && - (matchedType.coachTypeId === coachTypeId || matchedType.coachId === coachTypeId); + // Resolve the real CoachType UUID by cross-referencing the seatmap data, + // since the preview API (/seats/coaches) returns physical coach IDs, not CoachType UUIDs. + const resolvedCoachTypeId = resolveCoachTypeIdFromSeatmap(coach); - if (!matchedType || isSameType) { - // Same coach type — just bring this physical coach's seat map into view. + // Same coach type as currently loaded — just scroll to it. + // Only trust the resolved UUID; name/code comparisons are unreliable across + // different API responses and cause false positives for package bookings. + const isSameType = !!resolvedCoachTypeId && resolvedCoachTypeId === coachTypeId; + + if (isSameType) { focusCoachInPlace(coach); return; } - // Different coach type — switch to it and load its seat map; per-seat fare - // confirmation (if any) happens once the user picks an actual seat. - applyCoachTypeSwitch(coach, matchedType); + // Different coach type — build matchedType from coachTypes array or synthesise. + const types = (currentSchedule as any)?.coachTypes || []; + const matchedType = types.find( + (ct: any) => + (resolvedCoachTypeId && (ct.coachTypeId === resolvedCoachTypeId || ct.coachId === resolvedCoachTypeId)) || + (coach.coachTypeName && ct.coachTypeName === coach.coachTypeName) || + (coach.type && (ct.coachTypeCode === coach.type || ct.coachTypeName === coach.type)), + ) ?? { + coachTypeId: resolvedCoachTypeId, + coachId: resolvedCoachTypeId, + coachTypeName: coach.coachTypeName || coach.typeName || coach.type || "", + coachTypeCode: coach.type || "", + classes: [], + }; + + const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId); + const currentFare = originalFareForCurrentLeg ?? (currentSchedule as any)?.baseFareAdult ?? null; + + // Always confirm when switching to a different coach type — show fare difference + // if known, or a generic confirmation if fares can't be resolved. + if (newFare != null && currentFare != null && newFare !== currentFare) { + setModalState({ + isOpen: true, + title: "Fare Will Change", + message: `Switching to ${coach.label} (${matchedType.coachTypeName || coach.typeName || ""}) changes the fare to ETB ${(newFare / 100 * 2).toFixed(2)} per adult (currently ETB ${(currentFare / 100 * 2).toFixed(2)}). Continue?`, + type: "warning", + showCancel: true, + confirmText: "Switch Coach", + onConfirm: () => applyCoachTypeSwitch(coach, matchedType), + }); + return; + } + + // Same fare or fare unknown — still confirm the coach type switch. + const coachTypeName = matchedType.coachTypeName || coach.coachTypeName || coach.typeName || ""; + setModalState({ + isOpen: true, + title: "Switch Coach Type", + message: `Switch to ${coach.label}${coachTypeName ? ` (${coachTypeName})` : ""}?${ + newFare != null ? ` Fare: ETB ${(newFare / 100 * 2).toFixed(2)} per adult.` : " This will have a fare change." + }`, + type: "info", + showCancel: true, + confirmText: "Switch Coach", + onConfirm: () => applyCoachTypeSwitch(coach, matchedType), + }); }; const holdMutation = useMutation({ @@ -693,7 +769,9 @@ export default function SeatsPage() { const newSeat = validSeats?.find((s: any) => s.id === seatId); const newFare = newSeat ? getSeatFare(newSeat) : null; - if (newFare != null) { + // Package bookings: fare-change warning on individual seat clicks is suppressed. + // The only fare-change confirmation is when switching coach type via Train Coach Preview. + if (newFare != null && !isPackageBooking) { let referenceFare: number | null = null; let referenceLabel = "the fare you originally selected"; @@ -723,9 +801,10 @@ export default function SeatsPage() { setModalState({ isOpen: true, title: "Fare Will Change", - message: `${positionLabel} ${seatLabel} costs ETB ${(newFare / 100).toFixed(2)}, different from ${referenceLabel}. Continue with this selection?`, + message: `${positionLabel} ${seatLabel} costs ETB ${(newFare / 100 * 2).toFixed(2)}, different from ${referenceLabel}. Continue with this selection?`, type: "warning", showCancel: true, + confirmText: "Continue", onConfirm: () => commitSeatAssignment(seatId), }); return; @@ -764,6 +843,152 @@ export default function SeatsPage() { }; }); setPassengers(updatedPassengers); + + if (isPackageBooking && inboundSchedule) { + setAutoAssigningReturn(true); + try { + // Package bookings always use the same coach type for both legs — use the + // outbound's (just-confirmed) coachTypeId so a prior coach-type switch is + // reflected in the inbound seatmap fetch even if inboundSchedule wasn't updated. + const inboundCoachTypeId = (currentSchedule as any)?.selectedCoachTypeId || (inboundSchedule as any).selectedCoachTypeId; + const inboundOriginId = (inboundSchedule as any).originStationId || searchCriteria?.destinationStationId; + const inboundDestId = (inboundSchedule as any).destinationStationId || searchCriteria?.originStationId; + + // Fetch the full outbound seatmap to resolve seat number/bedPosition by ID — + // validSeats only holds the currently-expanded coach and may be empty. + const outboundCoachTypeId = (currentSchedule as any)?.selectedCoachTypeId; + const outboundOriginId = (currentSchedule as any)?.originStationId || searchCriteria?.originStationId; + const outboundDestId = (currentSchedule as any)?.destinationStationId || searchCriteria?.destinationStationId; + const outboundMapData: any = await apiClient.get( + `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${outboundCoachTypeId}&journeyDirection=OUTBOUND${outboundOriginId ? `&originStationId=${outboundOriginId}` : ''}${outboundDestId ? `&destinationStationId=${outboundDestId}` : ''}` + ); + const outboundCoaches: any[] = (outboundMapData as any)?.coaches || (outboundMapData as any)?.data?.coaches || []; + const allOutboundSeats: any[] = []; + outboundCoaches.forEach((c: any) => { + if (c.rooms?.length > 0) { + c.rooms.forEach((r: any) => r.beds?.forEach((b: any) => allOutboundSeats.push(b))); + } else { + (c.seats || []).forEach((s: any) => allOutboundSeats.push(s)); + } + }); + + const inboundMapData: any = await apiClient.get( + `/seats/seatmap/${inboundSchedule.id}?coachTypeId=${inboundCoachTypeId}&journeyDirection=RETURN${inboundOriginId ? `&originStationId=${inboundOriginId}` : ''}${inboundDestId ? `&destinationStationId=${inboundDestId}` : ''}` + ); + const inboundCoaches: any[] = (inboundMapData as any)?.coaches || (inboundMapData as any)?.data?.coaches || []; + + const allInboundSeats: any[] = []; + inboundCoaches.forEach((c: any) => { + const coachLabel = c.label || c.name || c.coachNumber || ''; + if (c.rooms?.length > 0) { + c.rooms.forEach((r: any) => r.beds?.forEach((b: any) => allInboundSeats.push({ ...b, _coachLabel: coachLabel }))); + } else { + (c.seats || []).forEach((s: any) => allInboundSeats.push({ ...s, _coachLabel: coachLabel })); + } + }); + + const claimedIds = new Set(); + const inboundSeatMap: Record = {}; + + for (const i of seatEligibleIndices) { + // Resolve outbound seat from the full seatmap fetch (not validSeats which + // only holds the currently-expanded coach and is often empty). + const outboundSeat = allOutboundSeats.find((s: any) => s.id === seatIds[i]); + const outboundBase = outboundSeat ? (outboundSeat.number || outboundSeat.label || outboundSeat.seatNumber || '') : ''; + const outboundBedPos: string | null = outboundSeat?.bedPosition || null; + + // Priority 1: exact same seat number + same bed position + // Priority 2: same bed position, any available seat + // Priority 3: any available seat (fallback) + const match = + allInboundSeats.find((s: any) => s.status === 'AVAILABLE' && !claimedIds.has(s.id) && (s.number || s.label || s.seatNumber || '') === outboundBase && (!outboundBedPos || s.bedPosition === outboundBedPos)) || + allInboundSeats.find((s: any) => s.status === 'AVAILABLE' && !claimedIds.has(s.id) && outboundBedPos && s.bedPosition === outboundBedPos) || + allInboundSeats.find((s: any) => s.status === 'AVAILABLE' && !claimedIds.has(s.id)); + + if (match) { + inboundSeatMap[i] = match.id; + claimedIds.add(match.id); + } + } + + if (claimedIds.size < seatEligibleIndices.length) { + // Not enough inbound seats found — fall through to manual inbound selection + setAutoAssigningReturn(false); + setCurrentJourneyType("inbound"); + setPassengerSeatMap({}); + setActivePassengerIndex(seatEligibleIndices[0] ?? 0); + setSelectedCoach(null); + return; + } + + // Hold inbound seats directly — holdMutation reads currentJourneyType + // which is still "outbound" at this point, so we call the API directly. + const inboundHoldData: any = await apiClient.post('/seats/hold', { + scheduleId: inboundSchedule.id, + originStationId: inboundOriginId, + destinationStationId: inboundDestId, + journeyDirection: 'RETURN', + passengers: seatEligibleIndices.map((i, offset) => ({ + passengerId: `temp-${Date.now()}-${offset}`, + seatId: inboundSeatMap[i], + })), + }); + + const currentHold = useBookingStore.getState().seatHold; + setSeatHold({ + holdId: currentHold?.holdId || '', + expiresAt: currentHold?.expiresAt || '', + returnHoldId: inboundHoldData.holdId || inboundHoldData.id, + returnExpiresAt: inboundHoldData.expiresAt, + }); + + const withInbound = updatedPassengers.map((p, i) => { + const inboundSeatId = inboundSeatMap[i]; + const inboundSeatData = inboundSeatId ? allInboundSeats.find((s: any) => s.id === inboundSeatId) : undefined; + return { + ...p, + inboundSeatId, + inboundSeatNumber: inboundSeatData ? buildSeatLabel(inboundSeatData) : '', + inboundCoachNumber: inboundSeatData?._coachLabel || '', + inboundSeatFareMinor: undefined, + inboundBedPosition: inboundSeatData?.bedPosition || undefined, + }; + }); + setPassengers(withInbound); + + // Update the stored tier price with the actual berth fare so the review page + // reflects the correct price when the user picks Upper/Middle/Lower berths + // (which are priced differently within the same coach type). + if (packageId) { + const firstEligibleIdx = seatEligibleIndices[0]; + const outboundSeat = firstEligibleIdx != null + ? allOutboundSeats.find((s: any) => s.id === seatIds[firstEligibleIdx]) + : null; + const berthFare = outboundSeat ? getSeatFare(outboundSeat) : null; + if (berthFare != null) { + setPackageContext( + packageId, + priceTierId ?? '', + berthFare, + packageName ?? undefined, + packageDepartureStationId ?? undefined, + packageDepartureStationName ?? undefined, + ); + } + } + + router.push('/booking/review'); + return; + } catch { + // Auto-assign failed — fall through to manual inbound selection + setAutoAssigningReturn(false); + setCurrentJourneyType("inbound"); + setPassengerSeatMap({}); + setActivePassengerIndex(seatEligibleIndices[0] ?? 0); + setSelectedCoach(null); + return; + } + } } catch (error: any) { setModalState({ isOpen: true, @@ -774,6 +999,7 @@ export default function SeatsPage() { type: "error", onConfirm: undefined, showCancel: false, + confirmText: "OK", }); return; } @@ -818,6 +1044,7 @@ export default function SeatsPage() { type: "error", onConfirm: undefined, showCancel: false, + confirmText: "OK", }); return; } @@ -841,6 +1068,7 @@ export default function SeatsPage() { type: "warning", onConfirm: undefined, showCancel: false, + confirmText: "OK", }); return; } @@ -1323,7 +1551,9 @@ export default function SeatsPage() { const isCurrentType = !!coachTypeId && (coach.coachTypeId === coachTypeId || + coach.coachId === coachTypeId || coach.type === (currentSchedule as any)?.selectedCoachTypeCode || + coach.coachTypeName === (currentSchedule as any)?.selectedCoachTypeName || coach.type === (currentSchedule as any)?.selectedCoachTypeName); return (
@@ -1503,14 +1733,16 @@ export default function SeatsPage() {

- {isRoundTrip - ? currentJourneyType === "outbound" - ? packageName ? `Select Outbound Seats for ${packageName}` : "Select Outbound Seats" - : packageName ? `Select Return Seats for ${packageName}` : "Select Return Seats" - : "Select Seats"} + {isPackageBooking + ? `Select Seats for ${packageName}` + : isRoundTrip + ? currentJourneyType === "outbound" + ? "Select Outbound Seats" + : "Select Return Seats" + : "Select Seats"}

{!allSeatsAssigned && (

diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx index 3e4dc04df..c7ef3fe52 100644 --- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx @@ -365,6 +365,7 @@ const PKG_CHILDREN_PER_ADULT = 5; function PassengerCountModal({ tier, + minPriceMinor, onClose, onConfirm, loading, @@ -373,6 +374,7 @@ function PassengerCountModal({ stations, }: { tier: PriceTier; + minPriceMinor: number; onClose: () => void; onConfirm: (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => void; loading: boolean; @@ -389,7 +391,7 @@ function PassengerCountModal({ const freeChildren = Math.min(childCount, adultCount); const paidChildren = Math.max(0, childCount - adultCount); // Only paid children need seats; free children share with an adult - const totalMinor = (adultCount * tier.priceMinor + paidChildren * tier.priceMinor) * priceMultiplier; + const totalMinor = (adultCount * minPriceMinor + paidChildren * minPriceMinor) * priceMultiplier; return ( <> @@ -406,7 +408,7 @@ function PassengerCountModal({

Coach type

{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}

-

{remaining} seats remaining · prices from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)

+

{remaining} seats remaining · from {formatPrice(minPriceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)

@@ -442,7 +444,7 @@ function PassengerCountModal({
)}
- Total{priceMultiplier === 2 ? ' (round-trip)' : ''} + Total{priceMultiplier === 2 ? ' (round-trip) from:' : ''} {formatPrice(totalMinor, tier.currency)}
@@ -557,6 +559,15 @@ export default function PackageDetailPage() { selectedSeatClassName: ctx.seatClassName ?? "", seatClassName: ctx.seatClassName ?? "", selectedCoachTypeId: ctx.coachTypeId ?? "", + selectedCoachTypeCode: ctx.coachTypeCode ?? "", + selectedCoachTypeName: ctx.coachTypeName ?? "", + coachTypes: Array.isArray(ctx.coachTypes) ? ctx.coachTypes : groups.map((g) => ({ + coachId: g.coachTypeId, + coachTypeId: g.coachTypeId, + coachTypeName: g.coachTypeName, + coachTypeCode: g.coachTypeCode, + classes: [{ name: g.coachTypeName, baseFareMinor: g.minPrice }], + })), }); const outboundSched = toSchedule(ctx.outboundSchedule); @@ -648,6 +659,7 @@ export default function PackageDetailPage() { {passengerModalOpen && representativeTier && ( { setPassengerModalOpen(false); setBookingContextError(null); }} onConfirm={handleBookNow} loading={bookingContextLoading} From 222687f4ac80fc81c9dc7a154cf072a4b75d287e Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 7 Jul 2026 15:46:46 +0300 Subject: [PATCH 03/18] Build issues resolution --- apps/edr-passenger-api/src/app.module.ts | 11 ------ .../src/modules/packages/packages.service.ts | 39 ++++++++++++------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index e552fad8c..1e41076e7 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -64,7 +64,6 @@ import { TasksModule } from './modules/tasks/tasks.module'; import { AppReleasesModule } from './modules/app-releases/app-releases.module'; import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module'; import { SegmentFareSeeder } from './seed/segment-fare.seeder'; -import { EOtpType } from "@tria-plc/iamapi-common"; @Module({ imports: [ @@ -98,16 +97,6 @@ import { EOtpType } from "@tria-plc/iamapi-common"; TriaIamModule.forRoot({ applications: [EDR_PASSENGER_APPLICATION], permissions: EDR_PASSENGER_PERMISSIONS, - otpMessages: { - [EOtpType.MFA_LOGIN]: ({ otp }) => - `Your EDR Passenger login code is ${otp}. It will expire in 5 minutes.`, - [EOtpType.VERIFY_PHONE_NUMBER]: ({ otp }) => - `Your EDR Passenger phone verification code is ${otp}. It will expire in 5 minutes.`, - [EOtpType.RESET_PASSWORD]: ({ route }) => - `Reset your EDR Passenger password using this link: ${route}`, - [EOtpType.SET_PASSWORD]: ({ route }) => - `Set your EDR Passenger password using this link: ${route}`, - }, }), SharedAuthModule, PrismaModule, diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index a474b1edf..916a0425b 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -75,7 +75,6 @@ export class PackagesService { const maxChildren = adultCount * PKG_CHILDREN_PER_ADULT; if (childCount > maxChildren) throw new BadRequestException(`Maximum ${PKG_CHILDREN_PER_ADULT} children per adult (${maxChildren} for ${adultCount} adult${adultCount !== 1 ? 's' : ''}) allowed per package booking`); - const remaining = tier.availableSeats - tier.bookedSeats; const isRoundTrip = !!pkg.returnScheduleId; const { adultFareMinor, freeChildren, paidChildren, totalMinor } = calculatePackageFareBreakdown( tier.priceMinor, isRoundTrip, adultCount, childCount, @@ -84,6 +83,10 @@ export class PackagesService { // Only adults and paid children need seats; free children travel without a seat const seatsNeeded = adultCount + paidChildren; const passengerCount = adultCount + childCount; + // Re-fetch tier from DB to get accurate live counts + const liveTier = await this.prisma.packagePriceTier.findUnique({ where: { id: tierId } }); + if (!liveTier) throw new NotFoundException('Price tier not found'); + const remaining = liveTier.availableSeats - liveTier.bookedSeats; if (seatsNeeded > remaining) throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`); @@ -392,25 +395,29 @@ export class PackagesService { if (childCount > maxChildrenBook) throw new BadRequestException(`Maximum ${PKG_CHILDREN_PER_ADULT} children per adult (${maxChildrenBook} for ${adultCount} adult${adultCount !== 1 ? 's' : ''}) allowed per package booking`); const passengerCount = adultCount + childCount; - const remaining = tier.availableSeats - tier.bookedSeats; - const isRoundTrip = !!pkg.returnScheduleId; const { adultFareMinor, freeChildren, paidChildren, totalMinor } = calculatePackageFareBreakdown( tier.priceMinor, isRoundTrip, adultCount, childCount, ); // Only adults and paid children need seats; free children travel without a seat const seatsNeeded = adultCount + paidChildren; - if (seatsNeeded > remaining) { - throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`); - } const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; - const [booking] = await this.prisma.$transaction([ - this.prisma.packageBooking.create({ + const [booking] = await this.prisma.$transaction(async (tx) => { + // Re-fetch tier inside transaction for race-condition-safe availability check + const freshTier = await tx.packagePriceTier.findUnique({ where: { id: dto.priceTierId } }); + if (!freshTier) throw new NotFoundException('Price tier not found'); + const remaining = freshTier.availableSeats - freshTier.bookedSeats; + if (seatsNeeded > remaining) { + throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`); + } + + return Promise.all([ + tx.packageBooking.create({ data: { bookingRef: generateRef(), packageId: dto.packageId, @@ -448,12 +455,16 @@ export class PackagesService { }, }, }, - }), - this.prisma.packagePriceTier.update({ - where: { id: dto.priceTierId }, - data: { bookedSeats: { increment: seatsNeeded } }, - }), - ]); + }), + tx.packagePriceTier.update({ + where: { id: dto.priceTierId }, + data: { + bookedSeats: { increment: seatsNeeded }, + availableSeats: { decrement: seatsNeeded }, + }, + }), + ]); + }); return { ...booking, From 692a24f5d3efecf51c37603d4402218bf625e150 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 7 Jul 2026 13:25:48 +0000 Subject: [PATCH 04/18] chore: add socket to packages api --- apps/edr-passenger-api/package.json | 3 +++ pnpm-lock.yaml | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 1501282fe..cdfe68787 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -31,10 +31,12 @@ "@nestjs/event-emitter": "^2.0.4", "@nestjs/microservices": "^11.1.24", "@nestjs/platform-express": "^11.1.19", + "@nestjs/platform-socket.io": "^11.1.27", "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^7.4.0", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.1", + "@nestjs/websockets": "^11.1.27", "@prisma/client": "^6.19.3", "@sendgrid/mail": "^8.1.0", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", @@ -54,6 +56,7 @@ "qrcode": "^1.5.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socket.io": "^4.8.3", "swagger-ui-express": "^5.0.0", "tsconfig-paths": "^4.2.0", "typeorm": "^0.3.30", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 653fe803c..b15d9a40e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -495,6 +495,9 @@ importers: '@nestjs/platform-express': specifier: ^11.1.19 version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + '@nestjs/platform-socket.io': + specifier: ^11.1.27 + version: 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.27)(rxjs@7.8.2) '@nestjs/schedule': specifier: ^6.1.3 version: 6.1.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) @@ -507,6 +510,9 @@ importers: '@nestjs/typeorm': specifier: ^11.0.1 version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + '@nestjs/websockets': + specifier: ^11.1.27 + version: 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@prisma/client': specifier: ^6.19.3 version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) @@ -564,6 +570,9 @@ importers: rxjs: specifier: ^7.8.1 version: 7.8.2 + socket.io: + specifier: ^4.8.3 + version: 4.8.3 swagger-ui-express: specifier: ^5.0.0 version: 5.0.1(express@4.22.2) From 17b7a4f6dc5ce04f791ec889b5da9fcbd2d1ccf0 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 7 Jul 2026 13:26:59 +0000 Subject: [PATCH 05/18] chore: update the supportconversation schema --- apps/edr-passenger-api/prisma/schema.prisma | 25 ++++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 0cd1756fd..b00408215 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -299,6 +299,7 @@ model Passenger { travelerProfiles TravelerProfile[] savedRoutes SavedRoute[] packageBookings PackageBooking[] + supportConversations SupportConversation[] @@index([userId]) @@index([iamUserId]) @@schema("passenger") @@ -881,12 +882,24 @@ model FaqArticle { } model SupportConversation { - id String @id @default(uuid()) - userId String - assignedAgentId String? - status SupportConversationStatus @default(OPEN) - createdAt DateTime @default(now()) - messages SupportMessage[] + id String @id @default(uuid()) + userId String + passengerId String? + passengerName String? + subject String? + assignedAgentId String? + status SupportConversationStatus @default(OPEN) + lastMessageAt DateTime? + lastMessagePreview String? + lastMessageSender SupportSender? + userLastReadAt DateTime? + agentLastReadAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @default(now()) + messages SupportMessage[] + passenger Passenger? @relation(fields: [passengerId], references: [id]) + @@index([userId]) + @@index([status, lastMessageAt]) @@schema("passenger") } From fb1d510bcf534c0d6d3683b2fb0331ebac889fee Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 7 Jul 2026 16:52:58 +0300 Subject: [PATCH 06/18] Update booking amount currency converstion --- .../portal/src/app/booking/detail/page.tsx | 385 ++++++++++++------ .../booking/payment/dmoney/success/page.tsx | 46 +-- .../portal/src/app/booking/payment/page.tsx | 60 ++- .../booking/payment/telebirr/failure/page.tsx | 15 +- .../booking/payment/telebirr/success/page.tsx | 46 +-- .../booking/payment/waafi/failure/page.tsx | 15 +- .../booking/payment/waafi/success/page.tsx | 53 +-- .../portal/src/app/booking/review/page.tsx | 23 +- .../portal/src/app/booking/seats/page.tsx | 81 +++- .../portal/src/lib/booking-store.ts | 11 + .../portal/src/utils/manage-booking-return.ts | 24 ++ 11 files changed, 516 insertions(+), 243 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/utils/manage-booking-return.ts diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index 5087b7e4d..a52a10612 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -5,28 +5,46 @@ import { useSearchParams, useRouter } from 'next/navigation'; import { useQuery, useMutation } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { useState } from 'react'; -import { - Clock, - Users, - CheckCircle2, +import { + Clock, + Users, + CheckCircle2, AlertCircle, Download, Share2, Copy, Check, CreditCard, - Wallet + Wallet, + Smartphone, + Loader2, + ChevronLeft, } from 'lucide-react'; import { format } from 'date-fns'; import { formatTime, getTimePeriod } from '@/utils/format'; +import { markManageBookingPaymentReturn, consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return'; import QRCode from 'qrcode.react'; +// Same convention as /booking/payment — payment methods are ETB-settled by default; +// a method only needs a currency conversion when its own currency differs. +const displayCurrency = 'ETB' as const; + +const getIconForMethod = (methodType: string) => { + if (methodType.includes('CARD')) return CreditCard; + if (methodType.includes('WALLET')) return Wallet; + return Smartphone; +}; + function BookingDetailContent() { const router = useRouter(); const searchParams = useSearchParams(); const bookingRef = searchParams.get('ref') || searchParams.get('bookingRef') || searchParams.get('pnr'); - - const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(''); + + // Mirrors /booking/payment's state shape: selectedMethod is the PaymentMethod `type` + // (used both for lookup and to decide provider-specific redirect handling), not the id. + const [selectedMethod, setSelectedMethod] = useState(null); + const [selectedMethodCurrency, setSelectedMethodCurrency] = useState(null); + const [paymentError, setPaymentError] = useState(null); const [copiedPNR, setCopiedPNR] = useState(false); const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); @@ -42,40 +60,84 @@ function BookingDetailContent() { retry: 1, }); - const { data: paymentMethods } = useQuery({ + const { data: paymentMethods } = useQuery({ queryKey: ['payment-methods'], queryFn: () => apiClient.get('/payments/methods'), enabled: booking?.status === 'PENDING_PAYMENT' || booking?.status === 'DRAFT', }); - const paymentMutation = useMutation({ - mutationFn: async (paymentData: any) => { - const response = await apiClient.post('/payments/intent', paymentData); + const selectedPaymentMethod = (paymentMethods || []).find((m: any) => m.type === selectedMethod) || null; + + // Same conversion logic as /booking/payment: only hit the booking-amount-changer API + // when the selected method actually settles in a different currency than ETB. + const isConversionNeeded = !!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency; + const amountCurrency = isConversionNeeded ? selectedMethodCurrency! : displayCurrency; + + const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({ + queryKey: ['bookingAmount', booking?.id, amountCurrency], + queryFn: async () => { + const url = `/payments/booking-amount?bookingId=${booking?.id}¤cy=${amountCurrency}`; + const response: any = await apiClient.get(url); return response; }, - onSuccess: async (data: any) => { - await apiClient.patch(`/bookings/${booking?.id}/confirm`, { - paymentIntentId: data.id, - paymentMethod: selectedPaymentMethod, + enabled: !!booking?.id && isConversionNeeded, + }); + + const totalAmountDisplay = isConversionNeeded + ? (bookingAmountData != null ? bookingAmountData.amount : null) + : ((booking?.totalMinor ?? 0) / 100); + const confirmedCurrency = isConversionNeeded ? (bookingAmountData?.currency || amountCurrency) : displayCurrency; + const awaitingAmount = isConversionNeeded && loadingAmount && totalAmountDisplay === null; + + const paymentMutation = useMutation({ + mutationFn: async (data: any) => { + return await apiClient.post('/payments/initiate', { + bookingId: data.bookingId, + method: data.method, + paymentMethodId: data.paymentMethodId, + platform: 'web', }); - refetch(); + }, + onSuccess: async (data: any) => { + setPaymentError(null); + + if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI') && data?.clientAction?.type === 'REDIRECT') { + window.location.href = data.clientAction.url; + return; + } + + // No redirect needed (e.g. WALLET) — the marker set in handlePayment is now moot. + consumeManageBookingPaymentReturn(); + await refetch(); }, onError: (error: any) => { - alert(error?.response?.data?.message || 'Payment failed. Please try again.'); + setPaymentError( + error?.response?.data?.message || + error?.message || + 'Payment failed. Please try again.', + ); }, }); const handlePayment = () => { - if (!selectedPaymentMethod) { - alert('Please select a payment method'); + if (!selectedMethod || !booking?.id) { + setPaymentError('Please select a payment method'); return; } + if (!selectedPaymentMethod) { + setPaymentError('Invalid payment method selected'); + return; + } + + setPaymentError(null); + // Flag this as a Manage Booking payment so the gateway's success/failure return page + // sends the user back here instead of the new-booking confirmation flow. + markManageBookingPaymentReturn(booking.bookingRef); paymentMutation.mutate({ - bookingId: booking?.id, - amount: booking?.totalMinor || 0, - currency: booking?.currency || 'ETB', - paymentMethodId: selectedPaymentMethod, + bookingId: booking.id, + method: selectedMethod, + paymentMethodId: selectedPaymentMethod.id, }); }; @@ -163,38 +225,115 @@ function BookingDetailContent() { ); }; + // Order summary card — mirrors /booking/payment's OrderSummary: fare breakdown per + // passenger, Total with a loading spinner while a currency conversion is in flight, and + // a note confirming what will actually be charged once a payment method is selected. + const OrderSummary = () => ( +
+

+ Order summary + + Ref: {booking.bookingRef} + +

+ +
+

Fare breakdown

+ {(booking.passengers || []).map((passenger: any, idx: number) => ( +
+ + {passenger.fullName || `Passenger ${idx + 1}`} + {passenger.category === 'CHILD' && ( + (CHILD) + )} + + + {displayCurrency} {((passenger.fareMinor ?? 0) / 100).toFixed(2)} + +
+ ))} +
+ +
+
+ Total + + {awaitingAmount ? ( + + ) : ( + <>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)} + )} + +
+ {selectedPaymentMethod && !awaitingAmount && ( +

+ You will be charged {confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)} via {selectedPaymentMethod.displayName} +

+ )} +
+ + {/* Pay + back buttons — desktop sidebar only */} +
+ {paymentError && ( +

⚠️ {paymentError}

+ )} + + +

+ 🔒 Secure & encrypted payment +

+
+
+ ); + if (isPendingPayment && !isExpired) { return ( -
+
-
- -
-
-
-

Complete Payment

-

- Booking Reference: {booking.bookingRef} -

-
- -
- - {booking.createdAt && ( -
- - +
+

Complete payment

+ +
+
+

+ Booking Reference: {booking.bookingRef} +

+ {booking.createdAt && ( +

+ Booking created on {format(new Date(booking.createdAt), 'PPpp')} - -

- )} +

+ )} +
+
-
- -
- -
+ {/* Two-column grid — matches /booking/payment's layout */} +
+ + {/* Left column — trip/payment method (2/3 width) */} +
+ +

Trip Summary

@@ -302,43 +441,40 @@ function BookingDetailContent() {
-
-

Select Payment Method

- +
+

Select payment method

+ {paymentMethods && Array.isArray(paymentMethods) && paymentMethods.length > 0 ? ( -
- {paymentMethods.map((method: any) => ( -
- - ))} + + ); + })}
) : (
@@ -347,39 +483,60 @@ function BookingDetailContent() { )}
- -
- -
-
-

Order Summary

- -
-
- Subtotal ({booking.adultCount} Adult{booking.adultCount > 1 ? 's' : ''}{booking.childCount > 0 ? `, ${booking.childCount} Child${booking.childCount > 1 ? 'ren' : ''}` : ''}) - - {booking.currency} {((booking.totalMinor || 0) / 100).toFixed(2)} - -
-
- -
-
- Total - - {booking.displayCurrency} {((booking.displayTotalMinor || booking.totalMinor || 0) / 100).toFixed(2)} - -
-
+ {/* Order summary inline — mobile only */} +
+
-
+ + {/* Right column — sticky order summary (desktop only) */} +
+
+ +
+
+ +
{/* end grid */} +
+
+ + {/* Mobile sticky bottom bar */} +
+
+ Total + + {awaitingAmount ? ( + + ) : ( + <>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)} + )} + +
+ {paymentError && ( +

⚠️ {paymentError}

+ )} +
+ +
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx index e75f9b36a..33d6a20f6 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx @@ -2,45 +2,33 @@ import { useEffect, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { useBookingStore } from '@/lib/booking-store'; import { usePaymentStore } from '@/lib/payment-store'; -import { apiClient } from '@/lib/api-client'; +import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return'; import { CheckCircle, Loader2 } from 'lucide-react'; import { Suspense } from 'react'; function DmoneySuccessContent() { const router = useRouter(); const searchParams = useSearchParams(); - const { bookingId } = useBookingStore(); const { updateStatus } = usePaymentStore(); const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing'); + const [returnTarget, setReturnTarget] = useState('/booking/confirmation'); // D-Money callback query params (mirrors Telebirr) - const orderid = searchParams.get('orderid') || ''; - const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || ''; - const bookingIdQp = searchParams.get('bookingId') || bookingId || ''; + const orderid = searchParams.get('orderid') || ''; + const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || ''; useEffect(() => { - const confirm = async () => { - try { - if (bookingIdQp) { - await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, { - paymentReference: orderid || trxRef, - paymentMethod: 'DMONEY', - }); - } - - updateStatus('SUCCEEDED'); - setStatus('done'); - setTimeout(() => router.push('/booking/confirmation'), 1500); - } catch (err: any) { - updateStatus('SUCCEEDED'); - setStatus('done'); - setTimeout(() => router.push('/booking/confirmation'), 1500); - } - }; - - confirm(); + // Actual booking confirmation happens server-side via the provider webhook — this page + // only reflects that back to the user. A Manage Booking payment (paying for an + // already-existing booking) has no in-progress booking-store session to show a + // confirmation from, so it goes back to that booking's detail view instead. + const manageBookingRef = consumeManageBookingPaymentReturn(); + const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation'; + setReturnTarget(target); + updateStatus('SUCCEEDED'); + setStatus('done'); + setTimeout(() => router.push(target), 1500); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -61,7 +49,7 @@ function DmoneySuccessContent() {

Your D-Money payment was received.

{orderid &&

Order ID: {orderid}

} {trxRef &&

Transaction Ref: {trxRef}

} -

Redirecting to your booking confirmation…

+

Redirecting…

)} {status === 'error' && ( @@ -71,8 +59,8 @@ function DmoneySuccessContent() {

Something went wrong

Unable to confirm payment

- + )}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 0b27dfa87..a95179762 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -48,9 +48,16 @@ export default function PaymentPage() { }, }); - // Fetch actual booking amount from API when a payment method is selected - const amountCurrency = selectedMethodCurrency || displayCurrency; + const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod) || null; + // A payment method only needs a currency conversion when its own currency differs from + // the default booking currency (e.g. Waafi settles in USD) — otherwise the reviewed ETB + // total already shown on the review page is exact and there's nothing to convert. + const isConversionNeeded = !!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency; + const amountCurrency = isConversionNeeded ? selectedMethodCurrency! : displayCurrency; + + // Fetch the converted booking amount from the booking-amount-changer API whenever a + // currency-specific payment method is selected. const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({ queryKey: ['bookingAmount', bookingId, amountCurrency], queryFn: async () => { @@ -58,7 +65,7 @@ export default function PaymentPage() { const response: any = await apiClient.get(url); return response; }, - enabled: !!bookingId, + enabled: !!bookingId && isConversionNeeded, }); // Per-leg subtotals for the journey header — sum each paying passenger's reviewed fare @@ -71,32 +78,39 @@ export default function PaymentPage() { ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0) : 0; - // reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display — - // they were computed and shown to the user on the review page, so the Total here must match. - // The API booking-amount is used only as the charge amount sent to the payment provider. + // reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display + // in the booking's default currency (ETB) — they were computed and shown to the user on + // the review page. But once a payment method with its own currency is selected (e.g. + // Waafi/USD), the converted amount from the booking-amount API takes over so the user + // sees the actual amount they'll be charged in that currency. const reviewedTotal = reviewedTotalMinor ?? (reviewedPassengerFares?.reduce((s, f) => s + f.fareMinor, 0) ?? null); - const totalAmountDisplay = reviewedTotal != null ? reviewedTotal / 100 : (bookingAmountData != null ? bookingAmountData.amount : null); - const totalAmount = bookingAmountData != null - ? Math.round(bookingAmountData.amount * 100) - : (reviewedTotal ?? 0); - const confirmedCurrency = bookingAmountData?.currency || amountCurrency; + const totalAmountDisplay = isConversionNeeded + ? (bookingAmountData != null ? bookingAmountData.amount : null) + : (reviewedTotal != null ? reviewedTotal / 100 : (bookingAmountData != null ? bookingAmountData.amount : null)); + const totalAmount = isConversionNeeded + ? (bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : (reviewedTotal ?? 0)) + : (reviewedTotal ?? (bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : 0)); + const confirmedCurrency = isConversionNeeded ? (bookingAmountData?.currency || amountCurrency) : displayCurrency; - // Show loading spinner only when the API hasn't responded AND we have no review-page - // total to fall back on — once reviewedTotalMinor is set the button is always enabled. - const awaitingAmount = !isPackage && loadingAmount && totalAmountDisplay === null; + // Show loading spinner while the converted amount is still in flight for a + // currency-specific method; ETB methods always have the reviewed total instantly. + const awaitingAmount = !isPackage && isConversionNeeded && loadingAmount && totalAmountDisplay === null; useEffect(() => { - // Always store the reviewed total (minor, ETB) as the paid amount — it's what was - // shown to the user and matches the fare breakdown. The API amount is only used as - // the charge sent to the provider (may differ due to currency conversion). - if (reviewedTotal != null) { + // Once a currency-specific payment method's converted amount has loaded, that's the + // real charge amount and currency — store it as the paid amount. Otherwise fall back + // to the reviewed ETB total shown on the review page. + if (isConversionNeeded && bookingAmountData != null) { + setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD'); + setPaidAmount(Math.round(bookingAmountData.amount * 100)); + } else if (reviewedTotal != null) { setCurrency('ETB'); setPaidAmount(reviewedTotal); } else if (bookingAmountData != null) { setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD'); setPaidAmount(Math.round(bookingAmountData.amount * 100)); } - }, [bookingAmountData, confirmedCurrency, reviewedTotal, setCurrency, setPaidAmount]); + }, [isConversionNeeded, bookingAmountData, confirmedCurrency, reviewedTotal, setCurrency, setPaidAmount]); const paymentMutation = useMutation({ mutationFn: async (data: any) => { @@ -145,9 +159,6 @@ export default function PaymentPage() { setIsProcessing(true); setPaymentError(null); - // Find the selected payment method to get its ID - const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod); - if (!selectedPaymentMethod) { alert("Invalid payment method selected"); setIsProcessing(false); @@ -320,6 +331,11 @@ export default function PaymentPage() { )}
+ {selectedPaymentMethod && !awaitingAmount && ( +

+ You will be charged {confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)} via {selectedPaymentMethod.displayName} +

+ )}
{/* Pay + back buttons — desktop sidebar only */} diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx index 773afc79f..6ea64036b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx @@ -2,13 +2,18 @@ import { useSearchParams, useRouter } from 'next/navigation'; import { usePaymentStore } from '@/lib/payment-store'; -import { useEffect, Suspense } from 'react'; +import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return'; +import { useEffect, useState, Suspense } from 'react'; import { XCircle, Loader2, ChevronLeft } from 'lucide-react'; function TelebirrFailureContent() { const router = useRouter(); const searchParams = useSearchParams(); const { updateStatus } = usePaymentStore(); + // A Manage Booking payment (paying for an already-existing booking) has no in-progress + // booking-store session to go "back to review" from — send it back to that booking's + // detail view instead, where the user can pick a different payment method. + const [backTarget, setBackTarget] = useState('/booking/review'); const merchantOrderId = searchParams.get('merchantOrderId') || ''; const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || ''; @@ -16,6 +21,10 @@ function TelebirrFailureContent() { const resultMsg = searchParams.get('resultMsg') || searchParams.get('message') || 'Payment was not completed.'; useEffect(() => { + const manageBookingRef = consumeManageBookingPaymentReturn(); + if (manageBookingRef) { + setBackTarget(`/booking/detail?ref=${encodeURIComponent(manageBookingRef)}`); + } updateStatus('FAILED'); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -30,10 +39,10 @@ function TelebirrFailureContent() { {merchantOrderId &&

Order ID: {merchantOrderId}

} {trxRef &&

Ref: {trxRef}

}
-
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx index 9b190fbf2..1a830cee3 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx @@ -2,45 +2,33 @@ import { useEffect, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { useBookingStore } from '@/lib/booking-store'; import { usePaymentStore } from '@/lib/payment-store'; -import { apiClient } from '@/lib/api-client'; +import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return'; import { CheckCircle, Loader2 } from 'lucide-react'; import { Suspense } from 'react'; function TelebirrSuccessContent() { const router = useRouter(); const searchParams = useSearchParams(); - const { bookingId } = useBookingStore(); const { updateStatus } = usePaymentStore(); const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing'); + const [returnTarget, setReturnTarget] = useState('/booking/confirmation'); // Telebirr callback query params - const orderid = searchParams.get('orderid') || ''; - const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || ''; - const bookingIdQp = searchParams.get('bookingId') || bookingId || ''; + const orderid = searchParams.get('orderid') || ''; + const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || ''; useEffect(() => { - const confirm = async () => { - try { - if (bookingIdQp) { - await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, { - paymentReference: orderid || trxRef, - paymentMethod: 'TELEBIRR', - }); - } - - updateStatus('SUCCEEDED'); - setStatus('done'); - setTimeout(() => router.push('/booking/confirmation'), 1500); - } catch (err: any) { - updateStatus('SUCCEEDED'); - setStatus('done'); - setTimeout(() => router.push('/booking/confirmation'), 1500); - } - }; - - confirm(); + // Actual booking confirmation happens server-side via the provider webhook — this page + // only reflects that back to the user. A Manage Booking payment (paying for an + // already-existing booking) has no in-progress booking-store session to show a + // confirmation from, so it goes back to that booking's detail view instead. + const manageBookingRef = consumeManageBookingPaymentReturn(); + const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation'; + setReturnTarget(target); + updateStatus('SUCCEEDED'); + setStatus('done'); + setTimeout(() => router.push(target), 1500); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -61,7 +49,7 @@ function TelebirrSuccessContent() {

Your Telebirr payment was received.

{orderid &&

Order ID: {orderid}

} {trxRef &&

Transaction Ref: {trxRef}

} -

Redirecting to your booking confirmation…

+

Redirecting…

)} {status === 'error' && ( @@ -71,8 +59,8 @@ function TelebirrSuccessContent() {

Something went wrong

Unable to confirm payment

- + )}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx index 6a32c388b..7d5d4566b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx @@ -2,13 +2,18 @@ import { useSearchParams, useRouter } from 'next/navigation'; import { usePaymentStore } from '@/lib/payment-store'; -import { useEffect, Suspense } from 'react'; +import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return'; +import { useEffect, useState, Suspense } from 'react'; import { XCircle, Loader2, ChevronLeft } from 'lucide-react'; function WaafiFailureContent() { const router = useRouter(); const searchParams = useSearchParams(); const { updateStatus } = usePaymentStore(); + // A Manage Booking payment (paying for an already-existing booking) has no in-progress + // booking-store session to go "back to review" from — send it back to that booking's + // detail view instead, where the user can pick a different payment method. + const [backTarget, setBackTarget] = useState('/booking/review'); const referenceId = searchParams.get('referenceId') || ''; const responseCode = searchParams.get('responseCode') || ''; @@ -17,6 +22,10 @@ function WaafiFailureContent() { const state = searchParams.get('state') || ''; useEffect(() => { + const manageBookingRef = consumeManageBookingPaymentReturn(); + if (manageBookingRef) { + setBackTarget(`/booking/detail?ref=${encodeURIComponent(manageBookingRef)}`); + } updateStatus('FAILED'); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -33,10 +42,10 @@ function WaafiFailureContent() {

Ref: {referenceId || transactionId}

)}
-
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx index 073cd610b..c75a0c390 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx @@ -2,57 +2,32 @@ import { useEffect, useState, Suspense } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { useBookingStore } from '@/lib/booking-store'; import { usePaymentStore } from '@/lib/payment-store'; -import { apiClient } from '@/lib/api-client'; +import { consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return'; import { CheckCircle, Loader2 } from 'lucide-react'; function WaafiSuccessContent() { const router = useRouter(); const searchParams = useSearchParams(); - const { bookingId } = useBookingStore(); const { updateStatus } = usePaymentStore(); const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing'); // Waafi callback query params - const accountNo = searchParams.get('accountNo') || ''; - const currency = searchParams.get('currency') || ''; - const referenceId = searchParams.get('referenceId') || ''; - const state = searchParams.get('state') || ''; + const referenceId = searchParams.get('referenceId') || ''; const transactionId = searchParams.get('transactionId') || ''; - const txAmount = searchParams.get('txAmount') || ''; - const timestamp = searchParams.get('timestamp') || ''; - const bookingIdQp = searchParams.get('bookingId') || bookingId || ''; + const txAmount = searchParams.get('txAmount') || ''; + const currency = searchParams.get('currency') || ''; useEffect(() => { - const confirm = async () => { - try { - if (bookingIdQp) { - await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, { - paymentReference: referenceId || transactionId, - paymentMethod: 'WAAFI', - transactionDetails: { - transactionId, - accountNo, - amount: txAmount, - currency, - state, - timestamp, - }, - }); - } - - updateStatus('SUCCEEDED'); - setStatus('done'); - setTimeout(() => router.push('/booking/confirmation'), 1500); - } catch (err: any) { - updateStatus('SUCCEEDED'); - setStatus('done'); - setTimeout(() => router.push('/booking/confirmation'), 1500); - } - }; - - confirm(); + // Actual booking confirmation happens server-side via the provider webhook — this page + // only reflects that back to the user. A Manage Booking payment (paying for an + // already-existing booking) has no in-progress booking-store session to show a + // confirmation from, so it goes back to that booking's detail view instead. + const manageBookingRef = consumeManageBookingPaymentReturn(); + const target = manageBookingRef ? `/booking/detail?ref=${encodeURIComponent(manageBookingRef)}` : '/booking/confirmation'; + updateStatus('SUCCEEDED'); + setStatus('done'); + setTimeout(() => router.push(target), 1500); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -76,7 +51,7 @@ function WaafiSuccessContent() { {txAmount && currency && (

Amount: {txAmount} {currency}

)} -

Redirecting to your booking confirmation…

+

Redirecting…

)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 194b47dff..535fa73c7 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -47,7 +47,7 @@ function getPassengerIdFromToken(token: string): string | null { export default function ReviewPage() { const router = useRouter(); - const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, seatHold, setBookingId, setPNR, setReviewedTotal, createAccount, passengerId: storedPassengerId, searchCriteria, packageId, priceTierId, packageTierPriceMinor, packageName } = useBookingStore(); + const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, seatHold, bookingId, pnr, bookingHoldId, bookingReturnHoldId, reviewedTotalMinor, setBookingId, setPNR, setBookingHoldReference, setReviewedTotal, createAccount, passengerId: storedPassengerId, searchCriteria, packageId, priceTierId, packageTierPriceMinor, packageName } = useBookingStore(); const { user, isAuthenticated } = useAuthStore(); const [timeLeft, setTimeLeft] = useState(''); const [seatDetails, setSeatDetails] = useState>({}); @@ -159,6 +159,11 @@ export default function ReviewPage() { const pnrValue = data.pnr || data.bookingReference || data.bookingRef; setBookingId(bookingIdValue); setPNR(pnrValue); + // Remember which hold(s) this booking was created from, so if the user comes back + // here (e.g. hitting "back" from the payment gateway) with the same hold still in + // the store, we can detect it's the same booking and reuse it instead of creating + // another one. + setBookingHoldReference(seatHold?.holdId || null, seatHold?.returnHoldId || null); const totalAmount = data.totalMinor || data.totalAmount || 0; setTimeout(() => { if (totalAmount > 0) { @@ -177,7 +182,21 @@ export default function ReviewPage() { const handleConfirm = async () => { try { const { searchCriteria } = useBookingStore.getState(); - + + // A booking already exists for the exact hold(s) currently in the store — e.g. the + // user was sent to the payment gateway and hit "back". Reuse it instead of creating + // a duplicate booking; just resume the payment step. + const sameHoldAsExistingBooking = + !!bookingId && + !!pnr && + !!seatHold?.holdId && + seatHold.holdId === bookingHoldId && + (isRoundTrip ? (seatHold.returnHoldId || null) === bookingReturnHoldId : true); + if (sameHoldAsExistingBooking) { + router.push((reviewedTotalMinor ?? 0) > 0 ? '/booking/payment' : '/booking/confirmation'); + return; + } + if (!seatHold?.holdId) { alert('Please select seats before continuing.'); router.push('/booking/seats'); diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index b0a7e8fd8..4c751862d 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -4,6 +4,7 @@ export const dynamic = "force-dynamic"; import { useRouter } from "next/navigation"; import { useBookingStore } from "@/lib/booking-store"; +import { useAuthStore } from "@/lib/auth-store"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; import { useState, useEffect, useCallback, useMemo, useRef, memo } from "react"; @@ -164,6 +165,7 @@ export default function SeatsPage() { outboundSchedule, inboundSchedule, passengers, + seatHold, setSeatHold, setPassengers, setSelectedSchedule, @@ -173,6 +175,7 @@ export default function SeatsPage() { bookingId, packageName, } = useBookingStore(); + const { isAuthenticated } = useAuthStore(); // Maps passenger index -> assigned seat id. A passenger can only get a seat while // they are the "active" passenger, which prevents bulk/batch selection across passengers. const [passengerSeatMap, setPassengerSeatMap] = useState>({}); @@ -211,6 +214,29 @@ export default function SeatsPage() { ? currentJourneyType === "inbound" ? "RETURN" : "OUTBOUND" : "ONE_WAY"; + // The hold id/expiry that already covers the CURRENT leg, if any — used to avoid + // creating a second, redundant hold when the user navigates back to this page (e.g. + // browser back button, or bouncing between passengers/seats) without actually changing + // their seat pick. + const currentLegHoldId = isRoundTrip && currentJourneyType === "inbound" ? seatHold?.returnHoldId : seatHold?.holdId; + const currentLegHoldExpiresAt = isRoundTrip && currentJourneyType === "inbound" ? seatHold?.returnExpiresAt : seatHold?.expiresAt; + const isCurrentLegHoldValid = + !!currentLegHoldId && (!currentLegHoldExpiresAt || new Date(currentLegHoldExpiresAt).getTime() > Date.now()); + + // The seat id(s) already recorded against each passenger for the current leg from a + // previous pass through this page (persisted in the store) — the counterpart of the + // hold above, so we can tell whether the current on-screen selection actually differs + // from what's already held. + const previouslyHeldSeatIds = useMemo( + () => + passengers.map((p) => + isRoundTrip + ? (currentJourneyType === "inbound" ? (p as any).inboundSeatId : (p as any).outboundSeatId) + : (p as any).seatId, + ), + [passengers, isRoundTrip, currentJourneyType], + ); + // Baseline fare for each leg as it was when this page first loaded — i.e. whatever was // picked on the results page ("starting from" price). Captured once and never // overwritten, so a later coach-type switch (or just picking a pricier berth) can still @@ -267,6 +293,29 @@ export default function SeatsPage() { [passengers, seatEligibility], ); + // If this leg already has a valid (unexpired) hold from a previous pass through this + // page — e.g. the user hit "back" from a later step — restore the seat(s) that hold + // actually covers instead of leaving the seat map blank and letting them pick (and + // hold) another seat on top of it. Runs once per leg; the ref stops it from fighting a + // deliberate deselect/re-pick afterwards. + const restoredLegRef = useRef(null); + useEffect(() => { + const legKey = `${currentSchedule?.id || ''}-${currentJourneyType}`; + if (restoredLegRef.current === legKey) return; + restoredLegRef.current = legKey; + + if (!isCurrentLegHoldValid) return; + + const restored: Record = {}; + seatEligibleIndices.forEach((i) => { + const seatId = previouslyHeldSeatIds[i]; + if (seatId) restored[i] = seatId; + }); + if (Object.keys(restored).length > 0) { + setPassengerSeatMap(restored); + } + }, [currentSchedule?.id, currentJourneyType, isCurrentLegHoldValid, seatEligibleIndices, previouslyHeldSeatIds]); + const { data: seatMapData, isLoading, @@ -741,6 +790,34 @@ export default function SeatsPage() { seatEligibleIndices.length > 0 && seatEligibleIndices.every((i) => !!passengerSeatMap[i]); + // Only ever hold seats once per leg. If the current on-screen picks are exactly what's + // already held (valid, unexpired), skip the API call entirely and reuse that hold — this + // is what stops "back, then Continue again" from stacking up a second hold on the same + // seats. If the user genuinely picked different seats than what was previously held, + // best-effort release the stale hold first (authenticated sessions only — the release + // endpoint requires a login) before holding the new selection, so at most one hold for + // this leg is ever active at a time. + const ensureLegHold = async (seatIdsForHold: string[]) => { + const selectionMatchesExistingHold = + isCurrentLegHoldValid && + seatEligibleIndices.every((i) => passengerSeatMap[i] === previouslyHeldSeatIds[i]); + + if (selectionMatchesExistingHold) { + return; + } + + if (isCurrentLegHoldValid && isAuthenticated && currentLegHoldId) { + try { + await apiClient.delete(`/seats/hold/${currentLegHoldId}`); + } catch { + // Best-effort — an expired/already-released hold, or a guest session that can't + // call this endpoint, shouldn't block picking the new seat(s). + } + } + + await holdMutation.mutateAsync(seatIdsForHold); + }; + const handleContinue = async () => { if (!allSeatsAssigned) return; // Indexed by original passenger position — holes for passengers who share a seat @@ -751,7 +828,7 @@ export default function SeatsPage() { if (isRoundTrip && currentJourneyType === "outbound") { try { - await holdMutation.mutateAsync(seatIdsForHold); + await ensureLegHold(seatIdsForHold); const updatedPassengers = passengers.map((p, i) => { const seatData = validSeats?.find((s: any) => s.id === seatIds[i]); return { @@ -785,7 +862,7 @@ export default function SeatsPage() { } try { - await holdMutation.mutateAsync(seatIdsForHold); + await ensureLegHold(seatIdsForHold); const updatedPassengers = passengers.map((p, i) => { const seatData = validSeats?.find((s: any) => s.id === seatIds[i]); if (isRoundTrip && currentJourneyType === "inbound") { diff --git a/apps/edr-passenger-web/portal/src/lib/booking-store.ts b/apps/edr-passenger-web/portal/src/lib/booking-store.ts index 0972e0881..e5d1d83aa 100644 --- a/apps/edr-passenger-web/portal/src/lib/booking-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/booking-store.ts @@ -109,6 +109,11 @@ interface BookingState { seatHold: SeatHold | null; bookingId: string | null; pnr: string | null; + // The hold id(s) the current bookingId was actually created with — lets the review + // page detect "user came back from the payment gateway with the same booking still + // valid" and reuse it instead of creating a duplicate booking. + bookingHoldId: string | null; + bookingReturnHoldId: string | null; selectedPaymentMethod: string | null; createAccount: boolean; passengerId: string | null; @@ -133,6 +138,7 @@ interface BookingState { setSeatHold: (hold: SeatHold | null) => void; setBookingId: (id: string) => void; setPNR: (pnr: string) => void; + setBookingHoldReference: (holdId: string | null, returnHoldId?: string | null) => void; setPaymentMethod: (method: string) => void; setCreateAccount: (create: boolean) => void; setPassengerId: (id: string | null) => void; @@ -151,6 +157,8 @@ export const useBookingStore = create()(persist( seatHold: null, bookingId: null, pnr: null, + bookingHoldId: null, + bookingReturnHoldId: null, selectedPaymentMethod: null, createAccount: false, passengerId: null, @@ -172,6 +180,7 @@ export const useBookingStore = create()(persist( setSeatHold: (hold) => set({ seatHold: hold }), setBookingId: (id) => set({ bookingId: id }), setPNR: (pnr) => set({ pnr }), + setBookingHoldReference: (holdId, returnHoldId) => set({ bookingHoldId: holdId, bookingReturnHoldId: returnHoldId ?? null }), setPaymentMethod: (method) => set({ selectedPaymentMethod: method }), setCreateAccount: (create) => set({ createAccount: create }), setPassengerId: (id) => set({ passengerId: id }), @@ -185,6 +194,8 @@ export const useBookingStore = create()(persist( seatHold: null, bookingId: null, pnr: null, + bookingHoldId: null, + bookingReturnHoldId: null, selectedPaymentMethod: null, createAccount: false, passengerId: null, diff --git a/apps/edr-passenger-web/portal/src/utils/manage-booking-return.ts b/apps/edr-passenger-web/portal/src/utils/manage-booking-return.ts new file mode 100644 index 000000000..14d82415c --- /dev/null +++ b/apps/edr-passenger-web/portal/src/utils/manage-booking-return.ts @@ -0,0 +1,24 @@ +// Payment gateway return URLs (Telebirr/Waafi/D-Money success & failure pages) are fixed, +// app-wide URLs configured once in the payment provider — they can't carry a per-request +// query param telling the return page which flow initiated payment. The normal +// results -> seats -> review -> payment flow always ends at /booking/confirmation, which +// reads its data from useBookingStore. But paying for an existing booking from the +// Manage Booking page (/booking/detail) doesn't populate that store, so returning to +// /booking/confirmation there would render blank/broken. +// +// This marker records "the last payment was initiated from Manage Booking for booking +// ref X" right before redirecting to the gateway, so the return page can send the user +// back to that booking's detail view instead. It's consumed (read + cleared) exactly once. +const STORAGE_KEY = 'edr_manage_booking_payment_ref'; + +export function markManageBookingPaymentReturn(bookingRef: string) { + if (typeof window === 'undefined' || !bookingRef) return; + localStorage.setItem(STORAGE_KEY, bookingRef); +} + +export function consumeManageBookingPaymentReturn(): string | null { + if (typeof window === 'undefined') return null; + const ref = localStorage.getItem(STORAGE_KEY); + if (ref) localStorage.removeItem(STORAGE_KEY); + return ref; +} From b949d8fd4c88df571596828eb43956a3e11733c3 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 7 Jul 2026 14:06:31 +0000 Subject: [PATCH 07/18] chore: add guest support to the schema --- apps/edr-passenger-api/prisma/schema.prisma | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index b00408215..8a4224e73 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -883,7 +883,11 @@ model FaqArticle { model SupportConversation { id String @id @default(uuid()) - userId String + userId String? + guestId String? + guestName String? + guestEmail String? + guestPhone String? passengerId String? passengerName String? subject String? @@ -899,6 +903,7 @@ model SupportConversation { messages SupportMessage[] passenger Passenger? @relation(fields: [passengerId], references: [id]) @@index([userId]) + @@index([guestId]) @@index([status, lastMessageAt]) @@schema("passenger") } From 24fc27854a7a6b38fb0afb92bf6d7d4f18bcbc83 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 7 Jul 2026 14:06:38 +0000 Subject: [PATCH 08/18] chore: db migration --- .../migration.sql | 19 +++++++++++++++++++ .../migration.sql | 9 +++++++++ 2 files changed, 28 insertions(+) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260707134245_enhanced_support_chat/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260707135233_add_guest_support/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260707134245_enhanced_support_chat/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260707134245_enhanced_support_chat/migration.sql new file mode 100644 index 000000000..299a2abf8 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260707134245_enhanced_support_chat/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "SupportConversation" ADD COLUMN "agentLastReadAt" TIMESTAMP(3), +ADD COLUMN "lastMessageAt" TIMESTAMP(3), +ADD COLUMN "lastMessagePreview" TEXT, +ADD COLUMN "lastMessageSender" "SupportSender", +ADD COLUMN "passengerId" TEXT, +ADD COLUMN "passengerName" TEXT, +ADD COLUMN "subject" TEXT, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "userLastReadAt" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX "SupportConversation_userId_idx" ON "SupportConversation"("userId"); + +-- CreateIndex +CREATE INDEX "SupportConversation_status_lastMessageAt_idx" ON "SupportConversation"("status", "lastMessageAt"); + +-- AddForeignKey +ALTER TABLE "SupportConversation" ADD CONSTRAINT "SupportConversation_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260707135233_add_guest_support/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260707135233_add_guest_support/migration.sql new file mode 100644 index 000000000..a6b1fc9e7 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260707135233_add_guest_support/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "SupportConversation" ADD COLUMN "guestEmail" TEXT, +ADD COLUMN "guestId" TEXT, +ADD COLUMN "guestName" TEXT, +ADD COLUMN "guestPhone" TEXT, +ALTER COLUMN "userId" DROP NOT NULL; + +-- CreateIndex +CREATE INDEX "SupportConversation_guestId_idx" ON "SupportConversation"("guestId"); From 105605f9041fa98dd9d15c06c0318b99a19620ef Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 7 Jul 2026 14:07:15 +0000 Subject: [PATCH 09/18] feat: add the support feature to the passenger api --- .../src/modules/support/support.controller.ts | 204 ++++++++- .../src/modules/support/support.dto.ts | 127 ++++++ .../src/modules/support/support.gateway.ts | 134 ++++++ .../src/modules/support/support.module.ts | 13 +- .../src/modules/support/support.service.ts | 430 +++++++++++++++++- .../src/modules/support/ws-auth.service.ts | 48 ++ 6 files changed, 926 insertions(+), 30 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/support/support.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/support/support.gateway.ts create mode 100644 apps/edr-passenger-api/src/modules/support/ws-auth.service.ts diff --git a/apps/edr-passenger-api/src/modules/support/support.controller.ts b/apps/edr-passenger-api/src/modules/support/support.controller.ts index c13b3cd33..63adefe5a 100644 --- a/apps/edr-passenger-api/src/modules/support/support.controller.ts +++ b/apps/edr-passenger-api/src/modules/support/support.controller.ts @@ -1,15 +1,207 @@ -import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, + Req, + UnauthorizedException, + UseGuards, +} from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SupportService } from './support.service'; import { JwtGuard } from '../../common/jwt.guard'; +import { + CreateConversationDto, + CreateGuestConversationDto, + GuestIdBodyDto, + GuestSendMessageDto, + ListConversationsQueryDto, + SendMessageDto, + UpdateStatusDto, +} from './support.dto'; + +function userId(req: any): string { + const id = req?.user?.id ?? req?.user?.sub; + if (!id) throw new UnauthorizedException(); + return id; +} @ApiTags('Support') @Controller('support') export class SupportController { constructor(private service: SupportService) {} - @Get('faq') @ApiOperation({ summary: 'Get FAQ categories' }) getFaqCategories() { return this.service.getFaqCategories(); } - @Get('faq/:categoryId/articles') @ApiOperation({ summary: 'Get FAQ articles for a category' }) getFaqArticles(@Param('categoryId') id: string) { return this.service.getFaqArticles(id); } - @Post('conversations') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Start a support conversation' }) startConversation(@Body('userId') userId: string) { return this.service.startConversation(userId); } - @Post('conversations/:id/messages') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Send a message in a conversation' }) sendMessage(@Param('id') id: string, @Body() body: { sender: 'USER' | 'BOT' | 'AGENT'; text: string }) { return this.service.sendMessage(id, body.sender, body.text); } - @Get('conversations/:id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Get conversation with messages' }) getConversation(@Param('id') id: string) { return this.service.getConversation(id); } + + // ---- FAQ (public) ------------------------------------------------------ + + @Get('faq') + @IsPublic() + @ApiOperation({ summary: 'Get FAQ categories' }) + getFaqCategories() { + return this.service.getFaqCategories(); + } + + @Get('faq/:categoryId/articles') + @IsPublic() + @ApiOperation({ summary: 'Get FAQ articles for a category' }) + getFaqArticles(@Param('categoryId') id: string) { + return this.service.getFaqArticles(id); + } + + // ---- customer: authenticated passenger -------------------------------- + + @Post('conversations') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Open a new support conversation' }) + createConversation(@Req() req: any, @Body() body: CreateConversationDto) { + return this.service.createConversation(userId(req), body); + } + + @Get('conversations') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'List my support conversations' }) + listMine(@Req() req: any, @Query() query: ListConversationsQueryDto) { + return this.service.listForCustomer({ iamUserId: userId(req) }, query); + } + + @Get('conversations/:id/messages') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'List messages in one of my conversations' }) + messages(@Req() req: any, @Param('id') id: string) { + return this.service.getMessages(id, { iamUserId: userId(req) }); + } + + @Post('conversations/:id/messages') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Send a message as the customer' }) + send(@Req() req: any, @Param('id') id: string, @Body() body: SendMessageDto) { + return this.service.sendMessage(id, 'USER', body.text, { + iamUserId: userId(req), + }); + } + + @Post('conversations/:id/read') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Mark a conversation read (customer side)' }) + read(@Req() req: any, @Param('id') id: string) { + return this.service.markRead(id, 'USER', { iamUserId: userId(req) }); + } + + @Get('unread-count') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Count my unread support conversations' }) + unread(@Req() req: any) { + return this.service.unreadCount('USER', { iamUserId: userId(req) }); + } + + // ---- customer: guest (unauthenticated) -------------------------------- + // No JwtGuard. Access is scoped by a client-generated `guestId` (the bearer + // of access — anyone with it sees that thread; accepted MVP trade-off). + + @Post('guest/conversations') + @IsPublic() + @ApiOperation({ summary: 'Open a support conversation as a guest' }) + guestCreate(@Body() body: CreateGuestConversationDto) { + return this.service.createGuestConversation(body); + } + + @Get('guest/conversations') + @IsPublic() + @ApiOperation({ summary: 'List a guest\'s conversations' }) + guestList( + @Query('guestId') guestId: string, + @Query() query: ListConversationsQueryDto, + ) { + return this.service.listForCustomer({ guestId }, query); + } + + @Get('guest/conversations/:id/messages') + @IsPublic() + @ApiOperation({ summary: 'List messages in a guest conversation' }) + guestMessages(@Param('id') id: string, @Query('guestId') guestId: string) { + return this.service.getMessages(id, { guestId }); + } + + @Post('guest/conversations/:id/messages') + @IsPublic() + @ApiOperation({ summary: 'Send a message as a guest' }) + guestSend(@Param('id') id: string, @Body() body: GuestSendMessageDto) { + return this.service.sendMessage(id, 'USER', body.text, { + guestId: body.guestId, + }); + } + + @Post('guest/conversations/:id/read') + @IsPublic() + @ApiOperation({ summary: 'Mark a guest conversation read' }) + guestRead(@Param('id') id: string, @Body() body: GuestIdBodyDto) { + return this.service.markRead(id, 'USER', { guestId: body.guestId }); + } + + @Get('guest/unread-count') + @IsPublic() + @ApiOperation({ summary: 'Count a guest\'s unread conversations' }) + guestUnread(@Query('guestId') guestId: string) { + return this.service.unreadCount('USER', { guestId }); + } + + // ---- agent (backoffice) ------------------------------------------------ + // TODO: gate agent routes with a staff permission once passenger RBAC is wired. + + @Get('agent/conversations') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'List all support conversations (shared inbox)' }) + agentList(@Query() query: ListConversationsQueryDto) { + return this.service.listForAgents(query); + } + + @Get('agent/conversations/:id/messages') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'List messages in a conversation' }) + agentMessages(@Param('id') id: string) { + return this.service.getMessages(id); + } + + @Post('agent/conversations/:id/messages') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Reply as an agent' }) + agentSend(@Param('id') id: string, @Body() body: SendMessageDto) { + return this.service.sendMessage(id, 'AGENT', body.text); + } + + @Patch('agent/conversations/:id/status') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: "Change a conversation's status" }) + agentStatus(@Param('id') id: string, @Body() body: UpdateStatusDto) { + return this.service.setStatus(id, body.status); + } + + @Post('agent/conversations/:id/read') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Mark a conversation read (agent side)' }) + agentRead(@Param('id') id: string) { + return this.service.markRead(id, 'AGENT'); + } + + @Get('agent/unread-count') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Count unread conversations (agent side)' }) + agentUnread() { + return this.service.unreadCount('AGENT'); + } } diff --git a/apps/edr-passenger-api/src/modules/support/support.dto.ts b/apps/edr-passenger-api/src/modules/support/support.dto.ts new file mode 100644 index 000000000..77a3ee3ed --- /dev/null +++ b/apps/edr-passenger-api/src/modules/support/support.dto.ts @@ -0,0 +1,127 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsEmail, + IsEnum, + IsInt, + IsOptional, + IsString, + Length, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +export enum SupportStatusDto { + OPEN = 'OPEN', + RESOLVED = 'RESOLVED', + CLOSED = 'CLOSED', +} + +export class CreateConversationDto { + @ApiProperty({ description: 'Short subject / topic of the request.' }) + @IsString() + @Length(3, 200) + subject!: string; + + @ApiProperty({ description: 'The first message body.' }) + @IsString() + @MinLength(1) + @MaxLength(4000) + initialMessage!: string; +} + +export class SendMessageDto { + @ApiProperty({ description: 'Message text.' }) + @IsString() + @MinLength(1) + @MaxLength(4000) + text!: string; +} + +export class CreateGuestConversationDto { + @ApiProperty({ description: 'Client-generated anonymous id (localStorage).' }) + @IsString() + @Length(8, 120) + guestId!: string; + + @ApiProperty({ description: 'Guest full name.' }) + @IsString() + @Length(1, 120) + name!: string; + + @ApiProperty({ description: 'Guest email for follow-up.' }) + @IsEmail() + email!: string; + + @ApiPropertyOptional({ description: 'Guest phone (optional).' }) + @IsOptional() + @IsString() + @MaxLength(40) + phone?: string; + + @ApiProperty() + @IsString() + @Length(3, 200) + subject!: string; + + @ApiProperty() + @IsString() + @MinLength(1) + @MaxLength(4000) + initialMessage!: string; +} + +export class GuestSendMessageDto { + @ApiProperty({ description: 'The guest id that owns the conversation.' }) + @IsString() + @Length(8, 120) + guestId!: string; + + @ApiProperty({ description: 'Message text.' }) + @IsString() + @MinLength(1) + @MaxLength(4000) + text!: string; +} + +export class GuestIdBodyDto { + @ApiProperty() + @IsString() + @Length(8, 120) + guestId!: string; +} + +export class UpdateStatusDto { + @ApiProperty({ enum: SupportStatusDto }) + @IsEnum(SupportStatusDto) + status!: SupportStatusDto; +} + +export class ListConversationsQueryDto { + @ApiPropertyOptional({ enum: SupportStatusDto }) + @IsOptional() + @IsEnum(SupportStatusDto) + status?: SupportStatusDto; + + @ApiPropertyOptional({ description: 'Search subject / passenger name.' }) + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ minimum: 1, default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 100 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/apps/edr-passenger-api/src/modules/support/support.gateway.ts b/apps/edr-passenger-api/src/modules/support/support.gateway.ts new file mode 100644 index 000000000..203c39035 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/support/support.gateway.ts @@ -0,0 +1,134 @@ +import { Logger } from '@nestjs/common'; +import { + OnGatewayConnection, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { Passenger as PassengerTypes } from '@edr/types'; + +import { PrismaService } from '../../common/prisma.service'; +import { WsAuthService } from './ws-auth.service'; + +/** + * Server → client push for passenger support chat. Clients only *listen* (no + * `@SubscribeMessage`); the handshake is authenticated in `handleConnection`. + * Each socket joins a room based on its side: + * - backoffice staff → the shared `backoffice` room (see every conversation). + * - passengers → their `user:` room (their own tickets only). + * + * Side is decided by the presence of a `Passenger` row for the IAM user id + * (staff have none). A message is emitted to BOTH the owner's room and the + * backoffice room so the customer thread, the sender's echo, and every agent's + * inbox update live. + */ +@WebSocketGateway({ + namespace: PassengerTypes.PASSENGER_SUPPORT_WS_NAMESPACE, + cors: { origin: true, credentials: true }, +}) +export class SupportGateway implements OnGatewayConnection { + private readonly logger = new Logger(SupportGateway.name); + + private static readonly BACKOFFICE_ROOM = 'backoffice'; + + @WebSocketServer() + private readonly server!: Server; + + constructor( + private readonly wsAuth: WsAuthService, + private readonly prisma: PrismaService, + ) {} + + async handleConnection(socket: Socket): Promise { + const userId = await this.wsAuth.resolveUserId(this.extractToken(socket)); + + // Authenticated: passenger (own room) or backoffice staff (shared room). + if (userId) { + socket.data.userId = userId; + const passenger = await this.prisma.passenger.findUnique({ + where: { iamUserId: userId }, + }); + if (passenger) { + await socket.join(`user:${userId}`); + socket.data.side = 'USER'; + } else { + await socket.join(SupportGateway.BACKOFFICE_ROOM); + socket.data.side = 'AGENT'; + } + return; + } + + // Guest: no valid token, but a client-generated guestId scopes the room. + // Anyone holding the guestId can see that thread (no account = weaker + // ownership) — an accepted MVP trade-off for guest support. + const guestId = this.extractGuestId(socket); + if (guestId) { + socket.data.guestId = guestId; + socket.data.side = 'USER'; + await socket.join(`guest:${guestId}`); + return; + } + + this.logger.debug(`Rejected passenger-support handshake ${socket.id}`); + socket.disconnect(true); + } + + /** Push a new message + updated conversation to the owner + backoffice rooms. */ + emitMessage( + ownerRoom: string | null, + conversation: PassengerTypes.PassengerSupportConversationDto, + message: PassengerTypes.PassengerSupportMessageDto, + ): void { + const payload = { conversation, message }; + for (const room of this.targetRooms(ownerRoom)) { + const to = this.server.to(room); + to.emit(PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW, payload); + to.emit( + PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, + conversation, + ); + } + } + + /** Push a conversation metadata change (e.g. status) to both rooms. */ + emitConversationUpdated( + ownerRoom: string | null, + conversation: PassengerTypes.PassengerSupportConversationDto, + ): void { + for (const room of this.targetRooms(ownerRoom)) { + this.server + .to(room) + .emit( + PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, + conversation, + ); + } + } + + private targetRooms(ownerRoom: string | null): string[] { + const rooms = [SupportGateway.BACKOFFICE_ROOM]; + if (ownerRoom) rooms.push(ownerRoom); + return rooms; + } + + private extractGuestId(socket: Socket): string | undefined { + const authGuest = socket.handshake.auth?.guestId as string | undefined; + if (authGuest) return authGuest; + const queryGuest = socket.handshake.query?.guestId; + if (typeof queryGuest === 'string') return queryGuest; + return undefined; + } + + private extractToken(socket: Socket): string | undefined { + const authToken = socket.handshake.auth?.token as string | undefined; + if (authToken) return authToken; + + const queryToken = socket.handshake.query?.token; + if (typeof queryToken === 'string') return queryToken; + + const header = socket.handshake.headers?.authorization; + if (header?.startsWith('Bearer ')) return header.slice(7); + + return undefined; + } +} diff --git a/apps/edr-passenger-api/src/modules/support/support.module.ts b/apps/edr-passenger-api/src/modules/support/support.module.ts index 17f139a07..136fcf196 100644 --- a/apps/edr-passenger-api/src/modules/support/support.module.ts +++ b/apps/edr-passenger-api/src/modules/support/support.module.ts @@ -1,6 +1,17 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; + import { SupportController } from './support.controller'; import { SupportService } from './support.service'; +import { SupportGateway } from './support.gateway'; +import { WsAuthService } from './ws-auth.service'; -@Module({ controllers: [SupportController], providers: [SupportService] }) +@Module({ + // Session is served by the app's default TypeORM DataSource (IAM schema) — + // used by WsAuthService to authenticate WebSocket handshakes. + imports: [TypeOrmModule.forFeature([Session])], + controllers: [SupportController], + providers: [SupportService, SupportGateway, WsAuthService], +}) export class SupportModule {} diff --git a/apps/edr-passenger-api/src/modules/support/support.service.ts b/apps/edr-passenger-api/src/modules/support/support.service.ts index dd155bff8..dfbbb3b12 100644 --- a/apps/edr-passenger-api/src/modules/support/support.service.ts +++ b/apps/edr-passenger-api/src/modules/support/support.service.ts @@ -1,35 +1,419 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Passenger as T } from '@edr/types'; import { PrismaService } from '../../common/prisma.service'; +import { SupportGateway } from './support.gateway'; + +type Side = 'USER' | 'AGENT'; +type PrismaSender = 'USER' | 'BOT' | 'AGENT'; +type PrismaStatus = 'OPEN' | 'RESOLVED' | 'CLOSED'; + +/** Who the caller is on the customer side: an authed passenger or a guest. */ +export interface CustomerOwner { + iamUserId?: string | null; + guestId?: string | null; +} + +interface ListQuery { + status?: PrismaStatus; + search?: string; + page?: number; + limit?: number; +} + +type ConversationRow = { + id: string; + userId: string | null; + guestId: string | null; + guestName: string | null; + guestEmail: string | null; + guestPhone: string | null; + passengerId: string | null; + passengerName: string | null; + subject: string | null; + status: PrismaStatus; + assignedAgentId: string | null; + lastMessageAt: Date | null; + lastMessagePreview: string | null; + lastMessageSender: PrismaSender | null; + userLastReadAt: Date | null; + agentLastReadAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; @Injectable() export class SupportService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private gateway: SupportGateway, + ) {} - getFaqCategories() { return this.prisma.faqCategory.findMany({ include: { _count: { select: { articles: true } } } }); } + // ---- FAQ (unchanged) --------------------------------------------------- - getFaqArticles(categoryId: string) { return this.prisma.faqArticle.findMany({ where: { categoryId }, orderBy: { rank: 'asc' } }); } - - startConversation(userId: string) { return this.prisma.supportConversation.create({ data: { userId } }); } - - async sendMessage(conversationId: string, sender: 'USER' | 'BOT' | 'AGENT', text: string) { - const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId } }); - if (!conv) throw new NotFoundException('Conversation not found'); - const message = await this.prisma.supportMessage.create({ data: { conversationId, sender, text } }); - if (sender === 'USER') await this.prisma.supportMessage.create({ data: { conversationId, sender: 'BOT', text: this.getBotReply(text) } }); - return message; + getFaqCategories() { + return this.prisma.faqCategory.findMany({ + include: { _count: { select: { articles: true } } }, + }); } - async getConversation(conversationId: string) { - const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId }, include: { messages: { orderBy: { createdAt: 'asc' } } } }); - if (!conv) throw new NotFoundException('Conversation not found'); - return conv; + getFaqArticles(categoryId: string) { + return this.prisma.faqArticle.findMany({ + where: { categoryId }, + orderBy: { rank: 'asc' }, + }); } - private getBotReply(text: string): string { - const lower = text.toLowerCase(); - if (lower.includes('cancel') || lower.includes('refund')) return 'To cancel or refund, go to Bookings and select the booking. Refunds are processed within 3-5 business days.'; - if (lower.includes('miss') || lower.includes('missed')) return 'If you missed your train, please check the Disruptions section for alternative options.'; - if (lower.includes('seat')) return 'You can select or change seats during booking. Seat changes after confirmation may incur a fee.'; - return 'Thank you for contacting EDR support. An agent will assist you shortly.'; + // ---- customer: authed passenger --------------------------------------- + + async createConversation( + iamUserId: string, + input: { subject: string; initialMessage: string }, + ): Promise { + const passenger = await this.prisma.passenger.findUnique({ + where: { iamUserId }, + include: { user: { select: { fullName: true } } }, + }); + const conversation = (await this.prisma.supportConversation.create({ + data: { + userId: iamUserId, + passengerId: passenger?.id ?? null, + passengerName: passenger?.user?.fullName ?? null, + subject: input.subject, + status: 'OPEN', + }, + })) as ConversationRow; + return this.firstMessage(conversation, input.initialMessage); + } + + // ---- customer: guest (unauthenticated) -------------------------------- + + async createGuestConversation(input: { + guestId: string; + name: string; + email: string; + phone?: string; + subject: string; + initialMessage: string; + }): Promise { + const conversation = (await this.prisma.supportConversation.create({ + data: { + guestId: input.guestId, + guestName: input.name, + guestEmail: input.email, + guestPhone: input.phone ?? null, + passengerName: input.name, // uniform display name for the agent inbox + subject: input.subject, + status: 'OPEN', + }, + })) as ConversationRow; + return this.firstMessage(conversation, input.initialMessage); + } + + async listForCustomer( + owner: CustomerOwner, + query: ListQuery, + ): Promise { + const scope = this.ownerScope(owner); + const where = { ...this.listWhere(query), ...scope }; + const rows = (await this.prisma.supportConversation.findMany({ + where, + orderBy: [{ lastMessageAt: 'desc' }, { createdAt: 'desc' }], + take: query.limit ?? 100, + skip: ((query.page ?? 1) - 1) * (query.limit ?? 100), + })) as ConversationRow[]; + const count = await this.prisma.supportConversation.count({ where }); + return this.buildListResult(rows, count, 'USER'); + } + + // ---- agent (backoffice) ------------------------------------------------ + + async listForAgents( + query: ListQuery, + ): Promise { + const where = this.listWhere(query); + const rows = (await this.prisma.supportConversation.findMany({ + where, + orderBy: [{ lastMessageAt: 'desc' }, { createdAt: 'desc' }], + take: query.limit ?? 100, + skip: ((query.page ?? 1) - 1) * (query.limit ?? 100), + })) as ConversationRow[]; + const count = await this.prisma.supportConversation.count({ where }); + return this.buildListResult(rows, count, 'AGENT'); + } + + async setStatus( + conversationId: string, + status: PrismaStatus, + ): Promise { + await this.requireConversation(conversationId); + const updated = (await this.prisma.supportConversation.update({ + where: { id: conversationId }, + data: { status }, + })) as ConversationRow; + const dto = this.toConversationDto(updated, 0); + this.gateway.emitConversationUpdated(this.ownerRoom(updated), dto); + return dto; + } + + // ---- shared ------------------------------------------------------------ + + async getMessages( + conversationId: string, + asCustomer?: CustomerOwner, + ): Promise { + const conversation = await this.requireConversation(conversationId); + if (asCustomer) this.assertOwns(conversation, asCustomer); + const rows = await this.prisma.supportMessage.findMany({ + where: { conversationId }, + orderBy: { createdAt: 'asc' }, + }); + return rows.map((m) => this.toMessageDto(m)); + } + + async sendMessage( + conversationId: string, + sender: Side, + text: string, + asCustomer?: CustomerOwner, + ): Promise { + const conversation = await this.requireConversation(conversationId); + if (sender === 'USER') { + this.assertOwns(conversation, asCustomer ?? {}); + } + const updated = await this.appendMessage(conversation, sender, text); + const last = updated.messages[updated.messages.length - 1]; + return this.toMessageDto(last); + } + + async markRead( + conversationId: string, + side: Side, + asCustomer?: CustomerOwner, + ): Promise<{ unreadCount: number }> { + const conversation = await this.requireConversation(conversationId); + if (side === 'USER') { + this.assertOwns(conversation, asCustomer ?? {}); + await this.prisma.supportConversation.update({ + where: { id: conversationId }, + data: { userLastReadAt: new Date() }, + }); + return this.unreadCount('USER', asCustomer); + } + await this.prisma.supportConversation.update({ + where: { id: conversationId }, + data: { agentLastReadAt: new Date() }, + }); + return this.unreadCount('AGENT'); + } + + async unreadCount( + side: Side, + owner?: CustomerOwner, + ): Promise<{ unreadCount: number }> { + const rows = (await this.prisma.supportConversation.findMany({ + where: side === 'USER' ? this.ownerScope(owner ?? {}) : {}, + select: { id: true, userLastReadAt: true, agentLastReadAt: true }, + })) as Array<{ + id: string; + userLastReadAt: Date | null; + agentLastReadAt: Date | null; + }>; + const map = await this.computeUnread(rows, side); + let unreadCount = 0; + for (const n of map.values()) if (n > 0) unreadCount++; + return { unreadCount }; + } + + // ---- internals --------------------------------------------------------- + + private async firstMessage( + conversation: ConversationRow, + text: string, + ): Promise { + const { conversation: updated } = await this.appendMessageRaw( + conversation, + 'USER', + text, + ); + return this.toConversationDto(updated, 0); + } + + private async appendMessage( + conversation: ConversationRow, + sender: PrismaSender, + text: string, + ) { + const { conversation: updated } = await this.appendMessageRaw( + conversation, + sender, + text, + ); + return updated as ConversationRow & { messages: any[] }; + } + + /** Persist a message, bump the conversation's denormalized fields, emit live. */ + private async appendMessageRaw( + conversation: ConversationRow, + sender: PrismaSender, + text: string, + ): Promise<{ conversation: ConversationRow & { messages: any[] }; message: any }> { + const message = await this.prisma.supportMessage.create({ + data: { conversationId: conversation.id, sender, text }, + }); + const updated = (await this.prisma.supportConversation.update({ + where: { id: conversation.id }, + data: { + lastMessageAt: message.createdAt, + lastMessagePreview: text.slice(0, 280), + lastMessageSender: sender, + }, + include: { messages: { orderBy: { createdAt: 'asc' } } }, + })) as ConversationRow & { messages: any[] }; + + const dto = this.toConversationDto(updated, 0); + this.gateway.emitMessage(this.ownerRoom(updated), dto, this.toMessageDto(message)); + return { conversation: updated, message }; + } + + private async buildListResult( + rows: ConversationRow[], + count: number, + side: Side, + ): Promise { + const unreadMap = await this.computeUnread(rows, side); + const items = rows.map((r) => + this.toConversationDto(r, unreadMap.get(r.id) ?? 0), + ); + let unreadCount = 0; + for (const n of unreadMap.values()) if (n > 0) unreadCount++; + return { items, count, unreadCount }; + } + + private async computeUnread( + rows: Array<{ + id: string; + userLastReadAt: Date | null; + agentLastReadAt: Date | null; + }>, + side: Side, + ): Promise> { + const map = new Map(); + if (rows.length === 0) return map; + const otherSender: PrismaSender = side === 'USER' ? 'AGENT' : 'USER'; + const ids = rows.map((r) => r.id); + const msgs = await this.prisma.supportMessage.findMany({ + where: { conversationId: { in: ids }, sender: otherSender }, + select: { conversationId: true, createdAt: true }, + }); + const cursorById = new Map( + rows.map((r) => [ + r.id, + side === 'USER' ? r.userLastReadAt : r.agentLastReadAt, + ]), + ); + for (const m of msgs) { + const cursor = cursorById.get(m.conversationId) ?? null; + if (!cursor || m.createdAt > cursor) { + map.set(m.conversationId, (map.get(m.conversationId) ?? 0) + 1); + } + } + return map; + } + + private listWhere(query: ListQuery) { + const where: Record = {}; + if (query.status) where.status = query.status; + if (query.search?.trim()) { + const contains = query.search.trim(); + where.OR = [ + { subject: { contains, mode: 'insensitive' } }, + { passengerName: { contains, mode: 'insensitive' } }, + { guestEmail: { contains, mode: 'insensitive' } }, + ]; + } + return where; + } + + /** Prisma where-fragment scoping to the calling customer (authed or guest). */ + private ownerScope(owner: CustomerOwner): Record { + if (owner.iamUserId) return { userId: owner.iamUserId }; + if (owner.guestId) return { guestId: owner.guestId }; + // No identity ⇒ match nothing. + return { id: '__none__' }; + } + + private assertOwns(conversation: ConversationRow, owner: CustomerOwner): void { + const ok = + (owner.iamUserId && conversation.userId === owner.iamUserId) || + (owner.guestId && conversation.guestId === owner.guestId); + if (!ok) { + throw new ForbiddenException('This conversation belongs to someone else.'); + } + } + + private ownerRoom(c: ConversationRow): string | null { + if (c.guestId) return `guest:${c.guestId}`; + if (c.userId) return `user:${c.userId}`; + return null; + } + + private async requireConversation(id: string): Promise { + const conversation = (await this.prisma.supportConversation.findUnique({ + where: { id }, + })) as ConversationRow | null; + if (!conversation) throw new NotFoundException('Conversation not found'); + return conversation; + } + + private toConversationDto( + c: ConversationRow, + unreadCount: number, + ): T.PassengerSupportConversationDto { + return { + id: c.id, + userId: c.userId, + guestId: c.guestId, + guestEmail: c.guestEmail, + guestPhone: c.guestPhone, + passengerId: c.passengerId, + passengerName: c.passengerName ?? c.guestName ?? null, + subject: c.subject, + status: c.status as T.PassengerSupportStatus, + assignedAgentId: c.assignedAgentId, + lastMessageAt: c.lastMessageAt ? c.lastMessageAt.toISOString() : null, + lastMessagePreview: c.lastMessagePreview, + lastMessageSender: this.toDtoSender(c.lastMessageSender), + unreadCount, + createdAt: c.createdAt.toISOString(), + updatedAt: c.updatedAt.toISOString(), + }; + } + + private toMessageDto(m: { + id: string; + conversationId: string; + sender: PrismaSender; + text: string; + createdAt: Date; + }): T.PassengerSupportMessageDto { + return { + id: m.id, + conversationId: m.conversationId, + sender: this.toDtoSender(m.sender) ?? T.PassengerSupportSender.AGENT, + text: m.text, + createdAt: m.createdAt.toISOString(), + }; + } + + /** Legacy BOT messages are surfaced as AGENT to the UI. */ + private toDtoSender(s: PrismaSender | null): T.PassengerSupportSender | null { + if (!s) return null; + return s === 'USER' + ? T.PassengerSupportSender.USER + : T.PassengerSupportSender.AGENT; } } diff --git a/apps/edr-passenger-api/src/modules/support/ws-auth.service.ts b/apps/edr-passenger-api/src/modules/support/ws-auth.service.ts new file mode 100644 index 000000000..2b1ad8b6b --- /dev/null +++ b/apps/edr-passenger-api/src/modules/support/ws-auth.service.ts @@ -0,0 +1,48 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { verifyToken } from '@tria-plc/api-common/utils/token'; +import { ESessionStatus } from '@tria-plc/api-common/utils/enums/user.enum'; +import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; + +/** + * Authenticates a WebSocket handshake by mirroring the HTTP JwtGuard: the access + * token payload is only a *session* pointer (`{ id: }`), not the + * user — so we verify the signature (`verifyToken`), then load the IAM session + * and require it to be ACTIVE and unexpired, and read the real user id out of + * `session.userInfo`. The `Session` entity is served by the app's default + * TypeORM DataSource (the same one the shared JwtGuard queries for `iam.sessions`). + * + * Returns the IAM user id, or null for any invalid/expired/revoked/malformed token. + */ +@Injectable() +export class WsAuthService { + private readonly logger = new Logger(WsAuthService.name); + + constructor( + @InjectRepository(Session) + private readonly sessions: Repository, + ) {} + + async resolveUserId(token?: string): Promise { + if (!token) return null; + try { + const payload = verifyToken(token) as { id?: string }; + const sessionId = payload?.id; + if (!sessionId) return null; + + const session = await this.sessions.findOne({ where: { id: sessionId } }); + if (!session) return null; + if (session.status !== ESessionStatus.ACTIVE) return null; + if (!session.expiryTime || new Date(session.expiryTime) <= new Date()) { + return null; + } + + return session.userInfo?.id ?? null; + } catch (err) { + this.logger.debug(`WS auth rejected: ${(err as Error).message}`); + return null; + } + } +} From db21f691c797634eecf3cc905ded8e154066ad8e Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 7 Jul 2026 14:07:50 +0000 Subject: [PATCH 10/18] chore: add socket io pkg to the passenger client --- apps/edr-passenger-web/backoffice/package.json | 1 + apps/edr-passenger-web/portal/package.json | 1 + pnpm-lock.yaml | 6 ++++++ 3 files changed, 8 insertions(+) diff --git a/apps/edr-passenger-web/backoffice/package.json b/apps/edr-passenger-web/backoffice/package.json index 654e19ef9..858fcd781 100644 --- a/apps/edr-passenger-web/backoffice/package.json +++ b/apps/edr-passenger-web/backoffice/package.json @@ -21,6 +21,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "recharts": "^2.12.0", + "socket.io-client": "^4.8.3", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/apps/edr-passenger-web/portal/package.json b/apps/edr-passenger-web/portal/package.json index 8323c3bb0..5c6104e94 100644 --- a/apps/edr-passenger-web/portal/package.json +++ b/apps/edr-passenger-web/portal/package.json @@ -28,6 +28,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.51.0", + "socket.io-client": "^4.8.3", "zod": "^3.22.4", "zustand": "^5.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b15d9a40e..a77ffce64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -676,6 +676,9 @@ importers: recharts: specifier: ^2.12.0 version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 zustand: specifier: ^5.0.0 version: 5.0.14(@types/react@18.3.31)(immer@11.1.8)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) @@ -761,6 +764,9 @@ importers: react-hook-form: specifier: ^7.51.0 version: 7.77.0(react@18.3.1) + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 zod: specifier: ^3.22.4 version: 3.25.76 From 9cd3ac42b46f859ceb07834f50777282dbbb0606 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 7 Jul 2026 14:09:28 +0000 Subject: [PATCH 11/18] feat: add the support to the client --- .../backoffice/src/app/support/page.tsx | 396 ++++++++++++++-- .../src/components/layout/Sidebar.tsx | 2 +- .../src/features/support/supportApi.ts | 36 ++ .../src/features/support/useSupport.ts | 65 +++ .../src/features/support/useSupportSocket.ts | 67 +++ .../portal/src/app/layout.tsx | 2 + .../src/features/support/SupportPanel.tsx | 445 ++++++++++++++++++ .../src/features/support/SupportWidget.tsx | 51 ++ .../src/features/support/guestIdentity.ts | 24 + .../portal/src/features/support/supportApi.ts | 75 +++ .../portal/src/features/support/useSupport.ts | 65 +++ .../src/features/support/useSupportSocket.ts | 71 +++ .../portal/src/lib/api-client.ts | 51 +- packages/types/src/passenger/index.ts | 2 + packages/types/src/passenger/support-chat.ts | 103 ++++ 15 files changed, 1389 insertions(+), 66 deletions(-) create mode 100644 apps/edr-passenger-web/backoffice/src/features/support/supportApi.ts create mode 100644 apps/edr-passenger-web/backoffice/src/features/support/useSupport.ts create mode 100644 apps/edr-passenger-web/backoffice/src/features/support/useSupportSocket.ts create mode 100644 apps/edr-passenger-web/portal/src/features/support/SupportPanel.tsx create mode 100644 apps/edr-passenger-web/portal/src/features/support/SupportWidget.tsx create mode 100644 apps/edr-passenger-web/portal/src/features/support/guestIdentity.ts create mode 100644 apps/edr-passenger-web/portal/src/features/support/supportApi.ts create mode 100644 apps/edr-passenger-web/portal/src/features/support/useSupport.ts create mode 100644 apps/edr-passenger-web/portal/src/features/support/useSupportSocket.ts create mode 100644 packages/types/src/passenger/support-chat.ts diff --git a/apps/edr-passenger-web/backoffice/src/app/support/page.tsx b/apps/edr-passenger-web/backoffice/src/app/support/page.tsx index d6ad8fb38..8102cc556 100644 --- a/apps/edr-passenger-web/backoffice/src/app/support/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/support/page.tsx @@ -1,66 +1,362 @@ 'use client'; -import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { Download } from 'lucide-react'; -import DataTable from '@/components/ui/DataTable'; -import Badge from '@/components/ui/Badge'; -import ActionButton from '@/components/ui/ActionButton'; -import { supportApi } from '@/lib/api'; -import { formatDateTime, formatCurrency } from '@/lib/utils'; +import { Passenger } from '@edr/types'; +import { Headset, Search, Send, User } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { + useConversations, + useMarkRead, + useMessages, + useSendMessage, + useSetStatus, +} from '@/features/support/useSupport'; +import { useSupportSocket } from '@/features/support/useSupportSocket'; + +const GREEN = 'rgb(20 113 76)'; +type ConversationDto = Passenger.PassengerSupportConversationDto; +type MessageDto = Passenger.PassengerSupportMessageDto; + +const STATUS_CLASS: Record = { + OPEN: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', + RESOLVED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + CLOSED: 'bg-gray-200 text-gray-600 dark:bg-slate-700 dark:text-slate-300', +}; + +const FILTERS = ['ALL', 'OPEN', 'RESOLVED', 'CLOSED'] as const; + +function formatTime(iso?: string | null): string { + if (!iso) return ''; + const d = new Date(iso); + const now = new Date(); + return d.toDateString() === now.toDateString() + ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : d.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} export default function SupportPage() { - const [filters, setFilters] = useState({ search: '', status: '' }); + const [status, setStatus] = useState<(typeof FILTERS)[number]>('ALL'); + const [search, setSearch] = useState(''); + const [selectedId, setSelectedId] = useState(null); - const { data, isLoading } = useQuery({ - queryKey: ['support', filters], - queryFn: () => supportApi.getConversations(filters), - }); + const { data, isLoading } = useConversations( + status === 'ALL' ? { search } : { status, search }, + ); + const items = data?.items ?? []; - const columns = [ - { key: 'subject', label: 'Subject', render: (conv: any) => conv.subject || 'No Subject' }, - { key: 'passenger', label: 'Passenger', render: (conv: any) => conv.passenger?.fullName || 'N/A' }, - { key: 'status', label: 'Status', render: (conv: any) => {conv.status} }, - { key: 'createdAt', label: 'Created', render: (conv: any) => formatDateTime(conv.createdAt) }, - ]; + useSupportSocket(true); + + const selected = useMemo( + () => items.find((c) => c.id === selectedId) ?? null, + [items, selectedId], + ); return ( -
-
+
+
+ + +

Support Center

-

Manage customer support conversations

-
- Export -
- -
-
- -
- - setFilters({ ...filters, search: e.target.value })} /> -
-
- - -
- +

+ Shared inbox — respond to passenger requests in real time +

- +
+ {/* Conversation list */} +
+
+
+ + setSearch(e.target.value)} + placeholder="Search subject or passenger" + className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-3 text-sm outline-none focus:border-emerald-500" + /> +
+
+ {FILTERS.map((f) => ( + + ))} +
+
+
+ {isLoading ? ( +
+ Loading… +
+ ) : items.length === 0 ? ( +
+ No conversations. +
+ ) : ( + items.map((c) => ( + setSelectedId(c.id)} + /> + )) + )} +
+
+ + {/* Thread */} +
+ {selected ? ( + + ) : ( +
+ + + +

Select a conversation to start replying.

+
+ )} +
+
+
+ ); +} + +function InboxRow({ + c, + active, + onClick, +}: { + c: ConversationDto; + active: boolean; + onClick: () => void; +}) { + const unread = c.unreadCount > 0; + return ( + + ); +} + +function ConversationThread({ conversation }: { conversation: ConversationDto }) { + const { data: messages, isLoading } = useMessages(conversation.id); + const send = useSendMessage(conversation.id); + const setStatus = useSetStatus(); + const markRead = useMarkRead(); + const [draft, setDraft] = useState(''); + const viewport = useRef(null); + + useEffect(() => { + markRead.mutate(conversation.id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [conversation.id, messages?.length]); + + useEffect(() => { + viewport.current?.scrollTo({ top: viewport.current.scrollHeight }); + }, [messages?.length, conversation.id]); + + const submit = async () => { + const text = draft.trim(); + if (!text) return; + setDraft(''); + await send.mutateAsync(text); + }; + + const changeStatus = (status: string) => + setStatus.mutate({ id: conversation.id, status }); + + return ( +
+
+
+
+ + {conversation.subject || 'Conversation'} + + + {conversation.status.toLowerCase()} + +
+

+ {conversation.passengerName || 'Passenger'} + {conversation.guestId ? ' · Guest' : ''} + {conversation.guestEmail ? ` · ${conversation.guestEmail}` : ''} + {conversation.guestPhone ? ` · ${conversation.guestPhone}` : ''} +

+
+
+ {conversation.status !== 'OPEN' && ( + + )} + {conversation.status === 'OPEN' && ( + + )} + {conversation.status !== 'CLOSED' && ( + + )} +
+
+ +
+ {isLoading ? ( +
+ Loading… +
+ ) : ( + (messages ?? []).map((m) => ) + )} +
+ +
+
+