From f9b87b9911ec763c9aa5bf5259ae89cec016e566 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 16 Jul 2026 16:41:36 +0300 Subject: [PATCH 1/5] Fix waiting time loading --- .../src/app/booking/confirmation/page.tsx | 45 +++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 37c110329..d9253a93e 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -7,7 +7,7 @@ import { useBookingStore } from "@/lib/booking-store"; import { usePaymentStore } from "@/lib/payment-store"; import { useQuery } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { CheckCircle, Clock, Copy, Train, FileText } from "lucide-react"; import { format } from "date-fns"; import { isChild, isFirstChild } from "@/utils/fare-utils"; @@ -60,6 +60,27 @@ export default function ConfirmationPage() { const [copied, setCopied] = useState(false); const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); + // Grace period after landing on this page: keep showing the generic "processing" + // spinner instead of the "payment pending" screen, and poll the payment intent + // frequently — booking.status and paymentIntent.status flip to CONFIRMED/SUCCEEDED + // together (see finalizePaymentSuccess in payments.service.ts), so a payment that + // already succeeded at the provider often just needs a few more seconds for its + // webhook to reach us. Once the grace period elapses, fall back to the normal + // pending screen with slower background polling. + const CONFIRMATION_GRACE_PERIOD_MS = 10_000; + const FAST_POLL_INTERVAL_MS = 2_500; + const SLOW_POLL_INTERVAL_MS = 10_000; + const mountTimeRef = useRef(Date.now()); + const [withinGracePeriod, setWithinGracePeriod] = useState(true); + + useEffect(() => { + const timer = setTimeout( + () => setWithinGracePeriod(false), + CONFIRMATION_GRACE_PERIOD_MS, + ); + return () => clearTimeout(timer); + }, []); + // Warms the code-split voucher module ahead of the click so the handler's own // `await import(...)` resolves near-instantly — on iOS Safari, a file save triggered // too long after the originating click's synchronous execution window is silently @@ -92,15 +113,20 @@ export default function ConfirmationPage() { enabled: !!bookingId, }); - // Poll the payment intent every 10 s while the booking is PENDING_PAYMENT. - // The backend auto-confirms (and generates tickets) when the payment-api reports - // SUCCEEDED, so detecting that here means the booking is now CONFIRMED — refetch - // to update the UI without requiring the user to refresh. + // Poll the payment intent while the booking is PENDING_PAYMENT — fast during the + // grace period (catches a webhook that's just a few seconds behind), then slower + // in the background afterward. The backend auto-confirms (and generates tickets) + // when the payment-api reports SUCCEEDED, so detecting that here means the + // booking is now CONFIRMED — refetch to update the UI without requiring the user + // to refresh. const { data: intentStatus } = useQuery({ queryKey: ["payment-intent-status", bookingId], queryFn: () => apiClient.get(`/payments/intents/${bookingId}`), enabled: _booking?.status === "PENDING_PAYMENT" && !!bookingId, - refetchInterval: 10_000, + refetchInterval: () => + Date.now() - mountTimeRef.current < CONFIRMATION_GRACE_PERIOD_MS + ? FAST_POLL_INTERVAL_MS + : SLOW_POLL_INTERVAL_MS, }); useEffect(() => { @@ -279,8 +305,11 @@ export default function ConfirmationPage() { // without this, _booking is briefly undefined on first load, isConfirmed reads // as false, and the page flashes "payment pending" before flipping to // "confirmed" once the fetch resolves (common, since the payment webhook has - // often already completed by the time the user lands here). - if (isBookingLoading) { + // often already completed by the time the user lands here). Also keep showing + // this same spinner through the grace period above if the booking is still + // PENDING_PAYMENT — most of the time the webhook lands within that window, so + // the user goes straight to "confirmed" without ever seeing "pending" at all. + if (isBookingLoading || (withinGracePeriod && _booking?.status === "PENDING_PAYMENT")) { return (
From fe5b258fcae9d7704f40300da57fadc1c29019aa Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 16 Jul 2026 17:02:23 +0300 Subject: [PATCH 2/5] Fix confirmation page pending payment --- .../portal/src/app/booking/confirmation/page.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index d9253a93e..66a7bf9c3 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -110,6 +110,14 @@ export default function ConfirmationPage() { }; } }, + // Payment status must never be served from a stale cache — the app-wide default + // (providers.tsx) is a 60s staleTime, which would otherwise block React Query's + // own refetch-on-window-focus from firing (it only refetches stale data). Without + // this override, a tab left open past a payment completing can sit showing + // "pending" long after it's actually confirmed, even after being refocused, + // until the interval below happens to tick — which browsers throttle heavily in + // backgrounded tabs, so that can take a very long time. + staleTime: 0, enabled: !!bookingId, }); @@ -123,6 +131,7 @@ export default function ConfirmationPage() { queryKey: ["payment-intent-status", bookingId], queryFn: () => apiClient.get(`/payments/intents/${bookingId}`), enabled: _booking?.status === "PENDING_PAYMENT" && !!bookingId, + staleTime: 0, refetchInterval: () => Date.now() - mountTimeRef.current < CONFIRMATION_GRACE_PERIOD_MS ? FAST_POLL_INTERVAL_MS From 6d524ce6be5a855b6c7601987b16446b6bc3be78 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 16 Jul 2026 17:27:32 +0300 Subject: [PATCH 3/5] Fix payment fallback redirect --- .../src/app/booking/confirmation/page.tsx | 47 +++++++++++++------ .../booking/payment/dmoney/success/page.tsx | 18 ++++--- .../booking/payment/telebirr/success/page.tsx | 18 ++++--- .../booking/payment/waafi/success/page.tsx | 18 ++++--- 4 files changed, 68 insertions(+), 33 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 66a7bf9c3..7d9cd691a 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -93,23 +93,16 @@ export default function ConfirmationPage() { data: _booking, refetch: refetchBooking, isLoading: isBookingLoading, + isError: isBookingError, } = useQuery({ queryKey: ["booking", bookingId], - queryFn: async (): Promise => { - try { - return await apiClient.get(`/bookings/${bookingId}`); - } catch (error) { - return { - id: bookingId || "", - pnr: pnr || undefined, - status: "PENDING_PAYMENT", - totalMinor: passengers.reduce( - (sum) => sum + (selectedSchedule?.baseFareAdult || 0), - 0, - ), - }; - } - }, + // Let a real fetch failure surface as a real error (React Query's global retry:1 + // default then retries once automatically) instead of silently returning a + // fabricated "PENDING_PAYMENT" object — that used to mask genuine failures (a + // transient blip right after a cross-domain redirect from the payment gateway is + // common) as normal pending state forever, since a caught error that returns data + // looks like a success to React Query and never gets retried. + queryFn: (): Promise => apiClient.get(`/bookings/${bookingId}`), // Payment status must never be served from a stale cache — the app-wide default // (providers.tsx) is a 60s staleTime, which would otherwise block React Query's // own refetch-on-window-focus from firing (it only refetches stale data). Without @@ -333,6 +326,30 @@ export default function ConfirmationPage() { ); } + // A real fetch failure (not just "still pending") — surface it honestly instead of + // silently pretending the booking is pending, and let the user retry the check + // without needing a full page refresh. + if (isBookingError) { + return ( +
+
+
+

