Merge branch 'alpha' into passenger/feat/iam

This commit is contained in:
Abubeker Yasin
2026-06-23 14:47:24 +03:00
37 changed files with 1271 additions and 1048 deletions

View File

@@ -11,7 +11,7 @@ export class SeatsService {
private segmentsService: SegmentsService,
) {}
async getSeatMap(scheduleId: string, coachId?: string) {
async getSeatMap(scheduleId: string, coachId?: string, originStationId?: string, destinationStationId?: string) {
const assignments = await this.prisma.coachAssignment.findMany({
where: { scheduleId, ...(coachId ? { coachId } : {}) },
include: {
@@ -25,16 +25,14 @@ export class SeatsService {
orderBy: { positionNumber: 'asc' },
});
console.log(`[getSeatMap] scheduleId=${scheduleId}, coachId=${coachId}, found ${assignments.length} coach assignments`);
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds);
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, originStationId, destinationStationId);
const response = {
return {
coaches: assignments.map((a) => {
const allSeats = a.coach.seats;
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
return {
id: a.coach.id,
assignmentId: a.id,
@@ -68,43 +66,95 @@ export class SeatsService {
};
}),
};
console.log(`[getSeatMap] returning ${response.coaches.length} coaches with seats`);
return response;
}
async resolveEffectiveStatuses(
scheduleId: string,
seatIds: string[],
originStationId?: string,
destinationStationId?: string,
): Promise<Map<string, string>> {
const statusMap = new Map<string, string>();
if (seatIds.length === 0) return statusMap;
// Resolve the requested leg's sequence range once
let reqFrom: number | undefined;
let reqTo: number | undefined;
let allStopTimes: { stationId: string; sequence: number }[] | null = null;
const getStopTimes = async () => {
if (!allStopTimes) {
allStopTimes = await this.prisma.tripStopTime.findMany({
where: { scheduleId },
select: { stationId: true, sequence: true },
});
}
return allStopTimes;
};
if (originStationId && destinationStationId) {
const stops = await getStopTimes();
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
reqFrom = seqOf(originStationId);
reqTo = seqOf(destinationStationId);
}
// ── Active holds ──────────────────────────────────────────────────────────
const activeHolds = await this.prisma.seatHold.findMany({
where: {
scheduleId,
expiresAt: { gt: new Date() },
seatIds: { hasSome: seatIds },
},
select: { seatIds: true },
where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } },
select: { seatIds: true, createdBy: true },
});
for (const hold of activeHolds) {
let holdFrom: number | undefined;
let holdTo: number | undefined;
try {
if (hold.createdBy?.trimStart().startsWith('{')) {
const meta = JSON.parse(hold.createdBy);
const stops = await getStopTimes();
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
holdFrom = seqOf(meta.originStationId);
holdTo = seqOf(meta.destinationStationId);
}
} catch { /* ignore */ }
for (const seatId of hold.seatIds) {
if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD');
if (!seatIds.includes(seatId)) continue;
if (reqFrom !== undefined && reqTo !== undefined && holdFrom !== undefined && holdTo !== undefined) {
if (holdFrom < reqTo && reqFrom < holdTo) statusMap.set(seatId, 'HELD');
} else {
statusMap.set(seatId, 'HELD');
}
}
}
// ── Confirmed bookings via JourneySegment ─────────────────────────────────
const bookedSegments = await this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId: { in: seatIds },
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
select: { seatId: true },
select: { seatId: true, departureStationId: true, arrivalStationId: true },
});
for (const seg of bookedSegments) {
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
if (reqFrom !== undefined && reqTo !== undefined) {
const stops = await getStopTimes();
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
for (const seg of bookedSegments) {
if (!seg.seatId) continue;
const segFrom = seqOf(seg.departureStationId);
const segTo = seqOf(seg.arrivalStationId);
if (segFrom !== undefined && segTo !== undefined) {
if (segFrom < reqTo && reqFrom < segTo) statusMap.set(seg.seatId, 'BOOKED');
} else {
statusMap.set(seg.seatId, 'BOOKED');
}
}
} else {
for (const seg of bookedSegments) {
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
}
}
return statusMap;
@@ -154,6 +204,7 @@ export class SeatsService {
if (reqFrom >= reqTo)
throw new BadRequestException('Origin must come before destination');
// ── Check existing holds for overlap ────────────────────────────────────
const activeHolds = await tx.seatHold.findMany({
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
select: { seatIds: true, createdBy: true },
@@ -197,6 +248,29 @@ export class SeatsService {
}
}
// ── Check confirmed JourneySegments for overlap ──────────────────────────
const bookedSegments = await tx.journeySegment.findMany({
where: {
scheduleId: dto.scheduleId,
seatId: { in: seatIds },
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
select: { seatId: true, departureStationId: true, arrivalStationId: true },
});
for (const seg of bookedSegments) {
if (!seg.seatId) continue;
const segFrom = seqOf(seg.departureStationId);
const segTo = seqOf(seg.arrivalStationId);
if (segFrom !== undefined && segTo !== undefined) {
if (segFrom < reqTo && reqFrom < segTo) {
throw new ConflictException(
`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`,
);
}
}
}
const holdMeta = {
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
@@ -347,16 +421,12 @@ export class SeatsService {
return { released: true, holdId };
}
async confirmSeats(seatIds: string[]) {
// No-op
}
// Physical seat.status stays AVAILABLE — segment rows are the source of truth for occupancy.
async confirmSeats(_seatIds: string[]) {}
async releaseSeats(seatIds: string[]) {
if (seatIds.length > 0) {
await this.prisma.journeySegment.deleteMany({
where: { seatId: { in: seatIds } },
});
}
// Delete the Journey (and its JourneySegments) scoped to this booking.
async releaseSeats(bookingId: string) {
await this.prisma.journey.deleteMany({ where: { bookingId } });
}
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
@@ -424,7 +494,7 @@ export class SeatsService {
invalid++;
continue;
}
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
const [coachId, , row, col, seatNumber] = parts;
if (!coachId || !row || !col || !seatNumber) {
errors.push(`Line ${i + 2}: Missing required fields`);
invalid++;
@@ -448,7 +518,7 @@ export class SeatsService {
for (let i = 0; i < lines.length; i++) {
try {
const parts = lines[i].split(',');
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
const [coachId, , row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
await this.prisma.seat.upsert({
where: { coachId_row_col: { coachId, row: parseInt(row), col } },
@@ -481,18 +551,8 @@ export class SeatsService {
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.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } });
await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } });
return { blocked: true, seatId, reason };
}
@@ -501,14 +561,8 @@ export class SeatsService {
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.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } });
await this.prisma.seatBlock.deleteMany({ where: { seatId } });
return { unblocked: true, seatId };
}
@@ -518,11 +572,9 @@ export class SeatsService {
if (!seat) throw new NotFoundException('Seat not found');
if (!seat.seatNumber) throw new BadRequestException('Seat already removed');
// Mark removed seat with negative seatNumber (e.g., '1' → '-1') to show empty space
const negatedNumber = `-${seat.seatNumber}`;
await this.prisma.seat.update({
where: { id: seatId },
data: { seatNumber: negatedNumber },
data: { seatNumber: `-${seat.seatNumber}` },
});
return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
@@ -535,26 +587,15 @@ export class SeatsService {
throw new BadRequestException('Seat is not removed');
}
// Restore original seatNumber by removing the negative sign
const originalNumber = seat.seatNumber.slice(1);
await this.prisma.seat.update({
where: { id: seatId },
data: { seatNumber: originalNumber },
});
await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: originalNumber } });
return { restored: true, seatId, seatNumber: originalNumber };
}
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
const now = new Date();
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: now } } });
if (expired.length === 0) return;
const expiredIds = expired.map(h => h.id);
for (const hold of expired) {
await this.releaseSeats(hold.seatIds);
}
await this.prisma.seatHold.deleteMany({ where: { id: { in: expiredIds } } });
// Holds are temporary and don't create Journey rows — just delete expired ones.
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
}
}