diff --git a/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts b/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts index 36c0f872c..5a0fb50b1 100644 --- a/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts +++ b/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts @@ -1,19 +1,21 @@ /** - * Resolves the booking/check-in cutoff for one boarding stop. + * Resolves the booking/check-in cutoff for one boarding stop: stop-level + * `RouteStop.checkinMinutesBefore` override wins, else the route-level default + * (`Route.checkinMinutesBefore`), else a bare 30-minute fallback for routes/stops with + * neither configured. The basis is the stop's own estimated ARRIVAL time (the train reaching + * that stop), not its departure or the schedule's overall origin departure — a downstream + * stop's cutoff must be independent of how long ago the train left its origin. The first stop + * of a route has no arrival (nothing to arrive at), so it falls back to its own departure. * - * Priority for checkinMinutes: RouteStop.checkinMinutesBefore → Route.checkinMinutesBefore → 30. - * - * Anchor (segmentTime): plannedDepartureAt ?? plannedArrivalAt ?? schedule.departureAt. - * - For the origin stop: plannedDepartureAt = schedule.departureAt (no arrival). - * - For intermediate stops: plannedDepartureAt = plannedArrivalAt + dwell (checkinMinutesBefore). - * cutoffAt = departureAt − checkinMinutesBefore = arrivalAt, so booking closes the - * moment the train reaches the stop — independent of how long ago it left the origin. - * - * Single source of truth — SeatsService.holdSeats and SearchService.buildScheduleResult both - * apply it; GuestBookingService.createGuestBooking also applies it per boarding stop. + * Single source of truth for this computation — SeatsService.holdSeats and + * SearchService.buildScheduleResult already applied it (search results only ever showed a + * segment as bookable if this same cutoff hadn't passed); GuestBookingService.createGuestBooking + * used to independently hardcode a flat, non-configurable 30 minutes off the schedule's origin + * departure, which could reject a booking the search/hold steps had just accepted under the + * route's actual configured cutoff. */ export interface CheckinCutoff { - /** The stop's planned departure time (or arrival / schedule departure as fallback). */ + /** The stop's own estimated arrival time (or departure, for the first stop / missing data). */ segmentTime: Date; /** Minutes before segmentTime that booking/holding closes. */ checkinMinutes: number; @@ -32,7 +34,7 @@ export function resolveCheckinCutoff( stopTime: { plannedArrivalAt?: Date | null; plannedDepartureAt?: Date | null } | null | undefined, stationId: string | null | undefined, ): CheckinCutoff { - const segmentTime = stopTime?.plannedDepartureAt ?? stopTime?.plannedArrivalAt ?? schedule.departureAt; + const segmentTime = stopTime?.plannedArrivalAt ?? stopTime?.plannedDepartureAt ?? schedule.departureAt; const routeStop = stationId ? schedule.route?.stops?.find((s) => s.stationId === stationId) : undefined; const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30; return { 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 bd04ed34f..b14084d43 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -369,6 +369,24 @@ export class BookingsController { return this.guestService.issueBookingFromReservation(seatId, dto, actingUserId); } + @Delete("reservations/:seatId") + @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ + summary: "Cancel a seat's pending-payment reservation and release the seat", + description: + "For a seat with an active PASSENGER-kind reservation (payment link sent, not yet paid): cancels that booking and releases the seat's hold, so it's genuinely free for someone else. The old payment link stops working immediately (the booking is no longer PENDING_PAYMENT).", + }) + @ApiQuery({ name: "scheduleId", required: true, description: "TrainSchedule UUID the reservation was issued on" }) + cancelReservationForSeat( + @Param("seatId") seatId: string, + @Query("scheduleId") scheduleId: string, + @Req() req: any, + ) { + const actingUserId = req.user?.id ?? req.user?.sub ?? null; + return this.service.cancelReservationForSeat(seatId, scheduleId, actingUserId); + } + @Get("pay/:token") @SetMetadata("isPublic", true) @ApiOperation({ 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 b5825ddc5..26ab854d3 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -2138,6 +2138,39 @@ export class BookingsService { return { cancelled: true, refundAmount: refundAmount / 100, currency: booking.displayCurrency}; } + /** + * Staff releasing a seat that already has an in-flight backoffice reservation must not + * leave that booking dangling as PENDING_PAYMENT with a still-payable link — the traveler + * could pay for a seat that's since been given away. Finds the active reservation covering + * this exact seat+schedule and cancels it via the normal cancel() path (refund=0, since it's + * still unpaid), then separately releases the SeatHold issueBookingFromReservation created — + * cancel()'s releaseSeats() only deletes Journey/JourneySegment rows, which don't exist yet + * for an unpaid reservation, so without this the seat would stay held until the hold's own + * expiry. Once status flips to CANCELLED, getByPayToken's existing status check already + * rejects the old payToken with "This booking is no longer awaiting payment" — no separate + * payToken invalidation needed. + */ + async cancelReservationForSeat(seatId: string, scheduleId: string, actingUserId: string | null) { + const bookingSeat = await this.prisma.bookingSeat.findFirst({ + where: { + seatId, + scheduleId, + booking: { source: 'BACKOFFICE_RESERVATION', status: 'PENDING_PAYMENT' }, + }, + include: { booking: true }, + }); + if (!bookingSeat) throw new NotFoundException('No pending reservation found for this seat'); + + const { bookingRef } = bookingSeat.booking; + const result = await this.cancel(bookingRef, 'Seat released by staff before payment', actingUserId ?? undefined); + + await this.prisma.seatHold.deleteMany({ + where: { scheduleId, seatIds: { hasSome: [seatId] } }, + }); + + return { ...result, bookingRef }; + } + async update(id: string, dto: any) { const booking = await this.prisma.booking.findUnique({ where: { id } }); if (!booking) throw new NotFoundException('Booking not found'); 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 238d58d82..9be3e8c4b 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 @@ -10,6 +10,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service'; import { AuditService } from '../../common/audit.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { PaymentsService } from '../payments/payments.service'; +import { AuditService } from '../../common/audit.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { CreateGuestBookingDto, SavedPassengerProfileDto, IssueReservationBookingDto, ReservationBookingKind } from './guest-booking.dto'; import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client'; @@ -98,6 +99,7 @@ export class GuestBookingService { private auditService: AuditService, private eventEmitter: EventEmitter2, private paymentsService: PaymentsService, + private auditService: AuditService, private smsClient: SmsClientService, ) { } diff --git a/apps/edr-passenger-api/src/modules/live/live.module.ts b/apps/edr-passenger-api/src/modules/live/live.module.ts index 268ba2385..aa46c7d57 100644 --- a/apps/edr-passenger-api/src/modules/live/live.module.ts +++ b/apps/edr-passenger-api/src/modules/live/live.module.ts @@ -2,5 +2,5 @@ import { Module } from '@nestjs/common'; import { LiveController } from './live.controller'; import { LiveService } from './live.service'; -@Module({ controllers: [LiveController], providers: [LiveService] }) +@Module({ controllers: [LiveController], providers: [LiveService], exports: [LiveService] }) export class LiveModule {} diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 12f549e9f..9ef922bb3 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -312,6 +312,18 @@ export class NotificationsService { const ref = booking?.bookingRef ?? payload.booking.bookingRef; const passengerId = booking?.passengerId ?? payload.booking.passengerId; + // A backoffice-issued reservation already sends its own purpose-built message — + // GuestBookingService.issueBookingFromReservation texts /reserve/pay/ for a + // PASSENGER-kind booking (the traveler has no portal session, so this generic template's + // /booking/detail?ref= link doesn't work), and for STAFF kind the booking is finalized + // immediately after this event fires, so onPaymentSucceeded's "ticket ready" message is + // the correct one to send, not a redundant/contradictory "awaiting payment" notice. + const source = (booking as any)?.source ?? payload.booking?.source; + if (source === 'BACKOFFICE_RESERVATION') { + this.logger.log(`Skipping generic booking.created notification for ${ref} — reservation flow sends its own`); + return; + } + const template = await this.prisma.notificationTemplate.findUnique({ where: { code: 'booking.created' }, }); diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index 56ff8ddfe..bbf63ab91 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -4,7 +4,6 @@ import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateD import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { AuditService } from '../../common/audit.service'; import { parseEthiopianTime } from '../../common/utils/timezone.utils'; -import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils'; @Injectable() export class RoutesService { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 8cee1e94f..3f6228bf3 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseInt import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SchedulesService } from './schedules.service'; -import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto'; +import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus, ApplyDelayDto } from './schedules.dto'; import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @@ -177,6 +177,22 @@ export class SchedulesController { @Body() dto: UpdateStopTimeDto, ) { return this.service.updateStop(id, sequence, dto); } + @Post(':id/delay') + @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @ApiOperation({ + summary: 'Report a delay — pushes every downstream stop\'s planned times (and check-in cutoffs) back by the same amount', + description: `Shifts plannedArrivalAt/plannedDepartureAt on every stop not yet BOARDED/COMPLETED (or from fromSequence +onward, if given) by delayMinutes. Since check-in cutoffs are derived directly from these planned +times, this is the only action needed for booking closure to reflect the delay — no separate cutoff +update. Also shifts the schedule's own departureAt/arrivalAt when the origin stop is included, and +records the accumulated delay on the schedule's live status. Does not change schedule/stop status.`, + }) + @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) + @ApiResponse({ status: 200, description: 'Schedule with shifted stop times' }) + applyDelay(@Param('id') id: string, @Body() dto: ApplyDelayDto) { + return this.service.applyDelay(id, dto); + } + @Put(':scheduleId/fares/:seatClassId') @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @ApiOperation({ 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 49dec1d96..333eb7d26 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -113,6 +113,14 @@ export class UpdateScheduleStatusDto { @ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus; } +export class ApplyDelayDto { + @ApiProperty({ example: 60, description: 'Minutes to shift downstream stop times by. Negative to correct an over-reported delay.' }) + @IsInt() delayMinutes: number; + + @ApiPropertyOptional({ example: 3, description: 'Only shift stops from this sequence onward. Omit to default to every stop not yet BOARDED/COMPLETED.' }) + @IsOptional() @IsInt() @Min(1) fromSequence?: number; +} + export class BulkCreateSchedulesDto { @ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string; @ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string; diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts index 18d88d631..e01dc616b 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts @@ -5,9 +5,10 @@ import { RoutesController } from './routes.controller'; import { RoutesService } from './routes.service'; import { FareEngineModule } from '../fare-engine/fare-engine.module'; import { AuditModule } from '../../common/audit.module'; +import { LiveModule } from '../live/live.module'; @Module({ - imports: [FareEngineModule, AuditModule], + imports: [FareEngineModule, AuditModule, LiveModule], controllers: [RoutesController, SchedulesController], providers: [RoutesService, SchedulesService], exports: [RoutesService, SchedulesService], diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index d6dfda6ec..f0dc45e4d 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -2,11 +2,11 @@ import { Injectable, Logger, NotFoundException, BadRequestException } from '@nes import { PrismaService } from '../../common/prisma.service'; import { RoutesService } from './routes.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; -import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto'; +import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, ApplyDelayDto } from './schedules.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils'; import { AuditService } from '../../common/audit.service'; -import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils'; +import { LiveService } from '../live/live.service'; @Injectable() export class SchedulesService { @@ -17,8 +17,47 @@ export class SchedulesService { private routesService: RoutesService, private fareEngine: FareEngineService, private auditService: AuditService, + private liveService: LiveService, ) { } + /** + * Computes each stop's planned arrival/departure time by walking the route in sequence + * order and accumulating `RouteStop.travelMinutesToStop` (minutes of travel from the + * previous stop). Falls back to distance-proportional interpolation over `distanceKm` for + * any stop missing `travelMinutesToStop`. The last stop is always locked to the confirmed + * overall `arr` regardless of the accumulated cursor, so schedule.arrivalAt stays + * authoritative even if per-stop estimates drift. + */ + private computePlannedTimes( + route: { id: string; stops: { sequence: number; distanceKm: number | null; travelMinutesToStop: number | null }[] }, + dep: Date, + arr: Date, + ) { + const totalDuration = arr.getTime() - dep.getTime(); + const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + + let cursor = dep; + return route.stops.map((stop, index) => { + if (index === 0) { + cursor = dep; + } else if (index === route.stops.length - 1) { + cursor = arr; + } else if (stop.travelMinutesToStop != null) { + cursor = new Date(cursor.getTime() + stop.travelMinutesToStop * 60_000); + } else { + const stopDistance = stop.distanceKm || 0; + const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); + cursor = new Date(dep.getTime() + totalDuration * progress); + this.logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`); + } + return { + sequence: stop.sequence, + plannedArrivalAt: index === 0 ? undefined : cursor.toISOString(), + plannedDepartureAt: index === route.stops.length - 1 ? undefined : cursor.toISOString(), + }; + }); + } + async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) { const startDate = parseEthiopianTime(dto.startDateTime); const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000); @@ -89,6 +128,7 @@ export class SchedulesService { include: { coach: true }, orderBy: { positionNumber: 'asc' }, }, + liveStatus: { select: { delayMinutes: true } }, _count: { select: { coachAssignments: true, bookings: true } }, }, orderBy: { departureAt: 'asc' }, @@ -132,7 +172,7 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - plannedTimes = computePlannedStopTimes(route, dep, arr); + plannedTimes = this.computePlannedTimes(route, dep, arr); } const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence)); @@ -209,6 +249,7 @@ export class SchedulesService { orderBy: { positionNumber: 'asc' }, }, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + liveStatus: { select: { delayMinutes: true } }, }, }); if (!schedule) throw new NotFoundException('Schedule not found'); @@ -303,7 +344,7 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - plannedTimes = computePlannedStopTimes(route, dep, arr); + plannedTimes = this.computePlannedTimes(route, dep, arr); } const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); @@ -425,6 +466,70 @@ export class SchedulesService { }); } + /** + * Shifts stored planned times additively rather than reusing updateSchedulePartial's + * recompute-from-route-interpolation path — that path also guards `departureAt must be in the + * future`, which a delay report for an already-departed/EN_ROUTE train would legitimately + * fail. Check-in cutoffs (resolveCheckinCutoff, SeatsService.holdSeats) are both derived + * directly from TripStopTime.plannedArrivalAt/plannedDepartureAt at read time, so shifting the + * stored values here is the entire fix — neither of those needs to change. + */ + async applyDelay(scheduleId: string, dto: ApplyDelayDto) { + const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }); + if (!schedule) throw new NotFoundException('Schedule not found'); + + const stopWhere: any = { scheduleId }; + if (dto.fromSequence != null) { + stopWhere.sequence = { gte: dto.fromSequence }; + } else { + // Default: only stops the train hasn't reached yet — a delay report must not retroactively + // move a stop that's already BOARDED/COMPLETED. + stopWhere.status = { notIn: ['BOARDED', 'COMPLETED'] }; + } + + const stopsToShift = await this.prisma.tripStopTime.findMany({ where: stopWhere }); + const shiftMs = dto.delayMinutes * 60_000; + const includesOrigin = stopsToShift.some((s) => s.sequence === 1); + + await this.prisma.$transaction(async (tx) => { + for (const stop of stopsToShift) { + await tx.tripStopTime.update({ + where: { id: stop.id }, + data: { + plannedArrivalAt: stop.plannedArrivalAt ? new Date(stop.plannedArrivalAt.getTime() + shiftMs) : undefined, + plannedDepartureAt: stop.plannedDepartureAt ? new Date(stop.plannedDepartureAt.getTime() + shiftMs) : undefined, + }, + }); + } + + // Origin stop shifted → the schedule's own departureAt/arrivalAt drive search's day-window + // queries and the displayed departure time, so they must move too (both together, so + // durationMinutes stays correct). + if (includesOrigin) { + await tx.trainSchedule.update({ + where: { id: scheduleId }, + data: { + departureAt: new Date(schedule.departureAt.getTime() + shiftMs), + arrivalAt: new Date(schedule.arrivalAt.getTime() + shiftMs), + }, + }); + } + }); + + const currentLive = await this.prisma.tripLiveStatus.findUnique({ where: { scheduleId } }); + const accumulatedDelayMinutes = Math.max(0, (currentLive?.delayMinutes ?? 0) + dto.delayMinutes); + await this.liveService.updateLiveStatus(scheduleId, { delayMinutes: accumulatedDelayMinutes }); + + await this.auditService.log({ + action: 'UPDATE', + entityType: 'Schedule', + entityId: scheduleId, + newData: { delayMinutes: dto.delayMinutes, fromSequence: dto.fromSequence, accumulatedDelayMinutes }, + }); + + return this.getSchedule(scheduleId); + } + async upsertScheduleFare( scheduleId: string, seatClassId: string, @@ -681,7 +786,7 @@ export class SchedulesService { include: { stops: { orderBy: { sequence: 'asc' } } }, }); if (route && route.stops.length >= 2) { - const plannedTimes = computePlannedStopTimes(route, dep, arr); + const plannedTimes = this.computePlannedTimes(route, dep, arr); const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap); } diff --git a/apps/edr-passenger-api/src/modules/search/search.controller.ts b/apps/edr-passenger-api/src/modules/search/search.controller.ts index 384592dc9..92aff000a 100644 --- a/apps/edr-passenger-api/src/modules/search/search.controller.ts +++ b/apps/edr-passenger-api/src/modules/search/search.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Post, Get, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SearchService } from './search.service'; -import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto'; +import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, AvailableDatesQueryDto } from './search.dto'; @ApiTags('Search') @Controller('search') @@ -67,6 +67,21 @@ Nationality-Based: return this.service.getFareQuote(dto); } + @Get('available-dates') + @ApiOperation({ + summary: 'Which dates in a range have a bookable schedule for an origin/destination pair', + description: `Used to disable schedule-less dates on the search date picker before the user submits a search. + +For each date in the (server-clamped, max 90-day) range, a date is "available" if at least one +schedule exists for the origin→destination pair whose status/package/coach state is bookable and +whose check-in cutoff has not yet passed. This does not check seat-level availability — a date +can be marked available and still turn out fully booked when actually searched.`, + }) + @ApiResponse({ status: 200, description: 'routeExists flag plus a per-date availability list' }) + getAvailableDates(@Query() dto: AvailableDatesQueryDto) { + return this.service.getAvailableDates(dto); + } + @Get('fare-breakdown') @ApiOperation({ summary: 'Per-passenger fare breakdown for booking review page', diff --git a/apps/edr-passenger-api/src/modules/search/search.dto.ts b/apps/edr-passenger-api/src/modules/search/search.dto.ts index cfb1075ca..1c99ce418 100644 --- a/apps/edr-passenger-api/src/modules/search/search.dto.ts +++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts @@ -29,6 +29,20 @@ export class SearchTripsDto { @IsOptional() @IsDateString() returnDate?: string; } +export class AvailableDatesQueryDto { + @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' }) + @IsString() originStationId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' }) + @IsString() destinationStationId: string; + + @ApiProperty({ example: '2026-06-15', description: 'Start of the date range (YYYY-MM-DD)' }) + @IsDateString() from: string; + + @ApiProperty({ example: '2026-09-13', description: 'End of the date range (YYYY-MM-DD), inclusive — server clamps to a max 90-day span' }) + @IsDateString() to: string; +} + export class FareQuoteDto { @ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID from search results' }) @IsString() scheduleId: string; 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 9c368a976..de07f5d6a 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -5,6 +5,7 @@ import { FareQuoteDto, FareBreakdownRequestDto, FareBreakdownPassengerDto, + AvailableDatesQueryDto, } from "./search.dto"; import { CurrencyService } from "../currency/currency.service"; import { FareEngineService } from "../fare-engine/fare-engine.service"; @@ -383,16 +384,9 @@ export class SearchService { // 1. Does any active route connect these two stations, in this direction, at all — // ignoring date entirely? - const candidateRoutes = await this.prisma.route.findMany({ - where: { active: true, stops: { some: { stationId: originStationId } } }, - select: { stops: { select: { stationId: true, sequence: true } } }, - }); - const routeExists = candidateRoutes.some((r) => { - const o = r.stops.find((s) => s.stationId === originStationId); - const d = r.stops.find((s) => s.stationId === destinationStationId); - return !!o && !!d && o.sequence < d.sequence; - }); - if (!routeExists) return withCode(Passenger.SearchEmptyReasonCode.NoRoute); + if (!(await this.routeExistsForPair(originStationId, destinationStationId))) { + return withCode(Passenger.SearchEmptyReasonCode.NoRoute); + } // 2. A route exists — is there any schedule at all on the requested date for this pair // (regardless of status/package/coach/cutoff — those are checked next)? @@ -425,12 +419,7 @@ export class SearchService { // 3. Schedules exist that date — narrow to ones that would otherwise be bookable // (right status, not package-only, has at least one coach assigned). - const bookable = sameDayForPair.filter( - (s) => - (["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) && - !s.isPackageOnly && - s.coachAssignments.length > 0, - ); + const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s)); if (bookable.length === 0) { if (sameDayForPair.every((s) => s.status === "CANCELLED")) return withCode(Passenger.SearchEmptyReasonCode.Cancelled); @@ -452,6 +441,110 @@ export class SearchService { return withCode(Passenger.SearchEmptyReasonCode.FullyBooked); } + /** + * Whether any active route connects originStationId → destinationStationId in this + * direction, ignoring date/schedule state entirely. Shared by classifyEmptySearch and + * getAvailableDates. + */ + private async routeExistsForPair(originStationId: string, destinationStationId: string): Promise { + const candidateRoutes = await this.prisma.route.findMany({ + where: { active: true, stops: { some: { stationId: originStationId } } }, + select: { stops: { select: { stationId: true, sequence: true } } }, + }); + return candidateRoutes.some((r) => { + const o = r.stops.find((s) => s.stationId === originStationId); + const d = r.stops.find((s) => s.stationId === destinationStationId); + return !!o && !!d && o.sequence < d.sequence; + }); + } + + /** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */ + private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean { + return ( + (["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) && + !s.isPackageOnly && + s.coachAssignments.length > 0 + ); + } + + private readonly MAX_AVAILABLE_DATES_SPAN_DAYS = 90; + private readonly ADDIS_OFFSET_MS = 3 * 60 * 60 * 1000; + private readonly ONE_DAY_MS = 24 * 60 * 60 * 1000; + + /** Converts an absolute instant to its calendar date string in Africa/Addis_Ababa (fixed UTC+3, no DST). */ + private toAddisDateStr(d: Date): string { + return new Date(d.getTime() + this.ADDIS_OFFSET_MS).toISOString().slice(0, 10); + } + + /** + * For each date in the (server-clamped) range, whether at least one bookable schedule exists + * for originStationId → destinationStationId — used to disable schedule-less dates on the + * search date picker before the user submits a search. Reuses the same route-existence and + * bookability checks as classifyEmptySearch, plus the same check-in cutoff resolution used + * throughout this service, but does not compute seat-level availability (see buildScheduleResult) + * — a date can be marked available and still turn out fully booked when actually searched. + */ + async getAvailableDates(dto: AvailableDatesQueryDto) { + const { originStationId, destinationStationId } = dto; + + const todayStr = this.toAddisDateStr(new Date()); + const from = dto.from > todayStr ? dto.from : todayStr; + const fromDate = new Date(`${from}T00:00:00+03:00`); + + const maxToDate = new Date(fromDate.getTime() + this.MAX_AVAILABLE_DATES_SPAN_DAYS * this.ONE_DAY_MS); + const requestedToDate = new Date(`${dto.to}T00:00:00+03:00`); + const toDate = requestedToDate < maxToDate ? requestedToDate : maxToDate; + const to = this.toAddisDateStr(toDate); + + if (!(await this.routeExistsForPair(originStationId, destinationStationId))) { + return { + originStationId, + destinationStationId, + from, + to, + routeExists: false, + dates: [] as { date: string; available: boolean }[], + }; + } + + const rangeEnd = new Date(toDate.getTime() + this.ONE_DAY_MS); + const schedules = await this.prisma.trainSchedule.findMany({ + where: { + departureAt: { gte: fromDate, lt: rangeEnd }, + stopTimes: { some: { stationId: originStationId } }, + }, + select: { + departureAt: true, + status: true, + isPackageOnly: true, + route: { + select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } }, + }, + stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } }, + coachAssignments: { select: { id: true } }, + }, + }); + + const now = Date.now(); + const availableDays = new Set(); + for (const s of schedules) { + const originStop = s.stopTimes.find((st) => st.stationId === originStationId); + const destinationStop = s.stopTimes.find((st) => st.stationId === destinationStationId); + if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) continue; + if (!this.isBookableSchedule(s)) continue; + if (now >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime()) continue; + availableDays.add(this.toAddisDateStr(s.departureAt)); + } + + const dates: { date: string; available: boolean }[] = []; + for (let cursor = fromDate; cursor <= toDate; cursor = new Date(cursor.getTime() + this.ONE_DAY_MS)) { + const dateStr = this.toAddisDateStr(cursor); + dates.push({ date: dateStr, available: availableDays.has(dateStr) }); + } + + return { originStationId, destinationStationId, from, to, routeExists: true, dates }; + } + // ── Transit search ───────────────────────────────────────────────────────── private readonly MIN_CONNECTION_MINUTES = 30; private readonly MAX_CONNECTION_MINUTES = 360; 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 79773a712..77e13740a 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -45,13 +45,16 @@ export class SeatsService { }); const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id)); - const effectiveStatuses = await this.resolveEffectiveStatuses( - scheduleId, - allSeatIds, - originStationId ?? schedule.originStationId, - destinationStationId ?? schedule.destinationStationId, - journeyDirection - ); + const [effectiveStatuses, reservations] = await Promise.all([ + this.resolveEffectiveStatuses( + scheduleId, + allSeatIds, + originStationId ?? schedule.originStationId, + destinationStationId ?? schedule.destinationStationId, + journeyDirection + ), + this.resolveActiveReservations(allSeatIds, scheduleId), + ]); return { coaches: assignments.map((a) => { @@ -70,6 +73,7 @@ export class SeatsService { ? this.resolveBedPosition(s.col, s.bedPosition) : s.bedPosition; const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE'); + const reservation = reservations.get(s.id); return { id: s.id, seatNumber: s.seatNumber, @@ -88,6 +92,15 @@ export class SeatsService { position: this.colToPosition(s.col, a.coach.arrangement), bed_type: this.bedPositionToType(resolvedBedPosition), } : {}), + // Backoffice-issued reservation covering this seat, if any — lets staff see who's + // paying/ticketed for a HELD (awaiting payment) or BLOCKED (ticketed) seat without + // leaving the seat map. See resolveActiveReservations. + ...(reservation ? { + bookingRef: reservation.bookingRef, + reservationStatus: reservation.status, + reservationPassengerName: reservation.passengerName, + reservationContactPhone: reservation.contactPhone, + } : {}), }; }); @@ -259,6 +272,46 @@ export class SeatsService { return statusMap; } + /** + * Batch-resolves the backoffice-issued reservation (if any) covering each of these seats on + * this schedule — a booking created via GuestBookingService.issueBookingFromReservation + * (`source: 'BACKOFFICE_RESERVATION'`), still PENDING_PAYMENT (payment link sent, not yet + * paid) or already CONFIRMED (ticketed). Used to surface the booking reference on the + * backoffice seat map so staff can see who's paying/ticketed for a given seat without + * looking it up separately. + */ + private async resolveActiveReservations( + seatIds: string[], + scheduleId: string, + ): Promise> { + const map = new Map(); + if (seatIds.length === 0) return map; + + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { + seatId: { in: seatIds }, + scheduleId, + booking: { source: 'BACKOFFICE_RESERVATION', status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] } }, + }, + select: { + seatId: true, + passengerName: true, + booking: { select: { bookingRef: true, status: true, contactPhone: true } }, + }, + }); + + for (const bs of bookingSeats) { + if (!bs.seatId) continue; + map.set(bs.seatId, { + bookingRef: bs.booking.bookingRef, + status: bs.booking.status, + passengerName: bs.passengerName, + contactPhone: bs.booking.contactPhone, + }); + } + return map; + } + async holdSeats(dto: HoldSeatsDto) { const passengerIds = dto.passengers.map(p => p.passengerId); const seatIds = dto.passengers.map(p => p.seatId); @@ -295,10 +348,9 @@ export class SeatsService { // Stop-level override wins; falls back to route-level; then to 30 min. const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30; - // Departure basis: plannedDepartureAt = arrival + dwell. For the origin there is no - // arrival so plannedDepartureAt = schedule.departureAt. cutoffAt = departure - dwell = arrival, - // so holding closes the moment the train reaches the boarding stop. - const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? originStopTime?.plannedArrivalAt ?? schedule.departureAt; + // Arrival basis: the origin stop's own estimated arrival, not its departure. The first + // stop of a route has no arrival (nothing to arrive at), so it falls back to its departure. + const segmentDepartureAt = originStopTime?.plannedArrivalAt ?? originStopTime?.plannedDepartureAt ?? schedule.departureAt; const msUntilDeparture = segmentDepartureAt.getTime() - Date.now(); if (msUntilDeparture <= checkinMinutes * 60 * 1000) { throw new BadRequestException( diff --git a/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts b/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts index 75f822289..5d63d7a06 100644 --- a/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts +++ b/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts @@ -26,6 +26,7 @@ import { validateSync } from "class-validator"; import { plainToInstance } from "class-transformer"; import { SchedulesService } from "../src/modules/schedules/schedules.service"; import { SeatsService } from "../src/modules/seats/seats.service"; +import { SegmentsService } from "../src/modules/segments/segments.service"; import { TicketsService } from "../src/modules/tickets/tickets.service"; import { PaymentsService } from "../src/modules/payments/payments.service"; import { CurrencyService } from "../src/modules/currency/currency.service"; @@ -34,6 +35,7 @@ import { SystemConfigService } from "../src/modules/system-config/system-config. import { BookingsService } from "../src/modules/bookings/bookings.service"; import { GuestBookingService } from "../src/modules/bookings/guest-booking.service"; import { ReservationBookingKind, IssueReservationBookingDto } from "../src/modules/bookings/guest-booking.dto"; +import { NotificationsService } from "../src/modules/notifications/notifications.service"; import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; import { IDS, resetAndSeedCore } from "./fixtures/seed-core"; @@ -48,7 +50,9 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { let seatsService: SeatsService; let guestBookingService: GuestBookingService; let bookingsService: BookingsService; + let notificationsService: NotificationsService; let smsClient: { sendSms: jest.Mock }; + let emailClient: { sendEmail: jest.Mock }; beforeAll(async () => { harness = await createServiceHarness(); @@ -57,7 +61,11 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { const fareEngine = harness.moduleRef.get(FareEngineService); const systemConfig = new SystemConfigService(harness.prisma as any); - seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + // Real SegmentsService (not asyncStub) — the getSeatMap test below exercises + // resolveEffectiveStatuses, which calls segmentsService.getSeatAvailabilityMap and needs + // an actual Map back, not asyncStub's `async () => undefined`. + const segmentsService = new SegmentsService(harness.prisma as any); + seatsService = new SeatsService(harness.prisma as any, segmentsService, systemConfig, asyncStub(), asyncStub()); const ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); const paymentsService = new PaymentsService( harness.prisma as any, @@ -85,12 +93,26 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { harness.prisma as any, asyncStub(), // dataSource seatsService, - { emit: () => true } as any, + ticketsService, + { emit: () => true } as any, // eventEmitter asyncStub(), // verifaydaService currencyService, fareEngine, asyncStub(), // auditService ); + + emailClient = { sendEmail: jest.fn().mockResolvedValue({ queued: true }) }; + // Same smsClient instance guestBookingService uses — lets the notification-suppression + // test assert on ONE shared call count across both services, proving the reservation + // flow's own SMS is the only message sent for a BACKOFFICE_RESERVATION booking. + notificationsService = new NotificationsService( + harness.prisma as any, + asyncStub(), // dataSource (TypeORM) — only reached for non-UUID recipients / IAM lookups, + // never hit by these guest-passenger-id-keyed test bookings + emailClient as any, + smsClient as any, + asyncStub(), // pushAdapter + ); }); afterAll(async () => { @@ -99,6 +121,7 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { beforeEach(() => { smsClient.sendSms.mockClear(); + emailClient.sendEmail.mockClear(); }); /** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation. */ @@ -294,6 +317,53 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { expect(byToken.schedule.origin.id).toBe(IDS.stationA); }); + it("NotificationsService.onBookingCreated skips its own message for a BACKOFFICE_RESERVATION booking (issueBookingFromReservation already sent one), but still fires for a normal booking", async () => { + // Regression for: the customer got TWO conflicting messages for one reservation — + // the reservation-specific /reserve/pay/ SMS from issueBookingFromReservation, + // AND a second, generic booking.created notification pointing at /booking/detail?ref=, + // a page that doesn't work for a traveler with no portal session. + await resetAndSeedCore(harness.prisma); + await harness.prisma.notificationTemplate.upsert({ + where: { code: "booking.created" }, + update: { active: true }, + create: { code: "booking.created", channel: "SMS,EMAIL", bodyTemplate: "Booking {{bookingRef}} created. Pay: {{payLink}}", active: true }, + }); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-NOTIFY-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id); + const result: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any, + "staff-user-5", + ); + expect(smsClient.sendSms).toHaveBeenCalledTimes(1); // the reservation flow's own SMS + + // Directly invoke the event handler (the test harness's eventEmitter is a stub, so the + // real 'booking.created' emit from issueBookingFromReservation never reaches it) — this + // is what NotificationsService would have done had it received that event. + await notificationsService.onBookingCreated({ booking: { id: result.booking.id, bookingRef: result.booking.bookingRef } }); + expect(smsClient.sendSms).toHaveBeenCalledTimes(1); // still 1 — onBookingCreated no-oped + expect(emailClient.sendEmail).not.toHaveBeenCalled(); + + // Control: a normal (non-reservation) booking must still get the generic notification. + const passenger = await harness.prisma.passenger.create({ data: {} }); + const normalBooking = await harness.prisma.booking.create({ + data: { + bookingRef: `WEB-CTRL-${Date.now()}`, + passengerId: passenger.id, + scheduleId: schedule.id, + status: "PENDING_PAYMENT", + totalMinor: 10_000, + contactPhone: "+251911234567", + source: "WEB", + }, + }); + await notificationsService.onBookingCreated({ booking: { id: normalBooking.id, bookingRef: normalBooking.bookingRef } }); + expect(smsClient.sendSms).toHaveBeenCalledTimes(2); // suppression didn't leak to non-reservation bookings + }); + it("PASSENGER path: the seat stays reserved (not publicly available) after the payment link is sent", async () => { // Regression for: unblockSeat() released the reservation's SeatBlock and confirmSeats() // was a no-op with no SeatHold to extend, so the seat had no SeatBlock, no SeatHold, and @@ -325,6 +395,87 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { ).rejects.toThrow(/already (held|booked)/i); }); + it("cancelReservationForSeat: cancels the pending booking, frees the seat, and kills the old pay link", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CANCEL-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id); + const result: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any, + "staff-user-7", + ); + const payToken = result.booking.payToken; + + const cancelResult: any = await bookingsService.cancelReservationForSeat(seats[0].id, schedule.id, "staff-user-7"); + expect(cancelResult.cancelled).toBe(true); + expect(cancelResult.bookingRef).toBe(result.booking.bookingRef); + + const cancelledBooking = await harness.prisma.booking.findUnique({ where: { id: result.booking.id } }); + expect(cancelledBooking?.status).toBe("CANCELLED"); + + // The seat is genuinely free — a member of the public can now hold it. + await expect( + seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + passengers: [{ passengerId: "someone-else", seatId: seats[0].id }], + } as any), + ).resolves.toBeTruthy(); + + // The old payment link no longer works. + await expect(bookingsService.getByPayToken(payToken)).rejects.toThrow(/no longer awaiting payment/i); + }); + + it("cancelReservationForSeat 404s when there's no pending reservation for this seat", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CANCEL-404-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await expect( + bookingsService.cancelReservationForSeat(seats[0].id, schedule.id, "staff-user-8"), + ).rejects.toThrow(/no pending reservation/i); + }); + + it("getSeatMap surfaces the bookingRef (PNR) for a seat with an active reservation — pending payment AND ticketed", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-SEATMAP-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + // Seat 0: PASSENGER reservation — still PENDING_PAYMENT. + await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id); + const pending: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any, + "staff-user-6", + ); + + // Seat 1: STAFF reservation — fee-waived, ticketed, CONFIRMED immediately. + await seatsService.blockSeat(seats[1].id, "Reserved for staff issue", schedule.id); + const staffResult: any = await guestBookingService.issueBookingFromReservation( + seats[1].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.STAFF }) as any, + "staff-user-6", + ); + + const seatMap: any = await seatsService.getSeatMap(schedule.id); + const flatSeats = seatMap.coaches.flatMap((c: any) => c.seats ?? []); + const pendingSeat = flatSeats.find((s: any) => s.id === seats[0].id); + const ticketedSeat = flatSeats.find((s: any) => s.id === seats[1].id); + + expect(pendingSeat.bookingRef).toBe(pending.booking.bookingRef); + expect(pendingSeat.reservationStatus).toBe("PENDING_PAYMENT"); + expect(pendingSeat.status).toBe("HELD"); // covered by the SeatHold, not a SeatBlock + + expect(ticketedSeat.bookingRef).toBe(staffResult.booking.bookingRef); + expect(ticketedSeat.reservationStatus).toBe("CONFIRMED"); + }); + it("requires a phone number for a PASSENGER booking", async () => { await resetAndSeedCore(harness.prisma); const dep = new Date(Date.now() + 3 * 60 * 60_000); diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index 17a31832a..b2c63e885 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, RefreshCw } from 'lucide-react'; +import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, Clock } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; @@ -28,6 +28,7 @@ interface Schedule { destinationStation?: { id: string; name: string }; coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>; isPackageOnly?: boolean; + liveStatus?: { delayMinutes: number } | null; } interface Train { @@ -210,13 +211,6 @@ export default function SchedulesPage() { }, }); - const recalculateStopsMutation = useMutation({ - mutationFn: (id: string) => apiClient.post(`/schedules/${id}/recalculate-stops`, {}), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['schedules'] }); - }, - }); - const deleteScheduleMutation = useMutation({ mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/schedules/${id}${cascade ? '?cascade=true' : ''}`), onSuccess: () => { @@ -461,6 +455,16 @@ export default function SchedulesPage() { {formatDateTime(schedule.arrivalAt)} ), }, + { + key: 'liveStatus.delayMinutes', + label: 'Delay', + sortable: true, + render: (schedule: Schedule) => { + const delay = schedule.liveStatus?.delayMinutes ?? 0; + if (delay <= 0) return On time; + return +{delay} min; + }, + }, { key: 'coachAssignments', label: 'Coaches', @@ -493,6 +497,15 @@ export default function SchedulesPage() { const [cancelConfirm, setCancelConfirm] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null }); + const applyDelayMutation = useMutation({ + mutationFn: ({ id, minutes }: { id: string; minutes: number }) => + apiClient.post(`/schedules/${id}/delay`, { delayMinutes: minutes }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules'] }), + }); + const [delayPrompt, setDelayPrompt] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null }); + const [delayMinutesInput, setDelayMinutesInput] = useState(''); + const [delayError, setDelayError] = useState(null); + const scheduleActions = [ { label: 'Edit', @@ -500,6 +513,17 @@ export default function SchedulesPage() { variant: 'secondary' as const, icon: Edit, }, + { + label: 'Report Delay', + onClick: (schedule: Schedule) => { + setDelayMinutesInput(''); + setDelayError(null); + setDelayPrompt({ isOpen: true, item: schedule }); + }, + variant: 'secondary' as const, + icon: Clock, + hidden: (schedule: Schedule) => schedule.status === 'CANCELLED', + }, { label: 'Cancel', onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }), @@ -660,6 +684,67 @@ export default function SchedulesPage() { isLoading={cancelScheduleMutation.isPending} /> + setDelayPrompt({ isOpen: false, item: null })} + title={`Report Delay${delayPrompt.item ? `: ${delayPrompt.item.originStation?.name ?? ''} → ${delayPrompt.item.destinationStation?.name ?? ''}` : ''}`} + size="sm" + > + {delayPrompt.item && ( +
{ + e.preventDefault(); + const minutes = parseInt(delayMinutesInput, 10); + if (Number.isNaN(minutes)) { setDelayError('Enter a whole number of minutes.'); return; } + try { + await applyDelayMutation.mutateAsync({ id: delayPrompt.item!.id, minutes }); + setDelayPrompt({ isOpen: false, item: null }); + } catch (err: any) { + setDelayError(err?.response?.data?.message || 'Failed to apply delay.'); + } + }} + className="space-y-4" + > + {delayError && ( +
{delayError}
+ )} +
+ Current reported delay + {(delayPrompt.item.liveStatus?.delayMinutes ?? 0) > 0 ? ( + +{delayPrompt.item.liveStatus?.delayMinutes} min + ) : ( + On time + )} +
+
+ + setDelayMinutesInput(e.target.value)} + placeholder="e.g. 60" + className="input" + required + autoFocus + /> +

+ Adds to the current reported delay above and pushes every downstream station's + check-in cutoff back by this many minutes. Use a negative number to correct an + over-reported delay. +

+
+
+ setDelayPrompt({ isOpen: false, item: null })}> + Cancel + + + Apply Delay + +
+
+ )} +
+ setDeleteConfirm({ isOpen: false, item: null })} diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index 3ce651ac8..092e26309 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -39,7 +39,7 @@ export default function SeatsPage() { phone: '', email: '', }); - const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string } | null>(null); + const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string; bookingRef?: string } | null>(null); const queryClient = useQueryClient(); const { data: schedulesData } = useQuery({ @@ -126,13 +126,19 @@ export default function SeatsPage() { bookingsApi.issueFromReservation(seatId, data), onSuccess: (result: any) => { invalidateSeatData(); - setIssueBookingResult({ payUrl: result?.payUrl }); - if (!result?.payUrl) { - // STAFF booking — nothing further to show the admin, close immediately. - setShowIssueBookingModal(false); - setSelectedSeat(null); - setIssueBookingCoach(null); - } + // Always show the reference — the PASSENGER path also needs the PNR alongside the + // pay link (staff need to know which booking a seat belongs to, whether it's + // awaiting payment or already ticketed), so no longer auto-closing for STAFF. + setIssueBookingResult({ payUrl: result?.payUrl, bookingRef: result?.booking?.bookingRef }); + }, + }); + + // Cancels a seat's still-unpaid reservation (payment link sent) and releases the seat — + // distinct from unblockMutation, which only handles a plain SeatBlock (no booking involved). + const cancelReservationMutation = useMutation({ + mutationFn: (seatId: string) => bookingsApi.cancelReservation(seatId, selectedSchedule), + onSuccess: () => { + invalidateSeatData(); }, }); @@ -222,6 +228,16 @@ export default function SeatsPage() { } }; + // Distinct from handleUnblock — this seat has no SeatBlock (issuing the reservation already + // released it), it's HELD by the SeatHold behind an unpaid booking. Cancelling that booking + // invalidates its payment link immediately, so warn staff explicitly about that. + const handleCancelReservation = async (seat: any) => { + if (!selectedSchedule) return; + if (confirm(`Cancel the reservation for seat ${seat.seatNumber} (PNR ${seat.bookingRef})? The payment link already sent to the traveler will stop working.`)) { + await cancelReservationMutation.mutateAsync(seat.id); + } + }; + const handleIssueBooking = (seat: any, coach: any) => { if (activeTab !== 'schedule' || !selectedSchedule) { alert('Select a specific schedule (Schedule tab) to issue a booking for a reserved seat.'); @@ -433,6 +449,7 @@ export default function SeatsPage() { handleBlock={handleBlock} handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} + handleCancelReservation={handleCancelReservation} handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} @@ -529,6 +546,7 @@ export default function SeatsPage() { handleBlock={handleBlock} handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} + handleCancelReservation={handleCancelReservation} handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} @@ -552,6 +570,7 @@ export default function SeatsPage() { handleBlock={handleBlock} handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} + handleCancelReservation={handleCancelReservation} handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} @@ -888,12 +907,25 @@ export default function SeatsPage() { title="Issue Booking" size="md" > - {issueBookingResult?.payUrl ? ( + {issueBookingResult ? (

- Booking created. A payment link has been sent via SMS to the traveler. + {issueBookingResult.payUrl + ? 'Booking created. A payment link has been sent via SMS to the traveler.' + : 'Booking confirmed and ticketed.'}

-
{issueBookingResult.payUrl}
+ {issueBookingResult.bookingRef && ( +
+ +
{issueBookingResult.bookingRef}
+
+ )} + {issueBookingResult.payUrl && ( +
+ +
{issueBookingResult.payUrl}
+
+ )}
{ @@ -1231,6 +1263,7 @@ interface SeatIconProps { handleBlock: (seat: any) => void; handleRemoveSeat: (seat: any) => void; handleUnblock: (seat: any) => void; + handleCancelReservation: (seat: any) => void; handleUndoRemove: (seat: any) => void; handleSetMaintenance: (seat: any) => void; handleClearMaintenance: (seat: any) => void; @@ -1248,6 +1281,7 @@ function SeatIcon({ handleBlock, handleRemoveSeat, handleUnblock, + handleCancelReservation, handleUndoRemove, handleSetMaintenance, handleClearMaintenance, @@ -1285,6 +1319,10 @@ function SeatIcon({ const color = getSeatColor(status); const canBlock = status === 'AVAILABLE'; const canUnblock = status === 'BLOCKED'; + // A HELD seat with a bookingRef + PENDING_PAYMENT is a backoffice reservation awaiting + // payment (see resolveActiveReservations) — issuing it already released the SeatBlock, so + // it's not reachable via canUnblock anymore; this is the seat's own release path. + const canCancelReservation = status === 'HELD' && !!seat.bookingRef && seat.reservationStatus === 'PENDING_PAYMENT'; const canMaintenance = false; const canClearMaintenance = status === 'UNDER_MAINTENANCE'; @@ -1299,7 +1337,7 @@ function SeatIcon({ {isBedCoach ? (
@@ -1307,14 +1345,23 @@ function SeatIcon({ ) : (
)} - {(canBlock || canUnblock || canMaintenance || canClearMaintenance) && ( + {seat.bookingRef && ( + + {seat.bookingRef} + + )} + + {(canBlock || canUnblock || canCancelReservation || canMaintenance || canClearMaintenance) && (
{canBlock && ( <> @@ -1334,6 +1381,15 @@ function SeatIcon({ )} + {canCancelReservation && ( + + )} {canUnblock && ( <>