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 }),