+ We're having trouble loading your booking status right + now. This is usually temporary — tap below to try again. +

+ +
+
+
+ ); + } + return (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx index cf132bc7f..c63cea9aa 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/dmoney/success/page.tsx @@ -55,13 +55,20 @@ function DmoneySuccessContent() { // Manage Booking sessions don't carry a bookingId in the client store — the detail page // it lands on re-fetches the booking's real status itself, so there's nothing to verify // client-side here; just hand off without claiming an outcome we can't confirm. - if (!bookingId) { - if (!cancelled) { - router.push(target); - } + if (manageBookingRef) { + router.push(target); return; } + // Normal booking flow: bookingId comes from a Zustand store persisted to localStorage. + // This page is always reached via a real cross-domain redirect from the payment gateway + // (a full page load, not an in-app navigation), so that store has to rehydrate from + // localStorage asynchronously — bookingId can read as empty on the first render or two. + // Wait for it instead of treating an empty first-render value as "nothing to verify", + // which would silently skip this page's whole verification step and hand off to + // /booking/confirmation without ever having checked payment status here. + if (!bookingId) return; + verifyBookingPaid(bookingId).then((result) => { if (cancelled) return; if (result === 'SUCCEEDED') { @@ -82,8 +89,7 @@ function DmoneySuccessContent() { return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [bookingId, router, updateStatus]); return (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx index a47fdd07c..94d66121b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx @@ -55,13 +55,20 @@ function TelebirrSuccessContent() { // Manage Booking sessions don't carry a bookingId in the client store — the detail page // it lands on re-fetches the booking's real status itself, so there's nothing to verify // client-side here; just hand off without claiming an outcome we can't confirm. - if (!bookingId) { - if (!cancelled) { - router.push(target); - } + if (manageBookingRef) { + router.push(target); return; } + // Normal booking flow: bookingId comes from a Zustand store persisted to localStorage. + // This page is always reached via a real cross-domain redirect from the payment gateway + // (a full page load, not an in-app navigation), so that store has to rehydrate from + // localStorage asynchronously — bookingId can read as empty on the first render or two. + // Wait for it instead of treating an empty first-render value as "nothing to verify", + // which would silently skip this page's whole verification step and hand off to + // /booking/confirmation without ever having checked payment status here. + if (!bookingId) return; + verifyBookingPaid(bookingId).then((result) => { if (cancelled) return; if (result === 'SUCCEEDED') { @@ -82,8 +89,7 @@ function TelebirrSuccessContent() { return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [bookingId, router, updateStatus]); return (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx index 89a8b3098..a33eaec41 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/success/page.tsx @@ -55,13 +55,20 @@ function WaafiSuccessContent() { // Manage Booking sessions don't carry a bookingId in the client store — the detail page // it lands on re-fetches the booking's real status itself, so there's nothing to verify // client-side here; just hand off without claiming an outcome we can't confirm. - if (!bookingId) { - if (!cancelled) { - router.push(target); - } + if (manageBookingRef) { + router.push(target); return; } + // Normal booking flow: bookingId comes from a Zustand store persisted to localStorage. + // This page is always reached via a real cross-domain redirect from the payment gateway + // (a full page load, not an in-app navigation), so that store has to rehydrate from + // localStorage asynchronously — bookingId can read as empty on the first render or two. + // Wait for it instead of treating an empty first-render value as "nothing to verify", + // which would silently skip this page's whole verification step and hand off to + // /booking/confirmation without ever having checked payment status here. + if (!bookingId) return; + verifyBookingPaid(bookingId).then((result) => { if (cancelled) return; if (result === 'SUCCEEDED') { @@ -82,8 +89,7 @@ function WaafiSuccessContent() { return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [bookingId, router, updateStatus]); return (
From e3075d1486161bc7773fece50fcb10b3c92a3e11 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 16 Jul 2026 19:28:00 +0300 Subject: [PATCH 4/5] Seats holding, booking, blocking inconsistencies resolution --- apps/edr-passenger-api/prisma/schema.prisma | 2 + .../src/modules/seats/seats.controller.ts | 8 +- .../src/modules/seats/seats.service.ts | 113 ++++++++++-------- .../backoffice/src/app/seats/page.tsx | 12 +- .../backoffice/src/lib/api/index.ts | 2 +- 5 files changed, 78 insertions(+), 59 deletions(-) diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index cef7d0033..d2dc44751 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1312,6 +1312,7 @@ model NotificationTemplate { model SeatBlock { id String @id @default(uuid()) seatId String + scheduleId String? reason String blockedBy String approvedBy String? @@ -1320,6 +1321,7 @@ model SeatBlock { seat Seat @relation(fields: [seatId], references: [id]) @@index([seatId]) + @@index([scheduleId]) @@schema("passenger") } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 752b66390..596c88bb3 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -211,8 +211,8 @@ This makes it clear which segment of the route each seat is held for, enabling s @ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)" }) @ApiParam({ name: "seatId", description: "Seat UUID" }) @ApiResponse({ status: 200, description: "Seat blocked" }) - blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string }) { - return this.service.blockSeat(seatId, body.reason); + blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string; scheduleId?: string }) { + return this.service.blockSeat(seatId, body.reason, body.scheduleId); } @Delete(":seatId/block") @@ -221,8 +221,8 @@ This makes it clear which segment of the route each seat is held for, enabling s @ApiOperation({ summary: "Unblock a seat" }) @ApiParam({ name: "seatId", description: "Seat UUID" }) @ApiResponse({ status: 200, description: "Seat unblocked" }) - unblockSeat(@Param("seatId") seatId: string) { - return this.service.unblockSeat(seatId); + unblockSeat(@Param("seatId") seatId: string, @Query("scheduleId") scheduleId?: string) { + return this.service.unblockSeat(seatId, scheduleId); } // ── Maintenance ─────────────────────────────────────────────────────────── 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 340900fdd..10368f380 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -224,7 +224,7 @@ export class SeatsService { } } - const [availability, persistedSeats] = await Promise.all([ + const [availability, persistedSeats, scheduleBlocks] = await Promise.all([ this.segmentsService.getSeatAvailabilityMap( scheduleId, seatIds, stopTimes, reqFrom, reqTo, journeyDirection || JourneyDirection.ONE_WAY, ), @@ -232,16 +232,23 @@ export class SeatsService { where: { id: { in: seatIds } }, select: { id: true, status: true }, }), + this.prisma.seatBlock.findMany({ + where: { seatId: { in: seatIds }, scheduleId }, + select: { seatId: true }, + }), ]); const persistedStatus = new Map(persistedSeats.map(s => [s.id, s.status])); + const scheduleBlockedIds = new Set(scheduleBlocks.map(b => b.seatId)); for (const seatId of seatIds) { const persisted = persistedStatus.get(seatId); - // BLOCKED and UNDER_MAINTENANCE are cross-schedule flags set by admins — - // always honour them regardless of hold/booking state. + // Global BLOCKED/UNDER_MAINTENANCE (no scheduleId) — always honour if ((persisted as string) === 'BLOCKED' || (persisted as string) === 'UNDER_MAINTENANCE') { statusMap.set(seatId, persisted!); + } else if (scheduleBlockedIds.has(seatId)) { + // Schedule-scoped block — only blocked for this schedule + statusMap.set(seatId, 'BLOCKED'); } else { statusMap.set(seatId, availability.get(seatId) ?? 'AVAILABLE'); } @@ -291,15 +298,12 @@ export class SeatsService { throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`); } - // Only the raw BLOCKED status (seat pulled out of service — a genuine - // cross-schedule flag) is trusted here. BOOKED is intentionally NOT checked - // against this raw column: the same physical Seat row is reused across every - // recurring date a coach runs, and Seat.status only resets to AVAILABLE via a - // trip-completion event that isn't guaranteed to fire, so a stale BOOKED value - // here would wrongly block a seat that's actually free for this schedule/leg. - // The schedule- and leg-scoped SeatHold/JourneySegment checks below are the - // authoritative source for whether a seat is actually taken. - const blocked = seats.filter(s => s.status === 'BLOCKED'); + // Only the raw BLOCKED/UNDER_MAINTENANCE status (seat pulled out of service — + // a genuine cross-schedule flag) is checked here. Seat.status is never written + // for holds/bookings because coaches are reused across schedules; the + // schedule-scoped SeatHold/JourneySegment checks below are the authoritative + // source for whether a seat is taken on this specific schedule/leg. + const blocked = seats.filter(s => s.status === 'BLOCKED' || (s.status as string) === 'UNDER_MAINTENANCE'); if (blocked.length > 0) throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`); @@ -400,11 +404,6 @@ export class SeatsService { passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })), }; - await tx.seat.updateMany({ - where: { id: { in: seatIds } }, - data: { status: 'HELD' }, - }); - return tx.seatHold.create({ data: { scheduleId: dto.scheduleId, @@ -545,13 +544,7 @@ export class SeatsService { async releaseHold(holdId: string) { const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } }); if (!hold) throw new NotFoundException('Hold not found'); - await this.prisma.$transaction([ - this.prisma.seat.updateMany({ - where: { id: { in: hold.seatIds as string[] }, status: 'HELD' }, - data: { status: 'AVAILABLE' }, - }), - this.prisma.seatHold.delete({ where: { id: holdId } }), - ]); + await this.prisma.seatHold.delete({ where: { id: holdId } }); return { released: true, holdId }; } @@ -657,21 +650,41 @@ export class SeatsService { } async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { originStationId: true, destinationStationId: true }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + const seats = await this.prisma.seat.findMany({ where: { coach: { assignments: { some: { scheduleId } } }, - status: 'AVAILABLE', seatNumber: { not: '' }, - NOT: { seatNumber: { startsWith: '-' } }, + NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }, { status: 'UNDER_MAINTENANCE' as any }], }, orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], }); - if (seats.length < count) { - throw new ConflictException(`Only ${seats.length} seats available, requested ${count}`); + const allSeatIds = seats.map(s => s.id); + const stopTimes = await this.prisma.tripStopTime.findMany({ + where: { scheduleId }, + select: { stationId: true, sequence: true }, + }); + const seqOf = (id: string) => stopTimes.find(s => s.stationId === id)?.sequence; + const reqFrom = seqOf(schedule.originStationId) ?? 0; + const reqTo = seqOf(schedule.destinationStationId) ?? stopTimes.length; + + const unavailable = await this.segmentsService.getSeatAvailabilityMap( + scheduleId, allSeatIds, stopTimes, reqFrom, reqTo, + ); + + const availableSeats = seats.filter(s => !unavailable.has(s.id)); + + if (availableSeats.length < count) { + throw new ConflictException(`Only ${availableSeats.length} seats available, requested ${count}`); } - const assigned = this.findContiguousSeats(seats, count); + const assigned = this.findContiguousSeats(availableSeats, count); return assigned.map((s) => s.id); } @@ -774,24 +787,34 @@ export class SeatsService { return { imported, errors: errors.slice(0, 10) }; } - async blockSeat(seatId: string, reason: string) { + async blockSeat(seatId: string, reason: string, scheduleId?: string) { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); - await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } }); - await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } }); - await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason } }); - return { blocked: true, seatId, reason }; + // Schedule-scoped block: only affects this schedule, not all schedules + // Global block (no scheduleId): sets Seat.status = BLOCKED for all schedules + if (scheduleId) { + await this.prisma.seatBlock.create({ data: { seatId, scheduleId, reason, blockedBy: 'system' } }); + } else { + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } }); + await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } }); + } + await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason, scheduleId } }); + return { blocked: true, seatId, reason, scheduleId }; } - async unblockSeat(seatId: string) { + async unblockSeat(seatId: string, scheduleId?: string) { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); - await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } }); - await this.prisma.seatBlock.deleteMany({ where: { seatId } }); - await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE' } }); - return { unblocked: true, seatId }; + if (scheduleId) { + await this.prisma.seatBlock.deleteMany({ where: { seatId, scheduleId } }); + } else { + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } }); + await this.prisma.seatBlock.deleteMany({ where: { seatId, scheduleId: null } }); + } + await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE', scheduleId } }); + return { unblocked: true, seatId, scheduleId }; } async setMaintenance(seatId: string, reason: string) { @@ -929,22 +952,12 @@ export class SeatsService { } } - if (releasedSeatIds.size > 0) { - await this.prisma.seat.updateMany({ - where: { id: { in: Array.from(releasedSeatIds) }, status: 'HELD' }, - // heldUntil is cleared alongside status — leaving a stale (past) heldUntil on an - // AVAILABLE seat is stale data that any future code reading heldUntil directly - // (instead of re-deriving availability live) would misinterpret. - data: { status: 'AVAILABLE', heldUntil: null }, - }); - } - await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: now } } }); return { expiredHolds: expired.length, releasedSeatIds: Array.from(releasedSeatIds), - skippedSeatIds: Array.from(skippedSeatIds), + skippedSeatIds: Array.from(skippedSeatIds), // kept for logging/API compat; no DB writes needed }; } } 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 f849654b5..be5449e97 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -87,7 +87,8 @@ export default function SeatsPage() { }; const blockMutation = useMutation({ - mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }), + mutationFn: ({ seatId, reason }: any) => + seatsApi.block(seatId, { reason, ...(activeTab === 'schedule' && selectedSchedule ? { scheduleId: selectedSchedule } : {}) }), onSuccess: () => { invalidateSeatData(); setShowBlockModal(false); @@ -97,7 +98,8 @@ export default function SeatsPage() { }); const unblockMutation = useMutation({ - mutationFn: (seatId: string) => seatsApi.unblock(seatId), + mutationFn: (seatId: string) => + seatsApi.unblock(seatId, activeTab === 'schedule' ? selectedSchedule : undefined), onSuccess: () => { invalidateSeatData(); }, @@ -143,7 +145,8 @@ export default function SeatsPage() { mutationFn: async ({ coachId, reason }: any) => { const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || []; const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id); - return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason }))); + const scheduleId = activeTab === 'schedule' ? selectedSchedule : undefined; + return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason, ...(scheduleId ? { scheduleId } : {}) }))); }, onSuccess: () => { invalidateSeatData(); @@ -157,7 +160,8 @@ export default function SeatsPage() { mutationFn: async ({ coachId }: any) => { const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || []; const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id); - return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId))); + const scheduleId = activeTab === 'schedule' ? selectedSchedule : undefined; + return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId, scheduleId))); }, onSuccess: () => { invalidateSeatData(); diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 2dae6560f..e8ed56195 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -154,7 +154,7 @@ export const seatsApi = { hold: (data: any) => apiClient.post('/seats/hold', data), release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`), block: (seatId: string, data: any) => apiClient.post(`/seats/${seatId}/block`, data), - unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`), + unblock: (seatId: string, scheduleId?: string) => apiClient.delete(`/seats/${seatId}/block${scheduleId ? `?scheduleId=${scheduleId}` : ''}`), removeSeat: (seatId: string) => apiClient.patch(`/seats/${seatId}/remove`, {}), undoRemove: (seatId: string) => apiClient.patch(`/seats/${seatId}/undo-remove`, {}), setMaintenance: (seatId: string, reason: string) => apiClient.post(`/seats/${seatId}/maintenance`, { reason }), From 3726ac8390953a9c2fd97bcb4b94a1abba88a4e6 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 16 Jul 2026 20:11:42 +0300 Subject: [PATCH 5/5] Add seat block schedule id for consistency --- .../20260716170852_add_seat_block_schedule_id/migration.sql | 5 +++++ .../src/modules/payments/payments.service.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260716170852_add_seat_block_schedule_id/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260716170852_add_seat_block_schedule_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716170852_add_seat_block_schedule_id/migration.sql new file mode 100644 index 000000000..11fb856c9 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260716170852_add_seat_block_schedule_id/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "SeatBlock" ADD COLUMN "scheduleId" TEXT; + +-- CreateIndex +CREATE INDEX "SeatBlock_scheduleId_idx" ON "SeatBlock"("scheduleId"); 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 8bf07c6bf..a9e4557f1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -432,8 +432,8 @@ export class PaymentsService { expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null, failureCode: snapshot.failureCode ?? null, failureMessage: snapshot.failureMessage ?? null, - rawInitiation: snapshot.providerResponse - ? (snapshot.providerResponse as unknown as Prisma.InputJsonValue) + rawInitiation: (snapshot as any).providerResponse + ? ((snapshot as any).providerResponse as unknown as Prisma.InputJsonValue) : Prisma.DbNull, }; return this.prisma.paymentIntent.upsert({