Fix expired seat

This commit is contained in:
Roba Boru
2026-07-15 08:13:23 +03:00
parent ae2db7feb4
commit 64b362ff51
2 changed files with 132 additions and 27 deletions

View File

@@ -1,4 +1,4 @@
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
import { Injectable, BadRequestException, ConflictException, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SegmentsService, Segment } from '../segments/segments.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
@@ -18,6 +18,8 @@ export interface BookingConfirmRequest {
@Injectable()
export class EnhancedSeatsService {
private readonly logger = new Logger(EnhancedSeatsService.name);
constructor(
private prisma: PrismaService,
private segmentsService: SegmentsService,
@@ -57,6 +59,15 @@ export class EnhancedSeatsService {
},
});
// Mirrors SeatsService.holdSeats() — without this, a seat held through this path
// reads back as status 'AVAILABLE' in the DB despite being actively held, which is
// wrong for any consumer that trusts `status` directly instead of re-deriving
// availability live from SeatHold.
await tx.seat.updateMany({
where: { id: { in: request.seatIds } },
data: { status: 'HELD', heldUntil: expiresAt },
});
this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments });
return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds };
});
@@ -157,19 +168,58 @@ export class EnhancedSeatsService {
});
}
async expireHolds() {
return this.prisma.$transaction(async (tx) => {
const expiredHolds = await tx.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
const expiredSeatIds = expiredHolds.flatMap(h => h.seatIds);
// now/expiresAt are absolute UTC instants (Date objects), not wall-clock strings, so
// this comparison is correct regardless of the server's local timezone.
async expireHolds(now: Date = new Date()) {
try {
const result = await this.prisma.$transaction(async (tx) => {
const expiredHolds = await tx.seatHold.findMany({ where: { expiresAt: { lt: now } } });
if (expiredHolds.length === 0) {
return { expiredHolds: 0, releasedSeats: [] as string[] };
}
if (expiredSeatIds.length > 0) {
await tx.seat.updateMany({ where: { id: { in: expiredSeatIds } }, data: { status: 'AVAILABLE', heldUntil: null } });
await tx.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
this.eventEmitter.emit('holds.expired', { expiredHolds: expiredHolds.length, releasedSeats: expiredSeatIds });
// Still-active holds — scoped per (scheduleId, seatId). The same physical Seat
// row is reused across every recurring date a coach runs, so the same seatId can
// legitimately appear in an unrelated hold for a different schedule; without this
// scoping, that unrelated hold would wrongly be treated as covering THIS
// schedule's seat too, and a seat still genuinely held (same schedule, a newer
// non-expired hold) could be released out from under it.
const activeHolds = await tx.seatHold.findMany({ where: { expiresAt: { gte: now } } });
const stillHeldKeys = new Set(
activeHolds.flatMap(h => h.seatIds.map(seatId => `${h.scheduleId}:${seatId}`)),
);
const releasedSeatIds = new Set<string>();
for (const hold of expiredHolds) {
for (const seatId of hold.seatIds) {
if (!stillHeldKeys.has(`${hold.scheduleId}:${seatId}`)) releasedSeatIds.add(seatId);
}
}
if (releasedSeatIds.size > 0) {
await tx.seat.updateMany({
where: { id: { in: Array.from(releasedSeatIds) } },
data: { status: 'AVAILABLE', heldUntil: null },
});
}
await tx.seatHold.deleteMany({ where: { expiresAt: { lt: now } } });
return { expiredHolds: expiredHolds.length, releasedSeats: Array.from(releasedSeatIds) };
});
if (result.expiredHolds > 0) {
this.logger.log(`Expired ${result.expiredHolds} hold(s), released ${result.releasedSeats.length} seat(s)`);
this.eventEmitter.emit('holds.expired', { expiredHolds: result.expiredHolds, releasedSeats: result.releasedSeats });
}
return { expiredHolds: expiredHolds.length, releasedSeats: expiredSeatIds };
});
return result;
} catch (error) {
// A failed run must not go unnoticed — nothing is deleted/updated until the
// transaction commits, so the next caller/scheduled run simply retries the same
// still-expired holds.
this.logger.error('Failed to expire seat holds', error instanceof Error ? error.stack : error);
return { expiredHolds: 0, releasedSeats: [] as string[] };
}
}
async getSeatAvailability(scheduleId: string, originStationId: string, destinationStationId: string) {