# Implementation Guide: User Profile & Booking Flow Update ## Overview - **Auto-populate**: Logged-in users see their profile details pre-filled in passenger form - **View Bookings**: New endpoint to retrieve user's booking history - **Duplicate Allowed**: Passenger details always saved as NEW records for each booking - **No PassengerId Dependency**: Remove JWT passengerId encoding, fetch from profile when needed --- ## BACKEND CHANGES (edr-passenger-api) ### 1. Bookings Service - Add findByPassengerId Method **File**: `src/modules/bookings/bookings.service.ts` Add method after `findAll()`: ```typescript async findByPassengerId(passengerId: string, filters: BookingFilters = {}) { const { search, status, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; const where: any = { passengerId }; if (search) { where.OR = [ { bookingRef: { contains: search, mode: 'insensitive' } }, { seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } }, ]; } if (status) { where.status = status; } const [items, total] = await Promise.all([ this.prisma.booking.findMany({ where, skip, take: pageSize, orderBy: { createdAt: 'desc' }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } }, paymentIntent: true, }, }), this.prisma.booking.count({ where }), ]); return { items: items.map(booking => ({ id: booking.id, bookingRef: booking.bookingRef, status: booking.status, totalMinor: booking.totalMinor, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, createdAt: booking.createdAt, schedule: { number: booking.schedule.train.number, origin: booking.schedule.originStation, destination: booking.schedule.destinationStation, departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt, }, passengers: booking.seats.map(s => ({ name: s.passengerName, category: s.passengerCategory, seat: { number: s.seat.label, coach: s.seat.coach.label, class: s.seat.coach.seatClass.name, }, })), payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined, })), meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize), }, }; } ``` ### 2. Bookings Controller - Add Endpoint for User Bookings **File**: `src/modules/bookings/bookings.controller.ts` Add imports: ```typescript import { Request, UnauthorizedException } from '@nestjs/common'; ``` Add method before the `create()` method: ```typescript @Get('my/bookings') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Get current user bookings', description: 'Retrieve all bookings made by the logged-in user with search and filter options' }) @ApiQuery({ name: 'search', required: false, description: 'Search by booking reference or passenger name' }) @ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' }) @ApiQuery({ name: 'page', required: false, description: 'Page number' }) @ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' }) @ApiResponse({ status: 200, description: 'List of user bookings with schedule and passenger details' }) getMyBookings( @Request() req: any, @Query('search') search?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { const passengerId = req.user?.passengerId; if (!passengerId) { throw new UnauthorizedException('Passenger ID not found in token'); } return this.service.findByPassengerId(passengerId, { search, status, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 20 }); } ``` ### 3. Auth Service - OPTIONAL: Remove passengerId from JWT **File**: `src/modules/auth/auth.service.ts` If you want to remove passengerId from JWT (recommended for security): - Update `signToken()` to NOT include passengerId in payload - Users will fetch it from `/passengers/me` when needed ```typescript private async signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true, email: true, fullName: true, role: true } }); // Don't include passengerId in JWT - fetch from profile instead const token = this.jwt.sign({ sub: userId, email, role, agentId }); return { token, user: { id: userId, email, fullName: user?.fullName || email, role, agentId } }; } ``` --- ## FRONTEND CHANGES (edr-passenger-web/portal) ### 1. Booking Store - Remove passengerId **File**: `src/lib/booking-store.ts` Remove: ```typescript passengerId: string | null; setPassengerId: (id: string | null) => void; ``` ### 2. Passengers Page - Update onSubmit **File**: `src/app/booking/passengers/page.tsx` Replace the `onSubmit` function: ```typescript const onSubmit = async (data: FormData) => { setSaving(true); try { // Build passenger details - ALWAYS create new records const passengerDetails = data.passengers.map((p, i) => ({ name: p.name, dateOfBirth: p.dateOfBirth, gender: p.gender, nationality: p.nationality, nationalId: p.nationalId, passportNumber: p.passportNumber, passportCountry: p.passportCountry, phone: p.phone, email: p.email, isPrimaryPassenger: i === 0, })); const deviceId = typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || crypto.randomUUID()) : crypto.randomUUID(); await apiClient.post('/passengers/save-details', { passengers: passengerDetails, userId: user?.id, deviceId, }); setPassengers(passengerDetails); setCreateAccount(data.createAccount); router.push('/booking/seats'); } catch (error) { console.error('Failed to save passenger details:', error); alert('Failed to save passenger details. Please try again.'); } finally { setSaving(false); } }; ``` ### 3. Review Page - Get passengerId from Profile **File**: `src/app/booking/review/page.tsx` Replace the `handleConfirm` function to fetch passengerId: ```typescript const handleConfirm = async () => { console.log('handleConfirm called'); try { const { searchCriteria } = useBookingStore.getState(); if (!seatHold?.holdId) { alert('Please select seats before continuing.'); router.push('/booking/seats'); return; } if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) { alert('Missing search criteria. Please start over.'); router.push('/booking/search'); return; } let seatClassId = 'default-seat-class-id'; try { const seatClasses: any = await apiClient.get('/seat-classes'); if (seatClasses && seatClasses.length > 0) { seatClassId = seatClasses[0].id; } } catch (err) { console.error('Failed to fetch seat classes:', err); } let bookingData: any; if (isAuthenticated) { // Fetch passengerId from user profile let passengerId = ''; try { const passengerProfile: any = await apiClient.get('/passengers/me'); passengerId = passengerProfile?.id; console.log('Got passengerId from profile:', passengerId); } catch (error) { console.error('Failed to get passenger profile:', error); throw new Error('Unable to retrieve your passenger profile. Please try again.'); } if (!passengerId) { throw new Error('Passenger profile not found. Please update your profile and try again.'); } bookingData = { scheduleId: selectedSchedule?.id || '', holdId: seatHold.holdId, originStationId: searchCriteria.originStationId, destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, displayCurrency: 'ETB', passengerId: passengerId, passengers: passengers.map((p) => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { seatId: p.seatId || '', passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', idDocumentNumber: isEthiopian ? (p.nationalId || '') : '', passportNumber: !isEthiopian ? (p.passportNumber || '') : '', passportCountry: !isEthiopian ? (p.passportCountry || '') : '', nationality: p.nationality, }; }), }; } else { // Guest booking bookingData = { scheduleId: selectedSchedule?.id || '', holdId: seatHold.holdId, originStationId: searchCriteria.originStationId, destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, displayCurrency: 'ETB', passengers: passengers.map(p => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { seatId: p.seatId || '', passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', idDocumentNumber: isEthiopian ? (p.nationalId || '') : '', passportNumber: !isEthiopian ? (p.passportNumber || '') : '', passportCountry: !isEthiopian ? (p.passportCountry || '') : '', nationality: p.nationality, phone: p.phone || '', email: p.email || '', }; }), createAccount: createAccount || false, savePassengerDetails: true, deviceId: typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || `device-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`) : undefined, }; } if (typeof window !== 'undefined' && !isAuthenticated && bookingData.deviceId && !localStorage.getItem('deviceId')) { localStorage.setItem('deviceId', bookingData.deviceId); } console.log('Creating booking with payload:', bookingData); await createBookingMutation.mutateAsync(bookingData); } catch (error) { console.error('Error in handleConfirm:', error); alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.'); } }; ``` ### 4. Create New Bookings List Component **File**: `src/app/bookings/page.tsx` (new file) Create a page to display user's bookings: ```typescript 'use client'; import { useEffect, useState } from 'react'; import { useAuthStore } from '@/lib/auth-store'; import { apiClient } from '@/lib/api-client'; import { format } from 'date-fns'; export default function BookingsPage() { const { isAuthenticated } = useAuthStore(); const [bookings, setBookings] = useState([]); const [loading, setLoading] = useState(true); const [filters, setFilters] = useState({ status: '', search: '' }); useEffect(() => { if (!isAuthenticated) return; const fetchBookings = async () => { try { const response: any = await apiClient.get('/bookings/my/bookings', { params: filters, }); setBookings(response.items || []); } catch (error) { console.error('Failed to fetch bookings:', error); } finally { setLoading(false); } }; fetchBookings(); }, [isAuthenticated, filters]); if (!isAuthenticated) { return
Please log in to view your bookings.
; } if (loading) { return
Loading bookings...
; } return (

My Bookings

setFilters({ ...filters, search: e.target.value })} className="input-field flex-1" />
{bookings.length === 0 ? (

No bookings found

) : (
{bookings.map((booking) => (

{booking.bookingRef}

{booking.schedule.origin.name} → {booking.schedule.destination.name}

{format(new Date(booking.schedule.departureAt), 'PPp')}

{booking.status}

{booking.displayCurrency} {(booking.displayTotalMinor / 100).toFixed(2)}

))}
)}
); } ``` --- ## FLOW SUMMARY ### Logged-in User Booking Flow: 1. User logs in → Profile data loaded 2. Search for trip → Select passengers 3. **Passengers Page**: Auto-populate first passenger from profile, allow editing 4. **Seats Page**: Select seats 5. **Review Page**: Fetch latest passengerId from `/passengers/me`, create booking 6. **Confirmation**: Booking created with new passenger details ### View Bookings: 1. User clicks "My Bookings" → `/bookings/my/bookings` endpoint 2. Returns all bookings for that passengerId with details ### Duplicate Passengers: - Each booking creates NEW passenger records in BookingSeat table - No constraints on duplicate data - Allows flexibility for user changes --- ## Key Differences from Previous Implementation | Aspect | Old | New | |--------|-----|-----| | **PassengerId Storage** | Encoded in JWT | Fetched from `/passengers/me` profile | | **Passenger Details** | Linked to user profile | NEW copy created for each booking | | **View Bookings** | N/A | New `/bookings/my/bookings` endpoint | | **Auto-populate** | Limited | Complete profile details | | **Duplicates** | Not allowed | Always allowed and encouraged | | **Frontend Logic** | Complex passengerId fallbacks | Simple profile fetch |