Passenger portal issues resolution 2

This commit is contained in:
Stephanos A
2026-06-03 16:58:42 +03:00
parent 1bde9d401a
commit 9d75a5bc66
7 changed files with 21 additions and 741 deletions

View File

@@ -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 <jwt-token>
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 <accessToken>
# 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.

View File

@@ -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"
>
<ExternalLink className="w-5 h-5" />
Restart Booking
</button>
```
## 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

View File

@@ -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

View File

@@ -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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">

View File

@@ -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<string, string> = { ETHIOPIAN: 'Ethiopian', DJIBOUTIAN: 'Djiboutian', OTHER: 'Other' };

View File

@@ -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 (
<>

View File

@@ -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<any[]>([]);
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 <div className="p-4">Please log in to view your bookings.</div>;
}
if (loading) {
return <div className="p-4">Loading bookings...</div>;
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<h1 className="text-3xl font-bold mb-6">My Bookings</h1>
<div className="mb-6 flex gap-4">
<input
type="text"
placeholder="Search bookings..."
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="input-field flex-1"
/>
<select
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="input-field"
>
<option value="">All Status</option>
<option value="CONFIRMED">Confirmed</option>
<option value="PENDING_PAYMENT">Pending</option>
<option value="CANCELLED">Cancelled</option>
</select>
</div>
{bookings.length === 0 ? (
<div className="card text-center">
<p className="text-gray-600">No bookings found</p>
</div>
) : (
<div className="space-y-4">
{bookings.map((booking) => (
<div key={booking.id} className="card">
<div className="flex justify-between items-start">
<div>
<h3 className="font-semibold text-lg">{booking.bookingRef}</h3>
<p className="text-sm text-gray-600">
{booking.schedule.origin.name} {booking.schedule.destination.name}
</p>
<p className="text-sm text-gray-600">
{format(new Date(booking.schedule.departureAt), 'PPp')}
</p>
</div>
<div className="text-right">
<p className={`font-semibold ${booking.status === 'CONFIRMED' ? 'text-green-600' : 'text-gray-600'}`}>
{booking.status}
</p>
<p className="text-lg font-bold">
{booking.displayCurrency} {(booking.displayTotalMinor / 100).toFixed(2)}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
```
---
## 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 |