diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index e31bb35f8..000000000 --- a/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,103 +0,0 @@ -# User Booking History Implementation - COMPLETED - -## Changes Made - -### Backend Implementation - -#### 1. **BookingsService** (`apps/edr-passenger-api/src/modules/bookings/bookings.service.ts`) -Added new method `findByPassengerId()`: -- Retrieves all bookings for an authenticated user by their passengerId -- Supports filtering by: search (booking ref, station names), status, pagination (page/pageSize) -- Returns paginated list with booking details, schedule, train, and payment info -- Structured response includes meta data (page, pageSize, total, totalPages) - -**Method Signature:** -```typescript -async findByPassengerId(passengerId: string, filters: BookingFilters = {}) -``` - -#### 2. **BookingsController** (`apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts`) -Added new endpoint `GET /bookings/my/bookings`: -- Protected by `JwtGuard` (requires valid JWT token) -- Extracts passengerId from authenticated user object in request -- Passes through search, status, page, pageSize query parameters -- Fully documented with Swagger decorators and API responses - -**Endpoint Details:** -``` -GET /bookings/my/bookings -Authorization: Bearer -Query Parameters: - - search?: string (search by booking ref or station names) - - status?: string (filter by booking status) - - page?: string (default: 1) - - pageSize?: string (default: 20) - -Response: Paginated list of user's bookings with schedule and payment details -``` - -## Architecture - -### Data Flow -1. User sends GET request to `/bookings/my/bookings` with JWT token -2. JwtGuard validates token and populates `req.user` with decoded payload -3. Controller extracts `passengerId` from `req.user` -4. `findByPassengerId()` queries Prisma for bookings where `passengerId` matches -5. Returns paginated, formatted booking list - -### Key Features -- **Multi-level filtering**: Search across booking reference and station names -- **Pagination support**: Full pagination with total count and page info -- **Authenticated access**: Only authenticated users can access their bookings -- **Swagger documentation**: Complete API documentation with query parameters and response schema -- **Related data**: Includes schedule (train, stations, times), payment info, and seat count - -## Verification - -✅ TypeScript compilation successful (no type errors) -✅ New method added to BookingsService -✅ New endpoint added to BookingsController -✅ JWT authentication guard applied -✅ Swagger documentation complete -✅ Imports updated (added Req from @nestjs/common) - -## Testing - -To test the new endpoint: - -```bash -# 1. Login to get JWT token -POST /auth/login -{ - "email": "passenger@example.com", - "password": "password123" -} - -# 2. Copy the accessToken from response - -# 3. Call the new endpoint -GET /bookings/my/bookings?page=1&pageSize=10 -Authorization: Bearer - -# Optional filtering -GET /bookings/my/bookings?search=ABC&status=CONFIRMED&page=1 -``` - -## Frontend Integration Notes - -The frontend can now: -- Fetch authenticated user's booking history without manually managing passengerId -- Filter bookings by status (PENDING_PAYMENT, CONFIRMED, CANCELLED) -- Search bookings by reference or station names -- Handle pagination with page/pageSize parameters -- Display booking history in a user profile/dashboard view - -## Database Query Structure - -The implementation queries: -- `Booking` table filtered by passengerId -- Joins with `TrainSchedule` (includes train, origin/destination stations) -- Joins with `PaymentIntent` (payment info) -- Joins with `BookingSeat` (seat count) - -No N+1 query issues due to Prisma's include optimization. diff --git a/RESTART_BOOKING_IMPLEMENTATION.md b/RESTART_BOOKING_IMPLEMENTATION.md deleted file mode 100644 index 451c625dc..000000000 --- a/RESTART_BOOKING_IMPLEMENTATION.md +++ /dev/null @@ -1,75 +0,0 @@ -# Nationality Mismatch Restart Booking - IMPLEMENTED - -## Changes Made - -### File: `edr-passenger-web/portal/src/app/booking/passengers/page.tsx` - -**Changes:** -1. Added `clearBooking` to the imported hooks from `useBookingStore` -2. Updated the "Restart Booking" button click handler to: - - Clear the entire booking state using `clearBooking()` - - Perform a full page reload to `/booking/search` using `window.location.href` - - Added visual feedback with `ExternalLink` icon - -**Before:** -```typescript -onClick={() => router.push('/booking/search')} -``` - -**After:** -```typescript -onClick={() => { - clearBooking(); - window.location.href = '/booking/search'; -}} -className="btn-primary w-full flex items-center justify-center gap-2" -> - - Restart Booking - -``` - -## Behavior - -When user encounters a nationality mismatch error: -1. Warning card displays with ⚠️ icon -2. User clicks "Restart Booking" button -3. Button action triggers: - - **State Clearing**: All booking data is cleared from Zustand store and localStorage: - - `searchCriteria` - - `selectedSchedule` - - `passengers` - - `seatHold` - - `bookingId` - - `pnr` - - `selectedPaymentMethod` - - `createAccount` - - `passengerId` - - **Full Page Reload**: Browser navigates to `/booking/search` with full page reload (not client-side navigation) - - **Visual Feedback**: ExternalLink icon indicates external/full navigation action - -## Benefits - -✅ **Complete State Reset**: Ensures all booking data is cleared, preventing stale data issues -✅ **Fresh Start**: Full page reload ensures clean state on search page -✅ **Clear UX**: ExternalLink icon visually indicates a reload action -✅ **No Residual Data**: Prevents any leftover booking information from previous attempt -✅ **Consistent User Flow**: Forces fresh search criteria entry - -## Technical Details - -- Uses `useBookingStore().clearBooking()` from Zustand store -- `window.location.href` triggers full page reload (unlike `router.push()` which is client-side navigation) -- All booking state is reset to initial values defined in store -- localStorage is automatically cleared due to Zustand's persist middleware - -## Testing Checklist - -- [ ] Login with account that has nationality A -- [ ] Search for passenger with nationality B -- [ ] Verify nationality mismatch error appears -- [ ] Click "Restart Booking" button -- [ ] Verify page reloads to search page -- [ ] Verify booking store is completely cleared -- [ ] Verify search page shows default/empty state -- [ ] Verify user can perform new search diff --git a/SEAT_SELECTION_FIX.md b/SEAT_SELECTION_FIX.md deleted file mode 100644 index 3a459dd5e..000000000 --- a/SEAT_SELECTION_FIX.md +++ /dev/null @@ -1,81 +0,0 @@ -# Seat Selection Fix - COMPLETED - -## Issue -Users were unable to select another seat after clicking on one of the available seats. The seat selection wasn't responding to subsequent clicks. - -## Root Cause -The `toggleSeat` function was using stale state from closures. When `setSelectedSeats` was called, it was based on the current value of `selectedSeats` at function definition time, not at click time. This caused state updates to be lost when clicking multiple seats rapidly. - -## Solution - -### 1. Fixed toggleSeat Function (Lines 103-112) -**Before:** -```typescript -const toggleSeat = (seatId: string) => { - if (selectedSeats.includes(seatId)) { - setSelectedSeats(selectedSeats.filter(id => id !== seatId)); - } else if (selectedSeats.length < passengers.length) { - setSelectedSeats([...selectedSeats, seatId]); - } -}; -``` - -**After:** -```typescript -const toggleSeat = (seatId: string) => { - setSelectedSeats(prev => { - if (prev.includes(seatId)) { - return prev.filter(id => id !== seatId); - } else if (prev.length < passengers.length) { - return [...prev, seatId]; - } - return prev; - }); -}; -``` - -**Change:** Use functional state update pattern (`prev =>`) to ensure we always work with the latest state value. - -### 2. Improved Seat Button Click Handler (Lines 211-215) -**Before:** -```typescript -onClick={() => seat.status === 'AVAILABLE' && toggleSeat(seat.id)} -``` - -**After:** -```typescript -onClick={() => { - if (seat.status === 'AVAILABLE') { - toggleSeat(seat.id); - } -}} -``` - -**Change:** Explicit conditional check in the handler for better clarity and reliability. - -### 3. Added Cursor Pointer Style (Line 223) -Added `cursor-pointer` class to available seats to give better visual feedback that they're clickable. - -## Benefits - -✅ **Functional State Updates**: Uses React's functional setState pattern to always access latest state -✅ **No Stale Closures**: Eliminates closure issues that prevented multiple seat selections -✅ **Multiple Selections Work**: Users can now click multiple seats in sequence without issues -✅ **Better UX**: Explicit conditional makes code more maintainable and easier to debug -✅ **Visual Feedback**: Added cursor pointer to indicate clickable seats - -## Testing Steps - -1. Navigate to seat selection page -2. Click on first available seat - should highlight in primary color -3. Click on another available seat - should also highlight -4. Continue clicking multiple seats - all should remain selected -5. Click on a selected seat - should deselect it -6. Verify seat count updates in "Selection Summary" panel - -## Technical Details - -- React's `setSelectedSeats(prev => {...})` functional update ensures state consistency -- Each click now works with the current state, not a stale copy -- No race conditions with rapid clicks -- Maximum seats can still be selected based on passenger count diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 05cceeaf6..69ce77dd5 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -71,10 +71,13 @@ export default function ConfirmationPage() { router.push('/booking/search'); }; - if (!bookingId || !pnr) { - router.push('/booking/search'); - return null; - } + useEffect(() => { + if (!bookingId || !pnr) { + router.push('/booking/search'); + } + }, [bookingId, pnr, router]); + + if (!bookingId || !pnr) return null; return (
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 e24d971c9..b8fb66dc3 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 @@ -282,10 +282,13 @@ export default function PassengersPage() { } }; - if (!searchCriteria) { - router.push('/booking/search'); - return null; - } + useEffect(() => { + if (!searchCriteria) { + router.push('/booking/search'); + } + }, [searchCriteria, router]); + + if (!searchCriteria) return null; if (nationalityMismatch && formInitialized) { const searchLabel: Record = { ETHIOPIAN: 'Ethiopian', DJIBOUTIAN: 'Djiboutian', OTHER: 'Other' }; 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 4b3b01143..d59179c49 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 @@ -156,10 +156,13 @@ export default function SeatsPage() { } }; - if (!selectedSchedule || !passengers.length) { - router.push('/booking/search'); - return null; - } + useEffect(() => { + if (!selectedSchedule || !passengers.length) { + router.push('/booking/search'); + } + }, [selectedSchedule, passengers.length, router]); + + if (!selectedSchedule || !passengers.length) return null; return ( <> diff --git a/implementation-guide.md b/implementation-guide.md deleted file mode 100644 index c37c21aeb..000000000 --- a/implementation-guide.md +++ /dev/null @@ -1,470 +0,0 @@ -# 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 | -