Files
edr-platform/implementation-guide.md
2026-06-03 15:36:37 +03:00

15 KiB

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():

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:

import { Request, UnauthorizedException } from '@nestjs/common';

Add method before the create() method:

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

passengerId: string | null;
setPassengerId: (id: string | null) => void;

2. Passengers Page - Update onSubmit

File: src/app/booking/passengers/page.tsx

Replace the onSubmit function:

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:

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:

'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