From f2c037a5b7c9317c4d128bdb1c278fc35b0b20e2 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Fri, 17 Jul 2026 23:18:52 +0300 Subject: [PATCH 01/13] Package pricing issue resolution, passenger report, tickets filter updates --- .../src/modules/bookings/bookings.service.ts | 16 +- .../src/modules/reports/reports.controller.ts | 6 + .../src/modules/reports/reports.service.ts | 116 +++++++ .../src/modules/schedules/schedules.dto.ts | 7 +- .../src/modules/tickets/tickets.controller.ts | 3 + .../src/modules/tickets/tickets.service.ts | 26 +- .../src/app/reports/passengers/layout.tsx | 5 + .../src/app/reports/passengers/page.tsx | 315 ++++++++++++++++++ .../backoffice/src/app/tickets/page.tsx | 49 +-- .../src/components/layout/Sidebar.tsx | 5 +- 10 files changed, 510 insertions(+), 38 deletions(-) create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/passengers/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx 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 bd66d04d1..29a262722 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -320,8 +320,8 @@ export class BookingsService { id: b.id, bookingRef: b.bookingRef, status: b.status, - totalMinor: b.totalMinor, - currency: b.currency || null, + totalMinor: b.displayTotalMinor ?? b.totalMinor, + currency: b.displayCurrency ?? b.currency ?? null, displayCurrency: b.displayCurrency ?? null, displayTotalMinor: b.displayTotalMinor ?? null, adultCount: b.adultCount, @@ -578,7 +578,7 @@ export class BookingsService { const mappedPkg = pkgItems.map((b: any) => ({ id: b.id, bookingRef: b.bookingRef, status: b.status, - totalMinor: b.totalMinor, currency: b.currency || b.displayCurrency, + totalMinor: b.displayTotalMinor ?? b.totalMinor, currency: b.displayCurrency || b.currency, displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor, contactEmail: b.contactEmail, contactPhone: b.contactPhone, bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId, @@ -732,8 +732,8 @@ export class BookingsService { id: b.id, bookingRef: b.bookingRef, status: b.status, - totalMinor: b.totalMinor, - currency: b.currency || b.displayCurrency, + totalMinor: b.displayTotalMinor ?? b.totalMinor, + currency: b.displayCurrency || b.currency, displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor, contactEmail: b.contactEmail, @@ -1822,8 +1822,8 @@ export class BookingsService { id: pkgBooking.id, bookingRef: pkgBooking.bookingRef, status: pkgBooking.status, - totalMinor: pkgBooking.totalMinor, - currency: pkgBooking.currency || pkgBooking.displayCurrency, + totalMinor: pkgBooking.displayTotalMinor ?? pkgBooking.totalMinor, + currency: pkgBooking.displayCurrency || pkgBooking.currency, adultCount: pkgBooking.passengerCount, childCount: 0, displayCurrency: pkgBooking.displayCurrency, @@ -1852,7 +1852,7 @@ export class BookingsService { fullName: p.passengerName, category: 'ADULT', leg: 1, - fareMinor: Math.round(pkgBooking.totalMinor / pkgBooking.passengerCount), + fareMinor: Math.round((pkgBooking.displayTotalMinor ?? pkgBooking.totalMinor) / pkgBooking.passengerCount), verifaydaVerified: false, seat: null, })), diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 0c0d02ea4..5c1d69c51 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -18,6 +18,12 @@ export class ReportsController { return this.service.generateReport(dto); } + @Get('occupancy') + @ApiOperation({ summary: 'Occupancy report for a specific schedule' }) + getOccupancyReport(@Query('scheduleId') scheduleId: string) { + return this.service.getOccupancyBySchedule(scheduleId); + } + @Get(':reportId') @ApiOperation({ summary: 'Get report by ID' }) getReport(@Param('reportId') reportId: string) { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index e37cac0d3..12f48b41f 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -191,6 +191,122 @@ export class ReportsService { }; } + async getOccupancyBySchedule(scheduleId: string) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + include: { + originStation: true, + destinationStation: true, + train: true, + coachAssignments: { + include: { + coach: { + include: { + coachType: true, + seats: { select: { id: true } }, + }, + }, + }, + }, + bookings: { + where: { status: { in: ['CONFIRMED', 'BOARDED'] } }, + include: { + seats: { + include: { + seat: { include: { coach: { include: { coachType: true } } } }, + }, + }, + }, + }, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + }, + }); + + if (!schedule) return null; + + const totalSeats = (schedule as any).coachAssignments.reduce((s: number, a: any) => s + a.coach.seats.length, 0); + const allBookingSeats = (schedule as any).bookings.flatMap((b: any) => b.seats); + const totalPassengers = allBookingSeats.length; + const occupancyRate = totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0; + + // Per-coach breakdown + const coachMap = new Map(); + for (const assignment of (schedule as any).coachAssignments) { + const c = assignment.coach; + coachMap.set(c.id, { + coachNumber: c.number, + coachType: (c as any).coachType?.name ?? 'Unknown', + totalSeats: c.seats.length, + booked: 0, + }); + } + for (const bs of allBookingSeats) { + const coachId = bs.seat?.coachId; + if (coachId && coachMap.has(coachId)) coachMap.get(coachId)!.booked++; + } + const byCoach = [...coachMap.values()].map(c => ({ + ...c, + occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0, + })); + + // Per-origin station breakdown (using booking's originStationId) + const originMap = new Map(); + for (const booking of (schedule as any).bookings) { + const stationId = booking.originStationId ?? schedule.originStationId; + const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name + ?? (schedule as any).originStation?.name + ?? stationId; + if (!originMap.has(stationId)) originMap.set(stationId, { stationName, passengers: 0 }); + originMap.get(stationId)!.passengers += booking.seats.length; + } + const byOrigin = [...originMap.values()].sort((a, b) => b.passengers - a.passengers); + + // Per-destination station breakdown + const destMap = new Map(); + for (const booking of (schedule as any).bookings) { + const stationId = booking.destinationStationId ?? schedule.destinationStationId; + const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name + ?? (schedule as any).destinationStation?.name + ?? stationId; + if (!destMap.has(stationId)) destMap.set(stationId, { stationName, passengers: 0 }); + destMap.get(stationId)!.passengers += booking.seats.length; + } + const byDestination = [...destMap.values()].sort((a, b) => b.passengers - a.passengers); + + // Per-class breakdown + const classMap = new Map(); + for (const assignment of (schedule as any).coachAssignments) { + const typeName = (assignment.coach as any).coachType?.name ?? 'Unknown'; + if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 }); + classMap.get(typeName)!.totalSeats += assignment.coach.seats.length; + } + for (const bs of allBookingSeats) { + const typeName = bs.seat?.coach?.coachType?.name ?? 'Unknown'; + if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 }); + classMap.get(typeName)!.booked++; + } + const byClass = [...classMap.values()].map(c => ({ + ...c, + occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0, + })); + + return { + schedule: { + id: schedule.id, + trainName: (schedule as any).train?.name ?? (schedule as any).train?.number, + origin: (schedule as any).originStation?.name, + destination: (schedule as any).destinationStation?.name, + departureAt: schedule.departureAt, + arrivalAt: schedule.arrivalAt, + }, + summary: { totalSeats, totalPassengers, occupancyRate }, + byCoach, + byClass, + byOrigin, + byDestination, + }; + } + async getReport(reportId: string) { return this.prisma.operationalReport.findUnique({ where: { id: reportId } }); } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 3844e57cf..d5209936f 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -54,11 +54,16 @@ export class CreateScheduleDto { plannedTimes?: PlannedStopTimeDto[]; } +export class CoachAssignmentDto { + @ApiProperty({ example: 'coach-uuid' }) @IsString() coachId: string; + @ApiProperty({ example: 1 }) @IsInt() @Min(1) positionNumber: number; +} + export class UpdateScheduleDto { @ApiPropertyOptional({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsOptional() @IsDateString() departureAt?: string; @ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string; @ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus; - @ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>; + @ApiPropertyOptional({ type: [CoachAssignmentDto], description: 'List of coaches to assign' }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => CoachAssignmentDto) coaches?: CoachAssignmentDto[]; @ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean; } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index b45b93a33..d82b24f71 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -53,6 +53,7 @@ export class TicketsController { @ApiQuery({ name: 'originStationId', required: false }) @ApiQuery({ name: 'destinationStationId', required: false }) @ApiQuery({ name: 'arrivalDate', required: false }) + @ApiQuery({ name: 'departureDate', required: false }) @ApiQuery({ name: 'dateFrom', required: false }) @ApiQuery({ name: 'dateTo', required: false }) @ApiQuery({ name: 'coachId', required: false }) @@ -64,6 +65,7 @@ export class TicketsController { @Query('originStationId') originStationId?: string, @Query('destinationStationId') destinationStationId?: string, @Query('arrivalDate') arrivalDate?: string, + @Query('departureDate') departureDate?: string, @Query('dateFrom') dateFrom?: string, @Query('dateTo') dateTo?: string, @Query('coachId') coachId?: string, @@ -76,6 +78,7 @@ export class TicketsController { originStationId, destinationStationId, arrivalDate, + departureDate, dateFrom, dateTo, coachId, 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 2769620d1..ada0b1ed6 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -27,7 +27,7 @@ export class TicketsService { @InjectDataSource() private readonly dataSource: DataSource, ) {} - async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) { + async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; departureDate?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) { const where: any = {}; if (filters.search) { where.OR = [ @@ -41,10 +41,16 @@ export class TicketsService { where.status = filters.status; } if (filters.originStationId) { - where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } }; + where.booking = { ...where.booking, originStationId: filters.originStationId }; } if (filters.destinationStationId) { - where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } }; + where.booking = { ...where.booking, destinationStationId: filters.destinationStationId }; + } + if (filters.departureDate) { + const start = new Date(filters.departureDate); + const end = new Date(filters.departureDate); + end.setDate(end.getDate() + 1); + where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, departureAt: { gte: start, lt: end } } }; } if (filters.arrivalDate) { const start = new Date(filters.arrivalDate); @@ -70,7 +76,7 @@ export class TicketsService { include: { booking: { include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, returnSchedule: { include: { originStation: true, destinationStation: true } }, passenger: { include: { travelerProfiles: true } }, seats: { include: { seat: { include: { coach: true } } } }, @@ -146,6 +152,18 @@ export class TicketsService { contactPhone: t.booking?.contactPhone, returnSchedule: t.booking?.returnSchedule ?? null, seats: t.booking?.seats ?? [], + originStation: (() => { + const id = t.booking?.originStationId; + if (!id) return t.booking?.schedule?.originStation ?? null; + const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id); + return stop?.station ?? t.booking?.schedule?.originStation ?? null; + })(), + destinationStation: (() => { + const id = t.booking?.destinationStationId; + if (!id) return t.booking?.schedule?.destinationStation ?? null; + const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id); + return stop?.station ?? t.booking?.schedule?.destinationStation ?? null; + })(), }, schedule: t.booking?.schedule, seat: t.seat ? { diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/layout.tsx new file mode 100644 index 000000000..eb0af3572 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../../dashboard/layout'; + +export default function PassengersLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx new file mode 100644 index 000000000..8ff54da6a --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -0,0 +1,315 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Users, Armchair, TrendingUp, Train, Download } from 'lucide-react'; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'; +import { apiClient } from '@/lib/api-client'; +import { formatDateTime } from '@/lib/utils'; +import ActionButton from '@/components/ui/ActionButton'; + +const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4']; + +function StatCard({ label, value, sub, icon: Icon, color }: { label: string; value: string | number; sub?: string; icon: any; color: string }) { + return ( +
+
+

{label}

+
+
+

{value}

+ {sub &&

{sub}

} +
+ ); +} + +export default function PassengersReportPage() { + const [scheduleId, setScheduleId] = useState(''); + + const { data: schedules = [] } = useQuery({ + queryKey: ['schedules-list'], + queryFn: () => apiClient.get('/schedules'), + select: (d: any) => d?.items ?? (Array.isArray(d) ? d : []), + }); + + const { data, isFetching } = useQuery({ + queryKey: ['occupancy-report', scheduleId], + queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`), + enabled: !!scheduleId, + }); + + const report = data as any; + + const doExport = () => { + if (!report) return; + const rows = [ + ['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy %'], + ...report.byCoach.map((c: any) => [c.coachNumber, c.coachType, c.totalSeats, c.booked, c.occupancyRate]), + ]; + const csv = rows.map(r => r.map((v: any) => `"${v}"`).join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `occupancy-${scheduleId}-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+

Passengers Report

+

Select a schedule to view passenger occupancy breakdown

+
+ + {/* Schedule Selector */} +
+
+
+ + +
+ {isFetching &&

Loading…

} + {report && ( + + Export CSV + + )} +
+
+ + {isFetching && ( +
Loading passengers data…
+ )} + + {report && ( + <> + {/* Schedule Info */} +
+
+ +
+
+

{report.schedule.trainName}

+

+ {report.schedule.origin} → {report.schedule.destination} · Departure: {formatDateTime(report.schedule.departureAt)} +

+
+
+ + {/* Summary Cards */} +
+ + + +
+ +
+ {/* By Coach */} +
+

Occupancy by Coach

+ {report.byCoach.length > 0 ? ( + <> + + + + `${v}%`} tick={{ fontSize: 11 }} /> + `Coach ${v}`} /> + [`${v}%`, 'Occupancy']} /> + + {report.byCoach.map((_: any, i: number) => ( + + ))} + + + + + + + + + + + + + + + {report.byCoach.map((c: any, i: number) => ( + + + + + + + + ))} + +
CoachTypeBookedTotalRate
Coach {c.coachNumber}{c.coachType}{c.booked}{c.totalSeats}{c.occupancyRate}%
+ + ) : ( +

No coach data

+ )} +
+ + {/* By Class */} +
+

Occupancy by Class

+ {report.byClass.length > 0 ? ( + <> + + + + + `${v}%`} tick={{ fontSize: 11 }} /> + [`${v}%`, 'Occupancy']} /> + + {report.byClass.map((_: any, i: number) => ( + + ))} + + + + + + + + + + + + + + {report.byClass.map((c: any, i: number) => ( + + + + + + + ))} + +
ClassBookedTotalRate
{c.className}{c.booked}{c.totalSeats}{c.occupancyRate}%
+ + ) : ( +

No class data

+ )} +
+ + {/* By Origin */} +
+

Passengers by Boarding Station

+ {report.byOrigin.length > 0 ? ( + + + + + + + + + + {report.byOrigin.map((o: any, i: number) => { + const pct = report.summary.totalPassengers > 0 + ? ((o.passengers / report.summary.totalPassengers) * 100).toFixed(1) + : '0'; + return ( + + + + + + ); + })} + +
StationPassengersShare
+
+
+ {o.stationName} +
+
{o.passengers}{pct}%
+ ) : ( +

No boarding station data

+ )} +
+ + {/* By Destination */} +
+

Passengers by Alighting Station

+ {report.byDestination.length > 0 ? ( + + + + + + + + + + {report.byDestination.map((d: any, i: number) => { + const pct = report.summary.totalPassengers > 0 + ? ((d.passengers / report.summary.totalPassengers) * 100).toFixed(1) + : '0'; + return ( + + + + + + ); + })} + +
StationPassengersShare
+
+
+ {d.stationName} +
+
{d.passengers}{pct}%
+ ) : ( +

No alighting station data

+ )} +
+
+ + )} + + {!report && !isFetching && scheduleId && ( +
No data found for this schedule.
+ )} + + {!scheduleId && ( +
+ +

Select a schedule above to load the occupancy report

+
+ )} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index 21b6e6253..1105e5b67 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -15,7 +15,7 @@ import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils import { useAuthStore } from '@/lib/auth-store'; export default function TicketsPage() { - const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' }); + const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', departureDate: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' }); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [ticketToDelete, setTicketToDelete] = useState(null); const [deleteError, setDeleteError] = useState(null); @@ -71,6 +71,7 @@ export default function TicketsPage() { status: filters.status || undefined, originStationId: filters.originStationId || undefined, destinationStationId: filters.destinationStationId || undefined, + departureDate: filters.departureDate || undefined, arrivalDate: filters.arrivalDate || undefined, dateFrom: filters.dateFrom || undefined, dateTo: filters.dateTo || undefined, @@ -358,11 +359,13 @@ export default function TicketsPage() { render: (ticket: any) => { const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT'; const returnDeparture = ticket.booking?.returnSchedule?.departureAt; - + const origin = ticket.booking?.originStation?.name || ticket.schedule?.originStation?.name || 'N/A'; + const destination = ticket.booking?.destinationStation?.name || ticket.schedule?.destinationStation?.name || 'N/A'; + return (
- {ticket.schedule?.originStation?.name || 'N/A'} → {ticket.schedule?.destinationStation?.name || 'N/A'} + {origin} → {destination}
{!isRoundTrip ? ( @@ -551,7 +554,7 @@ export default function TicketsPage() { Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'}
)} -
+
- + setFilters({ ...filters, arrivalDate: e.target.value })} + value={filters.departureDate} + onChange={(e) => setFilters({ ...filters, departureDate: e.target.value })} />
+
+ + +
{showExtraFilters && ( -
+
-
- - -
- - + + diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 62fa655c7..00e594601 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -121,8 +121,9 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Analytics & Reports', items: [ - { name: 'Overall', href: '/reports', icon: BarChart3, permission: PERMS.reports.view }, - { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view }, + { name: 'Overall', href: '/reports', icon: BarChart3, permission: PERMS.reports.view }, + { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view }, + { name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view }, // { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view }, ] }, From eee14a5051aa508d03585681eb65bfbfb7c9d502 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Fri, 17 Jul 2026 23:48:49 +0300 Subject: [PATCH 02/13] Package pricing issue resolution, passenger report updates --- .../src/modules/reports/reports.controller.ts | 4 ++-- .../portal/src/app/booking/seats/page.tsx | 9 +++++---- .../portal/src/app/packages/[id]/page.tsx | 13 +++++-------- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 5c1d69c51..a4da5b208 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -18,8 +18,8 @@ export class ReportsController { return this.service.generateReport(dto); } - @Get('occupancy') - @ApiOperation({ summary: 'Occupancy report for a specific schedule' }) + @Get('passengers') + @ApiOperation({ summary: 'Passengers report for a specific schedule' }) getOccupancyReport(@Query('scheduleId') scheduleId: string) { return this.service.getOccupancyBySchedule(scheduleId); } 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 b38e3aee9..0af9d90bc 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 @@ -949,10 +949,9 @@ export default function SeatsPage() { return; } - // No fare change — but for package bookings still sync the tier price to the - // actual berth fare (handles the case where the first seat picked matches the - // stored price but we still want it explicitly confirmed). - if (isPackageBooking && packageId && newFare !== packageTierPriceMinor) { + // No fare change — for package bookings still sync the tier price to the + // actual berth fare so the correct amount is always stored. + if (isPackageBooking && packageId) { setPackageContext( packageId, priceTierId ?? '', @@ -962,6 +961,8 @@ export default function SeatsPage() { packageDepartureStationName ?? undefined, ); } + } else if (isPackageBooking && packageId) { + // newFare is null (seat class data unavailable) — leave stored price as-is. } commitSeatAssignment(seatId); diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx index 067d93a1b..2c5123def 100644 --- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx @@ -401,7 +401,6 @@ const PKG_CHILDREN_PER_ADULT = 5; function PassengerCountModal({ tier, - minPriceMinor, onClose, onConfirm, loading, @@ -410,7 +409,6 @@ function PassengerCountModal({ stations, }: { tier: PriceTier; - minPriceMinor: number; onClose: () => void; onConfirm: (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => void; loading: boolean; @@ -423,11 +421,9 @@ function PassengerCountModal({ const [departureStationId, setDepartureStationId] = useState(''); const [showStationError, setShowStationError] = useState(false); const remaining = tier.availableSeats - tier.bookedSeats; - // First child per adult travels free (no seat); additional children pay full adult fare const freeChildren = Math.min(childCount, adultCount); const paidChildren = Math.max(0, childCount - adultCount); - // Only paid children need seats; free children share with an adult - const totalMinor = (adultCount * minPriceMinor + paidChildren * minPriceMinor) * priceMultiplier; + const totalMinor = (adultCount * tier.priceMinor + paidChildren * tier.priceMinor) * priceMultiplier; return ( <> @@ -444,7 +440,7 @@ function PassengerCountModal({

Coach type

{tier.seatClass?.coachType?.name ?? tier.label.trim()}

-

{remaining} seats remaining · from {formatPrice(minPriceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)

+

{remaining} seats remaining · from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)

@@ -646,6 +642,8 @@ export default function PackageDetailPage() { ); // Store per-adult tier price (×1 leg); review page applies round-trip multiplier and child pricing + // Store the group's cheapest tier price as a placeholder — the actual berth fare + // (Upper/Middle/Lower) will be resolved and saved when the user picks a seat. setPackageContext(id, representativeTier.id, representativeTier.priceMinor, pkg.name, departureStationId, departureStationName); router.push("/booking/passengers"); @@ -696,7 +694,6 @@ export default function PackageDetailPage() { {passengerModalOpen && representativeTier && ( { setPassengerModalOpen(false); setBookingContextError(null); }} onConfirm={handleBookNow} loading={bookingContextLoading} @@ -896,7 +893,7 @@ export default function PackageDetailPage() { {/* ── Sidebar — desktop ── */}
-
+
{ setSelectedCoachTypeId(coachTypeId); setPassengerModalOpen(true); }} From 8eb0582e4ee7662ae9ae1d83ce7beda5bf9c5a4f Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 00:34:12 +0300 Subject: [PATCH 03/13] Dashboard, seats report, package pricing updates --- .../src/modules/bookings/bookings.service.ts | 22 +++++++++++++++++ .../modules/bookings/guest-booking.service.ts | 19 +++++++++++++++ .../modules/dashboard/dashboard.controller.ts | 5 ++-- .../modules/dashboard/dashboard.service.ts | 4 +++- .../backoffice/src/app/dashboard/page.tsx | 3 ++- .../backoffice/src/app/reports/seats/page.tsx | 24 +++++++++++++++++-- .../src/components/layout/Header.tsx | 9 ++++++- .../backoffice/src/lib/api/dashboard.ts | 1 + 8 files changed, 80 insertions(+), 7 deletions(-) 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 29a262722..a0994d1be 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -866,6 +866,15 @@ export class BookingsService { resolvedTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB) : dto.reviewedTotalMinor; + // For package bookings where per-seat fares weren't supplied, back-derive the + // per-seat fare from reviewedTotalMinor so BookingSeat.fareMinor reflects the + // actual berth price (Upper/Middle/Lower) rather than the tier's minimum price. + if (dto.packageId && seatedPassengers.length > 0) { + const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length); + passengersWithFares.forEach(p => { + if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare; + }); + } } else if (allFaresProvided) { // seatFareMinor is in display currency — sum is already the display total displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0); @@ -1050,6 +1059,19 @@ export class BookingsService { totalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB) : dto.reviewedTotalMinor; + // For package bookings where per-seat fares weren't supplied, back-derive the + // per-leg per-seat fare from reviewedTotalMinor so BookingSeat.fareMinor reflects + // the actual berth price (Upper/Middle/Lower) rather than the tier's minimum price. + if (dto.packageId) { + const seatedCount = passengersData.filter(p => p.outboundSeatId).length; + if (seatedCount > 0) { + const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2)); + passengersWithFares.forEach(p => { + if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg; + if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg; + }); + } + } } else if (allRTFaresProvided && !dto.packageId) { // seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0); 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 821db5177..43470c160 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 @@ -231,6 +231,14 @@ export class GuestBookingService { let resolvedTotalMinor: number; if (dto.reviewedTotalMinor != null) { displayTotalMinor = dto.reviewedTotalMinor; + // For package bookings, back-derive per-seat fareMinor from reviewedTotalMinor + // so BookingSeat records store the actual berth price, not the tier minimum. + if (isPackageOneway && seatedPassengers.length > 0) { + const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length); + passengersWithFares.forEach(p => { + if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare; + }); + } } else if (allFaresProvided) { displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0); } else { @@ -515,6 +523,17 @@ export class GuestBookingService { totalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB) : displayTotalMinor; + // For package bookings, back-derive per-leg per-seat fareMinor from reviewedTotalMinor. + if (isPackageRoundTrip) { + const seatedCount = passengersData.filter(p => p.seatId).length; + if (seatedCount > 0) { + const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2)); + passengersWithFares.forEach(p => { + if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg; + if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg; + }); + } + } } else if (allRTFaresProvided && !isPackageRoundTrip) { // seatFareMinor/returnSeatFareMinor are display-currency — sum is already display total displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0); diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts index 776781ddd..eb7bd33b6 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts @@ -2,7 +2,8 @@ import { Controller, Get, Param, SetMetadata, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { DashboardService } from './dashboard.service'; import { JwtGuard } from '../../common/jwt.guard'; -import { PassengerAdmin } from '../../common/passenger-guards'; +import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Dashboard') @Controller('dashboard') @@ -10,7 +11,7 @@ export class DashboardController { constructor(private service: DashboardService) {} @Get('backoffice-stats') - @PassengerAdmin() + @PassengerStaff([PASSENGER_PERMS.dashboard.view, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Backoffice summary: totals and revenue by currency' }) getBackofficeStats() { return this.service.getBackofficeStats(); } diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts index aed950b73..62a1a6461 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts @@ -11,12 +11,13 @@ export class DashboardService { ) {} async getBackofficeStats() { - const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, revenueRows, packageRevenueRows] = + const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] = await Promise.all([ this.prisma.booking.count(), this.prisma.booking.count({ where: { packageId: { not: null } } }), this.prisma.ticket.count(), this.prisma.passenger.count(), + this.prisma.seat.count({ where: { status: 'BLOCKED' } }), this.prisma.$queryRaw<{ currency: string; total: bigint }[]>` SELECT COALESCE("displayCurrency"::text, "currency"::text) AS currency, @@ -56,6 +57,7 @@ export class DashboardService { totalPackageTickets, totalNormalTickets: totalTickets - totalPackageTickets, totalPassengers, + blockedSeatsCount, revenueByCurrency: toMap(revenueRows), packageRevenueByCurrency: toMap(packageRevenueRows), }; diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index bc657106d..09f1bdaf7 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -99,7 +99,8 @@ function DashboardPageContent() { queryKey: ['backoffice-stats'], queryFn: dashboardApi.getBackofficeStats, retry: 1, - staleTime: 60000, + staleTime: 30000, + refetchInterval: 60000, }); const { data: paymentMethods } = useQuery({ diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx index ea2d65cf0..08b4f2462 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx @@ -2,8 +2,9 @@ import { useState, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Download, Armchair, CheckCircle, Clock, AlertCircle } from 'lucide-react'; +import { Download, Armchair, CheckCircle, Clock, AlertCircle, Ban } from 'lucide-react'; import { bookingsApi } from '@/lib/api'; +import { dashboardApi } from '@/lib/api/dashboard'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import { formatDateTime, formatCurrency } from '@/lib/utils'; @@ -46,6 +47,12 @@ export default function SeatStatusReportPage() { const [statusFilter, setStatusFilter] = useState<'ALL' | 'PAID' | 'UNPAID'>('ALL'); const [search, setSearch] = useState(''); + const { data: stats } = useQuery({ + queryKey: ['backoffice-stats'], + queryFn: dashboardApi.getBackofficeStats, + staleTime: 30000, + }); + const { data: bookingsData, isLoading } = useQuery({ queryKey: ['seat-report-bookings'], queryFn: () => bookingsApi.getAll({ pageSize: 1000 }), @@ -150,7 +157,7 @@ export default function SeatStatusReportPage() {
{/* Summary Cards */} -
+
@@ -189,6 +196,19 @@ export default function SeatStatusReportPage() {
+ +
+
+
+

Blocked Seats

+

+ {stats?.blockedSeatsCount ?? '—'} +

+

Globally blocked

+
+ +
+
{/* Filters */} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Header.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Header.tsx index f4547c69e..48f0cd0e5 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Header.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Header.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Bell, LogOut, Moon, Sun, ChevronDown, HelpCircle, KeyRound } from 'lucide-react'; +import { Bell, LogOut, Moon, Sun, ChevronDown, HelpCircle, KeyRound, ScanLine } from 'lucide-react'; import { useAuthStore } from '@/lib/auth-store'; import { useTheme } from '@/lib/theme-store'; import { useState } from 'react'; @@ -18,6 +18,13 @@ export default function Header() {

+ + +
diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts b/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts index 2a7a7f767..bff0eaf08 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts @@ -11,6 +11,7 @@ export const dashboardApi = { totalNormalTickets: number; totalPackageTickets: number; totalPassengers: number; + blockedSeatsCount: number; revenueByCurrency: { currency: string; totalMinor: number }[]; packageRevenueByCurrency: { currency: string; totalMinor: number }[]; }>('/dashboard/backoffice-stats'); From 06696350951a3c1d55c894ff7558693d42bafdfd Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 00:41:26 +0300 Subject: [PATCH 04/13] Dashboard updates --- .../backoffice/src/app/dashboard/page.tsx | 18 ++++++++++++++---- .../src/components/layout/Header.tsx | 9 +-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index 09f1bdaf7..2e5063fc0 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PERMS } from '@/lib/permissions'; -import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight } from 'lucide-react'; +import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight, ScanLine } from 'lucide-react'; import { dashboardApi } from '@/lib/api/dashboard'; import { apiClient } from '@/lib/api-client'; import { formatCurrency } from '@/lib/utils'; @@ -144,9 +144,19 @@ function DashboardPageContent() { return (
-
-

Dashboard

-

Welcome back! Here's your operational summary.

+
+
+

Dashboard

+

Welcome back! Here's your operational summary.

+
+ + + Boarding +
{statsError && ( diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Header.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Header.tsx index 48f0cd0e5..f4547c69e 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Header.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Header.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Bell, LogOut, Moon, Sun, ChevronDown, HelpCircle, KeyRound, ScanLine } from 'lucide-react'; +import { Bell, LogOut, Moon, Sun, ChevronDown, HelpCircle, KeyRound } from 'lucide-react'; import { useAuthStore } from '@/lib/auth-store'; import { useTheme } from '@/lib/theme-store'; import { useState } from 'react'; @@ -18,13 +18,6 @@ export default function Header() {

- - -
From 85de05cb40e3d1efe74ad3a4109eb32d0635ebe7 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Sat, 18 Jul 2026 09:55:09 +0300 Subject: [PATCH 05/13] feat: ( payments ) add merchant-order and booking/PNR payment status lookups --- .../payments/payment-client.service.ts | 29 +++++++ .../modules/payments/payments.controller.ts | 27 ++++++ .../src/modules/payments/payments.service.ts | 50 ++++++++++- .../src/modules/intents/intents.controller.ts | 44 +++++++++- .../src/modules/intents/intents.service.ts | 84 +++++++++++++++++++ 5 files changed, 231 insertions(+), 3 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts index c9264eeda..84ca92c02 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts @@ -12,8 +12,15 @@ import { PaymentIntentSnapshot, PaymentReferenceType, PaymentService, + ProviderStatus, } from "@edr/types"; +/** Side-by-side DB row + live provider status from the payment service diagnostic endpoints. */ +export interface PaymentDiagnostic { + db: Record | null; + provider: ProviderStatus | null; +} + /** * Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's * side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here; @@ -55,6 +62,28 @@ export class PaymentClientService { } } + /** + * GET /payments/diagnostic?… — DB intent row + live provider status for a domain reference, + * side by side. Returns { db: null, provider: null } when the payment service has no intent. + */ + async getDiagnosticByReference( + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise { + const query = new URLSearchParams({ + service: PaymentService.PASSENGER, + referenceType, + referenceId, + }); + try { + return await this.call("GET", `/payments/diagnostic?${query.toString()}`); + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 404) + return { db: null, provider: null }; + throw err; + } + } + /** * POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider (CAC Bank). * A wrong/expired OTP comes back as 400 from the payment service; surface that as a diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 9a0288656..88510f1a5 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -133,6 +133,33 @@ export class PaymentsController { return this.service.getIntentByBookingId(bookingId); } + @Get("status/:bookingRefOrId") + @SetMetadata("isPublic", true) + @ApiOperation({ + summary: "Get payment status by booking id or booking reference (PNR)", + description: + "Accepts either a booking UUID or a booking reference / PNR (e.g. EDR-20240001), " + + "resolves it to the booking, and returns the authoritative payment status pulled from " + + "the payment microservice.", + }) + getStatusByBookingRefOrId(@Param("bookingRefOrId") bookingRefOrId: string) { + return this.service.getIntentByBookingRefOrId(bookingRefOrId); + } + + @Get("diagnostic/:bookingRefOrId") + @SetMetadata("isPublic", true) + @ApiOperation({ + summary: + "Get { db, provider } by booking id or booking reference (PNR) — diagnostic", + description: + "Accepts a booking UUID or a booking reference / PNR (e.g. EDR-20240001), resolves it to " + + "the booking, and returns { db, provider }: the payment service's stored intent row and a " + + "live provider status query, side by side. Pure read — does not reconcile the booking.", + }) + getPaymentDiagnostic(@Param("bookingRefOrId") bookingRefOrId: string) { + return this.service.getPaymentDiagnosticByBookingRefOrId(bookingRefOrId); + } + @Post(":bookingId/confirm") @SetMetadata("isPublic", true) @ApiOperation({ diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index a9e4557f1..e57d94643 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -24,7 +24,10 @@ import { ForceConfirmDto, } from "./payments.dto"; import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto"; -import { PaymentClientService } from "./payment-client.service"; +import { + PaymentClientService, + PaymentDiagnostic, +} from "./payment-client.service"; import { CurrencyService } from "../currency/currency.service"; import { AuditService } from "../../common/audit.service"; import { rebaseUrlOrigin } from "../../common/utils/redirect-origin.util"; @@ -534,6 +537,51 @@ export class PaymentsService { }; } + /** + * Payment status by booking id (UUID) OR booking reference / PNR (e.g. EDR-20240001). + * Resolves the PNR to its booking id, then pulls the authoritative status from the payment + * microservice (via {@link getIntentByBookingId}). + */ + async getIntentByBookingRefOrId( + bookingRefOrId: string, + ): Promise { + const bookingId = await this.resolveBookingId(bookingRefOrId); + return this.getIntentByBookingId(bookingId); + } + + /** + * Diagnostic view by booking id (UUID) OR booking reference / PNR: the payment service's + * stored intent row and a live provider status query, side by side ({ db, provider }). + * Pure read — does not reconcile or confirm the booking. + */ + async getPaymentDiagnosticByBookingRefOrId( + bookingRefOrId: string, + ): Promise { + const bookingId = await this.resolveBookingId(bookingRefOrId); + return this.paymentClient.getDiagnosticByReference( + PaymentReferenceType.BOOKING, + bookingId, + ); + } + + /** Accept a booking UUID as-is; otherwise look the id up from its bookingRef/PNR. */ + private async resolveBookingId(bookingRefOrId: string): Promise { + const isUuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + bookingRefOrId, + ); + if (isUuid) return bookingRefOrId; + + const booking = await this.prisma.booking.findUnique({ + where: { bookingRef: bookingRefOrId }, + select: { id: true }, + }); + if (!booking) { + throw new NotFoundException(`Booking not found: ${bookingRefOrId}`); + } + return booking.id; + } + async getIntentByBookingId(bookingId: string): Promise { const local = await this.prisma.paymentIntent.findUnique({ where: { bookingId }, diff --git a/apps/edr-payment-api/src/modules/intents/intents.controller.ts b/apps/edr-payment-api/src/modules/intents/intents.controller.ts index 35f3461d2..dc47adf6c 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.controller.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.controller.ts @@ -8,8 +8,12 @@ import { Query, UseGuards, } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; -import { PaymentIntentSnapshot } from "@edr/types"; +import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; +import { + PaymentIntentSnapshot, + ProviderMethod, + ProviderStatus, +} from "@edr/types"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { InitiatePaymentRequestDto, @@ -17,6 +21,7 @@ import { } from "./dto/initiate-payment.dto"; import { ConfirmPaymentDto } from "./dto/confirm-payment.dto"; import { IntentsService } from "./intents.service"; +import { PaymentIntent } from "./entities/payment-intent.entity"; /** * Internal surface — called only by the domain apps (service-authenticated), never by @@ -68,6 +73,41 @@ export class IntentsController { ); } + @Get("diagnostic") + @ApiOperation({ + summary: "DB row + live provider status by domain reference (diagnostic)", + description: + "Returns { db, provider } for a domain reference (service + referenceType + referenceId): " + + "the active stored intent row and a live provider status query, side by side. Pure read — " + + "does not mutate the intent. `db` is null when no active intent exists for the reference.", + }) + async getDiagnosticByReference( + @Query() query: IntentReferenceQueryDto, + ): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> { + return this.intentsService.getDiagnosticByReference( + query.service, + query.referenceType, + query.referenceId, + ); + } + + @Get("by-merchant-order/:merchantOrderId") + @ApiOperation({ + summary: "DB row + live provider status by merchant order id (diagnostic)", + description: + "Returns { db, provider } for a provider-facing merchant order id (PSG-/FRT-…): the " + + "stored intent row and a live provider status query, side by side. Pure read — does not " + + "mutate the intent. `db` is null when no intent has this merchant order id; supply " + + "`?provider=` in that case so the provider can still be queried by merchant order id.", + }) + @ApiQuery({ name: "provider", enum: ProviderMethod, required: false }) + async getByMerchantOrderId( + @Param("merchantOrderId") merchantOrderId: string, + @Query("provider") provider?: ProviderMethod, + ): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> { + return this.intentsService.getByMerchantOrderId(merchantOrderId, provider); + } + @Post("intents/:id/confirm") @ApiOperation({ summary: "Confirm an OTP-based payment intent (e.g. CAC Bank)", diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index a14469dd1..5a751c3b5 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -304,6 +304,90 @@ export class IntentsService { return this.toSnapshot(await this.refreshIfStale(intent)); } + /** + * Diagnostic lookup by domain reference (service + referenceType + referenceId). Returns the + * stored intent AND a LIVE provider status query side by side — the reference-keyed twin of + * {@link getByMerchantOrderId}, used by the domain apps to resolve a booking/shipment without + * knowing the merchant order id. Pure read (no state-machine mutation). + * + * - `db`: the active stored intent for the reference, or `null` when none exists. + * - `provider`: the raw provider status response (queried using the intent's own provider), or + * `null` when there is no intent or the query fails. + */ + async getDiagnosticByReference( + service: PaymentService, + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> { + const intent = await this.intentsRepository.findActiveByReference( + service, + referenceType, + referenceId, + ); + const providerStatus = intent + ? await this.queryProviderForMerchantOrder( + intent.merchantOrderId, + intent, + undefined, + ) + : null; + return { db: intent ?? null, provider: providerStatus }; + } + + /** + * Diagnostic lookup by provider-facing merchant order id (PSG-/FRT-…). Returns the stored + * intent AND a LIVE provider status query side by side, so the caller can compare what the + * platform believes against what the provider currently reports. This is a pure read — it + * does NOT mutate the intent (no state-machine transition, no outbox event). + * + * - `db`: the full stored intent row, or `null` when no intent has this merchant order id. + * - `provider`: the raw provider status response. When there is a DB row its provider is + * used; when there is no DB row a `providerHint` must be supplied to know which provider + * to ask (the merchant-order prefix only identifies the service). `null` if the provider + * is unknown or the query fails. + */ + async getByMerchantOrderId( + merchantOrderId: string, + providerHint?: ProviderMethod, + ): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> { + const intent = + await this.intentsRepository.findByMerchantOrderId(merchantOrderId); + + const providerStatus = await this.queryProviderForMerchantOrder( + merchantOrderId, + intent, + providerHint, + ); + + return { db: intent ?? null, provider: providerStatus }; + } + + /** Best-effort live provider status for a merchant order id; never throws (returns null). */ + private async queryProviderForMerchantOrder( + merchantOrderId: string, + intent: PaymentIntent | null, + providerHint?: ProviderMethod, + ): Promise { + try { + if (intent) { + const provider = this.providers.get(intent.provider); + if (!provider) return null; + return await this.queryProviderStatus(intent); + } + // No DB row — fall back to the caller-supplied provider hint keyed on merchantOrderId. + if (!providerHint) return null; + const provider = this.providers.get(providerHint); + if (!provider) return null; + return await provider.queryStatus(merchantOrderId); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `provider status query failed for merchantOrderId ${merchantOrderId}: ${message}`, + ); + return null; + } + } + /** * Pull-side reconciliation: when a polled intent is non-terminal and stale, ask the * provider for the truth and run the answer through the state machine. The browser From 854177c93b61df6ba88caeb336a4148bf7872e20 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 11:30:43 +0300 Subject: [PATCH 06/13] Seat reservation unique constrains, package totalMinor updates --- .../migration.sql | 33 +++++++++++++++++++ apps/edr-passenger-api/prisma/schema.prisma | 3 +- .../src/modules/agents/agents.service.ts | 1 + .../src/modules/bookings/bookings.service.ts | 1 + .../modules/bookings/guest-booking.service.ts | 1 + .../src/modules/packages/packages.service.ts | 4 +-- .../src/modules/payments/payments.e2e-spec.ts | 1 + .../src/modules/search/search.service.ts | 8 ++--- .../src/modules/seats/seats.service.ts | 2 +- 9 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql new file mode 100644 index 000000000..2a9a1be65 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql @@ -0,0 +1,33 @@ +-- Make scheduleId non-nullable: backfill from the parent booking, then add NOT NULL. +UPDATE passenger."BookingSeat" bs +SET schedule_id = b.schedule_id +FROM passenger."Booking" b +WHERE bs.booking_id = b.id + AND bs.schedule_id IS NULL + AND bs.leg = 1; + +-- For leg=2 rows (return/transit), pull from the booking's returnScheduleId / leg2ScheduleId. +UPDATE passenger."BookingSeat" bs +SET schedule_id = COALESCE( + (b.return_schedule_id), + (b.leg2_schedule_id), + b.schedule_id +) +FROM passenger."Booking" b +WHERE bs.booking_id = b.id + AND bs.schedule_id IS NULL + AND bs.leg = 2; + +-- Catch any remaining NULLs (leg 3/4 from ROUND_TRIP_TRANSIT) using the booking's schedule. +UPDATE passenger."BookingSeat" bs +SET schedule_id = b.schedule_id +FROM passenger."Booking" b +WHERE bs.booking_id = b.id + AND bs.schedule_id IS NULL; + +-- Now enforce NOT NULL. +ALTER TABLE passenger."BookingSeat" ALTER COLUMN schedule_id SET NOT NULL; + +-- Add the unique constraint that is the actual double-booking guard. +CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" + ON passenger."BookingSeat"(schedule_id, seat_id); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index fb9db346f..601410fae 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -580,7 +580,7 @@ model BookingSeat { bookingId String seatId String leg Int @default(1) // 1=outbound/leg-1, 2=return/leg-2 - scheduleId String? // which schedule this seat belongs to + scheduleId String // which schedule this seat belongs to passengerName String dateOfBirth DateTime? passengerCategory PassengerCategory @default(ADULT) @@ -599,6 +599,7 @@ model BookingSeat { displayFareMinor Int? booking Booking @relation(fields: [bookingId], references: [id]) seat Seat @relation(fields: [seatId], references: [id]) + @@unique([scheduleId, seatId]) @@schema("passenger") } diff --git a/apps/edr-passenger-api/src/modules/agents/agents.service.ts b/apps/edr-passenger-api/src/modules/agents/agents.service.ts index 4ba7afcf1..57f33c5aa 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.service.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.service.ts @@ -85,6 +85,7 @@ export class AgentsService { seats: { create: dto.passengers.map(p => ({ seat: { connect: { id: p.seatId } }, + scheduleId: dto.scheduleId, passengerName: p.fullName, idDocumentType: p.idDocumentType as IdDocumentType | undefined, idDocumentNumber: p.idDocumentNumber 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 a0994d1be..ada6e2a78 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -910,6 +910,7 @@ export class BookingsService { seats: { create: passengersWithFares.map(p => ({ seat: { connect: { id: p.seatId } }, + scheduleId: dto.scheduleId, passengerName: p.passengerName, dateOfBirth: p.dateOfBirth, passengerCategory: p.category, 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 43470c160..17e3a585e 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 @@ -299,6 +299,7 @@ export class GuestBookingService { seats: { create: passengersWithFares.map((p) => ({ seat: { connect: { id: p.seatId } }, + scheduleId: dto.scheduleId, passengerName: p.passengerName, dateOfBirth: p.dateOfBirth, passengerCategory: p.category, diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index 9ddc1fb0a..1528b2967 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -440,8 +440,8 @@ export class PackagesService { passengerCount, adultCount, childCount, - totalMinor, - currency: 'ETB', + totalMinor: displayTotalMinor, + currency: displayCurrency, displayCurrency, displayTotalMinor, status: 'PENDING_PAYMENT', diff --git a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts index 5393696bf..058b2f9f6 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts @@ -118,6 +118,7 @@ describe("Payments E2E", () => { data: { bookingId: booking.id, seatId: seat.id, + scheduleId: schedule.id, passengerName: "Test Passenger", }, }); diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index a51dc0142..29be84adc 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -535,8 +535,8 @@ export class SearchService { discountMinor: fare.discountMinor, taxesFeesMinor: 0, loyaltyRedemptionMinor: loyaltyMinor, - totalMinor, - currency: 'ETB', + totalMinor: displayTotalMinor, + currency: displayCurrency, displayCurrency, displayTotalMinor, }; @@ -653,8 +653,8 @@ export class SearchService { passengers: passengerLines, subtotalMinor, discountMinor, - totalMinor, - currency: 'ETB', + totalMinor: displayTotalMinor, + currency: displayCurrency, displayCurrency, displayTotalMinor, }; diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 963cc571d..3d87c8331 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -1020,7 +1020,7 @@ export class SeatsService { where: { OR: [ { scheduleId: schedule.id }, - { scheduleId: null, booking: { scheduleId: schedule.id } }, + { booking: { scheduleId: schedule.id } }, ], booking: { status: { in: ['CONFIRMED', 'BOARDED'] } }, }, From af39ab03947729b3bc62ba7a7aaf22a33aa13e88 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 11:44:11 +0300 Subject: [PATCH 07/13] Migration issue resolution --- .../migration.sql | 4 + .../migration.sql | 4 + .../migration.sql | 4 + .../migration.sql | 33 ++ booking-checker.html | 530 ++++++++++++++++++ booking-extractor.html | 256 +++++++++ booking-proxy.mjs | 53 ++ ticket-extractor.html | 239 ++++++++ 8 files changed, 1123 insertions(+) create mode 100644 booking-checker.html create mode 100644 booking-extractor.html create mode 100644 booking-proxy.mjs create mode 100644 ticket-extractor.html diff --git a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql index 2608b4cbf..83a49ef67 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql @@ -1,3 +1,4 @@ +<<<<<<< Updated upstream -- Remove duplicate JourneySegment rows, keeping the one with the lowest id -- (earliest created) per (scheduleId, seatId, departureStationId) group. -- This cleans up any existing double-bookings before the unique index is applied. @@ -16,3 +17,6 @@ AND "seatId" IS NOT NULL; CREATE UNIQUE INDEX "JourneySegment_scheduleId_seatId_departureStationId_key" ON passenger."JourneySegment" ("scheduleId", "seatId", "departureStationId") WHERE "seatId" IS NOT NULL; +======= +-- Migration already applied directly to the database. +>>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql index 375f40f7e..e04c02621 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql @@ -1,3 +1,4 @@ +<<<<<<< Updated upstream -- Rename StopStatus enum values to reflect segment-level booking lifecycle. -- UPCOMING → OPEN (segment is bookable) -- APPROACHING → CHECKIN_CLOSED (within check-in cutoff, no new bookings) @@ -10,3 +11,6 @@ ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'CURRENT' TO 'BOARDED'; -- Add per-route check-in window. Each route can define how many minutes before -- a stop's planned departure check-in is closed. Defaults to 30 minutes. ALTER TABLE "passenger"."Route" ADD COLUMN "checkinMinutesBefore" INTEGER NOT NULL DEFAULT 30; +======= +-- Migration already applied directly to the database. +>>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql index 0b0995292..5fd9ae0f5 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql @@ -1 +1,5 @@ +<<<<<<< Updated upstream ALTER TABLE "passenger"."RouteStop" ADD COLUMN "checkinMinutesBefore" INTEGER; +======= +-- Migration already applied directly to the database. +>>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql index 2a9a1be65..8553fc81b 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql @@ -1,13 +1,21 @@ -- Make scheduleId non-nullable: backfill from the parent booking, then add NOT NULL. UPDATE passenger."BookingSeat" bs +<<<<<<< Updated upstream SET schedule_id = b.schedule_id FROM passenger."Booking" b WHERE bs.booking_id = b.id AND bs.schedule_id IS NULL +======= +SET "scheduleId" = b."scheduleId" +FROM passenger."Booking" b +WHERE bs."bookingId" = b.id + AND bs."scheduleId" IS NULL +>>>>>>> Stashed changes AND bs.leg = 1; -- For leg=2 rows (return/transit), pull from the booking's returnScheduleId / leg2ScheduleId. UPDATE passenger."BookingSeat" bs +<<<<<<< Updated upstream SET schedule_id = COALESCE( (b.return_schedule_id), (b.leg2_schedule_id), @@ -31,3 +39,28 @@ ALTER TABLE passenger."BookingSeat" ALTER COLUMN schedule_id SET NOT NULL; -- Add the unique constraint that is the actual double-booking guard. CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" ON passenger."BookingSeat"(schedule_id, seat_id); +======= +SET "scheduleId" = COALESCE( + b."returnScheduleId", + b."leg2ScheduleId", + b."scheduleId" +) +FROM passenger."Booking" b +WHERE bs."bookingId" = b.id + AND bs."scheduleId" IS NULL + AND bs.leg = 2; + +-- Catch any remaining NULLs using the booking's schedule. +UPDATE passenger."BookingSeat" bs +SET "scheduleId" = b."scheduleId" +FROM passenger."Booking" b +WHERE bs."bookingId" = b.id + AND bs."scheduleId" IS NULL; + +-- Now enforce NOT NULL. +ALTER TABLE passenger."BookingSeat" ALTER COLUMN "scheduleId" SET NOT NULL; + +-- Add the unique constraint that is the actual double-booking guard. +CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" + ON passenger."BookingSeat"("scheduleId", "seatId"); +>>>>>>> Stashed changes diff --git a/booking-checker.html b/booking-checker.html new file mode 100644 index 000000000..9d2b7ded4 --- /dev/null +++ b/booking-checker.html @@ -0,0 +1,530 @@ + + + + + + EDR Booking Checker + + + + +

EDR Booking Checker

+ +
+ + +

No trailing slash. e.g. https://api.edrsc.com

+
+ +
+ + +

Supports any format: one per line, comma-separated, or {REF1,REF2} groups.

+ +
+ + + + +
+
+
+
+
+ +
+
+ +
+ + + + + +
+
+ + + + + + + +
+ +
+ + + + + + + + +
JourneyDuplicate Bookings
+
+
+ + + + + diff --git a/booking-extractor.html b/booking-extractor.html new file mode 100644 index 000000000..844c98b2c --- /dev/null +++ b/booking-extractor.html @@ -0,0 +1,256 @@ + + + + + + EDR Booking Extractor + + + + +

EDR Booking Extractor

+ +
+ +
+ + Drop bookings.json here or click to browse +
+

Accepts a JSON array of bookings or an object with a bookings key.

+
+ + + +
+
+ +
+
+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + +
#Booking RefStatusBooking TypePhoneEmailDepartureOriginDestinationPassenger(s)Coach - SeatPayment MethodPayment StatusTotal (DJF)Created At
+
+
+ + + + + diff --git a/booking-proxy.mjs b/booking-proxy.mjs new file mode 100644 index 000000000..27f9248ee --- /dev/null +++ b/booking-proxy.mjs @@ -0,0 +1,53 @@ +import http from 'http'; +import https from 'https'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const PORT = 8080; +const __dir = path.dirname(fileURLToPath(import.meta.url)); + +const server = http.createServer((req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + // Serve any .html file in the same directory + if (req.url === '/' || req.url.endsWith('.html')) { + const filename = req.url === '/' ? 'booking-checker.html' : req.url.slice(1); + const filepath = path.join(__dir, filename); + if (fs.existsSync(filepath)) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + fs.createReadStream(filepath).pipe(res); + } else { + res.writeHead(404); res.end('Not found'); + } + return; + } + + // Proxy /proxy?url= + if (req.url.startsWith('/proxy?url=')) { + const target = decodeURIComponent(req.url.slice('/proxy?url='.length)); + const parsed = new URL(target); + const mod = parsed.protocol === 'https:' ? https : http; + const options = { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: req.method, + headers: { ...req.headers, host: parsed.hostname }, + }; + const proxy = mod.request(options, (apiRes) => { + res.writeHead(apiRes.statusCode, apiRes.headers); + apiRes.pipe(res); + }); + proxy.on('error', (e) => { res.writeHead(502); res.end(e.message); }); + req.pipe(proxy); + return; + } + + res.writeHead(404); res.end(); +}); + +server.listen(PORT, () => console.log(`Booking checker: http://localhost:${PORT}/booking-checker.html`)); diff --git a/ticket-extractor.html b/ticket-extractor.html new file mode 100644 index 000000000..17be60576 --- /dev/null +++ b/ticket-extractor.html @@ -0,0 +1,239 @@ + + + + + + EDR Ticket Extractor + + + + +

EDR Ticket Extractor

+ +
+ +
+ + Drop tickets.json here or click to browse +
+

Accepts a JSON array of tickets or an object with a tickets key.

+
+ + + +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + +
#Ticket No.Booking RefPassengerPhoneEmailJourney TypeOriginDestinationSeat ClassCoachSeat
+
+
+ + + + + From 282bb949bae8525bb460914313ccc1757618b8de Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 11:45:35 +0300 Subject: [PATCH 08/13] Migration issue resolution --- .../migration.sql | 4 +++ .../migration.sql | 4 +++ .../migration.sql | 4 +++ .../migration.sql | 33 +++++++++++++++++++ 4 files changed, 45 insertions(+) diff --git a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql index 2608b4cbf..83a49ef67 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql @@ -1,3 +1,4 @@ +<<<<<<< Updated upstream -- Remove duplicate JourneySegment rows, keeping the one with the lowest id -- (earliest created) per (scheduleId, seatId, departureStationId) group. -- This cleans up any existing double-bookings before the unique index is applied. @@ -16,3 +17,6 @@ AND "seatId" IS NOT NULL; CREATE UNIQUE INDEX "JourneySegment_scheduleId_seatId_departureStationId_key" ON passenger."JourneySegment" ("scheduleId", "seatId", "departureStationId") WHERE "seatId" IS NOT NULL; +======= +-- Migration already applied directly to the database. +>>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql index 375f40f7e..e04c02621 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql @@ -1,3 +1,4 @@ +<<<<<<< Updated upstream -- Rename StopStatus enum values to reflect segment-level booking lifecycle. -- UPCOMING → OPEN (segment is bookable) -- APPROACHING → CHECKIN_CLOSED (within check-in cutoff, no new bookings) @@ -10,3 +11,6 @@ ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'CURRENT' TO 'BOARDED'; -- Add per-route check-in window. Each route can define how many minutes before -- a stop's planned departure check-in is closed. Defaults to 30 minutes. ALTER TABLE "passenger"."Route" ADD COLUMN "checkinMinutesBefore" INTEGER NOT NULL DEFAULT 30; +======= +-- Migration already applied directly to the database. +>>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql index 0b0995292..5fd9ae0f5 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql @@ -1 +1,5 @@ +<<<<<<< Updated upstream ALTER TABLE "passenger"."RouteStop" ADD COLUMN "checkinMinutesBefore" INTEGER; +======= +-- Migration already applied directly to the database. +>>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql index 2a9a1be65..8553fc81b 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql @@ -1,13 +1,21 @@ -- Make scheduleId non-nullable: backfill from the parent booking, then add NOT NULL. UPDATE passenger."BookingSeat" bs +<<<<<<< Updated upstream SET schedule_id = b.schedule_id FROM passenger."Booking" b WHERE bs.booking_id = b.id AND bs.schedule_id IS NULL +======= +SET "scheduleId" = b."scheduleId" +FROM passenger."Booking" b +WHERE bs."bookingId" = b.id + AND bs."scheduleId" IS NULL +>>>>>>> Stashed changes AND bs.leg = 1; -- For leg=2 rows (return/transit), pull from the booking's returnScheduleId / leg2ScheduleId. UPDATE passenger."BookingSeat" bs +<<<<<<< Updated upstream SET schedule_id = COALESCE( (b.return_schedule_id), (b.leg2_schedule_id), @@ -31,3 +39,28 @@ ALTER TABLE passenger."BookingSeat" ALTER COLUMN schedule_id SET NOT NULL; -- Add the unique constraint that is the actual double-booking guard. CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" ON passenger."BookingSeat"(schedule_id, seat_id); +======= +SET "scheduleId" = COALESCE( + b."returnScheduleId", + b."leg2ScheduleId", + b."scheduleId" +) +FROM passenger."Booking" b +WHERE bs."bookingId" = b.id + AND bs."scheduleId" IS NULL + AND bs.leg = 2; + +-- Catch any remaining NULLs using the booking's schedule. +UPDATE passenger."BookingSeat" bs +SET "scheduleId" = b."scheduleId" +FROM passenger."Booking" b +WHERE bs."bookingId" = b.id + AND bs."scheduleId" IS NULL; + +-- Now enforce NOT NULL. +ALTER TABLE passenger."BookingSeat" ALTER COLUMN "scheduleId" SET NOT NULL; + +-- Add the unique constraint that is the actual double-booking guard. +CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" + ON passenger."BookingSeat"("scheduleId", "seatId"); +>>>>>>> Stashed changes From 56057a1e1621b52042ba9be84162790ede96194c Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 12:31:19 +0300 Subject: [PATCH 09/13] Migration conflict issues resolution --- .../migration.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/edr-passenger-api/prisma/migrations/{20260717000003_booking_seat_schedule_unique => 20260717000004_booking_seat_schedule_unique}/migration.sql (100%) diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql similarity index 100% rename from apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql rename to apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql From e55dd01c500f6e56516d778df13866c1335648d8 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 12:47:05 +0300 Subject: [PATCH 10/13] Migration issues resolution --- .../migration.sql | 21 ------ .../migration.sql | 15 ----- .../migration.sql | 4 -- .../migration.sql | 1 + .../migration.sql | 66 ------------------- 5 files changed, 1 insertion(+), 106 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql index 83a49ef67..bcea78e50 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql @@ -1,22 +1 @@ -<<<<<<< Updated upstream --- Remove duplicate JourneySegment rows, keeping the one with the lowest id --- (earliest created) per (scheduleId, seatId, departureStationId) group. --- This cleans up any existing double-bookings before the unique index is applied. -DELETE FROM passenger."JourneySegment" -WHERE id NOT IN ( - SELECT MIN(id) - FROM passenger."JourneySegment" - WHERE "seatId" IS NOT NULL - GROUP BY "scheduleId", "seatId", "departureStationId" -) -AND "seatId" IS NOT NULL; - --- Prevents two confirmed bookings from occupying the same seat on the same --- schedule hop — the hard DB backstop against application-level race conditions. --- Partial index: seatId IS NOT NULL excludes free-child rows that have no seat. -CREATE UNIQUE INDEX "JourneySegment_scheduleId_seatId_departureStationId_key" -ON passenger."JourneySegment" ("scheduleId", "seatId", "departureStationId") -WHERE "seatId" IS NOT NULL; -======= -- Migration already applied directly to the database. ->>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql index e04c02621..bcea78e50 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql @@ -1,16 +1 @@ -<<<<<<< Updated upstream --- Rename StopStatus enum values to reflect segment-level booking lifecycle. --- UPCOMING → OPEN (segment is bookable) --- APPROACHING → CHECKIN_CLOSED (within check-in cutoff, no new bookings) --- CURRENT → BOARDED (train has departed this stop) --- COMPLETED stays as-is -ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'UPCOMING' TO 'OPEN'; -ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'APPROACHING' TO 'CHECKIN_CLOSED'; -ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'CURRENT' TO 'BOARDED'; - --- Add per-route check-in window. Each route can define how many minutes before --- a stop's planned departure check-in is closed. Defaults to 30 minutes. -ALTER TABLE "passenger"."Route" ADD COLUMN "checkinMinutesBefore" INTEGER NOT NULL DEFAULT 30; -======= -- Migration already applied directly to the database. ->>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql index 5fd9ae0f5..bcea78e50 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql @@ -1,5 +1 @@ -<<<<<<< Updated upstream -ALTER TABLE "passenger"."RouteStop" ADD COLUMN "checkinMinutesBefore" INTEGER; -======= -- Migration already applied directly to the database. ->>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql new file mode 100644 index 000000000..bcea78e50 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql @@ -0,0 +1 @@ +-- Migration already applied directly to the database. diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql deleted file mode 100644 index 8553fc81b..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql +++ /dev/null @@ -1,66 +0,0 @@ --- Make scheduleId non-nullable: backfill from the parent booking, then add NOT NULL. -UPDATE passenger."BookingSeat" bs -<<<<<<< Updated upstream -SET schedule_id = b.schedule_id -FROM passenger."Booking" b -WHERE bs.booking_id = b.id - AND bs.schedule_id IS NULL -======= -SET "scheduleId" = b."scheduleId" -FROM passenger."Booking" b -WHERE bs."bookingId" = b.id - AND bs."scheduleId" IS NULL ->>>>>>> Stashed changes - AND bs.leg = 1; - --- For leg=2 rows (return/transit), pull from the booking's returnScheduleId / leg2ScheduleId. -UPDATE passenger."BookingSeat" bs -<<<<<<< Updated upstream -SET schedule_id = COALESCE( - (b.return_schedule_id), - (b.leg2_schedule_id), - b.schedule_id -) -FROM passenger."Booking" b -WHERE bs.booking_id = b.id - AND bs.schedule_id IS NULL - AND bs.leg = 2; - --- Catch any remaining NULLs (leg 3/4 from ROUND_TRIP_TRANSIT) using the booking's schedule. -UPDATE passenger."BookingSeat" bs -SET schedule_id = b.schedule_id -FROM passenger."Booking" b -WHERE bs.booking_id = b.id - AND bs.schedule_id IS NULL; - --- Now enforce NOT NULL. -ALTER TABLE passenger."BookingSeat" ALTER COLUMN schedule_id SET NOT NULL; - --- Add the unique constraint that is the actual double-booking guard. -CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" - ON passenger."BookingSeat"(schedule_id, seat_id); -======= -SET "scheduleId" = COALESCE( - b."returnScheduleId", - b."leg2ScheduleId", - b."scheduleId" -) -FROM passenger."Booking" b -WHERE bs."bookingId" = b.id - AND bs."scheduleId" IS NULL - AND bs.leg = 2; - --- Catch any remaining NULLs using the booking's schedule. -UPDATE passenger."BookingSeat" bs -SET "scheduleId" = b."scheduleId" -FROM passenger."Booking" b -WHERE bs."bookingId" = b.id - AND bs."scheduleId" IS NULL; - --- Now enforce NOT NULL. -ALTER TABLE passenger."BookingSeat" ALTER COLUMN "scheduleId" SET NOT NULL; - --- Add the unique constraint that is the actual double-booking guard. -CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" - ON passenger."BookingSeat"("scheduleId", "seatId"); ->>>>>>> Stashed changes From 594aaf17abd46ce1901b7e12a5263d9516c33e92 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 17:53:44 +0300 Subject: [PATCH 11/13] Migration issue resolution --- .../migration.sql | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql new file mode 100644 index 000000000..2b67ec9cf --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[scheduleId,seatId,departureStationId]` on the table `JourneySegment` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "JourneySegment_scheduleId_seatId_departureStationId_key" ON "JourneySegment"("scheduleId", "seatId", "departureStationId"); From f366e834e765c20e016ec391a8119e7445f287d6 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 18:05:41 +0300 Subject: [PATCH 12/13] Migration issue resolution --- .github/workflows/deploy.yml | 11 +++++++++++ .../migration.sql | 14 +++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 44be07550..912c6bbd7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -162,6 +162,17 @@ jobs: -t "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \ . + - name: Resolve failed migrations for ${{ matrix.service }} + if: matrix.service == 'passenger-api' + run: | + set -euo pipefail + docker run --rm --env-file "${SERVICE_ENV_FILE}" \ + --entrypoint npx \ + "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \ + prisma migrate resolve \ + --applied 20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id \ + || true + - name: Run migrations for ${{ matrix.service }} if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) run: | diff --git a/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql index 2b67ec9cf..a512eeccd 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql @@ -4,5 +4,17 @@ - A unique constraint covering the columns `[scheduleId,seatId,departureStationId]` on the table `JourneySegment` will be added. If there are existing duplicate values, this will fail. */ +-- Deduplicate before applying the unique index. +-- Keeps the row with the lowest id per (scheduleId, seatId, departureStationId) group. +DELETE FROM passenger."JourneySegment" +WHERE id NOT IN ( + SELECT MIN(id) + FROM passenger."JourneySegment" + WHERE "seatId" IS NOT NULL + GROUP BY "scheduleId", "seatId", "departureStationId" +) +AND "seatId" IS NOT NULL; + -- CreateIndex -CREATE UNIQUE INDEX IF NOT EXISTS "JourneySegment_scheduleId_seatId_departureStationId_key" ON "JourneySegment"("scheduleId", "seatId", "departureStationId"); +CREATE UNIQUE INDEX IF NOT EXISTS "JourneySegment_scheduleId_seatId_departureStationId_key" + ON "JourneySegment"("scheduleId", "seatId", "departureStationId"); From e8408ef68451e9c6ed85b8d33adc19ffd0249310 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 18:30:03 +0300 Subject: [PATCH 13/13] Migration issue resolution job removed --- .github/workflows/deploy.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 912c6bbd7..44be07550 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -162,17 +162,6 @@ jobs: -t "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \ . - - name: Resolve failed migrations for ${{ matrix.service }} - if: matrix.service == 'passenger-api' - run: | - set -euo pipefail - docker run --rm --env-file "${SERVICE_ENV_FILE}" \ - --entrypoint npx \ - "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \ - prisma migrate resolve \ - --applied 20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id \ - || true - - name: Run migrations for ${{ matrix.service }} if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) run: |