mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 15:30:56 +00:00
471 lines
15 KiB
Markdown
471 lines
15 KiB
Markdown
# 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 |
|
|
|