From 7ff98bfb8182cd328de80acd3fc894c3a872da27 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 14:28:47 +0300 Subject: [PATCH 1/2] Ticketing seats conflict issue resolution --- .../src/modules/tickets/tickets.service.ts | 133 +++++++++++++----- 1 file changed, 99 insertions(+), 34 deletions(-) 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 b788c03df..736b781bd 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -210,15 +210,6 @@ export class TicketsService { }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); - // Seats taken by other confirmed/boarded bookings on this schedule - const takenByOthers = await this.prisma.bookingSeat.findMany({ - where: { - booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } }, - seat: { coach: { assignments: { some: { scheduleId: booking.scheduleId } } } }, - }, - select: { seatId: true }, - }).then(rows => new Set(rows.map(r => r.seatId))); - // Seats held by any active SeatHold (not yet expired) const heldSeatIds = await this.prisma.seatHold.findMany({ where: { expiresAt: { gt: new Date() } }, @@ -230,42 +221,62 @@ export class TicketsService { select: { seatId: true }, }).then(rows => new Set(rows.map(r => r.seatId))); - // Union of all unavailable seat IDs (excluding the booking's own seats) const ownSeatIds = new Set((booking as any).seats.map((bs: any) => bs.seatId as string)); + const reassigned: { seatNumber: string; newSeatNumber: string }[] = []; + + // Track newly assigned seats so the same seat isn't given to two passengers const unavailableIds = new Set([ - ...[...takenByOthers].filter(id => !ownSeatIds.has(id)), ...[...heldSeatIds], ...[...blockedSeatIds], ]); - const reassigned: { seatNumber: string; newSeatNumber: string }[] = []; - for (const bs of (booking as any).seats) { const originalSeatId: string = bs.seatId; + // Use the per-seat scheduleId — for ROUND_TRIP leg 2 this is the return schedule, + // not booking.scheduleId (the outbound schedule). + const legScheduleId: string = bs.scheduleId ?? booking.scheduleId; - // Case 1: original seat is still free — nothing to do - if (!takenByOthers.has(originalSeatId) && !heldSeatIds.has(originalSeatId) && !blockedSeatIds.has(originalSeatId)) continue; + // Seats taken by other confirmed/boarded bookings on THIS leg's schedule + const takenByOthersOnLeg = await this.prisma.bookingSeat.findMany({ + where: { + booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } }, + seat: { coach: { assignments: { some: { scheduleId: legScheduleId } } } }, + }, + select: { seatId: true }, + }).then(rows => new Set(rows.map(r => r.seatId))); - // Case 2: original seat is unavailable — find a truly available seat in the same coach type + // Case 1: original seat is still free on this leg — nothing to do + if ( + !takenByOthersOnLeg.has(originalSeatId) && + !heldSeatIds.has(originalSeatId) && + !blockedSeatIds.has(originalSeatId) + ) continue; + + // Case 2: original seat is unavailable — find a free seat of the same coach type on this leg's schedule const coachTypeId: string | undefined = bs.seat?.coach?.coachTypeId; + const allUnavailable = new Set([ + ...[...takenByOthersOnLeg].filter(id => !ownSeatIds.has(id)), + ...[...unavailableIds], + ]); + const candidate = await this.prisma.seat.findFirst({ where: { status: 'AVAILABLE', seatNumber: { not: '' }, NOT: [ { seatNumber: { startsWith: '-' } }, - { id: { in: [...unavailableIds] } }, + { id: { in: [...allUnavailable] } }, ], coach: { - assignments: { some: { scheduleId: booking.scheduleId } }, + assignments: { some: { scheduleId: legScheduleId } }, ...(coachTypeId ? { coachTypeId } : {}), }, }, orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], }); - // Case 3: no seats left in that class + // Case 3: no seats left in that class on this leg if (!candidate) { const className = bs.seat?.coach?.coachType?.name ?? 'the same class'; throw new ConflictException( @@ -278,10 +289,7 @@ export class TicketsService { data: { seatId: candidate.id }, }); - // Mark the newly assigned seat as taken so subsequent passengers in the - // same booking don't get assigned the same seat. unavailableIds.add(candidate.id); - reassigned.push({ seatNumber: bs.seat.seatNumber, newSeatNumber: candidate.seatNumber }); } @@ -364,22 +372,79 @@ export class TicketsService { }); // Check for seat conflicts — only seats confirmed/boarded by a *different* booking - // on the same schedule are a real conflict. SeatBlock rows created by a previous - // generate() run for this booking are NOT a conflict; they are cleaned up above. - const conflictingSeats = await this.prisma.bookingSeat.findMany({ + // on the SAME schedule AND with OVERLAPPING segments are a real conflict. + // Segment overlap: two bookings conflict on a seat when their stop-sequence ranges + // overlap: A.originSeq < B.destSeq AND B.originSeq < A.destSeq. + // We resolve sequences via TripStopTime using each booking's originStationId / + // destinationStationId. Bookings with no station IDs (full-route) are treated as + // seq 0 → ∞ and always overlap. + const thisBookingSeats = (booking as any).seats as Array<{ seatId: string; scheduleId: string | null }>; + + // Resolve this booking's stop sequences per leg schedule + const thisSeqMap = new Map(); + const legScheduleIds = [...new Set(thisBookingSeats.map(bs => bs.scheduleId ?? booking.scheduleId))]; + for (const schedId of legScheduleIds) { + const originId = (booking as any).originStationId; + const destId = (booking as any).destinationStationId; + if (!originId || !destId) { + thisSeqMap.set(schedId, { originSeq: 0, destSeq: Number.MAX_SAFE_INTEGER }); + continue; + } + const stops = await this.prisma.tripStopTime.findMany({ + where: { scheduleId: schedId, stationId: { in: [originId, destId] } }, + select: { stationId: true, sequence: true }, + }); + const oStop = stops.find(s => s.stationId === originId); + const dStop = stops.find(s => s.stationId === destId); + thisSeqMap.set(schedId, { + originSeq: oStop?.sequence ?? 0, + destSeq: dStop?.sequence ?? Number.MAX_SAFE_INTEGER, + }); + } + + // Find other confirmed/boarded bookings that share any (seatId, scheduleId) pair + const candidateConflicts = await this.prisma.bookingSeat.findMany({ where: { - seatId: { in: seatIds }, - booking: { - id: { not: bookingId }, - status: { in: ['CONFIRMED', 'BOARDED'] }, - }, + OR: thisBookingSeats.map(bs => ({ + seatId: bs.seatId, + scheduleId: bs.scheduleId ?? booking.scheduleId, + booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } }, + })), + }, + include: { + seat: true, + booking: { select: { id: true, originStationId: true, destinationStationId: true } }, }, - include: { seat: true }, }); - if (conflictingSeats.length > 0) { - const labels = [...new Set(conflictingSeats.map((s: any) => s.seat.seatNumber))].join(', '); + + const trueConflicts: string[] = []; + for (const other of candidateConflicts) { + const legScheduleId = other.scheduleId ?? booking.scheduleId; + const thisSeq = thisSeqMap.get(legScheduleId) ?? { originSeq: 0, destSeq: Number.MAX_SAFE_INTEGER }; + + const otherOriginId = (other.booking as any).originStationId; + const otherDestId = (other.booking as any).destinationStationId; + let otherOriginSeq = 0; + let otherDestSeq = Number.MAX_SAFE_INTEGER; + if (otherOriginId && otherDestId) { + const stops = await this.prisma.tripStopTime.findMany({ + where: { scheduleId: legScheduleId, stationId: { in: [otherOriginId, otherDestId] } }, + select: { stationId: true, sequence: true }, + }); + otherOriginSeq = stops.find(s => s.stationId === otherOriginId)?.sequence ?? 0; + otherDestSeq = stops.find(s => s.stationId === otherDestId)?.sequence ?? Number.MAX_SAFE_INTEGER; + } + + // Segments overlap when: thisOrigin < otherDest AND otherOrigin < thisDest + if (thisSeq.originSeq < otherDestSeq && otherOriginSeq < thisSeq.destSeq) { + trueConflicts.push((other as any).seat.seatNumber); + } + } + + if (trueConflicts.length > 0) { + const labels = [...new Set(trueConflicts)].join(', '); throw new ConflictException( - `Seat(s) ${labels} are already confirmed for another booking.`, + `Seat(s) ${labels} are already confirmed for another booking on the same schedule and overlapping segment.`, ); } From c6f6616218813c6e5fe743e4de78eca2ffc2aaaa Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 14:31:27 +0300 Subject: [PATCH 2/2] Restore ticket generation guard --- .../src/modules/tickets/tickets.controller.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 3a48ca2a1..4ecaeb267 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -34,10 +34,11 @@ export class TicketsController { } @Post('generate/:bookingId') - @SetMetadata('isPublic', true) + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ - summary: 'Generate ticket for booking (confirmation page)', - description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records. Requires payment to be SUCCEEDED and booking to be CONFIRMED.' + summary: 'Generate ticket for booking', + description: 'Creates a ticket for a confirmed booking with succeeded payment. Requires payment to be SUCCEEDED and booking to be CONFIRMED.' }) generateTicket(@Param('bookingId') bookingId: string) { return this.service.generate(bookingId);