diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d8413bf71..7de5c5239 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,7 +13,7 @@ permissions: jobs: detect-changes: name: Detect changed services - runs-on: self-hosted + runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }} outputs: matrix: ${{ steps.filter.outputs.matrix }} steps: @@ -52,7 +52,7 @@ jobs: NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$" - GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$" + GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$" DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true) if [ -z "$DEPLOYABLE" ]; then @@ -91,7 +91,7 @@ jobs: name: Deploy ${{ matrix.service }} needs: detect-changes if: ${{ needs.detect-changes.outputs.matrix != '[]' }} - runs-on: self-hosted + runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }} strategy: fail-fast: false matrix: diff --git a/.gitignore b/.gitignore index 0e3f0986f..ffdc4b78b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ coverage/ .idea/ .vscode/ .npmrc +# emacs cache files +*~ +\#*\# +.\#* diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 9ebd1cc01..c9f2d6282 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -272,8 +272,11 @@ export const api = { getAvailableDays: endpoint< { originYardId?: string; destinationYardId?: string }, string[] - >("train-scheduling", "availableDays", ({ originYardId, destinationYardId }) => - bookingsService.getAvailableDays({ originYardId, destinationYardId }), + >( + "train-scheduling", + "availableDays", + ({ originYardId, destinationYardId }) => + bookingsService.getAvailableDays({ originYardId, destinationYardId }), ), }, diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 54dcea344..285e38bf0 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -412,6 +412,17 @@ export class BookingsController { return this.service.create(dto); } + @Get(':id/usage') + @ApiOperation({ + summary: 'Check if booking is in use', + description: 'Returns list of modules/data that reference this booking' + }) + @ApiResponse({ status: 200, description: 'Usage information retrieved' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + checkUsage(@Param('id') id: string) { + return this.service.checkBookingUsage(id); + } + @Get(':bookingRef') @ApiOperation({ summary: 'Get booking details by reference (no auth required)', @@ -423,17 +434,6 @@ export class BookingsController { return this.service.getByRef(ref); } - @Patch(':id') - @ApiOperation({ - summary: 'Update booking details', - description: 'Updates booking information for admin/agent operations' - }) - @ApiResponse({ status: 200, description: 'Booking updated successfully' }) - @ApiResponse({ status: 404, description: 'Booking not found' }) - update(@Param('id') id: string, @Body() dto: any) { - return this.service.update(id, dto); - } - @Patch(':bookingRef/modify') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @@ -458,17 +458,17 @@ export class BookingsController { return this.service.delete(id); } - @Get(':id/usage') + @Patch(':id') @ApiOperation({ - summary: 'Check if booking is in use', - description: 'Returns list of modules/data that reference this booking' + summary: 'Update booking details', + description: 'Updates booking information for admin/agent operations' }) - @ApiResponse({ status: 200, description: 'Usage information retrieved' }) + @ApiResponse({ status: 200, description: 'Booking updated successfully' }) @ApiResponse({ status: 404, description: 'Booking not found' }) - checkUsage(@Param('id') id: string) { - return this.service.checkBookingUsage(id); + update(@Param('id') id: string, @Body() dto: any) { + return this.service.update(id, dto); } - + @Delete(':bookingRef') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 44a31c151..22c11b8f1 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -956,31 +956,47 @@ export class BookingsService { where: { bookingRef }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, - seats: { include: { seat: { include: { coach: true } } } }, + seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, paymentIntent: true, ticket: true, }, }); if (!booking) throw new NotFoundException('Booking not found'); return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount, - displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined, + totalMinor: booking.totalMinor, currency: 'ETB', + adultCount: booking.adultCount, childCount: booking.childCount, + displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined, bookingType: booking.bookingType, returnLegStatus: (booking as any).returnLegStatus ?? null, outboundBoardedAt: (booking as any).outboundBoardedAt ?? null, returnBoardedAt: (booking as any).returnBoardedAt ?? null, + contactEmail: booking.contactEmail, + contactPhone: booking.contactPhone, createdAt: booking.createdAt, schedule: { - number: booking.schedule.train.number, + id: booking.schedule.id, + trainNumber: booking.schedule.train.number, + trainName: booking.schedule.train.name, origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city }, destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city }, departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt, }, passengers: booking.seats?.map((bs: any) => ({ - fullName: bs.passengerName, category: bs.passengerCategory, verifaydaVerified: bs.verifaydaVerified, - seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass.name }, + fullName: bs.passengerName, + category: bs.passengerCategory, + leg: bs.leg ?? 1, + fareMinor: bs.fareMinor, + verifaydaVerified: bs.verifaydaVerified, + seat: { + id: bs.seat.id, + number: bs.seat.seatNumber, + coach: bs.seat.coach.number, + coachId: bs.seat.coach.id, + seatClass: bs.seat.coach.coachType?.seatClasses?.[0]?.name ?? null, + }, })), payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined, + ticket: booking.ticket ? { id: booking.ticket.id, qrPayload: booking.ticket.qrPayload, barcodePayload: booking.ticket.barcodePayload, status: booking.ticket.status } : undefined, }; } diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 48b84c4b6..922d7409b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -190,9 +190,9 @@ export class GuestBookingService { displayCurrency, displayTotalMinor, bookingType: 'ONE_WAY', - userAgent: dto.deviceId, - // contactEmail: firstPassenger.email, // Temporarily disabled until migration - // contactPhone: firstPassenger.phone, // Temporarily disabled until migration + userAgent: dto.deviceId, + contactEmail: firstPassenger.email || null, + contactPhone: firstPassenger.phone || null, seats: { create: passengersData.map((p) => ({ seat: { connect: { id: p.seatId } }, @@ -384,6 +384,8 @@ export class GuestBookingService { returnSeatClassId, returnLegStatus: 'NEITHER_USED', userAgent: dto.deviceId, + contactEmail: passengersData[0]?.email || null, + contactPhone: passengersData[0]?.phone || null, seats: { create: [ ...passengersData.map((p) => ({ @@ -574,6 +576,8 @@ export class GuestBookingService { leg2DestinationStationId: dto.leg2DestinationStationId, leg2SeatClassId: leg2SeatClassId, userAgent: dto.deviceId, + contactEmail: passengersData[0]?.email || null, + contactPhone: passengersData[0]?.phone || null, seats: { create: [ ...passengersData.map(p => ({ @@ -785,6 +789,8 @@ export class GuestBookingService { returnLeg2SeatClassId: retL2ClassId, returnLegStatus: 'NEITHER_USED', userAgent: dto.deviceId, + contactEmail: passengersData[0]?.email || null, + contactPhone: passengersData[0]?.phone || null, seats: { create: [ ...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)), @@ -859,13 +865,16 @@ export class GuestBookingService { return { guestPassenger, userId: user.id, createdAccount: true }; } - const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`; - if (firstPassenger.email) { - const existing = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } }); - if (existing) guestEmail = `guest-${uniqueId}@edr-platform.com`; + const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + + let guestEmail = firstPassenger.email; + if (guestEmail) { + const existing = await this.prisma.user.findUnique({ where: { email: guestEmail } }); + if (existing) guestEmail = null; } - let guestPhone = firstPassenger.phone || null; + if (!guestEmail) guestEmail = `guest-${uniqueId}@edr-platform.com`; + + let guestPhone = firstPassenger.phone; if (guestPhone) { const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } }); if (existing) guestPhone = null; diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 4cf4ce9d3..63b675157 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -93,10 +93,20 @@ export class TicketsService { include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, + paymentIntent: true, }, }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + if (booking.status !== 'CONFIRMED') { + const paymentStatus = booking.paymentIntent?.status ?? null; + throw new BadRequestException( + `Payment not completed. Please complete your payment before accessing the ticket. ` + + `Booking status: ${booking.status}` + + (paymentStatus ? `. Payment status: ${paymentStatus}` : ''), + ); + } + // Build a compact multi-leg payload for the QR so gate scanners see all legs const legSummary = this.buildLegSummary(booking); const qrData = JSON.stringify({ diff --git a/apps/edr-passenger-web/portal/package.json b/apps/edr-passenger-web/portal/package.json index 8e063d85a..8323c3bb0 100644 --- a/apps/edr-passenger-web/portal/package.json +++ b/apps/edr-passenger-web/portal/package.json @@ -13,13 +13,17 @@ "dependencies": { "@edr/types": "workspace:*", "@edr/ui-common": "workspace:*", - "@tanstack/react-query": "^5.59.0", "@hookform/resolvers": "^3.3.4", + "@tanstack/react-query": "^5.59.0", + "@types/qrcode": "^1.5.6", "axios": "^1.7.7", "clsx": "^2.1.1", "date-fns": "^3.0.0", + "jspdf": "^4.2.1", + "jspdf-autotable": "^5.0.8", "lucide-react": "^0.446.0", "next": "^14.2.0", + "qrcode": "^1.5.4", "qrcode.react": "^3.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", 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 1a36ac04b..f7cabc212 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 @@ -7,7 +7,7 @@ import { useBookingStore } from '@/lib/booking-store'; import { useMutation, useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { useEffect, useState, useRef } from 'react'; -import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train } from 'lucide-react'; +import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react'; import { QRCodeSVG } from 'qrcode.react'; import { format } from 'date-fns'; @@ -26,6 +26,7 @@ export default function ConfirmationPage() { const router = useRouter(); const { bookingId, pnr, selectedSchedule, passengers, clearBooking } = useBookingStore(); const [copied, setCopied] = useState(false); + const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); const confirmAttempted = useRef(false); const confirmMutation = useMutation({ @@ -69,8 +70,61 @@ export default function ConfirmationPage() { } }; - const handleDownloadTickets = () => { - alert('Ticket download will be available soon. Your tickets are displayed below.'); + const handleDownloadVoucher = async () => { + if (!_booking || !pnr) { + alert('Booking data not available. Please try again.'); + return; + } + + setIsGeneratingVoucher(true); + try { + console.log('📄 Generating voucher with data:', { _booking, pnr, selectedSchedule, passengers }); + + const { generateVoucherPDF } = await import('@/lib/generate-voucher'); + + const voucherData = { + bookingRef: pnr, + status: _booking.status || 'CONFIRMED', + passengers: passengers.map(p => ({ + fullName: p.name, + category: 'ADULT', + seat: p.seatNumber ? { + number: p.seatNumber, + coach: 'N/A', + seatClass: selectedSchedule?.selectedSeatClassName || 'Standard', + } : undefined, + })), + schedule: { + trainNumber: selectedSchedule?.trainNumber || 'N/A', + trainName: 'EDR Express', + origin: { + name: selectedSchedule?.origin || 'Origin', + code: 'ORG', + city: selectedSchedule?.origin || 'Origin', + }, + destination: { + name: selectedSchedule?.destination || 'Destination', + code: 'DST', + city: selectedSchedule?.destination || 'Destination', + }, + departureAt: selectedSchedule?.departureTime || new Date().toISOString(), + arrivalAt: selectedSchedule?.arrivalTime || new Date().toISOString(), + }, + totalMinor: _booking.totalMinor || passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0), + currency: 'ETB', + bookingType: 'ONE_WAY', + createdAt: new Date().toISOString(), + }; + + console.log('📄 Voucher data prepared:', voucherData); + await generateVoucherPDF(voucherData); + console.log('✅ Voucher generated successfully'); + } catch (error) { + console.error('❌ Failed to generate voucher:', error); + alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + setIsGeneratingVoucher(false); + } }; const handlePrintTickets = () => { @@ -131,47 +185,63 @@ export default function ConfirmationPage() { - {/* Trip Summary */} + {/* Trip Summary with QR Code */}
Scan at gate
Train number
-{selectedSchedule?.trainNumber}
-Route
-{selectedSchedule?.origin} → {selectedSchedule?.destination}
-Class
-{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}
+ + {/* Trip Details */} +Departure
-- {selectedSchedule?.departureTime && format(new Date(selectedSchedule.departureTime), 'PPp')} -
+Arrival
-- {selectedSchedule?.arrivalTime && format(new Date(selectedSchedule.arrivalTime), 'PPp')} -
-Duration
-{selectedSchedule?.duration}
+Train number
+{selectedSchedule?.trainNumber}
+Route
+{selectedSchedule?.origin} → {selectedSchedule?.destination}
+Class
+{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}
+Departure
++ {selectedSchedule?.departureTime && format(new Date(selectedSchedule.departureTime), 'PPp')} +
+Arrival
++ {selectedSchedule?.arrivalTime && format(new Date(selectedSchedule.arrivalTime), 'PPp')} +
+Duration
+{selectedSchedule?.duration}
+Passenger {index + 1}
-Ticket Number
-{ticketNumber}
-Date of Birth
-{format(new Date(passenger.dateOfBirth), 'PP')}
-Nationality
-{passenger.nationality}
-Seat
-{passenger.seatNumber || 'Will be assigned'}
-- 📱 Show this QR code at the gate for boarding -
+ {/* Ticket Info */} +Passenger {index + 1}
Scan at gate
+ +Ticket Number
+{ticketNumber}
+Date of Birth
+{format(new Date(passenger.dateOfBirth), 'PP')}
+Nationality
+{passenger.nationality}
+Seat
+{passenger.seatNumber || 'Will be assigned'}
++ Enter your booking reference (PNR) to view details +
+