diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts index 9c962477b..51c57fec8 100644 --- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts @@ -261,10 +261,26 @@ export class PassengerAuthService { if (!passenger) throw new Error('Passenger not found'); const iam = iamRows[0]; + const meta = iam?.metadata ?? {}; const faydaVerified = iam?.verified_by === 'fayda'; - const nationality = iam?.metadata?.nationality ?? null; + // A Fayda-verified holder is an Ethiopian national ID holder, so default nationality to + // Ethiopian when the metadata doesn't carry it explicitly. + const nationality = meta.nationality ?? (faydaVerified ? 'ETHIOPIAN' : null); + // Fayda stores gender as { am, en }; tolerate a legacy plain string too. + const gender = + meta.gender && typeof meta.gender === 'object' + ? (meta.gender.en ?? meta.gender.am ?? null) + : (meta.gender ?? null); + // birthdate is persisted as ISO by the Fayda upsert; tolerate a "/"-separated legacy value. + const rawDob = meta.dateOfBirth ?? meta.birthdate ?? null; + const dateOfBirth = rawDob ? String(rawDob).replace(/\//g, '-') : null; return { + // The web User object keys on `id` (the IAM user id) — the login response returns it, so + // this profile refresh MUST too, otherwise fetchProfile() overwrites the logged-in user + // with an id-less object and everything guarded on `user.id` (passenger-form prefill, + // save-details userId) silently breaks. + id: iamUserId, iamUserId, // Top-level passengerId keeps the profile shape consistent with the login // response so the web User object always carries it (the JWT does not). @@ -272,8 +288,11 @@ export class PassengerAuthService { email: iam?.email ?? null, phone: iam?.phone_number ?? null, fullName: iam?.name?.en ?? iam?.name?.am ?? null, + gender, + dateOfBirth, nationality, faydaVerified, + faydaSub: meta.sub ?? null, preferredCurrency: resolvePreferredCurrency(nationality, faydaVerified), createdAt: passenger.createdAt, passenger: { diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 5953d17a2..bd04ed34f 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -557,10 +557,13 @@ export class BookingsController { }) @ApiResponse({ status: 404, description: "Schedule or seat hold not found" }) create(@Req() req: any, @Body() dto: CreateBookingDto) { - // Always resolve passengerId from the authenticated JWT — never trust the request body + // Always resolve identity from the authenticated JWT — never trust the request body. + // Routed through the unified GuestBookingService: because req.user.id is present, it + // resolves the existing passenger from the token and layers on the authenticated-only + // behaviours (iam.users contact, loyalty, audit, package inventory, seat-vs-hold guard). const iamUserId = req.user?.id; if (!iamUserId) throw new UnauthorizedException(); - return this.service.create({ ...dto, passengerId: iamUserId }); + return this.guestService.createGuestBooking(dto as unknown as CreateGuestBookingDto, req); } @Get(":id/usage") diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 29c9a6798..9be3e8c4b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -1,10 +1,13 @@ import { Injectable, BadRequestException, NotFoundException, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { SeatsService } from '../seats/seats.service'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { CurrencyService } from '../currency/currency.service'; import { PassengerAuthService } from '../auth/passenger-auth.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; +import { AuditService } from '../../common/audit.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { PaymentsService } from '../payments/payments.service'; import { AuditService } from '../../common/audit.service'; @@ -87,11 +90,13 @@ export class GuestBookingService { constructor( private prisma: PrismaService, + @InjectDataSource() private readonly dataSource: DataSource, private seatsService: SeatsService, private verifaydaService: VerifaydaService, private currencyService: CurrencyService, private passengerAuthService: PassengerAuthService, private fareEngine: FareEngineService, + private auditService: AuditService, private eventEmitter: EventEmitter2, private paymentsService: PaymentsService, private auditService: AuditService, @@ -138,6 +143,7 @@ export class GuestBookingService { } private async createGuestOneWayBooking(dto: CreateGuestBookingDto, req?: any) { + const authUserId: string | null = req?.user?.id ?? null; // Validate hold const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); if (!hold || hold.expiresAt < new Date()) { @@ -319,6 +325,7 @@ export class GuestBookingService { // Resolve or create the guest Passenger record const firstPassenger = passengersData[0]; const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger, req); + const contact = await this.resolveActorContact(req, firstPassenger); // Save passenger details for future use (if requested) if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) { @@ -360,8 +367,8 @@ export class GuestBookingService { bookingType: 'ONE_WAY', ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), userAgent: dto.deviceId, - contactEmail: firstPassenger.email || null, - contactPhone: firstPassenger.phone || null, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone, seats: { create: passengersWithFares.map((p) => ({ seat: { connect: { id: p.seatId } }, @@ -385,11 +392,18 @@ export class GuestBookingService { }, }); - // Save passenger details as traveler profiles - await this.createTravelerProfiles(guestPassengerId, passengersData); + // Save passenger details as traveler profiles — guest bookings only. + // Authenticated passengers already have a profile, matching the old BookingsService. + if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData); // Confirm seats await this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)); + + // Authenticated-only side effect: audit the booking creation. + if (authUserId) { + await this.auditService.log({ userId: guestPassengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ONE_WAY', totalMinor: resolvedTotalMinor } }); + } + this.eventEmitter.emit('booking.created', { booking }); return { @@ -652,6 +666,7 @@ export class GuestBookingService { } private async createGuestRoundTripBooking(dto: CreateGuestBookingDto, req?: any) { + const authUserId: string | null = req?.user?.id ?? null; if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) { throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP'); } @@ -852,6 +867,7 @@ export class GuestBookingService { // Create or resolve guest passenger (same as one-way) const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); + const contact = await this.resolveActorContact(req, passengersData[0]); // Create booking with outbound seats; return seats confirmed separately const outboundSeatIds = dto.passengers.map(p => p.seatId).filter((id): id is string => !!id); @@ -880,8 +896,8 @@ export class GuestBookingService { returnLegStatus: 'NEITHER_USED', ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), userAgent: dto.deviceId, - contactEmail: passengersData[0]?.email || null, - contactPhone: passengersData[0]?.phone || null, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone, seats: { create: [ ...passengersWithFares.map((p) => ({ @@ -923,12 +939,19 @@ export class GuestBookingService { }, }); - await this.createTravelerProfiles(guestPassengerId, passengersData); + // Traveler profiles: guest bookings only (authenticated passengers already have one). + if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData); await Promise.all([ this.seatsService.confirmSeats(outboundSeatIds), this.seatsService.confirmSeats(returnSeatIds), ]); + + // Authenticated-only side effect: audit the booking creation. + if (authUserId) { + await this.auditService.log({ userId: guestPassengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ROUND_TRIP', totalMinor } }); + } + this.eventEmitter.emit('booking.created', { booking }); return { @@ -954,6 +977,7 @@ export class GuestBookingService { } private async createGuestTransitBooking(dto: CreateGuestBookingDto, req?: any) { + const authUserId: string | null = req?.user?.id ?? null; if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) { throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings'); } @@ -1059,6 +1083,7 @@ export class GuestBookingService { : totalMinor; const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); + const contact = await this.resolveActorContact(req, passengersData[0]); // Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2 const booking = await this.prisma.booking.create({ @@ -1081,8 +1106,8 @@ export class GuestBookingService { leg2DestinationStationId: dto.leg2DestinationStationId, leg2SeatClassId: leg2SeatClassId, userAgent: dto.deviceId, - contactEmail: passengersData[0]?.email || null, - contactPhone: passengersData[0]?.phone || null, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone, seats: { create: [ ...passengersData.map(p => ({ @@ -1124,7 +1149,8 @@ export class GuestBookingService { }, }); - await this.createTravelerProfiles(guestPassengerId, passengersData); + // Traveler profiles: guest bookings only (authenticated passengers already have one). + if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData); await Promise.all([ this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)), @@ -1150,6 +1176,7 @@ export class GuestBookingService { } private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto, req?: any) { + const authUserId: string | null = req?.user?.id ?? null; if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId || !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId || !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) { @@ -1261,6 +1288,7 @@ export class GuestBookingService { : totalMinor; const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); + const contact = await this.resolveActorContact(req, passengersData[0]); const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({ seat: { connect: { id: seatId } }, @@ -1302,8 +1330,8 @@ export class GuestBookingService { returnLeg2SeatClassId: retL2ClassId, returnLegStatus: 'NEITHER_USED', userAgent: dto.deviceId, - contactEmail: passengersData[0]?.email || null, - contactPhone: passengersData[0]?.phone || null, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone, seats: { create: [ ...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)), @@ -1319,7 +1347,8 @@ export class GuestBookingService { }, }); - await this.createTravelerProfiles(guestPassengerId, passengersData); + // Traveler profiles: guest bookings only (authenticated passengers already have one). + if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData); await Promise.all([ this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)), @@ -1348,11 +1377,40 @@ export class GuestBookingService { }; } + /** + * Resolves the booking contact. Authenticated callers get their contact from iam.users + * (matching the old BookingsService.resolveIamContact); guests fall back to the first + * passenger's inline phone/email exactly as before. + */ + private async resolveActorContact( + req: any, + firstPassenger: any, + ): Promise<{ contactEmail: string | null; contactPhone: string | null }> { + const iamUserId = req?.user?.id; + if (iamUserId) { + const rows = await this.dataSource.query<{ email: string; phone_number: string | null }[]>( + `SELECT email, phone_number FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ); + return { contactEmail: rows[0]?.email ?? null, contactPhone: rows[0]?.phone_number ?? null }; + } + return { contactEmail: firstPassenger?.email || null, contactPhone: firstPassenger?.phone || null }; + } + private async resolveGuestPassenger( dto: Pick, firstPassenger: any, req?: any, ): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> { + // Authenticated caller: resolve the existing passenger from the JWT subject. + // Never trust a client-supplied passengerId — identity comes from the token only. + const authUserId = req?.user?.id; + if (authUserId) { + const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId: authUserId }, select: { id: true } }); + if (!passenger) throw new NotFoundException('Passenger profile not found for this account'); + return { guestPassengerId: passenger.id, iamUserId: authUserId, createdAccount: false }; + } + if (dto.createAccount && firstPassenger.email && dto.password) { const guestName = firstPassenger.passengerName ?? 'Guest'; const result = await this.passengerAuthService.registerWithPassword( diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index 20de8ec23..e6c4d2fbc 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -254,11 +254,10 @@ export class PassengersController { } try { - const passenger = await this.prisma.passenger.findUnique({ - where: { iamUserId: req.user.id }, - }); - if (!passenger) return null; - return this.service.getProfile(passenger.id); + // Identity (name, DOB, gender, nationality, Fayda status) lives on the IAM user record, + // not the Passenger row — return the full booking-form payload built from it so an + // already-verified passenger's form can prefill and lock. See getMyProfile. + return await this.service.getMyProfile(req.user.id); } catch (error) { return null; } diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 742b7ae89..6792a8d76 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -22,6 +22,7 @@ type IamUserRow = { name: { en: string; am: string } | null; phone_number: string | null; metadata: Record | null; + verified_by?: string | null; }; @Injectable() @@ -237,6 +238,66 @@ export class PassengersService { }; } + /** + * Full passenger-form payload for the logged-in user, sourced from the IAM user record + * (iam.users) — where the Fayda-verified identity actually lives — rather than the sparse + * Passenger row. The booking passenger form (/booking/passengers) calls this via + * GET /passengers/me to prefill (and lock) an already-verified passenger's details. + * + * Identity fields don't depend on a Passenger row existing; only `id` (used later to tag the + * primary passenger on the booking) does, and it's null if no Passenger row is linked yet. + */ + async getMyProfile(iamUserId: string) { + const [passenger, iamRows] = await Promise.all([ + this.prisma.passenger.findUnique({ where: { iamUserId } }), + this.dataSource.query( + `SELECT id, email, name, phone_number, metadata, verified_by FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ), + ]); + + const iam = iamRows[0] ?? null; + if (!iam && !passenger) return null; + + const meta = iam?.metadata ?? {}; + const faydaVerified = + iam?.verified_by === 'fayda' || + meta.faydaVerified === true || + meta.faydaVerified === 'true'; + + // Fayda writes gender as { am, en }; older/manual records may store a plain string. + const gender = + meta.gender && typeof meta.gender === 'object' + ? (meta.gender.en ?? meta.gender.am ?? null) + : (meta.gender ?? null); + + const fullName = iam?.name?.en ?? iam?.name?.am ?? null; + + // Stored as ISO by the Fayda upsert; tolerate a "/"-separated legacy value. + const rawDob = meta.dateOfBirth ?? meta.birthdate ?? null; + const dateOfBirth = rawDob ? String(rawDob).replace(/\//g, '-') : null; + + // Nationality isn't always in metadata; a Fayda-verified holder is Ethiopian by definition. + const nationality = meta.nationality ?? (faydaVerified ? 'ETHIOPIAN' : null); + + return { + id: passenger?.id ?? null, + fullName, + email: iam?.email ?? meta.email ?? null, + phone: iam?.phone_number ?? meta.phoneNumber ?? null, + gender, + dateOfBirth, + nationality, + faydaVerified, + faydaSub: meta.sub ?? null, + passportNumber: meta.passportNumber ?? null, + passportCountry: meta.passportCountry ?? null, + passportIssueDate: meta.passportIssueDate ?? null, + passportExpiryDate: meta.passportExpiryDate ?? null, + passportIssuingAuthority: meta.passportIssuingAuthority ?? null, + }; + } + async getStats(passengerId: string) { const [totalTrips, totalSpendResult, loyalty] = await Promise.all([ this.prisma.booking.count({ where: { passengerId, status: 'BOARDED' as any } }), diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts index 89edf25a6..df6cfc21d 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -512,17 +512,30 @@ export class VerifaydaService { gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' }, name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' }, phoneNumber: normalized.rawPhoneNumber ?? '', + // Persist the identity fields the passenger booking form needs. Fayda returns + // these on every verification but they were previously dropped, leaving the + // logged-in/verified form with nothing to prefill. birthdate arrives as + // YYYY/MM/DD — store it as the ISO YYYY-MM-DD the form expects. A Fayda-verified + // holder is an Ethiopian national ID holder, so nationality is always Ethiopian. + dateOfBirth: normalized.birthdate ? normalized.birthdate.replace(/\//g, '-') : '', + nationality: 'ETHIOPIAN', }; - // Step 1 — already linked to this Fayda sub; ensure verified_by is set + // Step 1 — already linked to this Fayda sub; refresh metadata (backfills the newly + // persisted dateOfBirth/nationality for users linked before this change) and ensure + // verified_by is set. const bySub = await this.dataSource.query<{ id: string }[]>( `SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`, [normalized.sub], ); if (bySub.length > 0) { await this.dataSource.query( - `UPDATE iam.users SET verified_by = 'fayda', updated_at = NOW() WHERE id = $1`, - [bySub[0].id], + `UPDATE iam.users + SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb, + verified_by = 'fayda', + updated_at = NOW() + WHERE id = $2`, + [JSON.stringify(iamMetadata), bySub[0].id], ); return { iamUserId: bySub[0].id, userDataSaved: true }; } diff --git a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx index e38c07d90..e8f2d850e 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx @@ -4,8 +4,7 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; import { useBookingStore } from '@/lib/booking-store'; -import { UserPlus, ChevronLeft } from 'lucide-react'; -// import { LogIn } from 'lucide-react'; // TODO: re-enable auth — used by commented-out SignIn/Register button +import { UserPlus, ChevronLeft, LogIn } from 'lucide-react'; function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) { const [visible, setVisible] = useState(false); @@ -96,7 +95,6 @@ export default function AuthCheckPage() { - {/* TODO: re-enable auth — SignIn or Register button commented out until auth integration - */}
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 b29a4b102..60cd350a2 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 @@ -933,27 +933,60 @@ function PassengersForm() { } try { - // Fetch passenger profile from backend - const passengerData: any = await apiClient.get(`/passengers/me`); + // Fetch passenger profile from backend. This may be null (e.g. no Passenger row linked + // yet) — in that case fall back to the `user` object, which /auth/profile hydrates from + // the same IAM record (name, gender, DOB, nationality, Fayda status). Merging the two + // means an already-verified passenger's form still prefills from whichever source has + // the data, rather than being left blank. + const passengerData: any = (await apiClient.get(`/passengers/me`)) || {}; - if (!passengerData) { + const pick = (a: any, b: any) => (a !== undefined && a !== null && a !== '' ? a : b); + + // Nationality for THIS booking is the one chosen at search — the passengers-page + // nationality field is read-only. It, not the account's stored nationality, decides + // whether the Fayda gate applies, so a logged-in user who picked "Other"/"Djiboutian" + // isn't wrongly forced into Fayda (Fayda is only for Ethiopian nationals). + const nationality = searchCriteria?.nationality || pick(passengerData.nationality, user.nationality) || 'ETHIOPIAN'; + const isEthiopian = String(nationality).toUpperCase() === 'ETHIOPIAN'; + const isVerified = Boolean(pick(passengerData.faydaVerified, user.faydaVerified)); + + // A logged-in but NOT Fayda-verified Ethiopian must pass the Fayda gate exactly like a + // guest. Prefilling their identity and expanding the form would let them submit the + // booking without ever verifying — only a verified passenger may pass. When Fayda is + // globally disabled there is no gate, so the restriction doesn't apply. + const mustVerifyFayda = isEthiopian && !isVerified && faydaEnabled; + + // Nationality + contact aren't identity-verifying, so they're safe to prefill either way. + setValue('passengers.0.nationality', nationality); + const phoneVal = pick(passengerData.phone, user.phone); + if (phoneVal) setValue('passengers.0.phone', phoneVal); + const emailVal = pick(passengerData.email, user.email); + if (emailVal) setValue('passengers.0.email', emailVal); + + if (mustVerifyFayda) { + // Force the Fayda gate: leave name/DOB/gender empty and keep the form collapsed so the + // "Verify with Fayda" screen is shown instead of an editable, pre-filled form. + setValue('passengers.0.faydaVerified', false); + setValue('passengers.0.formExpanded', false); setFormInitialized(true); return; } - // Only populate first passenger - setValue('passengers.0.name', passengerData?.fullName || user.fullName || ''); - setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || ''); - if (passengerData?.gender || user.gender) setValue('passengers.0.gender', (passengerData?.gender || user.gender) as any); - setValue('passengers.0.nationality', passengerData?.nationality || user.nationality || 'ETHIOPIAN'); - if (passengerData?.phone || user.phone) setValue('passengers.0.phone', passengerData?.phone || user.phone || ''); - if (passengerData?.email || user.email) setValue('passengers.0.email', passengerData?.email || user.email || ''); - if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber); - setValue('passengers.0.passportCountry', passengerData?.passportCountry || (searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : '')); - if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate); - if (passengerData?.passportExpiryDate) setValue('passengers.0.passportExpiryDate', passengerData.passportExpiryDate); - if (passengerData?.passportIssuingAuthority) setValue('passengers.0.passportIssuingAuthority', passengerData.passportIssuingAuthority); - setValue('passengers.0.faydaVerified', passengerData?.faydaVerified || user.faydaVerified || false); + // Verified Ethiopian, or a non-Ethiopian (passport flow): prefill everything and expand. + setValue('passengers.0.name', pick(passengerData.fullName, user.fullName) || ''); + setValue('passengers.0.dateOfBirth', pick(passengerData.dateOfBirth, user.dateOfBirth) || ''); + const genderVal = pick(passengerData.gender, user.gender); + if (genderVal) setValue('passengers.0.gender', genderVal as any); + const passportNumberVal = pick(passengerData.passportNumber, user.passportNumber); + if (passportNumberVal) setValue('passengers.0.passportNumber', passportNumberVal); + setValue('passengers.0.passportCountry', pick(passengerData.passportCountry, user.passportCountry) || (searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : '')); + const passportIssueVal = pick(passengerData.passportIssueDate, user.passportIssueDate); + if (passportIssueVal) setValue('passengers.0.passportIssueDate', passportIssueVal); + const passportExpiryVal = pick(passengerData.passportExpiryDate, user.passportExpiryDate); + if (passportExpiryVal) setValue('passengers.0.passportExpiryDate', passportExpiryVal); + const passportAuthVal = pick(passengerData.passportIssuingAuthority, user.passportIssuingAuthority); + if (passportAuthVal) setValue('passengers.0.passportIssuingAuthority', passportAuthVal); + setValue('passengers.0.faydaVerified', isVerified); setValue('passengers.0.formExpanded', true); setFormInitialized(true); @@ -1126,10 +1159,21 @@ function PassengersForm() { // Identity fields sourced from a completed Fayda verification are locked — the // passenger can't edit the verified name / date of birth / gender. const isFaydaLocked = !!passengers[index]?.faydaVerified; + // ...but lock each field only when it actually carries a value. A verified profile + // can be missing a field (e.g. a Fayda *login* record whose metadata has no date of + // birth) — locking an empty, required input would strand the user with no way to + // fill it or submit. A missing field stays editable so they can complete it. + const isNameLocked = isFaydaLocked && !!passengers[index]?.name; + const isDobLocked = isFaydaLocked && !!passengers[index]?.dateOfBirth; + const isGenderLocked = isFaydaLocked && !!passengers[index]?.gender; // Contact fields lock only when Fayda actually supplied them; a value Fayda left // blank stays editable so the passenger can add their own phone/email. - const isPhoneLocked = !!passengers[index]?.faydaPhoneLocked; - const isEmailLocked = !!passengers[index]?.faydaEmailLocked; + // A logged-in, already-verified primary passenger's contact details also come from + // their verified profile — lock those too, alongside name/DOB/gender. Only lock a + // field that actually has a value, so an incomplete profile can't strand the user + // on an unfillable required field. + const isPhoneLocked = !!passengers[index]?.faydaPhoneLocked || (isLoggedInAndVerified && !!passengers[index]?.phone); + const isEmailLocked = !!passengers[index]?.faydaEmailLocked || (isLoggedInAndVerified && !!passengers[index]?.email); return (
@@ -1227,8 +1271,8 @@ function PassengersForm() { {errors.passengers?.[index]?.name && ( @@ -1244,14 +1288,14 @@ function PassengersForm() { onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })} error={errors.passengers?.[index]?.dateOfBirth?.message} passengerType={isChildPassenger ? 'CHILD' : 'ADULT'} - disabled={isFaydaLocked} + disabled={isDobLocked} />
{/* Gender */}
- {isFaydaLocked ? ( + {isGenderLocked ? ( setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })} error={errors.passengers?.[index]?.dateOfBirth?.message} passengerType={isChildPassenger ? 'CHILD' : 'ADULT'} - disabled={isFaydaLocked} + disabled={isDobLocked} />
{/* Gender */}
- {isFaydaLocked ? ( + {isGenderLocked ? ( setValue(`passengers.${index}.phone`, v)} onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })} error={errors.passengers?.[index]?.phone?.message} + disabled={isPhoneLocked} />
diff --git a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx index 08874193a..0acb5d9ea 100644 --- a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx @@ -192,22 +192,20 @@ export default function AppSidebar() { )}
) : ( - // TODO: re-enable auth — Sign in / Register links commented out until auth integration - //
- // - // Sign in - // - // - // Register - // - //
- null +
+ + Sign in + + + Register + +
)} diff --git a/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx b/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx index 8017f3087..ee4cdccad 100644 --- a/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx +++ b/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx @@ -1,10 +1,9 @@ 'use client'; -import { Home, Phone, Ticket } from 'lucide-react'; -// import { User } from 'lucide-react'; // TODO: re-enable auth — used by commented-out Sign in tab +import { Home, Phone, Ticket, User } from 'lucide-react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -// import { useAuthStore } from '@/lib/auth-store'; // TODO: re-enable auth +import { useAuthStore } from '@/lib/auth-store'; // The linear, one-screen-at-a-time booking flow — each of these pages already // has its own sticky mobile CTA bar (and the mobile step strip at the top), @@ -22,7 +21,7 @@ const LINEAR_FLOW_PREFIXES = [ export default function BottomTabBar() { const pathname = usePathname() ?? ''; - // const isAuthenticated = useAuthStore((s) => s.isAuthenticated); // TODO: re-enable auth + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isInLinearFlow = LINEAR_FLOW_PREFIXES.some((p) => pathname.startsWith(p)); if (isInLinearFlow) return null; @@ -31,13 +30,12 @@ export default function BottomTabBar() { { href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' }, { href: '/booking/lookup', label: 'Bookings', icon: Ticket, match: (p: string) => p.startsWith('/booking/lookup') || p.startsWith('/booking/detail') }, { href: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') }, - // TODO: re-enable auth — auth login/register tab commented out until auth integration - // { - // href: isAuthenticated ? '/profile' : '/login', - // label: isAuthenticated ? 'Account' : 'Sign in', - // icon: User, - // match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'), - // }, + { + href: isAuthenticated ? '/profile' : '/login', + label: isAuthenticated ? 'Account' : 'Sign in', + icon: User, + match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'), + }, ]; return ( diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index c75edef1f..ec334195b 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -296,9 +296,10 @@ export class IntentsService { * expiresAt, so opening a fresh session would leave two concurrently-payable * sessions and invite a double charge (observed in prod: a superseded Telebirr * session was paid after cancellation, orphaning the capture). - * - Expired, or the requested amount/currency changed: retired (CANCELLED, no - * notification — nothing was paid; a payment.failed here would wrongly fail the - * domain order mid-retry) and null is returned so the caller opens a fresh session. + * - Expired, or the requested amount/currency or platform (web↔mobile) changed: + * retired (CANCELLED, no notification — nothing was paid; a payment.failed here + * would wrongly fail the domain order mid-retry) and null is returned so the caller + * opens a fresh session with the correct amount/clientAction for the new platform. * * When the status query itself errors, the existing intent is reused unchanged: * superseding blind could leave two live sessions and a double charge. @@ -332,8 +333,15 @@ export class IntentsService { const chargeChanged = intent.amountMinor !== request.amountMinor || intent.currency !== request.currency; + // A web↔mobile switch needs a different clientAction shape (e.g. Telebirr: + // REDIRECT for web vs LAUNCH_APP for the native app), so reusing the stored + // session would hand the payer the wrong launch method and break the return. + // Detect the stored session's platform from its clientAction and retire on a switch. + const storedIsMobileLaunch = intent.clientAction?.type === "LAUNCH_APP"; + const requestedMobile = (request.platform ?? "web") === "mobile"; + const platformChanged = storedIsMobileLaunch !== requestedMobile; - if (!expired && !chargeChanged) { + if (!expired && !chargeChanged && !platformChanged) { this.logger.log( `intent ${intent.id} reused (live ${intent.provider} session, unpaid, not expired) for ` + `${request.service}/${request.referenceType}/${request.referenceId}`, @@ -346,7 +354,7 @@ export class IntentsService { failureCode: expired ? "EXPIRED" : "SUPERSEDED", failureMessage: expired ? "Provider session expired before the payer acted" - : "Payer re-initiated with a changed amount; previous session superseded", + : "Payer re-initiated with a changed amount or platform; previous session superseded", }); this.logger.log( `intent ${intent.id} retired (${expired ? "EXPIRED" : "SUPERSEDED"}) — fresh session will be opened`,