mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 11:18:17 +00:00
@@ -24,7 +24,11 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
const request = ctx.getRequest();
|
||||
|
||||
let prismaMessage: string | null = null;
|
||||
let prismaCode: string | null = null;
|
||||
let prismaMeta: unknown = null;
|
||||
if (exception instanceof PrismaClientKnownRequestError) {
|
||||
prismaCode = exception.code;
|
||||
prismaMeta = exception.meta;
|
||||
if (exception.code === 'P2003') {
|
||||
const field = (exception.meta?.field_name as string | undefined) ?? 'a related record';
|
||||
prismaMessage = `Cannot delete this record because it is still referenced by ${field}. Remove the related records first.`;
|
||||
@@ -68,6 +72,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
statusCode: status,
|
||||
message,
|
||||
error: exception instanceof Error ? exception.name : 'Error',
|
||||
...(prismaCode ? { prismaCode, prismaMeta } : {}),
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
...customFields,
|
||||
|
||||
@@ -13,10 +13,17 @@ export const MAX_PAYMENT_HOURS = 2;
|
||||
export const CUTOFF_MINUTES = 30;
|
||||
|
||||
/**
|
||||
* payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
|
||||
* payment_deadline = MIN(booking_time + MAX_PAYMENT_HOURS, segment_departure - checkinMinutes)
|
||||
*
|
||||
* checkinMinutes defaults to CUTOFF_MINUTES but callers should pass the route-level
|
||||
* checkinMinutesBefore so that each route's own window is respected.
|
||||
*/
|
||||
export function computePaymentDeadline(createdAt: Date, departureAt: Date): Date {
|
||||
export function computePaymentDeadline(
|
||||
createdAt: Date,
|
||||
departureAt: Date,
|
||||
checkinMinutes: number = CUTOFF_MINUTES,
|
||||
): Date {
|
||||
const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
|
||||
const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000);
|
||||
const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000);
|
||||
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
|
||||
}
|
||||
|
||||
@@ -18,12 +18,11 @@
|
||||
* parseEthiopianTime('2026-06-15') // Treats as midnight EAT
|
||||
*/
|
||||
export function parseEthiopianTime(dateInput: string | Date): Date {
|
||||
if (dateInput instanceof Date) {
|
||||
return dateInput;
|
||||
}
|
||||
|
||||
// Parse as local time (EAT) since TZ is set to Africa/Addis_Ababa
|
||||
return new Date(dateInput);
|
||||
if (dateInput instanceof Date) return dateInput;
|
||||
// Already has timezone info (Z or +HH:MM) — parse directly as UTC
|
||||
if (/Z$|[+-]\d{2}:\d{2}$/.test(dateInput)) return new Date(dateInput);
|
||||
// Bare local string (e.g. "2026-07-23T21:00") — treat explicitly as EAT (UTC+3)
|
||||
return new Date(dateInput + '+03:00');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -322,7 +322,7 @@ Payment providers send notifications to:
|
||||
- \`POST /payments/webhooks/card\` (International)
|
||||
|
||||
## Support
|
||||
- **Email:** support@edr-platform.com
|
||||
- **Email:** edr_@edrsc.com
|
||||
- **Documentation:** https://docs.edr-platform.com
|
||||
- **Status Page:** https://status.edr-platform.com
|
||||
`,
|
||||
|
||||
@@ -85,6 +85,7 @@ export class AgentsService {
|
||||
seats: {
|
||||
create: dto.passengers.map(p => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: p.fullName,
|
||||
idDocumentType: p.idDocumentType as IdDocumentType | undefined,
|
||||
idDocumentNumber: p.idDocumentNumber
|
||||
|
||||
@@ -9,9 +9,10 @@ import { VerifaydaModule } from '../verifayda/verifayda.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
import { TicketsModule } from '../tickets/tickets.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule],
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, GuestBookingService],
|
||||
exports: [BookingsService, GuestBookingService]
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { TicketsService } from '../tickets/tickets.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
@@ -101,6 +102,7 @@ export class BookingsService {
|
||||
private readonly prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly seatsService: SeatsService,
|
||||
private readonly ticketsService: TicketsService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly verifaydaService: VerifaydaService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
@@ -320,8 +322,8 @@ export class BookingsService {
|
||||
id: b.id,
|
||||
bookingRef: b.bookingRef,
|
||||
status: b.status,
|
||||
totalMinor: b.totalMinor,
|
||||
currency: b.currency || null,
|
||||
totalMinor: b.displayTotalMinor ?? b.totalMinor,
|
||||
currency: b.displayCurrency ?? b.currency ?? null,
|
||||
displayCurrency: b.displayCurrency ?? null,
|
||||
displayTotalMinor: b.displayTotalMinor ?? null,
|
||||
adultCount: b.adultCount,
|
||||
@@ -578,7 +580,7 @@ export class BookingsService {
|
||||
|
||||
const mappedPkg = pkgItems.map((b: any) => ({
|
||||
id: b.id, bookingRef: b.bookingRef, status: b.status,
|
||||
totalMinor: b.totalMinor, currency: b.currency || b.displayCurrency,
|
||||
totalMinor: b.displayTotalMinor ?? b.totalMinor, currency: b.displayCurrency || b.currency,
|
||||
displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor,
|
||||
contactEmail: b.contactEmail, contactPhone: b.contactPhone,
|
||||
bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId,
|
||||
@@ -732,8 +734,8 @@ export class BookingsService {
|
||||
id: b.id,
|
||||
bookingRef: b.bookingRef,
|
||||
status: b.status,
|
||||
totalMinor: b.totalMinor,
|
||||
currency: b.currency || b.displayCurrency,
|
||||
totalMinor: b.displayTotalMinor ?? b.totalMinor,
|
||||
currency: b.displayCurrency || b.currency,
|
||||
displayCurrency: b.displayCurrency,
|
||||
displayTotalMinor: b.displayTotalMinor,
|
||||
contactEmail: b.contactEmail,
|
||||
@@ -834,51 +836,96 @@ export class BookingsService {
|
||||
|
||||
// Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific
|
||||
// pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor.
|
||||
let freeChildUsed = false;
|
||||
let pkgChildIdx = 0;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let fareMinor: number;
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
|
||||
} else if (dto.packageId) {
|
||||
fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? fareCalculation.baseFareMinor);
|
||||
pkgChildIdx++;
|
||||
} else {
|
||||
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
|
||||
else fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
|
||||
// Free children have no seat (frontend excludes them from the DTO).
|
||||
// Guard by seatId: unseated = free (0), seated = paid child.
|
||||
// Applies to both package and regular bookings.
|
||||
fareMinor = p.seatId ? (p.seatFareMinor ?? fareCalculation.baseFareMinor) : 0;
|
||||
}
|
||||
return { ...p, fareMinor };
|
||||
});
|
||||
|
||||
// Use the sum of per-seat fares as the authoritative total when the client supplied
|
||||
// seatFareMinor for every seat-holding passenger — this captures berth-specific pricing
|
||||
// (Upper/Middle/Lower) that the fare engine cannot resolve from seatClassId alone.
|
||||
// Free children have no seatId and no seatFareMinor — exclude them from the check.
|
||||
// Determine the authoritative total.
|
||||
// Priority (one-way, non-package):
|
||||
// 1. Server-computed sum of per-seat fares when every seated passenger supplied
|
||||
// seatFareMinor — this captures berth-specific pricing (Upper/Middle/Lower)
|
||||
// exactly as shown to the user and cannot be corrupted by a frontend race
|
||||
// condition that sends reviewedTotalMinor before all fares are resolved.
|
||||
// 2. reviewedTotalMinor from the frontend — fallback when the server doesn't
|
||||
// have complete per-seat data (e.g. auto-assign with no seat map loaded).
|
||||
// 3. Fare engine total — last resort when neither is available.
|
||||
// For package bookings reviewedTotalMinor always wins because the tier price
|
||||
// may include berth-specific adjustments the server cannot derive alone.
|
||||
// Free children have no seatId and no seatFareMinor — exclude from the check.
|
||||
const seatedPassengers = passengersData.filter(p => p.seatId);
|
||||
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
||||
// seatFareMinor values from the client are in display-currency minor units (matching
|
||||
// displayAmountMinor from search results). reviewedTotalMinor is also display-currency minor.
|
||||
// In both cases: store as displayTotalMinor as-is, back-convert to ETB for totalMinor.
|
||||
let resolvedTotalMinor: number;
|
||||
let displayTotalMinor: number;
|
||||
if (dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
} else if (allFaresProvided) {
|
||||
// seatFareMinor is in display currency — sum is already the display total
|
||||
// True when the total came from a client-summed subtotal (per-seat sum or reviewedTotalMinor),
|
||||
// which the portal computes UNDISCOUNTED — the promo must still be applied to it (H-13).
|
||||
let usedClientSubtotal = false;
|
||||
|
||||
if (allFaresProvided && !dto.packageId) {
|
||||
// Server has every passenger's berth fare — sum is the authoritative display total.
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
usedClientSubtotal = true;
|
||||
if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor !== displayTotalMinor) {
|
||||
this.logger.warn(`createOneWayBooking: reviewedTotalMinor=${dto.reviewedTotalMinor} ignored — using server-computed sum=${displayTotalMinor}`);
|
||||
}
|
||||
} else if (dto.packageId && dto.reviewedTotalMinor != null) {
|
||||
// Package booking: client-supplied tier-adjusted total.
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
if (seatedPassengers.length > 0) {
|
||||
const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length);
|
||||
passengersWithFares.forEach(p => {
|
||||
if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare;
|
||||
});
|
||||
}
|
||||
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
|
||||
// Partial data on server: use client's total as best available.
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
usedClientSubtotal = true;
|
||||
} else {
|
||||
resolvedTotalMinor = fareCalculation.totalMinor;
|
||||
displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency)
|
||||
: resolvedTotalMinor;
|
||||
}
|
||||
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} displayTotalMinor=${displayTotalMinor} displayCurrency=${displayCurrency} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
|
||||
|
||||
// H-13 fix: the portal sums UNDISCOUNTED per-passenger fares into the total it sends, silently
|
||||
// dropping the promo the fare engine recognized (the discount lives only in the fare-breakdown).
|
||||
// When the total came from that client subtotal, apply the authoritative promo discount so the
|
||||
// customer is charged the discounted price. No-op when no promo applies (discountMinor === 0).
|
||||
// The fallback branch above already books fareCalculation.totalMinor (discount included), so it is
|
||||
// excluded via usedClientSubtotal to avoid double-subtracting.
|
||||
if (usedClientSubtotal && fareCalculation.discountMinor > 0) {
|
||||
resolvedTotalMinor = Math.max(0, resolvedTotalMinor - fareCalculation.discountMinor);
|
||||
const discountDisplayMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(fareCalculation.discountMinor, Currency.ETB, displayCurrency)
|
||||
: fareCalculation.discountMinor;
|
||||
displayTotalMinor = Math.max(0, displayTotalMinor - discountDisplayMinor);
|
||||
}
|
||||
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} displayTotalMinor=${displayTotalMinor} displayCurrency=${displayCurrency} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} discountMinor=${fareCalculation.discountMinor} fareEngine=${fareCalculation.totalMinor})`);
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative fare. resolvedTotalMinor
|
||||
// is the ETB charge basis; fareCalculation.totalMinor is the authoritative ETB fare (already net
|
||||
// of promo/loyalty/free-child). A client that forges seatFareMinor / reviewedTotalMinor below it
|
||||
// is rejected. Floor (not equality) so legitimate berth surcharges — which only raise the total —
|
||||
// still pass; the tolerance absorbs FX-conversion rounding.
|
||||
this.assertTotalNotUnderAuthoritative(resolvedTotalMinor, fareCalculation.totalMinor, 'createOneWayBooking');
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
@@ -890,7 +937,9 @@ export class BookingsService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ONE_WAY',
|
||||
totalMinor: resolvedTotalMinor,
|
||||
currency: displayCurrency,
|
||||
// Charge basis is ETB (resolvedTotalMinor). The passenger's currency and amount live in
|
||||
// displayCurrency/displayTotalMinor — keep currency coherent with totalMinor's units.
|
||||
currency: Currency.ETB,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -901,6 +950,7 @@ export class BookingsService {
|
||||
seats: {
|
||||
create: passengersWithFares.map(p => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
@@ -1008,6 +1058,9 @@ export class BookingsService {
|
||||
loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
|
||||
}
|
||||
// C-1 guard: authoritative ETB fare for both legs, captured before the client-driven branches
|
||||
// below may overwrite totalMinor with a per-seat sum or reviewedTotalMinor.
|
||||
const authoritativeTotalMinor = totalMinor;
|
||||
const taxesMinor = 0;
|
||||
|
||||
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
|
||||
@@ -1018,8 +1071,6 @@ export class BookingsService {
|
||||
|
||||
// Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when
|
||||
// present (berth-specific pricing). Fall back to fare engine values.
|
||||
let outboundFreeChildUsed = false;
|
||||
let returnFreeChildUsed = false;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let outboundFareMinor: number;
|
||||
let returnFareMinor: number;
|
||||
@@ -1027,14 +1078,12 @@ export class BookingsService {
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
|
||||
returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
|
||||
} else if (dto.packageId) {
|
||||
outboundFareMinor = 0;
|
||||
returnFareMinor = 0;
|
||||
} else {
|
||||
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
|
||||
else outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
|
||||
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
|
||||
else returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
|
||||
// Free children have no outbound seat (frontend excludes them).
|
||||
// Guard by outboundSeatId: unseated = free (0), seated = paid child.
|
||||
// Applies to both package and regular bookings.
|
||||
outboundFareMinor = p.outboundSeatId ? (p.seatFareMinor ?? outboundFare.baseFareMinor) : 0;
|
||||
returnFareMinor = p.outboundSeatId ? (p.returnSeatFareMinor ?? returnFare.baseFareMinor) : 0;
|
||||
}
|
||||
|
||||
return { ...p, outboundFareMinor, returnFareMinor };
|
||||
@@ -1042,22 +1091,44 @@ export class BookingsService {
|
||||
|
||||
// Override totalMinor with the sum of actual per-seat fares when all seated passengers
|
||||
// supplied their fares — free children (no seatId) are excluded from the check.
|
||||
// Same priority logic as one-way: server-computed sum wins when all per-seat fares
|
||||
// are present; reviewedTotalMinor is used only as fallback to avoid a frontend
|
||||
// race condition from under-counting passengers.
|
||||
const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId);
|
||||
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
||||
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
||||
if (dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
} else if (allRTFaresProvided && !dto.packageId) {
|
||||
// seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total
|
||||
|
||||
if (allRTFaresProvided && !dto.packageId) {
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor !== displayTotalMinor) {
|
||||
this.logger.warn(`createRoundTripBooking: reviewedTotalMinor=${dto.reviewedTotalMinor} ignored — using server-computed sum=${displayTotalMinor}`);
|
||||
}
|
||||
} else if (dto.packageId && dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
const seatedCount = rtSeatedPassengers.length;
|
||||
if (seatedCount > 0) {
|
||||
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
|
||||
passengersWithFares.forEach(p => {
|
||||
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
|
||||
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
|
||||
});
|
||||
}
|
||||
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
}
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
|
||||
this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createRoundTripBooking');
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
@@ -1068,7 +1139,7 @@ export class BookingsService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP',
|
||||
totalMinor,
|
||||
currency: displayCurrency,
|
||||
currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -1261,7 +1332,7 @@ export class BookingsService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'TRANSIT',
|
||||
totalMinor,
|
||||
currency: displayCurrency,
|
||||
currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -1471,7 +1542,7 @@ export class BookingsService {
|
||||
destinationStationId: dto.leg2DestinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP_TRANSIT',
|
||||
totalMinor, currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor,
|
||||
totalMinor, currency: Currency.ETB, adultCount, childCount, displayCurrency, displayTotalMinor, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
// Outbound transit leg-2
|
||||
leg2ScheduleId: dto.leg2ScheduleId,
|
||||
leg2OriginStationId: dto.transitStationId,
|
||||
@@ -1640,6 +1711,21 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* C-1 protection: reject a booking whose ETB charge basis is below the server-recomputed
|
||||
* authoritative fare. A floor (not equality) so legitimate berth surcharges — which only raise
|
||||
* the total — still pass; a 1% tolerance absorbs FX-conversion rounding. A forged seatFareMinor /
|
||||
* reviewedTotalMinor that lowers the charge (e.g. to 1 or 0) is refused with a 400 and nothing is
|
||||
* persisted.
|
||||
*/
|
||||
private assertTotalNotUnderAuthoritative(resolvedTotalMinor: number, authoritativeMinor: number, context: string): void {
|
||||
const tolerance = Math.max(1, Math.round(authoritativeMinor * 0.01));
|
||||
if (resolvedTotalMinor < authoritativeMinor - tolerance) {
|
||||
this.logger.warn(`${context}: rejecting booking — resolvedTotalMinor=${resolvedTotalMinor} below authoritative fare=${authoritativeMinor}`);
|
||||
throw new BadRequestException('Booking total does not match the authoritative fare');
|
||||
}
|
||||
}
|
||||
|
||||
private async calculateFare(
|
||||
scheduleId: string,
|
||||
seatClassId: string,
|
||||
@@ -1822,8 +1908,8 @@ export class BookingsService {
|
||||
id: pkgBooking.id,
|
||||
bookingRef: pkgBooking.bookingRef,
|
||||
status: pkgBooking.status,
|
||||
totalMinor: pkgBooking.totalMinor,
|
||||
currency: pkgBooking.currency || pkgBooking.displayCurrency,
|
||||
totalMinor: pkgBooking.displayTotalMinor ?? pkgBooking.totalMinor,
|
||||
currency: pkgBooking.displayCurrency || pkgBooking.currency,
|
||||
adultCount: pkgBooking.passengerCount,
|
||||
childCount: 0,
|
||||
displayCurrency: pkgBooking.displayCurrency,
|
||||
@@ -1852,7 +1938,7 @@ export class BookingsService {
|
||||
fullName: p.passengerName,
|
||||
category: 'ADULT',
|
||||
leg: 1,
|
||||
fareMinor: Math.round(pkgBooking.totalMinor / pkgBooking.passengerCount),
|
||||
fareMinor: Math.round((pkgBooking.displayTotalMinor ?? pkgBooking.totalMinor) / pkgBooking.passengerCount),
|
||||
verifaydaVerified: false,
|
||||
seat: null,
|
||||
})),
|
||||
@@ -1868,6 +1954,39 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-heal: if booking is CONFIRMED, payment SUCCEEDED, but tickets are missing
|
||||
// (ticket generation failed silently after payment — see finalizePaymentSuccess in
|
||||
// payments.service.ts), attempt to generate them now so the confirmation page
|
||||
// doesn't show "Not yet issued".
|
||||
if (
|
||||
booking.status === 'CONFIRMED' &&
|
||||
(booking as any).tickets?.length === 0 &&
|
||||
(booking as any).paymentIntent?.status === 'SUCCEEDED'
|
||||
) {
|
||||
try {
|
||||
await this.ticketsService.generate(booking.id);
|
||||
} catch (err) {
|
||||
this.logger.warn(`getByRef: generate failed for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}. Trying smart assign.`);
|
||||
try {
|
||||
await this.ticketsService.smartAssignAndGenerate(booking.id);
|
||||
} catch (retryErr) {
|
||||
this.logger.error(`getByRef: smart assign also failed for booking ${booking.id}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
|
||||
}
|
||||
}
|
||||
// Re-fetch to include any newly created tickets
|
||||
const refreshed = await this.prisma.booking.findUnique({
|
||||
where: { id: booking.id },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||||
paymentIntent: true, tickets: true,
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
},
|
||||
});
|
||||
if (refreshed) Object.assign(booking, refreshed);
|
||||
}
|
||||
|
||||
const outboundSegment = this.resolveSegmentStations(
|
||||
(booking as any).schedule,
|
||||
(booking as any).originStationId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, BadRequestException, NotFoundException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
@@ -42,6 +42,8 @@ function calculateAge(dateOfBirth: Date): number {
|
||||
|
||||
@Injectable()
|
||||
export class GuestBookingService {
|
||||
private readonly logger = new Logger(GuestBookingService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private seatsService: SeatsService,
|
||||
@@ -52,6 +54,21 @@ export class GuestBookingService {
|
||||
private eventEmitter: EventEmitter2,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* C-1 protection (guest path): reject a booking whose ETB charge basis is below the
|
||||
* server-recomputed authoritative fare. A floor (not equality) so legitimate berth surcharges —
|
||||
* which only raise the total — still pass; a 1% tolerance absorbs FX-conversion rounding. A forged
|
||||
* seatFareMinor / reviewedTotalMinor that lowers the charge (e.g. to 0) is refused with a 400 and
|
||||
* nothing is persisted.
|
||||
*/
|
||||
private assertTotalNotUnderAuthoritative(resolvedTotalMinor: number, authoritativeMinor: number, context: string): void {
|
||||
const tolerance = Math.max(1, Math.round(authoritativeMinor * 0.01));
|
||||
if (resolvedTotalMinor < authoritativeMinor - tolerance) {
|
||||
this.logger.warn(`${context}: rejecting booking — resolvedTotalMinor=${resolvedTotalMinor} below authoritative fare=${authoritativeMinor}`);
|
||||
throw new BadRequestException('Booking total does not match the authoritative fare');
|
||||
}
|
||||
}
|
||||
|
||||
async createGuestBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
// Enrich passengers with phone/email from SavedPassengerProfile when not supplied inline.
|
||||
// The portal calls /passengers/save-details before booking but doesn't re-send contact
|
||||
@@ -204,35 +221,41 @@ export class GuestBookingService {
|
||||
const taxesMinor = 0;
|
||||
|
||||
// Per-seat fare: use client-supplied seatFareMinor when present (berth-specific pricing).
|
||||
// Free children (first child, non-package) get fareMinor=0.
|
||||
let freeChildUsed = false;
|
||||
let pkgChildIdx = 0;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let fareMinor: number;
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
fareMinor = p.seatFareMinor ?? baseFareMinor;
|
||||
} else if (isPackageOneway) {
|
||||
fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? childUnitFare);
|
||||
pkgChildIdx++;
|
||||
} else {
|
||||
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
|
||||
else fareMinor = p.seatFareMinor ?? childUnitFare;
|
||||
// Free children have no seat (frontend excludes them from the DTO).
|
||||
// Guard by seatId: unseated = free (0), seated = paid child.
|
||||
// Applies to both package and regular bookings.
|
||||
fareMinor = p.seatId ? (p.seatFareMinor ?? childUnitFare) : 0;
|
||||
}
|
||||
return { ...p, fareMinor };
|
||||
});
|
||||
|
||||
// reviewedTotalMinor and seatFareMinor are both in display-currency minor units.
|
||||
// Store as displayTotalMinor as-is; back-convert to ETB for totalMinor.
|
||||
// Server-computed sum from per-seat fares is the authoritative total when all
|
||||
// seated passengers supplied seatFareMinor. This prevents a frontend race
|
||||
// condition (fareBreakdown not yet loaded → only partial fares summed →
|
||||
// reviewedTotalMinor reflects one passenger's fare instead of all).
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const seatedPassengers = passengersData.filter(p => p.seatId);
|
||||
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
||||
|
||||
let displayTotalMinor: number;
|
||||
let resolvedTotalMinor: number;
|
||||
if (dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
} else if (allFaresProvided) {
|
||||
if (allFaresProvided && !isPackageOneway) {
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
||||
} else if (isPackageOneway && dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
if (seatedPassengers.length > 0) {
|
||||
const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length);
|
||||
passengersWithFares.forEach(p => {
|
||||
if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare;
|
||||
});
|
||||
}
|
||||
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
} else {
|
||||
// fare engine returns ETB — convert forward to display currency
|
||||
const etbTotal = Math.max(0, totalBaseFareMinor - discountMinor);
|
||||
@@ -244,6 +267,9 @@ export class GuestBookingService {
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative ETB fare (net of promo).
|
||||
this.assertTotalNotUnderAuthoritative(resolvedTotalMinor, Math.max(0, totalBaseFareMinor - discountMinor), 'createGuestBooking');
|
||||
|
||||
// Resolve or create the guest Passenger record
|
||||
const firstPassenger = passengersData[0];
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger, req);
|
||||
@@ -278,7 +304,9 @@ export class GuestBookingService {
|
||||
destinationStationId: dto.destinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
totalMinor: resolvedTotalMinor,
|
||||
currency: displayCurrency,
|
||||
// Charge basis is ETB (resolvedTotalMinor). The passenger's currency and amount live in
|
||||
// displayCurrency/displayTotalMinor — keep currency coherent with totalMinor's units.
|
||||
currency: Currency.ETB,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -291,6 +319,7 @@ export class GuestBookingService {
|
||||
seats: {
|
||||
create: passengersWithFares.map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
@@ -478,6 +507,9 @@ export class GuestBookingService {
|
||||
|
||||
const taxesMinor = 0;
|
||||
let totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
|
||||
// C-1 guard: authoritative ETB fare for both legs, captured before the client-driven branches
|
||||
// below may overwrite totalMinor with a per-seat sum or reviewedTotalMinor.
|
||||
const authoritativeTotalMinor = totalMinor;
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
let displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
@@ -485,22 +517,18 @@ export class GuestBookingService {
|
||||
: totalMinor;
|
||||
|
||||
// Per-seat fares: use client-supplied seatFareMinor/returnSeatFareMinor when present.
|
||||
let outboundFreeChildUsed = false;
|
||||
let returnFreeChildUsed = false;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let outboundFareMinor: number;
|
||||
let returnFareMinor: number;
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
outboundFareMinor = p.seatFareMinor ?? outboundBaseFare;
|
||||
returnFareMinor = p.returnSeatFareMinor ?? returnBaseFare;
|
||||
} else if (isPackageRoundTrip) {
|
||||
outboundFareMinor = 0;
|
||||
returnFareMinor = 0;
|
||||
} else {
|
||||
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
|
||||
else outboundFareMinor = p.seatFareMinor ?? outboundChildUnitFare;
|
||||
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
|
||||
else returnFareMinor = p.returnSeatFareMinor ?? returnChildUnitFare;
|
||||
// Free children have no seat (frontend excludes them from the DTO).
|
||||
// Guard by seatId: unseated = free (0), seated = paid child.
|
||||
// Applies to both package and regular bookings.
|
||||
outboundFareMinor = p.seatId ? (p.seatFareMinor ?? outboundChildUnitFare) : 0;
|
||||
returnFareMinor = p.seatId ? (p.returnSeatFareMinor ?? returnChildUnitFare) : 0;
|
||||
}
|
||||
return { ...p, outboundFareMinor, returnFareMinor };
|
||||
});
|
||||
@@ -510,19 +538,36 @@ export class GuestBookingService {
|
||||
const rtSeatedPassengers = passengersData.filter(p => p.seatId);
|
||||
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
||||
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
||||
if (dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
} else if (allRTFaresProvided && !isPackageRoundTrip) {
|
||||
// seatFareMinor/returnSeatFareMinor are display-currency — sum is already display total
|
||||
|
||||
if (allRTFaresProvided && !isPackageRoundTrip) {
|
||||
// Server-computed sum is authoritative — prevents race-condition under-count.
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
} else if (isPackageRoundTrip && dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
const seatedCount = passengersData.filter(p => p.seatId).length;
|
||||
if (seatedCount > 0) {
|
||||
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
|
||||
passengersWithFares.forEach(p => {
|
||||
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
|
||||
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
|
||||
});
|
||||
}
|
||||
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
}
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
|
||||
this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createGuestRoundTripBooking');
|
||||
|
||||
// Create or resolve guest passenger (same as one-way)
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||
|
||||
@@ -540,7 +585,7 @@ export class GuestBookingService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP',
|
||||
totalMinor,
|
||||
currency: displayCurrency,
|
||||
currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -744,7 +789,7 @@ export class GuestBookingService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'TRANSIT',
|
||||
totalMinor,
|
||||
currency: displayCurrency,
|
||||
currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -960,7 +1005,7 @@ export class GuestBookingService {
|
||||
destinationStationId: dto.returnLeg2DestinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP_TRANSIT',
|
||||
totalMinor, currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor,
|
||||
totalMinor, currency: Currency.ETB, adultCount, childCount, displayCurrency, displayTotalMinor, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
leg2ScheduleId: dto.leg2ScheduleId,
|
||||
leg2OriginStationId: dto.transitStationId,
|
||||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||||
|
||||
@@ -140,10 +140,14 @@ export class CurrencyService {
|
||||
});
|
||||
|
||||
if (!exchangeRate) {
|
||||
this.logger.warn(
|
||||
`No exchange rate found for ${fromCurrency} to ${toCurrency}, using 1.0`,
|
||||
// H-2: fail closed. Never price at parity (1.0) when a required rate is absent — a silent 1.0
|
||||
// substitution underprices international fares ~100×. Reject the quote/booking instead.
|
||||
this.logger.error(
|
||||
`No exchange rate configured for ${fromCurrency}->${toCurrency}; refusing to price at parity`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
`No exchange rate configured for ${fromCurrency}->${toCurrency}`,
|
||||
);
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - exchangeRate.effectiveDate.getTime();
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Controller, Get, Param, SetMetadata, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@ApiTags('Dashboard')
|
||||
@Controller('dashboard')
|
||||
@@ -10,7 +11,7 @@ export class DashboardController {
|
||||
constructor(private service: DashboardService) {}
|
||||
|
||||
@Get('backoffice-stats')
|
||||
@PassengerAdmin()
|
||||
@PassengerStaff([PASSENGER_PERMS.dashboard.view, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Backoffice summary: totals and revenue by currency' })
|
||||
getBackofficeStats() { return this.service.getBackofficeStats(); }
|
||||
|
||||
@@ -11,12 +11,13 @@ export class DashboardService {
|
||||
) {}
|
||||
|
||||
async getBackofficeStats() {
|
||||
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, revenueRows, packageRevenueRows] =
|
||||
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] =
|
||||
await Promise.all([
|
||||
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }),
|
||||
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }),
|
||||
this.prisma.ticket.count(),
|
||||
this.prisma.passenger.count(),
|
||||
this.prisma.seat.count({ where: { status: 'BLOCKED' } }),
|
||||
this.prisma.$queryRaw<{ currency: string; total: bigint }[]>`
|
||||
SELECT
|
||||
COALESCE("displayCurrency"::text, "currency"::text) AS currency,
|
||||
@@ -56,6 +57,7 @@ export class DashboardService {
|
||||
totalPackageTickets,
|
||||
totalNormalTickets: totalTickets - totalPackageTickets,
|
||||
totalPassengers,
|
||||
blockedSeatsCount,
|
||||
revenueByCurrency: toMap(revenueRows),
|
||||
packageRevenueByCurrency: toMap(packageRevenueRows),
|
||||
};
|
||||
|
||||
@@ -23,6 +23,8 @@ export class CurrencyController {
|
||||
}
|
||||
|
||||
@Put()
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Upsert an exchange rate for today' })
|
||||
@ApiResponse({ status: 200, description: 'Rate created or updated for today\'s effective date' })
|
||||
upsert(@Body() dto: UpsertExchangeRateDto) {
|
||||
@@ -30,6 +32,8 @@ export class CurrencyController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update an exchange rate by ID' })
|
||||
@ApiParam({ name: 'id', description: 'CurrencyExchangeRate UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Rate updated' })
|
||||
|
||||
@@ -23,8 +23,11 @@ export class FareEngineService {
|
||||
|
||||
if (!originStop) throw new BadRequestException('Origin station not found on this route');
|
||||
if (!destStop) throw new BadRequestException('Destination station not found on this route');
|
||||
if (originStop.sequence >= destStop.sequence)
|
||||
throw new BadRequestException('Origin must come before destination in the route sequence');
|
||||
// Origin and destination must be distinct stops, but EITHER direction is valid: a round-trip
|
||||
// return leg traverses the same route high→low (e.g. C→A), so we price the segment by its
|
||||
// absolute distance rather than rejecting the reverse order.
|
||||
if (originStop.sequence === destStop.sequence)
|
||||
throw new BadRequestException('Origin and destination must be different stops on this route');
|
||||
|
||||
const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } });
|
||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||
@@ -43,8 +46,8 @@ export class FareEngineService {
|
||||
},
|
||||
}) ?? seatClass;
|
||||
|
||||
const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!;
|
||||
if (totalDistanceKm < 0 || isNaN(totalDistanceKm))
|
||||
const totalDistanceKm = Math.abs(destStop.distanceKm! - originStop.distanceKm!);
|
||||
if (totalDistanceKm <= 0 || isNaN(totalDistanceKm))
|
||||
throw new BadRequestException('Invalid distance calculation - check route stop distances');
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -12,7 +12,7 @@ export class LiveService {
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
const live = schedule.liveStatus;
|
||||
const nextStop = schedule.stopTimes.find((s) => s.status === 'UPCOMING' || s.status === 'APPROACHING');
|
||||
const nextStop = schedule.stopTimes.find((s) => s.status === 'OPEN' || s.status === 'CHECKIN_CLOSED');
|
||||
return {
|
||||
scheduleId: schedule.id, trainName: schedule.train.name,
|
||||
fromStationName: schedule.originStation.name, toStationName: schedule.destinationStation.name,
|
||||
|
||||
@@ -128,4 +128,13 @@ export class BookPackageDto {
|
||||
|
||||
/** Number of child passengers (<5 years). Derived from passengers array if omitted. */
|
||||
@ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() @Min(0) childCount?: number;
|
||||
|
||||
/**
|
||||
* SeatHold UUID returned by POST /seats/hold when the user selected seats on the
|
||||
* seatmap before proceeding to book. When provided, the hold's expiry is extended
|
||||
* to the payment deadline so the specific seat stays reserved on the seatmap for
|
||||
* the full payment window, matching the behaviour of normal bookings.
|
||||
*/
|
||||
@ApiPropertyOptional({ description: 'SeatHold ID from seatmap selection' })
|
||||
@IsOptional() @IsUUID() holdId?: string;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Currency } from '@prisma/client';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
import { GuestBookingService } from '../bookings/guest-booking.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { computePaymentDeadline, CUTOFF_MINUTES } from '../../common/utils/payment-deadline.utils';
|
||||
|
||||
/** Package-specific fare rules */
|
||||
const PKG_MAX_ADULTS = 5;
|
||||
@@ -235,18 +236,27 @@ export class PackagesService {
|
||||
});
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
|
||||
// Fetch live route stops so the departure station dropdown always reflects
|
||||
// the current route definition, not stale TripStopTime snapshots.
|
||||
// Build departure station list from live route stops when a routeId exists,
|
||||
// falling back to the schedule's own stopTimes (already included in the query).
|
||||
let routeStops: { sequence: number; station: any }[] = [];
|
||||
if (pkg.outboundSchedule.routeId) {
|
||||
const stops = await this.prisma.routeStop.findMany({
|
||||
where: { routeId: pkg.outboundSchedule.routeId },
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
const stationIds = stops.map((s) => s.stationId);
|
||||
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
|
||||
const stationMap = Object.fromEntries(stations.map((s) => [s.id, s]));
|
||||
routeStops = stops.map((s) => ({ sequence: s.sequence, station: stationMap[s.stationId] }));
|
||||
if (stops.length > 0) {
|
||||
const stationIds = stops.map((s) => s.stationId);
|
||||
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
|
||||
const stationMap = Object.fromEntries(stations.map((s) => [s.id, s]));
|
||||
routeStops = stops.map((s) => ({ sequence: s.sequence, station: stationMap[s.stationId] }));
|
||||
}
|
||||
}
|
||||
// Fall back to the schedule's own TripStopTimes when RouteStop table has no rows
|
||||
// for this route (e.g. route exists but stops were never seeded).
|
||||
if (routeStops.length === 0) {
|
||||
routeStops = (pkg.outboundSchedule.stopTimes ?? [])
|
||||
.filter((st: any) => st.station)
|
||||
.map((st: any) => ({ sequence: st.sequence, station: st.station }));
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -382,7 +392,10 @@ export class PackagesService {
|
||||
async book(dto: BookPackageDto, passengerId?: string) {
|
||||
const pkg = await this.prisma.travelPackage.findUnique({
|
||||
where: { id: dto.packageId },
|
||||
include: { priceTiers: true },
|
||||
include: {
|
||||
priceTiers: true,
|
||||
outboundSchedule: { select: { departureAt: true, route: { select: { checkinMinutesBefore: true } } } },
|
||||
},
|
||||
});
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking');
|
||||
@@ -440,8 +453,8 @@ export class PackagesService {
|
||||
passengerCount,
|
||||
adultCount,
|
||||
childCount,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
totalMinor: displayTotalMinor,
|
||||
currency: displayCurrency,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
status: 'PENDING_PAYMENT',
|
||||
@@ -477,6 +490,27 @@ export class PackagesService {
|
||||
]);
|
||||
});
|
||||
|
||||
// Extend the seatmap SeatHold (if one was passed) to the payment deadline so the
|
||||
// specific seat remains visually reserved on the seatmap during the full payment
|
||||
// window — matching the behaviour of normal bookings (which call confirmSeats).
|
||||
if (dto.holdId) {
|
||||
const dep = (pkg as any).outboundSchedule?.departureAt as Date | undefined;
|
||||
if (dep) {
|
||||
const checkinMinutes = (pkg as any).outboundSchedule?.route?.checkinMinutesBefore ?? CUTOFF_MINUTES;
|
||||
const paymentDeadline = computePaymentDeadline(booking.createdAt as Date, dep, checkinMinutes);
|
||||
const hold = await this.prisma.seatHold.findUnique({
|
||||
where: { id: dto.holdId },
|
||||
select: { expiresAt: true },
|
||||
});
|
||||
if (hold && paymentDeadline > hold.expiresAt) {
|
||||
await this.prisma.seatHold.update({
|
||||
where: { id: dto.holdId },
|
||||
data: { expiresAt: paymentDeadline },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...booking,
|
||||
fareBreakdown: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
@@ -12,8 +13,15 @@ import {
|
||||
PaymentIntentSnapshot,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
ProviderStatus,
|
||||
} from "@edr/types";
|
||||
|
||||
/** Side-by-side DB row + live provider status from the payment service diagnostic endpoints. */
|
||||
export interface PaymentDiagnostic {
|
||||
db: Record<string, unknown> | null;
|
||||
provider: ProviderStatus | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's
|
||||
* side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here;
|
||||
@@ -55,6 +63,28 @@ export class PaymentClientService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /payments/diagnostic?… — DB intent row + live provider status for a domain reference,
|
||||
* side by side. Returns { db: null, provider: null } when the payment service has no intent.
|
||||
*/
|
||||
async getDiagnosticByReference(
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<PaymentDiagnostic> {
|
||||
const query = new URLSearchParams({
|
||||
service: PaymentService.PASSENGER,
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
try {
|
||||
return await this.call("GET", `/payments/diagnostic?${query.toString()}`);
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404)
|
||||
return { db: null, provider: null };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider (CAC Bank).
|
||||
* A wrong/expired OTP comes back as 400 from the payment service; surface that as a
|
||||
@@ -125,11 +155,16 @@ export class PaymentClientService {
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response) {
|
||||
// 4xx/5xx from the payment service: propagate 404 to callers that handle it;
|
||||
// everything else is a gateway-level failure from the client's perspective.
|
||||
// 409 = a legitimate conflict (e.g. another provider's payment is already in
|
||||
// flight for this booking) — surface its message as-is rather than masking it as
|
||||
// a gateway failure; everything else is a genuine gateway-level failure.
|
||||
if (err.response.status === 404) throw err;
|
||||
const detail =
|
||||
(err.response.data as { message?: string | string[] })?.message ??
|
||||
err.message;
|
||||
if (err.response.status === 409) {
|
||||
throw new ConflictException(detail);
|
||||
}
|
||||
this.logger.error(
|
||||
`payment service ${method} ${path} → ${err.response.status}: ${detail}`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { PaymentClientService } from './payment-client.service';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types';
|
||||
|
||||
// PaymentsService is request-scoped (AuditService.@Inject(REQUEST) bubbles up).
|
||||
// This service keeps only singleton deps so its @Cron method registers correctly,
|
||||
// then resolves PaymentsService per-tick via ModuleRef (same pattern as
|
||||
// PaymentEventsConsumer).
|
||||
@Injectable()
|
||||
export class PaymentSyncService {
|
||||
private readonly logger = new Logger(PaymentSyncService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 1 min: poll the payment service for any PENDING_PAYMENT bookings
|
||||
// whose payment intent has moved to SUCCEEDED on the gateway but whose
|
||||
// confirmation event was never delivered (missed RabbitMQ message, network
|
||||
// blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running
|
||||
// it for an already-confirmed booking is safe.
|
||||
//
|
||||
// Processes at most 50 bookings per cycle to avoid hammering the payment
|
||||
// service; the next tick picks up the remainder.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/1 * * * *')
|
||||
async syncPaymentStatuses() {
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } },
|
||||
},
|
||||
include: { paymentIntent: true },
|
||||
take: BATCH_SIZE,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
if (bookings.length === 0) return;
|
||||
|
||||
let confirmed = 0;
|
||||
let failed = 0;
|
||||
let errored = 0;
|
||||
|
||||
// resolve() (not get()) because PaymentsService is scoped — same pattern
|
||||
// as PaymentEventsConsumer.
|
||||
const paymentsService = await this.moduleRef.resolve(
|
||||
PaymentsService,
|
||||
undefined,
|
||||
{ strict: false },
|
||||
);
|
||||
|
||||
for (const booking of bookings) {
|
||||
if (!booking.paymentIntent) continue;
|
||||
|
||||
try {
|
||||
const snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
booking.id,
|
||||
);
|
||||
|
||||
if (!snapshot) continue;
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
const result = await paymentsService.finalizePaymentSuccess({
|
||||
intentId: booking.paymentIntent.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
if (!result.alreadyFinalized) {
|
||||
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
|
||||
confirmed++;
|
||||
}
|
||||
} else if (
|
||||
snapshot.status === ProviderPaymentStatus.FAILED ||
|
||||
snapshot.status === ProviderPaymentStatus.CANCELLED
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Payment sync: ${booking.bookingRef} intent is ${snapshot.status} — ` +
|
||||
`booking will be auto-cancelled at payment deadline`,
|
||||
);
|
||||
failed++;
|
||||
}
|
||||
// REQUIRES_ACTION / PROCESSING → still pending, retry next cycle
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Payment sync error for ${booking.bookingRef}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
errored++;
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmed > 0 || failed > 0 || errored > 0) {
|
||||
this.logger.log(
|
||||
`Payment sync run: ${bookings.length} checked, ` +
|
||||
`${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,33 @@ export class PaymentsController {
|
||||
return this.service.getIntentByBookingId(bookingId);
|
||||
}
|
||||
|
||||
@Get("status/:bookingRefOrId")
|
||||
@SetMetadata("isPublic", true)
|
||||
@ApiOperation({
|
||||
summary: "Get payment status by booking id or booking reference (PNR)",
|
||||
description:
|
||||
"Accepts either a booking UUID or a booking reference / PNR (e.g. EDR-20240001), " +
|
||||
"resolves it to the booking, and returns the authoritative payment status pulled from " +
|
||||
"the payment microservice.",
|
||||
})
|
||||
getStatusByBookingRefOrId(@Param("bookingRefOrId") bookingRefOrId: string) {
|
||||
return this.service.getIntentByBookingRefOrId(bookingRefOrId);
|
||||
}
|
||||
|
||||
@Get("diagnostic/:bookingRefOrId")
|
||||
@SetMetadata("isPublic", true)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Get { db, provider } by booking id or booking reference (PNR) — diagnostic",
|
||||
description:
|
||||
"Accepts a booking UUID or a booking reference / PNR (e.g. EDR-20240001), resolves it to " +
|
||||
"the booking, and returns { db, provider }: the payment service's stored intent row and a " +
|
||||
"live provider status query, side by side. Pure read — does not reconcile the booking.",
|
||||
})
|
||||
getPaymentDiagnostic(@Param("bookingRefOrId") bookingRefOrId: string) {
|
||||
return this.service.getPaymentDiagnosticByBookingRefOrId(bookingRefOrId);
|
||||
}
|
||||
|
||||
@Post(":bookingId/confirm")
|
||||
@SetMetadata("isPublic", true)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -118,6 +118,7 @@ describe("Payments E2E", () => {
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
seatId: seat.id,
|
||||
scheduleId: schedule.id,
|
||||
passengerName: "Test Passenger",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { SupplementaryChargesService } from "./supplementary-charges.service";
|
||||
import { InternalPaymentsController } from "./internal-payments.controller";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { PaymentEventsConsumer } from "./payment-events.consumer";
|
||||
import { PaymentSyncService } from "./payment-sync.service";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { SeatsModule } from "../seats/seats.module";
|
||||
import { TicketsModule } from "../tickets/tickets.module";
|
||||
@@ -70,6 +71,7 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
SupplementaryChargesService,
|
||||
PaymentClientService,
|
||||
PaymentEventsConsumer,
|
||||
PaymentSyncService,
|
||||
ServiceAuthGuard,
|
||||
],
|
||||
exports: [PaymentClientService, PaymentsService],
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
} from "@nestjs/common";
|
||||
import { PrismaService } from "../../common/prisma.service";
|
||||
import { SeatsService } from "../seats/seats.service";
|
||||
@@ -24,7 +25,10 @@ import {
|
||||
ForceConfirmDto,
|
||||
} from "./payments.dto";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import {
|
||||
PaymentClientService,
|
||||
PaymentDiagnostic,
|
||||
} from "./payment-client.service";
|
||||
import { CurrencyService } from "../currency/currency.service";
|
||||
import { AuditService } from "../../common/audit.service";
|
||||
import { rebaseUrlOrigin } from "../../common/utils/redirect-origin.util";
|
||||
@@ -159,29 +163,39 @@ export class PaymentsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the correct totalMinor for a booking, accounting for package round-trip bookings
|
||||
* where totalMinor may have been stored as a single-leg amount before the server fix.
|
||||
* A package round-trip booking has packageId set, bookingType ROUND_TRIP, and
|
||||
* totalMinor equal to a single-leg fare (i.e. seats split evenly across 2 legs).
|
||||
* Returns the correct totalMinor (in ETB) for a booking, accounting for package round-trip
|
||||
* bookings where totalMinor may have been stored as a single-leg amount before the server fix.
|
||||
*/
|
||||
private async resolveBookingTotal(booking: { id: string; totalMinor: number; bookingType: string; packageId?: string | null; priceTierId?: string | null }): Promise<number> {
|
||||
private async resolveBookingTotal(booking: {
|
||||
id: string;
|
||||
totalMinor: number;
|
||||
bookingType: string;
|
||||
packageId?: string | null;
|
||||
priceTierId?: string | null;
|
||||
displayTotalMinor?: number | null;
|
||||
}): Promise<number> {
|
||||
if (!booking.packageId || !booking.priceTierId || booking.bookingType !== 'ROUND_TRIP') {
|
||||
return booking.totalMinor;
|
||||
}
|
||||
// For package round-trip bookings, recompute from the tier price to handle
|
||||
// bookings created before the server fix stored the full round-trip total.
|
||||
// New bookings store displayTotalMinor from the frontend's reviewedTotalMinor; their
|
||||
// totalMinor was already computed in ETB at creation time — no recomputation needed.
|
||||
if (booking.displayTotalMinor != null && booking.displayTotalMinor > 0) {
|
||||
return booking.totalMinor;
|
||||
}
|
||||
// Legacy path: old bookings may have stored a single-leg totalMinor — recompute from tier.
|
||||
const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: booking.priceTierId } });
|
||||
if (!tier) return booking.totalMinor;
|
||||
// Count adults and children from booking seats
|
||||
const seats = await this.prisma.bookingSeat.findMany({ where: { bookingId: booking.id, leg: 1 }, select: { passengerCategory: true } });
|
||||
const adultCount = seats.filter(s => s.passengerCategory === 'ADULT').length || 1;
|
||||
const childCount = seats.filter(s => s.passengerCategory === 'CHILD').length;
|
||||
const adultFareMinor = tier.priceMinor * 2; // round-trip = 2 legs
|
||||
// tier.priceMinor may be in a non-ETB currency — convert to ETB so the result is
|
||||
// always in the same units as totalMinor (which is always the ETB canonical).
|
||||
const rawFare = tier.priceMinor * 2;
|
||||
const adultFareMinor = tier.currency && (tier.currency as string) !== 'ETB'
|
||||
? await this.currencyService.convertAmount(rawFare, tier.currency as any, 'ETB' as any)
|
||||
: rawFare;
|
||||
const childFareMinor = Math.round(adultFareMinor * 0.1);
|
||||
const correctTotal = adultCount * adultFareMinor + childCount * childFareMinor;
|
||||
// If stored total already matches the correct round-trip total, use it as-is.
|
||||
// If it's roughly half (single-leg), use the recomputed value.
|
||||
return correctTotal;
|
||||
return adultCount * adultFareMinor + childCount * childFareMinor;
|
||||
}
|
||||
|
||||
async initiatePayment(
|
||||
@@ -534,11 +548,67 @@ export class PaymentsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment status by booking id (UUID) OR booking reference / PNR (e.g. EDR-20240001).
|
||||
* Resolves the PNR to its booking id, then pulls the authoritative status from the payment
|
||||
* microservice (via {@link getIntentByBookingId}).
|
||||
*/
|
||||
async getIntentByBookingRefOrId(
|
||||
bookingRefOrId: string,
|
||||
): Promise<IntentStatusDto> {
|
||||
const bookingId = await this.resolveBookingId(bookingRefOrId);
|
||||
return this.getIntentByBookingId(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic view by booking id (UUID) OR booking reference / PNR: the payment service's
|
||||
* stored intent row and a live provider status query, side by side ({ db, provider }).
|
||||
* Pure read — does not reconcile or confirm the booking.
|
||||
*/
|
||||
async getPaymentDiagnosticByBookingRefOrId(
|
||||
bookingRefOrId: string,
|
||||
): Promise<PaymentDiagnostic> {
|
||||
const bookingId = await this.resolveBookingId(bookingRefOrId);
|
||||
return this.paymentClient.getDiagnosticByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
bookingId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Accept a booking UUID as-is; otherwise look the id up from its bookingRef/PNR. */
|
||||
private async resolveBookingId(bookingRefOrId: string): Promise<string> {
|
||||
const isUuid =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
bookingRefOrId,
|
||||
);
|
||||
if (isUuid) return bookingRefOrId;
|
||||
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef: bookingRefOrId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking not found: ${bookingRefOrId}`);
|
||||
}
|
||||
return booking.id;
|
||||
}
|
||||
|
||||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
||||
const local = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId },
|
||||
});
|
||||
|
||||
|
||||
if (local?.status === PaymentIntentStatus.SUCCEEDED) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (booking?.status === "CONFIRMED") {
|
||||
return this.formatIntentStatus(local);
|
||||
}
|
||||
}
|
||||
|
||||
// WALLET payments never leave this app — no remote intent exists for them.
|
||||
if (local?.method === PaymentMethodType.WALLET) {
|
||||
return this.formatIntentStatus(local);
|
||||
@@ -768,6 +838,27 @@ export class PaymentsService {
|
||||
});
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
if (intent.status === PaymentIntentStatus.SUCCEEDED) {
|
||||
// Idempotency guard — but still repair missing tickets. They can be absent
|
||||
// when the first finalization threw from generate() after the transaction
|
||||
// committed: the caller got a 500, retried, and now hits this early-return.
|
||||
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
|
||||
if (ticketCount === 0) {
|
||||
try {
|
||||
await this.ticketsService.generate(intent.bookingId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`,
|
||||
);
|
||||
try {
|
||||
await this.ticketsService.smartAssignAndGenerate(intent.bookingId);
|
||||
} catch (retryErr) {
|
||||
this.logger.error(
|
||||
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { alreadyFinalized: true };
|
||||
}
|
||||
if (intent.status === PaymentIntentStatus.CANCELLED) {
|
||||
@@ -818,10 +909,25 @@ export class PaymentsService {
|
||||
try {
|
||||
await this.ticketsService.generate(booking.id);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
throw err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Only reassign seats when a *different* booking genuinely holds the seat
|
||||
// (ConflictException). Any other error (transient DB issue, etc.) is logged
|
||||
// and swallowed — the passenger keeps their original seat and the ticket can
|
||||
// be retried via "Generate Missing" in the backoffice.
|
||||
if (err instanceof ConflictException) {
|
||||
this.logger.warn(
|
||||
`Seat conflict for booking ${booking.id}: ${msg}. Attempting smart seat reassignment.`,
|
||||
);
|
||||
try {
|
||||
await this.ticketsService.smartAssignAndGenerate(booking.id);
|
||||
} catch (retryErr) {
|
||||
this.logger.error(
|
||||
`Smart assign also failed for booking ${booking.id}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.error(`Error generating ticket for booking ${booking.id}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -911,6 +1017,19 @@ export class PaymentsService {
|
||||
return { processed: false, reason: "booking-not-found" };
|
||||
}
|
||||
|
||||
// C-4 guard: a settlement must cover what the passenger was quoted. Compare the provider-settled
|
||||
// amount against the booking's display-currency total (the amount the customer agreed to pay);
|
||||
// a short payment must NOT confirm the booking. Amount-only — the display↔charge currency
|
||||
// divergence is tracked separately under the USD/DJF findings. The 1% tolerance absorbs rounding.
|
||||
const expectedMinor = booking.displayTotalMinor ?? booking.totalMinor;
|
||||
const shortPayTolerance = Math.max(1, Math.round(expectedMinor * 0.01));
|
||||
if (event.amountMinor < expectedMinor - shortPayTolerance) {
|
||||
this.logger.error(
|
||||
`mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMinor} ${booking.displayCurrency}; not confirming`,
|
||||
);
|
||||
return { processed: false, reason: "amount-mismatch" };
|
||||
}
|
||||
|
||||
// Local intent row is a projection during the strangler migration: reuse it when the
|
||||
// legacy initiate path created one, otherwise materialize it from the event.
|
||||
let intent = await this.prisma.paymentIntent.findUnique({
|
||||
@@ -934,6 +1053,11 @@ export class PaymentsService {
|
||||
intentId: intent.id,
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
}).catch((err) => {
|
||||
this.logger.error(
|
||||
`finalizePaymentSuccess failed for booking ${event.referenceId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return { alreadyFinalized: false };
|
||||
});
|
||||
return { processed: true, alreadyFinalized };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsOptional, IsInt, IsBoolean } from 'class-validator';
|
||||
import { IsString, IsOptional, IsInt, IsBoolean, Min, Max } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreatePromotionDto {
|
||||
@@ -15,14 +15,17 @@ export class CreatePromotionDto {
|
||||
@IsString()
|
||||
subtitle?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 15 })
|
||||
@ApiPropertyOptional({ example: 15, description: 'Percentage discount, bounded 0..100' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
percentOff?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 5000 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
amountOffMinor?: number;
|
||||
|
||||
@ApiProperty({ example: '2026-12-31T23:59:59Z' })
|
||||
|
||||
@@ -1,50 +1,84 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ReportsService } from './reports.service';
|
||||
import { GenerateReportDto } from './reports.dto';
|
||||
import { PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
|
||||
import { ReportsService } from "./reports.service";
|
||||
import { GenerateReportDto } from "./reports.dto";
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
|
||||
@ApiTags('Reports')
|
||||
@Controller('reports')
|
||||
@ApiTags("Reports")
|
||||
@Controller("reports")
|
||||
@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
export class ReportsController {
|
||||
constructor(private service: ReportsService) {}
|
||||
|
||||
@Post('generate')
|
||||
@ApiOperation({ summary: 'Generate operational report' })
|
||||
@Post("generate")
|
||||
@ApiOperation({ summary: "Generate operational report" })
|
||||
generateReport(@Body() dto: GenerateReportDto) {
|
||||
return this.service.generateReport(dto);
|
||||
}
|
||||
|
||||
@Get('schedules')
|
||||
@ApiOperation({ summary: 'List schedules for the passengers report picker' })
|
||||
listSchedulesForPicker() {
|
||||
return this.service.listSchedulesForPicker();
|
||||
listSchedulesForPicker(@Query('all') all?: string) {
|
||||
return this.service.listSchedulesForPicker(all === 'true');
|
||||
}
|
||||
|
||||
@Get('passengers/list')
|
||||
@ApiOperation({ summary: 'Flat passenger list for a specific schedule' })
|
||||
getPassengerList(@Query('scheduleId') scheduleId: string) {
|
||||
@Get("passengers/list")
|
||||
@ApiOperation({ summary: "Flat passenger list for a specific schedule" })
|
||||
getPassengerList(@Query("scheduleId") scheduleId: string) {
|
||||
return this.service.getPassengerList(scheduleId);
|
||||
}
|
||||
|
||||
@Get('passengers')
|
||||
@ApiOperation({ summary: 'Passengers report for a specific schedule' })
|
||||
getOccupancyReport(@Query('scheduleId') scheduleId: string) {
|
||||
@Get("passengers")
|
||||
@ApiOperation({ summary: "Passengers report for a specific schedule" })
|
||||
getOccupancyReport(@Query("scheduleId") scheduleId: string) {
|
||||
return this.service.getOccupancyBySchedule(scheduleId);
|
||||
}
|
||||
|
||||
@Get(':reportId')
|
||||
@ApiOperation({ summary: 'Get report by ID' })
|
||||
getReport(@Param('reportId') reportId: string) {
|
||||
@Get("payment-discrepancy")
|
||||
@ApiOperation({ summary: "Payment discrepancy report — bookings where paid amount is less than the fare. Pass `search` to look up a specific PNR or ticket number." })
|
||||
getPaymentDiscrepancy(
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
@Query('sortBy') sortBy?: string,
|
||||
@Query('search') search?: string,
|
||||
) {
|
||||
return this.service.getPaymentDiscrepancyReport({ from, to, sortBy, search });
|
||||
}
|
||||
|
||||
@Get("seat-status")
|
||||
@ApiOperation({ summary: "Seat status breakdown for a schedule (paid, unpaid, expired holds, blocked)" })
|
||||
getSeatStatusReport(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.getSeatStatusReport(scheduleId);
|
||||
}
|
||||
|
||||
@Get("payments")
|
||||
@ApiOperation({ summary: "Payments collected for a schedule" })
|
||||
getPaymentsReport(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.getPaymentsReport(scheduleId);
|
||||
}
|
||||
|
||||
@Get("payments/discrepancy")
|
||||
@ApiOperation({ summary: "Payment discrepancy breakdown for a schedule" })
|
||||
getPaymentDiscrepancyBySchedule(
|
||||
@Query('scheduleId') scheduleId: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('seatClass') seatClass?: string,
|
||||
@Query('sort') sort?: string,
|
||||
) {
|
||||
return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort });
|
||||
}
|
||||
|
||||
@Get(":reportId")
|
||||
@ApiOperation({ summary: "Get report by ID" })
|
||||
getReport(@Param("reportId") reportId: string) {
|
||||
return this.service.getReport(reportId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List reports' })
|
||||
listReports(@Query('type') type?: string) {
|
||||
@ApiOperation({ summary: "List reports" })
|
||||
listReports(@Query("type") type?: string) {
|
||||
return this.service.listReports(type);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ export class RouteStopInputDto {
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string;
|
||||
@ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number;
|
||||
@ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number;
|
||||
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
|
||||
}
|
||||
|
||||
export class CreateRouteDto {
|
||||
@@ -35,6 +36,7 @@ export class AddRouteStopDto {
|
||||
@ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string;
|
||||
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
|
||||
@ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number;
|
||||
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
|
||||
}
|
||||
|
||||
export class UpdateRouteDto {
|
||||
@@ -42,6 +44,7 @@ export class UpdateRouteDto {
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
|
||||
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean;
|
||||
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
|
||||
@ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
|
||||
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[];
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export class RoutesService {
|
||||
stationId: s.stationId,
|
||||
sequence: s.sequence,
|
||||
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
||||
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
@@ -92,6 +93,7 @@ export class RoutesService {
|
||||
description: dto.description,
|
||||
active: dto.active,
|
||||
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined,
|
||||
...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -103,6 +105,7 @@ export class RoutesService {
|
||||
stationId: s.stationId,
|
||||
sequence: s.sequence,
|
||||
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
||||
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -221,6 +224,7 @@ export class RoutesService {
|
||||
stationId: dto.stationId,
|
||||
sequence: dto.sequence,
|
||||
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
|
||||
checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ export enum TripStatus {
|
||||
}
|
||||
|
||||
export enum StopStatus {
|
||||
OPEN = 'OPEN',
|
||||
CHECKIN_CLOSED = 'CHECKIN_CLOSED',
|
||||
BOARDED = 'BOARDED',
|
||||
COMPLETED = 'COMPLETED',
|
||||
APPROACHING = 'APPROACHING',
|
||||
CURRENT = 'CURRENT',
|
||||
UPCOMING = 'UPCOMING',
|
||||
}
|
||||
|
||||
export enum PassengerCategory {
|
||||
@@ -65,7 +65,7 @@ export class UpdateScheduleDto {
|
||||
export class UpdateStopTimeDto {
|
||||
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: string;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T09:45:00Z' }) @IsOptional() @IsDateString() plannedDepartureAt?: string;
|
||||
@ApiPropertyOptional({ enum: StopStatus, example: StopStatus.UPCOMING }) @IsOptional() @IsEnum(StopStatus) status?: StopStatus;
|
||||
@ApiPropertyOptional({ enum: StopStatus, example: StopStatus.OPEN }) @IsOptional() @IsEnum(StopStatus) status?: StopStatus;
|
||||
}
|
||||
|
||||
export class CreateFareRuleDto {
|
||||
|
||||
@@ -103,6 +103,8 @@ export class SchedulesService {
|
||||
const dep = parseEthiopianTime(dto.departureAt);
|
||||
const arr = parseEthiopianTime(dto.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
// M-4: a new schedule cannot depart in the past — the backoffice form does not enforce this.
|
||||
if (dep.getTime() < Date.now()) throw new BadRequestException('departureAt must be in the future');
|
||||
|
||||
const [train, route] = await Promise.all([
|
||||
this.prisma.train.findUnique({ where: { id: dto.trainId } }),
|
||||
|
||||
@@ -4,10 +4,9 @@ import { SearchService } from './search.service';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
import { SegmentsModule } from '../segments/segments.module';
|
||||
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [CurrencyModule, FareEngineModule, SegmentsModule, SystemConfigModule],
|
||||
imports: [CurrencyModule, FareEngineModule, SegmentsModule],
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
exports: [SearchService],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsInt, IsBoolean, IsOptional, IsIn } from 'class-validator';
|
||||
import { IsString, IsInt, IsBoolean, IsOptional, IsIn, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
|
||||
export class CreateSeatClassDto {
|
||||
@@ -27,11 +27,13 @@ export class CreateSeatClassDto {
|
||||
|
||||
@ApiProperty({ example: 3000, description: 'Per-km rate in minor units (tariff decimal × 100000)' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
basePrice: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 1200, description: 'Flat insurance fee in minor units' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
insuranceFeeMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: true })
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsDateString, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class GetDuplicateSeatsQuery {
|
||||
@ApiProperty({ example: '2026-07-17', description: 'Schedule date (YYYY-MM-DD)' })
|
||||
@IsDateString()
|
||||
date: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter to a specific schedule ID' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
scheduleId?: string;
|
||||
}
|
||||
|
||||
export class ResolveDuplicatesDto {
|
||||
@ApiProperty({
|
||||
description: 'BookingSeat IDs of the duplicate bookings to reassign',
|
||||
type: [String],
|
||||
example: ['uuid-booking-seat-1', 'uuid-booking-seat-2'],
|
||||
})
|
||||
@IsArray()
|
||||
@IsUUID(undefined, { each: true })
|
||||
bookingSeatIds: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Coach IDs to source replacement seats from (searched in order; first available seat per coach is used)',
|
||||
type: [String],
|
||||
example: ['uuid-coach-1', 'uuid-coach-2'],
|
||||
})
|
||||
@IsArray()
|
||||
@IsUUID(undefined, { each: true })
|
||||
coachIds: string[];
|
||||
}
|
||||
@@ -17,9 +17,11 @@ import {
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
ApiBody,
|
||||
} from "@nestjs/swagger";
|
||||
import { SeatsService } from "./seats.service";
|
||||
import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto";
|
||||
import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
@@ -316,4 +318,90 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
) {
|
||||
return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit);
|
||||
}
|
||||
|
||||
// ── Duplicate seat management (backoffice) ────────────────────────────────
|
||||
|
||||
@Get("duplicates")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({
|
||||
summary: "List duplicate seat assignments by schedule date",
|
||||
description:
|
||||
"Returns all schedules on the given date that have bookings sharing " +
|
||||
"the same seat, grouped by coach. Each coach entry includes the duplicate " +
|
||||
"groups (with full booking info) and the list of currently available seats " +
|
||||
"that can be used for reassignment.",
|
||||
})
|
||||
@ApiQuery({ name: "date", example: "2026-07-17", description: "Schedule date (YYYY-MM-DD)" })
|
||||
@ApiQuery({ name: "scheduleId", required: false, description: "Filter to a specific schedule" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Duplicate seat report grouped by schedule → coach",
|
||||
schema: {
|
||||
example: {
|
||||
date: "2026-07-17",
|
||||
totalDuplicates: 1,
|
||||
schedules: [{
|
||||
scheduleId: "uuid",
|
||||
departureAt: "2026-07-17T06:00:00.000Z",
|
||||
origin: "Addis Ababa",
|
||||
destination: "Dire Dawa",
|
||||
coaches: [{
|
||||
coachId: "uuid",
|
||||
coachNumber: "C1",
|
||||
coachTypeName: "SBC",
|
||||
duplicates: [{
|
||||
seatId: "uuid",
|
||||
seatNumber: "12A",
|
||||
leg: 1,
|
||||
bookings: [
|
||||
{ bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "ATPC9F", passengerName: "Abebe", contactPhone: "+251911000000", createdAt: "2026-07-16T10:00:00.000Z" },
|
||||
{ bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "XYZ123", passengerName: "Kebede", contactPhone: "+251922000000", createdAt: "2026-07-16T11:00:00.000Z" },
|
||||
],
|
||||
}],
|
||||
availableSeats: [
|
||||
{ seatId: "uuid", seatNumber: "14B" },
|
||||
{ seatId: "uuid", seatNumber: "15A" },
|
||||
],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
getDuplicateSeats(@Query() query: GetDuplicateSeatsQuery) {
|
||||
return this.service.getDuplicateSeats(query.date, query.scheduleId);
|
||||
}
|
||||
|
||||
@Post("duplicates/resolve")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({
|
||||
summary: "Auto-assign duplicate bookings to seats in selected coaches",
|
||||
description:
|
||||
"Staff selects which duplicate BookingSeat IDs to fix and which coaches to pull replacement seats from. " +
|
||||
"The system automatically picks the first available (non-blocked, non-occupied) seat in the given coaches " +
|
||||
"for each booking, updates BookingSeat + Ticket + JourneySegment atomically so the seatmap reflects the " +
|
||||
"change immediately, then sends an SMS notification to the passenger. " +
|
||||
"Coaches are searched in the order provided; seats within each coach are assigned by row then column.",
|
||||
})
|
||||
@ApiBody({ type: ResolveDuplicatesDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Resolution summary — resolved count, unresolved count, per-booking results",
|
||||
schema: {
|
||||
example: {
|
||||
resolved: 2,
|
||||
unresolved: 0,
|
||||
results: [
|
||||
{ bookingRef: "XYZ123", oldSeatNumber: "1A", newSeatNumber: "14B", contactPhone: "+251922000000" },
|
||||
{ bookingRef: "ABC456", oldSeatNumber: "1A", newSeatNumber: "15A", contactPhone: "+251933000000" },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Booking not in CONFIRMED/BOARDED status" })
|
||||
@ApiResponse({ status: 404, description: "BookingSeat ID not found" })
|
||||
resolveDuplicateSeats(@Body() dto: ResolveDuplicatesDto) {
|
||||
return this.service.resolveDuplicateSeats(dto.bookingSeatIds, dto.coachIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ import { SeatsService } from './seats.service';
|
||||
import { SegmentsModule } from '../segments/segments.module';
|
||||
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||
import { AuditModule } from '../../common/audit.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule],
|
||||
imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule, NotificationsModule],
|
||||
controllers: [SeatsController],
|
||||
providers: [SeatsService],
|
||||
exports: [SeatsService],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { SegmentsService } from '../segments/segments.service';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||
import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
|
||||
|
||||
@@ -17,6 +18,7 @@ export class SeatsService {
|
||||
private segmentsService: SegmentsService,
|
||||
private systemConfig: SystemConfigService,
|
||||
private auditService: AuditService,
|
||||
private sms: SmsClientService,
|
||||
) {}
|
||||
|
||||
async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) {
|
||||
@@ -266,23 +268,38 @@ export class SeatsService {
|
||||
if (new Set(seatIds).size !== seatIds.length)
|
||||
throw new BadRequestException('Duplicate seatId in passengers list');
|
||||
|
||||
const [holdMinutes, cutoffHours] = await Promise.all([
|
||||
this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES),
|
||||
this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE),
|
||||
]);
|
||||
const holdMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES);
|
||||
const expiresAt = new Date(Date.now() + holdMinutes * 60 * 1000);
|
||||
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
select: { departureAt: true },
|
||||
});
|
||||
const [schedule, originStopTime, originRouteStop] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
select: {
|
||||
departureAt: true,
|
||||
route: { select: { checkinMinutesBefore: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.tripStopTime.findFirst({
|
||||
where: { scheduleId: dto.scheduleId, stationId: dto.originStationId },
|
||||
select: { plannedDepartureAt: true },
|
||||
}),
|
||||
this.prisma.routeStop.findFirst({
|
||||
where: {
|
||||
route: { schedules: { some: { id: dto.scheduleId } } },
|
||||
stationId: dto.originStationId,
|
||||
},
|
||||
select: { checkinMinutesBefore: true },
|
||||
}),
|
||||
]);
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const msUntilDeparture = schedule.departureAt.getTime() - Date.now();
|
||||
const cutoffMs = cutoffHours * 60 * 60 * 1000;
|
||||
if (msUntilDeparture <= cutoffMs) {
|
||||
// Stop-level override wins; falls back to route-level; then to 30 min.
|
||||
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
|
||||
const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? schedule.departureAt;
|
||||
const msUntilDeparture = segmentDepartureAt.getTime() - Date.now();
|
||||
if (msUntilDeparture <= checkinMinutes * 60 * 1000) {
|
||||
throw new BadRequestException(
|
||||
`Seats cannot be held within ${cutoffHours} hour${cutoffHours !== 1 ? 's' : ''} of departure`,
|
||||
`Seats cannot be held within ${checkinMinutes} minute${checkinMinutes !== 1 ? 's' : ''} of departure`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -308,6 +325,18 @@ export class SeatsService {
|
||||
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
|
||||
|
||||
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
|
||||
|
||||
// Schedule-scoped blocks prevent holding a seat on this specific schedule
|
||||
// even if its global Seat.status is AVAILABLE.
|
||||
const scheduleBlockRecords = await tx.seatBlock.findMany({
|
||||
where: { scheduleId: dto.scheduleId, seatId: { in: seatIds } },
|
||||
select: { seatId: true },
|
||||
});
|
||||
if (scheduleBlockRecords.length > 0) {
|
||||
const blockedNums = scheduleBlockRecords.map(b => seatLabelById[b.seatId]).join(', ');
|
||||
throw new ConflictException(`Seat(s) ${blockedNums} are blocked for this schedule`);
|
||||
}
|
||||
|
||||
const stopTimes = await tx.tripStopTime.findMany({
|
||||
where: { scheduleId: dto.scheduleId },
|
||||
select: { stationId: true, sequence: true },
|
||||
@@ -600,9 +629,6 @@ export class SeatsService {
|
||||
|
||||
async getBlockedSeats() {
|
||||
const blocks = await this.prisma.seatBlock.findMany({
|
||||
where: {
|
||||
NOT: { reason: { startsWith: 'MAINTENANCE:' } },
|
||||
},
|
||||
include: {
|
||||
seat: { include: { coach: { select: { number: true } } } },
|
||||
},
|
||||
@@ -697,11 +723,18 @@ export class SeatsService {
|
||||
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 [unavailable, scheduleBlocks] = await Promise.all([
|
||||
this.segmentsService.getSeatAvailabilityMap(
|
||||
scheduleId, allSeatIds, stopTimes, reqFrom, reqTo,
|
||||
),
|
||||
this.prisma.seatBlock.findMany({
|
||||
where: { scheduleId, seatId: { in: allSeatIds } },
|
||||
select: { seatId: true },
|
||||
}),
|
||||
]);
|
||||
const scheduleBlockedIds = new Set(scheduleBlocks.map(b => b.seatId));
|
||||
|
||||
const availableSeats = seats.filter(s => !unavailable.has(s.id));
|
||||
const availableSeats = seats.filter(s => !unavailable.has(s.id) && !scheduleBlockedIds.has(s.id));
|
||||
|
||||
if (availableSeats.length < count) {
|
||||
throw new ConflictException(`Only ${availableSeats.length} seats available, requested ${count}`);
|
||||
@@ -983,4 +1016,488 @@ export class SeatsService {
|
||||
skippedSeatIds: Array.from(skippedSeatIds), // kept for logging/API compat; no DB writes needed
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Duplicate-seat management (backoffice)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async getDuplicateSeats(date: string, scheduleId?: string) {
|
||||
const dayStart = new Date(`${date}T00:00:00.000Z`);
|
||||
const dayEnd = new Date(`${date}T23:59:59.999Z`);
|
||||
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
departureAt: { gte: dayStart, lte: dayEnd },
|
||||
...(scheduleId ? { id: scheduleId } : {}),
|
||||
},
|
||||
orderBy: { departureAt: 'asc' },
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
coachAssignments: {
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
include: {
|
||||
coach: {
|
||||
include: {
|
||||
coachType: { select: { name: true } },
|
||||
seats: {
|
||||
orderBy: [{ row: 'asc' }, { col: 'asc' }],
|
||||
select: { id: true, seatNumber: true, status: true, coachId: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = [];
|
||||
|
||||
for (const schedule of schedules) {
|
||||
// All confirmed BookingSeat rows for this schedule
|
||||
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ scheduleId: schedule.id },
|
||||
{ booking: { scheduleId: schedule.id } },
|
||||
],
|
||||
booking: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
},
|
||||
select: {
|
||||
id: true, seatId: true, scheduleId: true, leg: true, passengerName: true,
|
||||
seat: { select: { coachId: true } },
|
||||
booking: {
|
||||
select: {
|
||||
id: true, bookingRef: true, scheduleId: true,
|
||||
originStationId: true, destinationStationId: true,
|
||||
createdAt: true, contactPhone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Seats occupied by any confirmed journey on this schedule (source of truth)
|
||||
const journeySegments = await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId: schedule.id,
|
||||
seatId: { not: null },
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||
},
|
||||
select: { seatId: true },
|
||||
});
|
||||
const occupiedIds = new Set(journeySegments.map(js => js.seatId!));
|
||||
|
||||
// Group BookingSeat rows by seatId::leg to find candidate duplicates,
|
||||
// then filter to only those whose booking segments actually overlap.
|
||||
type BS = (typeof bookingSeats)[number];
|
||||
const groups = new Map<string, BS[]>();
|
||||
for (const bs of bookingSeats) {
|
||||
const key = `${bs.seatId}::${bs.leg}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(bs);
|
||||
}
|
||||
|
||||
// Build stop-sequence map for this schedule once
|
||||
const stopTimes = await this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId: schedule.id },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
const seqOf = (stationId: string | null | undefined): number | undefined =>
|
||||
stationId ? stopTimes.find(s => s.stationId === stationId)?.sequence : undefined;
|
||||
|
||||
// Fetch JourneySegment ranges for all booking IDs in candidate groups
|
||||
const candidateBookingIds = [...new Set(
|
||||
[...groups.values()].filter(g => g.length > 1).flatMap(g => g.map(bs => bs.booking.id)),
|
||||
)];
|
||||
const candidateSegments = candidateBookingIds.length > 0
|
||||
? await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId: schedule.id,
|
||||
journey: { bookingId: { in: candidateBookingIds }, status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||
},
|
||||
select: { departureStationId: true, arrivalStationId: true, journey: { select: { bookingId: true } } },
|
||||
})
|
||||
: [];
|
||||
|
||||
// Collapse per-booking segments into a single [from, to) range
|
||||
const rangeByBookingId = new Map<string, { from: number; to: number }>();
|
||||
for (const seg of candidateSegments) {
|
||||
const bookingId = seg.journey.bookingId;
|
||||
if (!bookingId) continue;
|
||||
const depSeq = seqOf(seg.departureStationId);
|
||||
const arrSeq = seqOf(seg.arrivalStationId);
|
||||
if (depSeq === undefined || arrSeq === undefined) continue;
|
||||
const existing = rangeByBookingId.get(bookingId);
|
||||
rangeByBookingId.set(bookingId, existing
|
||||
? { from: Math.min(existing.from, depSeq), to: Math.max(existing.to, arrSeq) }
|
||||
: { from: depSeq, to: arrSeq });
|
||||
}
|
||||
|
||||
// Fall back to booking-level origin/destination when JourneySegments are missing
|
||||
const rangeForBooking = (bs: BS): { from: number; to: number } | null => {
|
||||
const fromSegments = rangeByBookingId.get(bs.booking.id);
|
||||
if (fromSegments) return fromSegments;
|
||||
// BookingSeat.scheduleId tells us which leg this seat belongs to
|
||||
const bsScheduleId = bs.scheduleId ?? bs.booking.scheduleId;
|
||||
if (bsScheduleId !== schedule.id) return null;
|
||||
const from = seqOf(bs.booking.originStationId);
|
||||
const to = seqOf(bs.booking.destinationStationId);
|
||||
if (from === undefined || to === undefined) return null;
|
||||
return { from, to };
|
||||
};
|
||||
|
||||
// Two bookings are true duplicates only if their segments overlap
|
||||
const segmentsOverlap = (a: { from: number; to: number }, b: { from: number; to: number }) =>
|
||||
a.from < b.to && b.from < a.to;
|
||||
|
||||
// All seats held by any confirmed BookingSeat — union of JourneySegment-based
|
||||
// occupancy AND BookingSeat-based occupancy so that seats whose JourneySegments
|
||||
// are missing (e.g. created via enhanced-seats path without bookingId) are still
|
||||
// excluded from the available list.
|
||||
const bookedSeatIds = new Set<string>([
|
||||
...occupiedIds,
|
||||
...bookingSeats.map(bs => bs.seatId).filter((id): id is string => id !== null && id !== undefined),
|
||||
]);
|
||||
|
||||
const coachReports = [];
|
||||
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
const coach = assignment.coach;
|
||||
|
||||
// Duplicate groups whose seat belongs to this coach
|
||||
const duplicates = [];
|
||||
for (const [key, group] of groups) {
|
||||
if (group.length <= 1) continue;
|
||||
if (group[0].seat.coachId !== coach.id) continue;
|
||||
|
||||
// Filter to bookings that actually have overlapping segments
|
||||
const overlapping: BS[] = [];
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
const rangeA = rangeForBooking(group[i]);
|
||||
for (let j = i + 1; j < group.length; j++) {
|
||||
const rangeB = rangeForBooking(group[j]);
|
||||
// If either range is unknown, conservatively treat as overlap
|
||||
const isOverlap = !rangeA || !rangeB || segmentsOverlap(rangeA, rangeB);
|
||||
if (isOverlap) {
|
||||
if (!overlapping.includes(group[i])) overlapping.push(group[i]);
|
||||
if (!overlapping.includes(group[j])) overlapping.push(group[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (overlapping.length <= 1) continue;
|
||||
|
||||
const [seatId] = key.split('::');
|
||||
const seat = coach.seats.find(s => s.id === seatId);
|
||||
duplicates.push({
|
||||
seatId,
|
||||
seatNumber: seat?.seatNumber ?? seatId,
|
||||
leg: overlapping[0].leg,
|
||||
bookings: overlapping.map(bs => ({
|
||||
bookingSeatId: bs.id,
|
||||
bookingId: bs.booking.id,
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
passengerName: bs.passengerName,
|
||||
contactPhone: bs.booking.contactPhone,
|
||||
createdAt: bs.booking.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Free seats in this coach — excludes BLOCKED, all confirmed BookingSeat
|
||||
// assignments, and all confirmed JourneySegment occupancies.
|
||||
const availableSeats = coach.seats
|
||||
.filter(s =>
|
||||
(s.status as string) !== 'BLOCKED' &&
|
||||
!s.seatNumber.startsWith('-') &&
|
||||
!bookedSeatIds.has(s.id),
|
||||
)
|
||||
.map(s => ({ seatId: s.id, seatNumber: s.seatNumber }));
|
||||
|
||||
coachReports.push({
|
||||
coachId: coach.id,
|
||||
coachNumber: coach.number,
|
||||
coachTypeName: coach.coachType.name,
|
||||
duplicates,
|
||||
availableSeats,
|
||||
});
|
||||
}
|
||||
|
||||
if (coachReports.some(c => c.duplicates.length > 0)) {
|
||||
result.push({
|
||||
scheduleId: schedule.id,
|
||||
departureAt: schedule.departureAt,
|
||||
origin: schedule.originStation.name,
|
||||
destination: schedule.destinationStation.name,
|
||||
coaches: coachReports,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalDuplicates = result.reduce(
|
||||
(sum, s) => sum + s.coaches.reduce((cs, c) => cs + c.duplicates.length, 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return { date, schedules: result, totalDuplicates };
|
||||
}
|
||||
|
||||
async resolveDuplicateSeats(bookingSeatIds: string[], coachIds: string[]) {
|
||||
if (bookingSeatIds.length === 0) return { resolved: 0, unresolved: 0, results: [] };
|
||||
|
||||
// Load BookingSeat rows with full booking + schedule context
|
||||
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: { id: { in: bookingSeatIds } },
|
||||
select: {
|
||||
id: true, seatId: true, leg: true, scheduleId: true,
|
||||
seat: { select: { seatNumber: true } },
|
||||
booking: {
|
||||
select: {
|
||||
id: true, bookingRef: true, scheduleId: true,
|
||||
status: true, contactPhone: true, passengerId: true,
|
||||
totalMinor: true, currency: true,
|
||||
originStationId: true, destinationStationId: true,
|
||||
schedule: {
|
||||
select: {
|
||||
originStationId: true,
|
||||
destinationStationId: true,
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
departureAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (bookingSeats.length !== bookingSeatIds.length) {
|
||||
const found = new Set(bookingSeats.map(bs => bs.id));
|
||||
const missing = bookingSeatIds.filter(id => !found.has(id));
|
||||
throw new NotFoundException(`BookingSeat(s) not found: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const invalid = bookingSeats.filter(bs => !['CONFIRMED', 'BOARDED'].includes(bs.booking.status));
|
||||
if (invalid.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Bookings must be CONFIRMED or BOARDED: ${invalid.map(bs => bs.booking.bookingRef).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Load all non-blocked, non-removed seats from the selected coaches (ordered for deterministic pick)
|
||||
const coachSeats = await this.prisma.seat.findMany({
|
||||
where: {
|
||||
coachId: { in: coachIds },
|
||||
status: { not: 'BLOCKED' },
|
||||
NOT: { seatNumber: { startsWith: '-' } },
|
||||
},
|
||||
select: { id: true, seatNumber: true, coachId: true, row: true, col: true },
|
||||
orderBy: [{ coachId: 'asc' }, { row: 'asc' }, { col: 'asc' }],
|
||||
});
|
||||
|
||||
// Build occupied-seat sets per schedule from confirmed JourneySegments
|
||||
const scheduleIds = [
|
||||
...new Set(
|
||||
bookingSeats
|
||||
.map(bs => bs.scheduleId ?? bs.booking.scheduleId)
|
||||
.filter((id): id is string => id !== null && id !== undefined),
|
||||
),
|
||||
];
|
||||
|
||||
const occupiedBySchedule = new Map<string, Set<string>>();
|
||||
await Promise.all(
|
||||
scheduleIds.map(async scheduleId => {
|
||||
const segments = await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
seatId: { not: null },
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT', 'BOARDED'] } },
|
||||
},
|
||||
select: { seatId: true },
|
||||
});
|
||||
occupiedBySchedule.set(scheduleId, new Set(segments.map(s => s.seatId!)));
|
||||
}),
|
||||
);
|
||||
|
||||
// Track seats assigned within this batch to prevent double-assignment
|
||||
const assignedInBatch = new Set<string>();
|
||||
|
||||
const results: { bookingRef: string; oldSeatNumber: string; newSeatNumber: string; contactPhone: string | null }[] = [];
|
||||
const unresolved: { bookingRef: string; reason: string }[] = [];
|
||||
|
||||
for (const bs of bookingSeats) {
|
||||
const scheduleId = (bs.scheduleId ?? bs.booking.scheduleId)!;
|
||||
const occupied = occupiedBySchedule.get(scheduleId) ?? new Set<string>();
|
||||
|
||||
// Pick the first available seat across the selected coaches
|
||||
const newSeat = coachSeats.find(
|
||||
seat =>
|
||||
!occupied.has(seat.id) &&
|
||||
!assignedInBatch.has(seat.id) &&
|
||||
seat.id !== bs.seatId,
|
||||
);
|
||||
|
||||
if (!newSeat) {
|
||||
unresolved.push({
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
reason: 'No available seat found in selected coaches',
|
||||
});
|
||||
this.logger.warn(
|
||||
`Duplicate resolve: no seat available for ${bs.booking.bookingRef} (schedule ${scheduleId})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async tx => {
|
||||
// 1. Change the seat on the booking and ticket.
|
||||
await tx.bookingSeat.update({
|
||||
where: { id: bs.id },
|
||||
data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber },
|
||||
});
|
||||
await tx.ticket.updateMany({
|
||||
where: { bookingId: bs.booking.id, seatId: bs.seatId, leg: bs.leg },
|
||||
data: { seatId: newSeat.id },
|
||||
});
|
||||
|
||||
// 2. Point the existing JourneySegments to the new seat.
|
||||
// The Journey is already linked to this booking via bookingId;
|
||||
// just update the seatId in its hop rows for this schedule.
|
||||
const journey = await tx.journey.findFirst({
|
||||
where: { bookingId: bs.booking.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!journey) {
|
||||
// No Journey/JourneySegment for this booking (e.g. duplicate that was never
|
||||
// processed by finalizePaymentSuccess). Create them now using the same logic,
|
||||
// scoped to the booking's origin→destination leg so the seatmap shows BOOKED
|
||||
// only for the correct range of stops.
|
||||
const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId;
|
||||
const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId;
|
||||
|
||||
const stopTimes = await tx.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
orderBy: { sequence: 'asc' },
|
||||
select: { stationId: true },
|
||||
});
|
||||
|
||||
const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0;
|
||||
const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1;
|
||||
const fromIdx = originIdx >= 0 ? originIdx : 0;
|
||||
const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1;
|
||||
|
||||
const newJourney = await tx.journey.create({
|
||||
data: {
|
||||
passengerId: bs.booking.passengerId,
|
||||
bookingId: bs.booking.id,
|
||||
status: 'CONFIRMED',
|
||||
totalMinor: bs.booking.totalMinor,
|
||||
currency: bs.booking.currency,
|
||||
} as any,
|
||||
});
|
||||
|
||||
const segments = [];
|
||||
for (let i = fromIdx; i < toIdx; i++) {
|
||||
segments.push({
|
||||
journeyId: newJourney.id,
|
||||
scheduleId,
|
||||
segmentOrder: i - fromIdx,
|
||||
seatId: newSeat.id,
|
||||
coachId: newSeat.coachId,
|
||||
departureStationId: stopTimes[i].stationId,
|
||||
arrivalStationId: stopTimes[i + 1].stationId,
|
||||
});
|
||||
}
|
||||
if (segments.length > 0) {
|
||||
await tx.journeySegment.createMany({ data: segments, skipDuplicates: true });
|
||||
}
|
||||
this.logger.log(
|
||||
`No Journey for ${bs.booking.bookingRef} — created Journey + ${segments.length} segment(s) for seat ${newSeat.seatNumber}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { count } = await tx.journeySegment.updateMany({
|
||||
where: { journeyId: journey.id, scheduleId, seatId: bs.seatId },
|
||||
data: { seatId: newSeat.id },
|
||||
});
|
||||
|
||||
// Journey exists but had no segments (e.g. booking confirmed via a path
|
||||
// that skipped JourneySegment creation). Create them now for the new seat
|
||||
// so the seatmap reflects BOOKED.
|
||||
if (count === 0) {
|
||||
const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId;
|
||||
const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId;
|
||||
const stopTimes = await tx.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
orderBy: { sequence: 'asc' },
|
||||
select: { stationId: true },
|
||||
});
|
||||
const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0;
|
||||
const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1;
|
||||
const fromIdx = originIdx >= 0 ? originIdx : 0;
|
||||
const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1;
|
||||
const segments = [];
|
||||
for (let i = fromIdx; i < toIdx; i++) {
|
||||
segments.push({
|
||||
journeyId: journey.id,
|
||||
scheduleId,
|
||||
segmentOrder: i - fromIdx,
|
||||
seatId: newSeat.id,
|
||||
coachId: newSeat.coachId,
|
||||
departureStationId: stopTimes[i].stationId,
|
||||
arrivalStationId: stopTimes[i + 1].stationId,
|
||||
});
|
||||
}
|
||||
if (segments.length > 0) {
|
||||
await tx.journeySegment.createMany({ data: segments, skipDuplicates: true });
|
||||
}
|
||||
this.logger.log(
|
||||
`Seat reassigned: ${bs.booking.bookingRef} ` +
|
||||
`${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` +
|
||||
`(0 existing segments — created ${segments.length} new hop(s))`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
`Seat reassigned: ${bs.booking.bookingRef} ` +
|
||||
`${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` +
|
||||
`(${count} segment hop(s) updated)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Mark as taken so the next booking in this batch doesn't get the same seat
|
||||
assignedInBatch.add(newSeat.id);
|
||||
occupied.add(newSeat.id);
|
||||
|
||||
const oldSeatNumber = bs.seat?.seatNumber ?? '?';
|
||||
const origin = bs.booking.schedule?.originStation?.name ?? '';
|
||||
const dest = bs.booking.schedule?.destinationStation?.name ?? '';
|
||||
|
||||
if (bs.booking.contactPhone) {
|
||||
const message =
|
||||
`EDR: Your booking ${bs.booking.bookingRef} (${origin} → ${dest}): ` +
|
||||
`your seat has been changed from seat ${oldSeatNumber} to seat ${newSeat.seatNumber}. ` +
|
||||
`We apologize for any inconvenience.`;
|
||||
await this.sms.sendSms({ to: bs.booking.contactPhone, message }).catch(() => null);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Duplicate resolved: ${bs.booking.bookingRef} seat ${oldSeatNumber} → ${newSeat.seatNumber}`,
|
||||
);
|
||||
|
||||
results.push({
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
oldSeatNumber,
|
||||
newSeatNumber: newSeat.seatNumber,
|
||||
contactPhone: bs.booking.contactPhone,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
resolved: results.length,
|
||||
unresolved: unresolved.length,
|
||||
results,
|
||||
...(unresolved.length > 0 ? { unresolvedDetails: unresolved } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
20
apps/edr-passenger-api/src/modules/storage/minio.config.ts
Normal file
20
apps/edr-passenger-api/src/modules/storage/minio.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
/**
|
||||
* Mirrors the freight API's MinIO config so both apps read the same env vars and
|
||||
* behave the same against the same object store. Kept as a copy rather than a
|
||||
* shared package because the two APIs share no runtime code today, and a config
|
||||
* package for six fields would be more coupling than it saves.
|
||||
*/
|
||||
export const minioConfig = registerAs('minio', () => ({
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'minio-dev.smart.aaca.gov.et',
|
||||
port: parseInt(process.env.MINIO_PORT || '443', 10),
|
||||
useSSL: process.env.MINIO_USE_SSL !== 'false',
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || '',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || '',
|
||||
bucket: process.env.MINIO_BUCKET || 'edr-dev',
|
||||
// Preset the region so presignedGetObject signs URLs locally. Without it the
|
||||
// minio client fires a live GetBucketLocation request on every sign, which
|
||||
// blocks (no timeout) when MinIO is slow and would hang every thread load.
|
||||
region: process.env.MINIO_REGION || 'us-east-1',
|
||||
}));
|
||||
90
apps/edr-passenger-api/src/modules/storage/minio.service.ts
Normal file
90
apps/edr-passenger-api/src/modules/storage/minio.service.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigType } from '@nestjs/config';
|
||||
import { Client } from 'minio';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { minioConfig } from './minio.config';
|
||||
|
||||
/**
|
||||
* Minimal object-storage client for the passenger API.
|
||||
*
|
||||
* A deliberate subset of the freight MinioService — only what chat attachments
|
||||
* need (put, sign, stream, key-from-url). Freight's extra surface (delete,
|
||||
* public URLs for unauthenticated links) is omitted rather than copied
|
||||
* speculatively.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MinioService {
|
||||
private readonly client: Client;
|
||||
private readonly logger = new Logger(MinioService.name);
|
||||
private readonly bucket: string;
|
||||
|
||||
constructor(
|
||||
@Inject(minioConfig.KEY)
|
||||
private readonly config: ConfigType<typeof minioConfig>,
|
||||
) {
|
||||
this.bucket = config.bucket;
|
||||
this.client = new Client({
|
||||
endPoint: config.endPoint,
|
||||
port: config.port,
|
||||
useSSL: config.useSSL,
|
||||
accessKey: config.accessKey,
|
||||
secretKey: config.secretKey,
|
||||
region: config.region,
|
||||
});
|
||||
}
|
||||
|
||||
async uploadFile(objectName: string, buffer: Buffer, contentType: string): Promise<string> {
|
||||
await this.client.putObject(this.bucket, objectName, buffer, buffer.length, {
|
||||
'Content-Type': contentType,
|
||||
});
|
||||
return this.getObjectUrl(objectName);
|
||||
}
|
||||
|
||||
/** Unsigned object URL — what gets persisted. Not browser-fetchable. */
|
||||
getObjectUrl(objectName: string): string {
|
||||
const protocol = this.config.useSSL ? 'https' : 'http';
|
||||
return `${protocol}://${this.config.endPoint}:${this.config.port}/${this.bucket}/${objectName}`;
|
||||
}
|
||||
|
||||
getObjectNameFromUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) throw new NotFoundException('File object path is empty');
|
||||
if (!/^https?:\/\//i.test(trimmed)) return trimmed.replace(/^\/+/, '');
|
||||
|
||||
const url = new URL(trimmed);
|
||||
// pathname percent-encodes the key (a space becomes "%20") but MinIO stores
|
||||
// the literal characters, so decode each segment or a file whose name had
|
||||
// spaces 404s with "specified key does not exist".
|
||||
const parts = url.pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => decodeURIComponent(segment));
|
||||
if (parts[0] === this.bucket) parts.shift();
|
||||
|
||||
const objectName = parts.join('/');
|
||||
if (!objectName) throw new NotFoundException('File object path is empty');
|
||||
return objectName;
|
||||
}
|
||||
|
||||
async getFileStream(objectName: string): Promise<Readable> {
|
||||
return this.client.getObject(this.bucket, objectName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived signed URL for inline preview.
|
||||
*
|
||||
* Unlike the freight twin this does NOT degrade to an unsigned public URL when
|
||||
* signing fails: a chat attachment is another passenger's file, and quietly
|
||||
* handing back a URL that only works if the bucket is world-readable trades a
|
||||
* visible error for a silent access-control surprise. Fail loudly instead.
|
||||
*/
|
||||
async getSignedUrl(objectName: string, expirySeconds: number): Promise<string> {
|
||||
try {
|
||||
return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to sign URL for ${objectName}: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
apps/edr-passenger-api/src/modules/storage/storage.module.ts
Normal file
12
apps/edr-passenger-api/src/modules/storage/storage.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { minioConfig } from './minio.config';
|
||||
import { MinioService } from './minio.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule.forFeature(minioConfig)],
|
||||
providers: [MinioService],
|
||||
exports: [MinioService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SUPPORT_ATTACHMENT_MAX_BYTES, SUPPORT_ATTACHMENT_MAX_PER_MESSAGE } from '@edr/types';
|
||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||
|
||||
/** Multipart field name carrying chat files. */
|
||||
export const SUPPORT_ATTACHMENT_FIELD = 'attachments';
|
||||
|
||||
/**
|
||||
* Multer-level caps for the chat send routes.
|
||||
*
|
||||
* These duplicate `SupportService.assertSendable` on purpose and don't replace
|
||||
* it: Multer stops reading the socket once a part exceeds `fileSize`, so an
|
||||
* oversized upload is cut off mid-stream rather than buffered into memory and
|
||||
* rejected afterwards. The service check produces the readable error.
|
||||
*/
|
||||
export const supportAttachmentMulterOptions: MulterOptions = {
|
||||
limits: {
|
||||
fileSize: SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
files: SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
},
|
||||
};
|
||||
52
apps/edr-passenger-api/src/modules/support/message-cursor.ts
Normal file
52
apps/edr-passenger-api/src/modules/support/message-cursor.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Keyset cursor for paging a thread backwards from newest.
|
||||
*
|
||||
* The sort key is the pair `(createdAt, id)`, not `createdAt` alone: two
|
||||
* messages can share a millisecond, and a cursor on a non-unique key either
|
||||
* re-serves or skips the tied rows depending which side of the boundary they
|
||||
* land on. The id breaks ties with a stable total order.
|
||||
*
|
||||
* Deliberately a twin of the freight API's `message-cursor.ts`, not a shared
|
||||
* import: the two APIs share no runtime package, and @edr/types is Nest-free by
|
||||
* design (this throws Nest exceptions). The wire format matches so a client can
|
||||
* treat both chats identically — keep them in step if either changes.
|
||||
*/
|
||||
export interface MessageCursor {
|
||||
createdAt: Date;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function encodeMessageCursor(cursor: MessageCursor): string {
|
||||
return Buffer.from(`${cursor.createdAt.toISOString()}|${cursor.id}`, 'utf8').toString(
|
||||
'base64url',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a client-supplied cursor. Rejects anything malformed rather than
|
||||
* silently falling back to "first page" — a corrupted cursor that degrades to
|
||||
* page 1 makes an infinite scroll loop forever over the same rows.
|
||||
*/
|
||||
export function decodeMessageCursor(raw: string): MessageCursor {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = Buffer.from(raw, 'base64url').toString('utf8');
|
||||
} catch {
|
||||
throw new BadRequestException('Malformed pagination cursor.');
|
||||
}
|
||||
|
||||
const separator = decoded.lastIndexOf('|');
|
||||
if (separator === -1) {
|
||||
throw new BadRequestException('Malformed pagination cursor.');
|
||||
}
|
||||
|
||||
const createdAt = new Date(decoded.slice(0, separator));
|
||||
const id = decoded.slice(separator + 1);
|
||||
if (Number.isNaN(createdAt.getTime()) || !id) {
|
||||
throw new BadRequestException('Malformed pagination cursor.');
|
||||
}
|
||||
|
||||
return { createdAt, id };
|
||||
}
|
||||
@@ -7,21 +7,33 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { Response } from 'express';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiConsumes } from '@nestjs/swagger';
|
||||
import { SUPPORT_ATTACHMENT_MAX_PER_MESSAGE } from '@edr/types';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SupportService } from './support.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import {
|
||||
SUPPORT_ATTACHMENT_FIELD,
|
||||
supportAttachmentMulterOptions,
|
||||
} from './attachment-upload.options';
|
||||
import {
|
||||
CreateConversationDto,
|
||||
CreateGuestConversationDto,
|
||||
DeviceIdBodyDto,
|
||||
DeviceSendMessageDto,
|
||||
DeviceThreadQueryDto,
|
||||
GuestIdBodyDto,
|
||||
GuestSendMessageDto,
|
||||
ListConversationsQueryDto,
|
||||
ListMessagesQueryDto,
|
||||
SendMessageDto,
|
||||
UpdateStatusDto,
|
||||
} from './support.dto';
|
||||
@@ -32,6 +44,35 @@ function userId(req: any): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multipart send routes accept `text` + `attachments` file parts; a plain-JSON
|
||||
* body still works (Multer passes non-multipart requests through untouched), so
|
||||
* text-only clients are unaffected.
|
||||
*/
|
||||
const attachmentsInterceptor = () =>
|
||||
UseInterceptors(
|
||||
FilesInterceptor(
|
||||
SUPPORT_ATTACHMENT_FIELD,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
supportAttachmentMulterOptions,
|
||||
),
|
||||
);
|
||||
|
||||
/** Swagger body schema for a send route: optional text + optional files. */
|
||||
const sendBodySchema = (extra: Record<string, unknown> = {}) => ({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...extra,
|
||||
text: { type: 'string' },
|
||||
attachments: {
|
||||
type: 'array',
|
||||
items: { type: 'string', format: 'binary' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ApiTags('Support')
|
||||
@Controller('support')
|
||||
export class SupportController {
|
||||
@@ -74,19 +115,37 @@ export class SupportController {
|
||||
@Get('conversations/:id/messages')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List messages in one of my conversations' })
|
||||
messages(@Req() req: any, @Param('id') id: string) {
|
||||
return this.service.getMessages(id, { iamUserId: userId(req) });
|
||||
@ApiOperation({
|
||||
summary: 'List messages in one of my conversations (newest page first)',
|
||||
description:
|
||||
'Keyset-paginated backwards from newest. Omit `before` for the newest ' +
|
||||
'page, then pass the previous `nextCursor`. Null means start of thread.',
|
||||
})
|
||||
messages(@Req() req: any, @Param('id') id: string, @Query() query: ListMessagesQueryDto) {
|
||||
return this.service.getMessages(id, query, { iamUserId: userId(req) });
|
||||
}
|
||||
|
||||
@Post('conversations/:id/messages')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@attachmentsInterceptor()
|
||||
@ApiConsumes('multipart/form-data', 'application/json')
|
||||
@ApiBody(sendBodySchema())
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Send a message as the customer' })
|
||||
send(@Req() req: any, @Param('id') id: string, @Body() body: SendMessageDto) {
|
||||
return this.service.sendMessage(id, 'USER', body.text, {
|
||||
iamUserId: userId(req),
|
||||
});
|
||||
send(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() body: SendMessageDto,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||
) {
|
||||
return this.service.sendMessage(
|
||||
id,
|
||||
'USER',
|
||||
body.text,
|
||||
{ iamUserId: userId(req) },
|
||||
attachments ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@Post('conversations/:id/read')
|
||||
@@ -111,16 +170,29 @@ export class SupportController {
|
||||
|
||||
@Get('device/thread')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: "Get the device's support thread + messages" })
|
||||
deviceThread(@Query('deviceId') deviceId: string) {
|
||||
return this.service.getDeviceThread(deviceId);
|
||||
@ApiOperation({
|
||||
summary: "Get the device's support thread + its newest page of messages",
|
||||
description:
|
||||
'`messages` is the newest page only, not the whole thread — page back ' +
|
||||
'with `nextCursor` via this same route.',
|
||||
})
|
||||
deviceThread(@Query() query: DeviceThreadQueryDto) {
|
||||
return this.service.getDeviceThread(query.deviceId, query);
|
||||
}
|
||||
|
||||
@Post('device/messages')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Send a message (creates the thread on first send)' })
|
||||
deviceSend(@Body() body: DeviceSendMessageDto) {
|
||||
return this.service.sendDeviceMessage(body.deviceId, body.text);
|
||||
@attachmentsInterceptor()
|
||||
@ApiConsumes('multipart/form-data', 'application/json')
|
||||
@ApiBody(sendBodySchema({ deviceId: { type: 'string' } }))
|
||||
@ApiOperation({
|
||||
summary: 'Send a message (creates the thread on first send)',
|
||||
})
|
||||
deviceSend(
|
||||
@Body() body: DeviceSendMessageDto,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||
) {
|
||||
return this.service.sendDeviceMessage(body.deviceId, body.text, attachments ?? []);
|
||||
}
|
||||
|
||||
@Post('device/read')
|
||||
@@ -137,6 +209,37 @@ export class SupportController {
|
||||
return this.service.unreadCount('USER', { guestId: deviceId });
|
||||
}
|
||||
|
||||
// ---- chat attachments --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Serves both audiences (portal device threads and the backoffice inbox) from
|
||||
* one path, because a new message is pushed to both over the socket in a
|
||||
* single payload — an identity-bearing URL would be wrong for one of them.
|
||||
*
|
||||
* Public for the same reason the device thread is: access to a passenger
|
||||
* support thread is already whoever-holds-the-id. See `streamAttachment` for
|
||||
* the full trade-off and the TODO to tighten it with the agent-route gating.
|
||||
*/
|
||||
@Get('attachments/:fileId')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Stream a support chat attachment' })
|
||||
async attachment(
|
||||
@Param('fileId') fileId: string,
|
||||
@Query('download') download: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { stream, mimeType, name } = await this.service.streamAttachment(fileId);
|
||||
const forceDownload = download === '1' || download === 'true';
|
||||
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`${forceDownload ? 'attachment' : 'inline'}; filename="${name}"`,
|
||||
);
|
||||
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
// ---- customer: guest (unauthenticated, multi-ticket) ------------------
|
||||
// No JwtGuard. Access is scoped by a client-generated `guestId` (the bearer
|
||||
// of access — anyone with it sees that thread; accepted MVP trade-off).
|
||||
@@ -150,28 +253,42 @@ export class SupportController {
|
||||
|
||||
@Get('guest/conversations')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'List a guest\'s conversations' })
|
||||
guestList(
|
||||
@Query('guestId') guestId: string,
|
||||
@Query() query: ListConversationsQueryDto,
|
||||
) {
|
||||
@ApiOperation({ summary: "List a guest's conversations" })
|
||||
guestList(@Query('guestId') guestId: string, @Query() query: ListConversationsQueryDto) {
|
||||
return this.service.listForCustomer({ guestId }, query);
|
||||
}
|
||||
|
||||
@Get('guest/conversations/:id/messages')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'List messages in a guest conversation' })
|
||||
guestMessages(@Param('id') id: string, @Query('guestId') guestId: string) {
|
||||
return this.service.getMessages(id, { guestId });
|
||||
@ApiOperation({
|
||||
summary: 'List messages in a guest conversation (newest page first)',
|
||||
})
|
||||
guestMessages(
|
||||
@Param('id') id: string,
|
||||
@Query('guestId') guestId: string,
|
||||
@Query() query: ListMessagesQueryDto,
|
||||
) {
|
||||
return this.service.getMessages(id, query, { guestId });
|
||||
}
|
||||
|
||||
@Post('guest/conversations/:id/messages')
|
||||
@IsPublic()
|
||||
@attachmentsInterceptor()
|
||||
@ApiConsumes('multipart/form-data', 'application/json')
|
||||
@ApiBody(sendBodySchema({ guestId: { type: 'string' } }))
|
||||
@ApiOperation({ summary: 'Send a message as a guest' })
|
||||
guestSend(@Param('id') id: string, @Body() body: GuestSendMessageDto) {
|
||||
return this.service.sendMessage(id, 'USER', body.text, {
|
||||
guestId: body.guestId,
|
||||
});
|
||||
guestSend(
|
||||
@Param('id') id: string,
|
||||
@Body() body: GuestSendMessageDto,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||
) {
|
||||
return this.service.sendMessage(
|
||||
id,
|
||||
'USER',
|
||||
body.text,
|
||||
{ guestId: body.guestId },
|
||||
attachments ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@Post('guest/conversations/:id/read')
|
||||
@@ -183,7 +300,7 @@ export class SupportController {
|
||||
|
||||
@Get('guest/unread-count')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Count a guest\'s unread conversations' })
|
||||
@ApiOperation({ summary: "Count a guest's unread conversations" })
|
||||
guestUnread(@Query('guestId') guestId: string) {
|
||||
return this.service.unreadCount('USER', { guestId });
|
||||
}
|
||||
@@ -202,17 +319,29 @@ export class SupportController {
|
||||
@Get('agent/conversations/:id/messages')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List messages in a conversation' })
|
||||
agentMessages(@Param('id') id: string) {
|
||||
return this.service.getMessages(id);
|
||||
@ApiOperation({
|
||||
summary: 'List messages in a conversation (newest page first)',
|
||||
description:
|
||||
'Keyset-paginated backwards from newest. Omit `before` for the newest ' +
|
||||
'page, then pass the previous `nextCursor`. Null means start of thread.',
|
||||
})
|
||||
agentMessages(@Param('id') id: string, @Query() query: ListMessagesQueryDto) {
|
||||
return this.service.getMessages(id, query);
|
||||
}
|
||||
|
||||
@Post('agent/conversations/:id/messages')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Reply as an agent' })
|
||||
agentSend(@Param('id') id: string, @Body() body: SendMessageDto) {
|
||||
return this.service.sendMessage(id, 'AGENT', body.text);
|
||||
@attachmentsInterceptor()
|
||||
@ApiConsumes('multipart/form-data', 'application/json')
|
||||
@ApiBody(sendBodySchema())
|
||||
@ApiOperation({ summary: 'Reply as an agent, optionally with attachments' })
|
||||
agentSend(
|
||||
@Param('id') id: string,
|
||||
@Body() body: SendMessageDto,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||
) {
|
||||
return this.service.sendMessage(id, 'AGENT', body.text, undefined, attachments ?? []);
|
||||
}
|
||||
|
||||
@Patch('agent/conversations/:id/status')
|
||||
|
||||
@@ -32,12 +32,19 @@ export class CreateConversationDto {
|
||||
initialMessage!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text is optional across the send DTOs because a message may be nothing but
|
||||
* attachments. "Neither text nor files" is rejected in the service rather than
|
||||
* here — the validator can't see the multipart file parts.
|
||||
*/
|
||||
export class SendMessageDto {
|
||||
@ApiProperty({ description: 'Message text.' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Message text. Optional only when attachments are present.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
text!: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export class CreateGuestConversationDto {
|
||||
@@ -79,11 +86,13 @@ export class GuestSendMessageDto {
|
||||
@Length(8, 120)
|
||||
guestId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Message text.' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Message text. Optional only when attachments are present.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
text!: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export class GuestIdBodyDto {
|
||||
@@ -99,11 +108,13 @@ export class DeviceSendMessageDto {
|
||||
@Length(8, 120)
|
||||
deviceId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Message text.' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Message text. Optional only when attachments are present.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
text!: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export class DeviceIdBodyDto {
|
||||
@@ -145,3 +156,38 @@ export class ListConversationsQueryDto {
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Default page size for a thread — roughly two screens of bubbles. */
|
||||
export const SUPPORT_MESSAGES_DEFAULT_LIMIT = 30;
|
||||
export const SUPPORT_MESSAGES_MAX_LIMIT = 100;
|
||||
|
||||
export class ListMessagesQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Opaque cursor from a previous response's `nextCursor`. Returns the page " +
|
||||
'of messages immediately OLDER than the cursor. Omit for the newest page.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
before?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 1,
|
||||
maximum: SUPPORT_MESSAGES_MAX_LIMIT,
|
||||
default: SUPPORT_MESSAGES_DEFAULT_LIMIT,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(SUPPORT_MESSAGES_MAX_LIMIT)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Query form of {@link ListMessagesQueryDto} for the device-scoped thread. */
|
||||
export class DeviceThreadQueryDto extends ListMessagesQueryDto {
|
||||
@ApiProperty({ description: 'Client device id (localStorage).' })
|
||||
@IsString()
|
||||
@Length(8, 120)
|
||||
deviceId!: string;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
|
||||
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { SupportController } from './support.controller';
|
||||
import { SupportService } from './support.service';
|
||||
import { SupportGateway } from './support.gateway';
|
||||
@@ -10,7 +11,8 @@ import { WsAuthService } from './ws-auth.service';
|
||||
@Module({
|
||||
// Session is served by the app's default TypeORM DataSource (IAM schema) —
|
||||
// used by WsAuthService to authenticate WebSocket handshakes.
|
||||
imports: [TypeOrmModule.forFeature([Session])],
|
||||
// StorageModule — MinioService for chat attachment bytes.
|
||||
imports: [TypeOrmModule.forFeature([Session]), StorageModule],
|
||||
controllers: [SupportController],
|
||||
providers: [SupportService, SupportGateway, WsAuthService],
|
||||
})
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Passenger as T } from '@edr/types';
|
||||
import {
|
||||
isSupportAttachmentAllowed,
|
||||
Passenger as T,
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
} from '@edr/types';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Readable } from 'stream';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { MinioService } from '../storage/minio.service';
|
||||
import { decodeMessageCursor, encodeMessageCursor } from './message-cursor';
|
||||
import { SUPPORT_MESSAGES_DEFAULT_LIMIT } from './support.dto';
|
||||
import { SupportGateway } from './support.gateway';
|
||||
|
||||
type Side = 'USER' | 'AGENT';
|
||||
type PrismaSender = 'USER' | 'BOT' | 'AGENT';
|
||||
type PrismaStatus = 'OPEN' | 'RESOLVED' | 'CLOSED';
|
||||
|
||||
/** Stand-in preview for a message that is nothing but files. */
|
||||
const ATTACHMENT_ONLY_PREVIEW = '📎';
|
||||
|
||||
interface MessagesQuery {
|
||||
before?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
type AttachmentRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type MessageRow = {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
sender: PrismaSender;
|
||||
text: string | null;
|
||||
createdAt: Date;
|
||||
attachments?: AttachmentRow[];
|
||||
};
|
||||
|
||||
/** Who the caller is on the customer side: an authed passenger or a guest. */
|
||||
export interface CustomerOwner {
|
||||
iamUserId?: string | null;
|
||||
@@ -50,6 +86,7 @@ export class SupportService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private gateway: SupportGateway,
|
||||
private minio: MinioService,
|
||||
) {}
|
||||
|
||||
// ---- FAQ (unchanged) ---------------------------------------------------
|
||||
@@ -115,29 +152,37 @@ export class SupportService {
|
||||
|
||||
// ---- customer: device-scoped single thread (portal) -------------------
|
||||
|
||||
/** The device's single conversation + its messages ({conversation:null} if none). */
|
||||
async getDeviceThread(deviceId: string): Promise<T.PassengerSupportThreadDto> {
|
||||
if (!deviceId) return { conversation: null, messages: [] };
|
||||
/**
|
||||
* The device's single conversation + its NEWEST page of messages
|
||||
* ({conversation:null} if none). Not the whole thread — the client pages back
|
||||
* with `nextCursor` exactly as the backoffice does.
|
||||
*/
|
||||
async getDeviceThread(
|
||||
deviceId: string,
|
||||
query: MessagesQuery = {},
|
||||
): Promise<T.PassengerSupportThreadDto> {
|
||||
const empty = { conversation: null, messages: [], nextCursor: null };
|
||||
if (!deviceId) return empty;
|
||||
const c = (await this.prisma.supportConversation.findFirst({
|
||||
where: { guestId: deviceId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})) as ConversationRow | null;
|
||||
if (!c) return { conversation: null, messages: [] };
|
||||
const rows = await this.prisma.supportMessage.findMany({
|
||||
where: { conversationId: c.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (!c) return empty;
|
||||
|
||||
const page = await this.listMessages(c.id, query);
|
||||
const unread = await this.computeUnread([c], 'USER');
|
||||
return {
|
||||
conversation: this.toConversationDto(c, unread.get(c.id) ?? 0),
|
||||
messages: rows.map((m) => this.toMessageDto(m)),
|
||||
messages: page.items,
|
||||
nextCursor: page.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
/** Append a message to the device's thread, creating it on first message. */
|
||||
async sendDeviceMessage(
|
||||
deviceId: string,
|
||||
text: string,
|
||||
text: string | undefined,
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<T.PassengerSupportMessageDto> {
|
||||
let c = (await this.prisma.supportConversation.findFirst({
|
||||
where: { guestId: deviceId },
|
||||
@@ -148,9 +193,7 @@ export class SupportService {
|
||||
data: { guestId: deviceId, subject: 'Support chat', status: 'OPEN' },
|
||||
})) as ConversationRow;
|
||||
}
|
||||
const updated = await this.appendMessage(c, 'USER', text);
|
||||
const last = updated.messages[updated.messages.length - 1];
|
||||
return this.toMessageDto(last);
|
||||
return this.appendMessage(c, 'USER', text, attachments);
|
||||
}
|
||||
|
||||
/** Mark the device's thread read (customer side). */
|
||||
@@ -186,9 +229,7 @@ export class SupportService {
|
||||
|
||||
// ---- agent (backoffice) ------------------------------------------------
|
||||
|
||||
async listForAgents(
|
||||
query: ListQuery,
|
||||
): Promise<T.PassengerSupportConversationListResult> {
|
||||
async listForAgents(query: ListQuery): Promise<T.PassengerSupportConversationListResult> {
|
||||
const where = this.listWhere(query);
|
||||
const rows = (await this.prisma.supportConversation.findMany({
|
||||
where,
|
||||
@@ -216,32 +257,29 @@ export class SupportService {
|
||||
|
||||
// ---- shared ------------------------------------------------------------
|
||||
|
||||
/** One page of a thread, newest first. See {@link listMessages}. */
|
||||
async getMessages(
|
||||
conversationId: string,
|
||||
query: MessagesQuery = {},
|
||||
asCustomer?: CustomerOwner,
|
||||
): Promise<T.PassengerSupportMessageDto[]> {
|
||||
): Promise<T.PassengerSupportMessageListResult> {
|
||||
const conversation = await this.requireConversation(conversationId);
|
||||
if (asCustomer) this.assertOwns(conversation, asCustomer);
|
||||
const rows = await this.prisma.supportMessage.findMany({
|
||||
where: { conversationId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return rows.map((m) => this.toMessageDto(m));
|
||||
return this.listMessages(conversationId, query);
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
conversationId: string,
|
||||
sender: Side,
|
||||
text: string,
|
||||
text: string | undefined,
|
||||
asCustomer?: CustomerOwner,
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<T.PassengerSupportMessageDto> {
|
||||
const conversation = await this.requireConversation(conversationId);
|
||||
if (sender === 'USER') {
|
||||
this.assertOwns(conversation, asCustomer ?? {});
|
||||
}
|
||||
const updated = await this.appendMessage(conversation, sender, text);
|
||||
const last = updated.messages[updated.messages.length - 1];
|
||||
return this.toMessageDto(last);
|
||||
return this.appendMessage(conversation, sender, text, attachments);
|
||||
}
|
||||
|
||||
async markRead(
|
||||
@@ -265,10 +303,7 @@ export class SupportService {
|
||||
return this.unreadCount('AGENT');
|
||||
}
|
||||
|
||||
async unreadCount(
|
||||
side: Side,
|
||||
owner?: CustomerOwner,
|
||||
): Promise<{ unreadCount: number }> {
|
||||
async unreadCount(side: Side, owner?: CustomerOwner): Promise<{ unreadCount: number }> {
|
||||
const rows = (await this.prisma.supportConversation.findMany({
|
||||
where: side === 'USER' ? this.ownerScope(owner ?? {}) : {},
|
||||
select: { id: true, userLastReadAt: true, agentLastReadAt: true },
|
||||
@@ -289,49 +324,197 @@ export class SupportService {
|
||||
conversation: ConversationRow,
|
||||
text: string,
|
||||
): Promise<T.PassengerSupportConversationDto> {
|
||||
const { conversation: updated } = await this.appendMessageRaw(
|
||||
conversation,
|
||||
'USER',
|
||||
text,
|
||||
);
|
||||
const { conversation: updated } = await this.appendMessageRaw(conversation, 'USER', text);
|
||||
return this.toConversationDto(updated, 0);
|
||||
}
|
||||
|
||||
private async appendMessage(
|
||||
conversation: ConversationRow,
|
||||
sender: PrismaSender,
|
||||
text: string,
|
||||
) {
|
||||
const { conversation: updated } = await this.appendMessageRaw(
|
||||
conversation,
|
||||
sender,
|
||||
text,
|
||||
);
|
||||
return updated as ConversationRow & { messages: any[] };
|
||||
text: string | undefined,
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<T.PassengerSupportMessageDto> {
|
||||
const { message } = await this.appendMessageRaw(conversation, sender, text, attachments);
|
||||
return message;
|
||||
}
|
||||
|
||||
/** Persist a message, bump the conversation's denormalized fields, emit live. */
|
||||
/**
|
||||
* One page of a thread, walking backwards from newest.
|
||||
*
|
||||
* Keyset, not offset: a message arriving while the reader is scrolled back
|
||||
* would shift every offset by one and duplicate/skip rows across pages. Rides
|
||||
* the (conversationId, createdAt) index; the id is a tiebreak for messages
|
||||
* sharing a millisecond.
|
||||
*/
|
||||
private async listMessages(
|
||||
conversationId: string,
|
||||
query: MessagesQuery,
|
||||
): Promise<T.PassengerSupportMessageListResult> {
|
||||
const limit = query.limit ?? SUPPORT_MESSAGES_DEFAULT_LIMIT;
|
||||
const before = query.before ? decodeMessageCursor(query.before) : undefined;
|
||||
|
||||
const rows = (await this.prisma.supportMessage.findMany({
|
||||
where: {
|
||||
conversationId,
|
||||
...(before
|
||||
? {
|
||||
// Strictly older than the cursor in (createdAt, id) order.
|
||||
OR: [
|
||||
{ createdAt: { lt: before.createdAt } },
|
||||
{
|
||||
createdAt: before.createdAt,
|
||||
id: { lt: before.id },
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
// One more than asked, to tell "there's another page" from "this page was
|
||||
// simply full" without a second COUNT.
|
||||
take: limit + 1,
|
||||
include: { attachments: { orderBy: { createdAt: 'asc' } } },
|
||||
})) as MessageRow[];
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
const oldest = page[page.length - 1];
|
||||
const nextCursor =
|
||||
hasMore && oldest
|
||||
? encodeMessageCursor({ createdAt: oldest.createdAt, id: oldest.id })
|
||||
: null;
|
||||
|
||||
// Flip to oldest-first so a page prepends as one block.
|
||||
const items = await Promise.all([...page].reverse().map((m) => this.toMessageDto(m)));
|
||||
return { items, nextCursor };
|
||||
}
|
||||
|
||||
/** Persist a message (+ attachments), bump denormalized fields, emit live. */
|
||||
private async appendMessageRaw(
|
||||
conversation: ConversationRow,
|
||||
sender: PrismaSender,
|
||||
text: string,
|
||||
): Promise<{ conversation: ConversationRow & { messages: any[] }; message: any }> {
|
||||
text: string | undefined,
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<{
|
||||
conversation: ConversationRow;
|
||||
message: T.PassengerSupportMessageDto;
|
||||
}> {
|
||||
const trimmed = (text ?? '').trim();
|
||||
this.assertSendable(trimmed, attachments);
|
||||
|
||||
const message = await this.prisma.supportMessage.create({
|
||||
data: { conversationId: conversation.id, sender, text },
|
||||
// NULL, not "", so "no text" is representable rather than inferred. The
|
||||
// DTO flattens it back to "" for rendering.
|
||||
data: { conversationId: conversation.id, sender, text: trimmed || null },
|
||||
});
|
||||
|
||||
// The row has to exist before the files, since each is stored against
|
||||
// `messageId`. That leaves a window: if an upload fails here, the message is
|
||||
// already committed. Undo it rather than leave the thread with a
|
||||
// permanently blank bubble — there is no delete flow, so an orphan would be
|
||||
// unremovable, and an attachment-only message that lost its files has no
|
||||
// content at all.
|
||||
let stored: AttachmentRow[];
|
||||
try {
|
||||
stored = await this.storeAttachments(message.id, attachments);
|
||||
} catch (error) {
|
||||
await this.prisma.supportMessage.delete({ where: { id: message.id } });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const updated = (await this.prisma.supportConversation.update({
|
||||
where: { id: conversation.id },
|
||||
data: {
|
||||
lastMessageAt: message.createdAt,
|
||||
lastMessagePreview: text.slice(0, 280),
|
||||
lastMessagePreview: this.buildPreview(trimmed, stored),
|
||||
lastMessageSender: sender,
|
||||
},
|
||||
include: { messages: { orderBy: { createdAt: 'asc' } } },
|
||||
})) as ConversationRow & { messages: any[] };
|
||||
// Deliberately NOT `include: { messages: ... }` — that loaded every
|
||||
// message in the thread on every send just to read back the one we had in
|
||||
// hand.
|
||||
})) as ConversationRow;
|
||||
|
||||
const messageDto = await this.toMessageDto({
|
||||
...(message as MessageRow),
|
||||
attachments: stored,
|
||||
});
|
||||
const dto = this.toConversationDto(updated, 0);
|
||||
this.gateway.emitMessage(this.ownerRoom(updated), dto, this.toMessageDto(message));
|
||||
return { conversation: updated, message };
|
||||
this.gateway.emitMessage(this.ownerRoom(updated), dto, messageDto);
|
||||
return { conversation: updated, message: messageDto };
|
||||
}
|
||||
|
||||
/**
|
||||
* Push bytes to MinIO, then record them. Object keys are namespaced by message
|
||||
* id and the stored name is sanitized so the key survives the round-trip
|
||||
* through its own URL (spaces/unicode would otherwise percent-encode and no
|
||||
* longer match the key).
|
||||
*/
|
||||
private async storeAttachments(
|
||||
messageId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<AttachmentRow[]> {
|
||||
return Promise.all(
|
||||
files.map(async (file) => {
|
||||
const safeName = file.originalname
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\w.\-]+/g, '_')
|
||||
.replace(/_{2,}/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
// The random segment is load-bearing: `Date.now()` is NOT unique across
|
||||
// this batch, since every callback runs to its first await in the same
|
||||
// tick and reads the same millisecond. Two files sharing a name — e.g.
|
||||
// two pasted screenshots, which browsers both call "image.png" — would
|
||||
// otherwise build the same key and silently overwrite each other.
|
||||
const objectName = `support_message/${messageId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
|
||||
const url = await this.minio.uploadFile(objectName, file.buffer, file.mimetype);
|
||||
return this.prisma.supportAttachment.create({
|
||||
data: {
|
||||
messageId,
|
||||
name: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
url,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat upload rules — kept in step with the freight side via the shared
|
||||
* SUPPORT_ATTACHMENT_* constants. Notably excludes SVG: it's executable markup
|
||||
* and this is a file one user pushes at another.
|
||||
*/
|
||||
private assertSendable(text: string, attachments: Express.Multer.File[]): void {
|
||||
if (!text && attachments.length === 0) {
|
||||
throw new BadRequestException('A message needs text or at least one attachment.');
|
||||
}
|
||||
if (attachments.length > SUPPORT_ATTACHMENT_MAX_PER_MESSAGE) {
|
||||
throw new BadRequestException(
|
||||
`At most ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`,
|
||||
);
|
||||
}
|
||||
for (const file of attachments) {
|
||||
if (!isSupportAttachmentAllowed(file.mimetype)) {
|
||||
throw new BadRequestException(`Unsupported attachment type: ${file.mimetype}`);
|
||||
}
|
||||
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
|
||||
throw new BadRequestException(
|
||||
`"${file.originalname}" exceeds the ${
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)
|
||||
}MB attachment limit.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Inbox preview line — falls back to filenames when there's no text. */
|
||||
private buildPreview(text: string, attachments: AttachmentRow[]): string {
|
||||
if (text) return text.slice(0, 280);
|
||||
if (attachments.length === 1) {
|
||||
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments[0].name}`.slice(0, 280);
|
||||
}
|
||||
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments.length} files`;
|
||||
}
|
||||
|
||||
private async buildListResult(
|
||||
@@ -340,9 +523,7 @@ export class SupportService {
|
||||
side: Side,
|
||||
): Promise<T.PassengerSupportConversationListResult> {
|
||||
const unreadMap = await this.computeUnread(rows, side);
|
||||
const items = rows.map((r) =>
|
||||
this.toConversationDto(r, unreadMap.get(r.id) ?? 0),
|
||||
);
|
||||
const items = rows.map((r) => this.toConversationDto(r, unreadMap.get(r.id) ?? 0));
|
||||
let unreadCount = 0;
|
||||
for (const n of unreadMap.values()) if (n > 0) unreadCount++;
|
||||
return { items, count, unreadCount };
|
||||
@@ -365,10 +546,7 @@ export class SupportService {
|
||||
select: { conversationId: true, createdAt: true },
|
||||
});
|
||||
const cursorById = new Map(
|
||||
rows.map((r) => [
|
||||
r.id,
|
||||
side === 'USER' ? r.userLastReadAt : r.agentLastReadAt,
|
||||
]),
|
||||
rows.map((r) => [r.id, side === 'USER' ? r.userLastReadAt : r.agentLastReadAt]),
|
||||
);
|
||||
for (const m of msgs) {
|
||||
const cursor = cursorById.get(m.conversationId) ?? null;
|
||||
@@ -448,27 +626,75 @@ export class SupportService {
|
||||
};
|
||||
}
|
||||
|
||||
private toMessageDto(m: {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
sender: PrismaSender;
|
||||
text: string;
|
||||
createdAt: Date;
|
||||
}): T.PassengerSupportMessageDto {
|
||||
private async toMessageDto(m: MessageRow): Promise<T.PassengerSupportMessageDto> {
|
||||
return {
|
||||
id: m.id,
|
||||
conversationId: m.conversationId,
|
||||
sender: this.toDtoSender(m.sender) ?? T.PassengerSupportSender.AGENT,
|
||||
text: m.text,
|
||||
text: m.text ?? '',
|
||||
attachments: (m.attachments ?? []).map((a) => this.toAttachmentDto(a)),
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the client fetches the bytes: this API's own stream route, NOT a
|
||||
* presigned MinIO URL. Presigned object URLs are not reachable from the
|
||||
* browser in this deployment, which is why every working file in the platform
|
||||
* streams through the API instead.
|
||||
*
|
||||
* The path is audience-independent on purpose. A new message is pushed over
|
||||
* the socket to the device room *and* the backoffice room in one payload, so a
|
||||
* URL that embedded the caller's identity (a `?deviceId=`, say) would be wrong
|
||||
* for one of the two recipients.
|
||||
*
|
||||
* The client can't use this path as an `<img src>` either — the agent side's
|
||||
* guard only reads a bearer header, which an image request can't send — so the
|
||||
* web apps fetch it through their authenticated client and render a blob.
|
||||
*/
|
||||
private toAttachmentDto(a: AttachmentRow): T.PassengerSupportAttachmentDto {
|
||||
return {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
url: `/support/attachments/${a.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes for a chat attachment.
|
||||
*
|
||||
* Deliberately unscoped, and this is a trade-off worth naming: passenger
|
||||
* support threads are already reachable by whoever holds the device/guest id
|
||||
* (see the device routes — "anyone with the device id can see that thread",
|
||||
* the accepted MVP posture), and the agent routes admit any authenticated
|
||||
* caller pending real staff gating. Scoping this endpoint tighter than the
|
||||
* thread it belongs to would buy nothing, so it matches that posture: the
|
||||
* attachment UUID is the capability.
|
||||
*
|
||||
* TODO: tighten alongside the agent-route staff permission — at that point
|
||||
* both the thread and its attachments should be gated the same way.
|
||||
*/
|
||||
async streamAttachment(
|
||||
fileId: string,
|
||||
): Promise<{ stream: Readable; mimeType: string; name: string }> {
|
||||
const attachment = await this.prisma.supportAttachment.findUnique({
|
||||
where: { id: fileId },
|
||||
});
|
||||
if (!attachment) throw new NotFoundException('Attachment not found');
|
||||
|
||||
const objectName = this.minio.getObjectNameFromUrl(attachment.url);
|
||||
return {
|
||||
stream: await this.minio.getFileStream(objectName),
|
||||
mimeType: attachment.mimeType,
|
||||
name: attachment.name,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy BOT messages are surfaced as AGENT to the UI. */
|
||||
private toDtoSender(s: PrismaSender | null): T.PassengerSupportSender | null {
|
||||
if (!s) return null;
|
||||
return s === 'USER'
|
||||
? T.PassengerSupportSender.USER
|
||||
: T.PassengerSupportSender.AGENT;
|
||||
return s === 'USER' ? T.PassengerSupportSender.USER : T.PassengerSupportSender.AGENT;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Get, Patch, SetMetadata, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { SystemConfigService } from './system-config.service';
|
||||
import { UpdateSystemConfigDto } from './system-config.dto';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { Roles } from '../../common/roles.decorator';
|
||||
|
||||
@@ -31,7 +32,12 @@ export class SystemConfigController {
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiOperation({ summary: 'Update system config (admin)' })
|
||||
update(@Body() body: Record<string, string>) {
|
||||
return this.service.updateMany(body);
|
||||
update(@Body() dto: UpdateSystemConfigDto) {
|
||||
// The DTO validates/coerces each known key to a positive integer; persist back as strings.
|
||||
const entries: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(dto)) {
|
||||
if (value !== undefined) entries[key] = String(value);
|
||||
}
|
||||
return this.service.updateMany(entries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IsInt, IsOptional, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* Whitelisted, typed body for `PATCH /config`. Config is persisted as string key/values, but every
|
||||
* known key is a positive integer (durations, hour windows, throttle limits/TTLs). Values arrive as
|
||||
* strings from the backoffice form; `@Type(() => Number)` coerces them so the numeric/range checks
|
||||
* apply (M-3 — the endpoint previously stored any raw string, e.g. `seat_hold_duration_minutes: -1`).
|
||||
* Unknown keys are stripped by the global whitelisting ValidationPipe.
|
||||
*/
|
||||
export class UpdateSystemConfigDto {
|
||||
@ApiPropertyOptional({ example: 5, description: 'Seat-hold duration in minutes (1..60)' })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(60)
|
||||
seat_hold_duration_minutes?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 2 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
|
||||
hold_cutoff_hours_before_departure?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 4 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
|
||||
boarding_window_hours_before_departure?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 5 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_auth_limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 60000 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_auth_ttl_ms?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 20 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_strict_limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 60000 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_strict_ttl_ms?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 100 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_default_limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 60000 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_default_ttl_ms?: number;
|
||||
}
|
||||
@@ -2,11 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { TasksService } from './tasks.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule],
|
||||
imports: [PrismaModule, NotificationsModule, CurrencyModule],
|
||||
providers: [TasksService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
|
||||
@@ -3,9 +3,6 @@ import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { PaymentClientService } from '../payments/payment-client.service';
|
||||
import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types';
|
||||
import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||
|
||||
// Retention windows
|
||||
@@ -31,8 +28,6 @@ export class TasksService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsClientService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
private readonly paymentsService: PaymentsService,
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -47,6 +42,7 @@ export class TasksService {
|
||||
const now = new Date();
|
||||
const thirtyMinFromNow = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
|
||||
|
||||
// ── Schedule-level transitions (operational display) ───────────────────
|
||||
const [boarding, departed, arrived] = await Promise.all([
|
||||
this.prisma.trainSchedule.updateMany({
|
||||
where: { status: 'SCHEDULED', departureAt: { lte: thirtyMinFromNow } },
|
||||
@@ -62,9 +58,71 @@ export class TasksService {
|
||||
}),
|
||||
]);
|
||||
|
||||
if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) {
|
||||
// ── Per-stop transitions (segment-level status) ────────────────────────
|
||||
// OPEN → CHECKIN_CLOSED: each RouteStop carries its own checkinMinutesBefore
|
||||
// override; falls back to the Route-level value when null.
|
||||
// Group by effective cutoff → one updateMany per (effectiveMins, routeId) pair.
|
||||
const routeStops = await this.prisma.routeStop.findMany({
|
||||
select: {
|
||||
routeId: true,
|
||||
stationId: true,
|
||||
checkinMinutesBefore: true,
|
||||
route: { select: { checkinMinutesBefore: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Map: effectiveMins → Map<routeId, stationId[]>
|
||||
const byMins = new Map<number, Map<string, string[]>>();
|
||||
for (const stop of routeStops) {
|
||||
const mins = stop.checkinMinutesBefore ?? stop.route.checkinMinutesBefore;
|
||||
if (!byMins.has(mins)) byMins.set(mins, new Map());
|
||||
const byRoute = byMins.get(mins)!;
|
||||
if (!byRoute.has(stop.routeId)) byRoute.set(stop.routeId, []);
|
||||
byRoute.get(stop.routeId)!.push(stop.stationId);
|
||||
}
|
||||
|
||||
let reopenedCount = 0;
|
||||
let checkinClosedCount = 0;
|
||||
for (const [mins, byRoute] of byMins) {
|
||||
const cutoffAt = new Date(now.getTime() + mins * 60 * 1000);
|
||||
for (const [routeId, stationIds] of byRoute) {
|
||||
// Revert first: if the cutoff was reduced, stops that were prematurely closed
|
||||
// should reopen (departure is still beyond the new cutoff window).
|
||||
const reverted = await this.prisma.tripStopTime.updateMany({
|
||||
where: {
|
||||
status: 'CHECKIN_CLOSED',
|
||||
plannedDepartureAt: { gt: cutoffAt },
|
||||
stationId: { in: stationIds },
|
||||
schedule: { routeId },
|
||||
},
|
||||
data: { status: 'OPEN' },
|
||||
});
|
||||
reopenedCount += reverted.count;
|
||||
|
||||
// Forward: close stops now within the cutoff window.
|
||||
const closed = await this.prisma.tripStopTime.updateMany({
|
||||
where: {
|
||||
status: 'OPEN',
|
||||
plannedDepartureAt: { lte: cutoffAt },
|
||||
stationId: { in: stationIds },
|
||||
schedule: { routeId },
|
||||
},
|
||||
data: { status: 'CHECKIN_CLOSED' },
|
||||
});
|
||||
checkinClosedCount += closed.count;
|
||||
}
|
||||
}
|
||||
|
||||
const boardedStops = await this.prisma.tripStopTime.updateMany({
|
||||
where: { status: 'CHECKIN_CLOSED', plannedDepartureAt: { lte: now } },
|
||||
data: { status: 'BOARDED' },
|
||||
});
|
||||
|
||||
if (boarding.count > 0 || departed.count > 0 || arrived.count > 0 ||
|
||||
reopenedCount > 0 || checkinClosedCount > 0 || boardedStops.count > 0) {
|
||||
this.logger.log(
|
||||
`Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
|
||||
`Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED | ` +
|
||||
`Stops: ${reopenedCount} → OPEN (reverted), ${checkinClosedCount} → CHECKIN_CLOSED, ${boardedStops.count} → BOARDED`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -88,6 +146,7 @@ export class TasksService {
|
||||
await Promise.all([
|
||||
this.sendPaymentReminders(now),
|
||||
this.cancelExpiredPendingBookings(now),
|
||||
this.cancelExpiredPendingPackageBookings(now),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -101,13 +160,17 @@ export class TasksService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentReminderSentAt: null,
|
||||
createdAt: { gte: threeHoursAgo },
|
||||
schedule: { departureAt: { gte: now } },
|
||||
} as any,
|
||||
// Do NOT filter by schedule.departureAt here: for multi-stop routes the
|
||||
// passenger's segment may depart well after the schedule's first stop, and
|
||||
// that first-stop time could already be in the past even though B→C is still open.
|
||||
},
|
||||
include: {
|
||||
schedule: {
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
|
||||
route: { select: { checkinMinutesBefore: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -115,9 +178,15 @@ export class TasksService {
|
||||
|
||||
for (const booking of bookings) {
|
||||
try {
|
||||
const createdAt = booking.createdAt as Date;
|
||||
const dep = booking.schedule.departureAt as Date;
|
||||
const paymentDeadline = computePaymentDeadline(createdAt, dep);
|
||||
const createdAt = booking.createdAt as Date;
|
||||
// Use the booking's origin-segment departure and the route's own check-in window.
|
||||
const originStop = (booking.schedule as any).stopTimes?.find(
|
||||
(s: any) => s.stationId === (booking as any).originStationId,
|
||||
);
|
||||
const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
|
||||
const checkinMinutes = (booking.schedule as any).route?.checkinMinutesBefore ?? 30;
|
||||
if (dep <= now) continue; // segment has already departed; cancel job handles clean-up
|
||||
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
|
||||
const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime();
|
||||
|
||||
// Skip degenerate windows (< 2 min) — the cancel job will handle these immediately
|
||||
@@ -181,6 +250,8 @@ export class TasksService {
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
|
||||
route: { select: { checkinMinutesBefore: true } },
|
||||
},
|
||||
},
|
||||
paymentIntent: { select: { method: true } },
|
||||
@@ -192,9 +263,14 @@ export class TasksService {
|
||||
|
||||
for (const booking of expiredBookings) {
|
||||
try {
|
||||
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation
|
||||
const createdAt = booking.createdAt as Date;
|
||||
const dep = booking.schedule.departureAt as Date;
|
||||
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation.
|
||||
// Use the booking's origin-segment departure for the deadline so that a B→C booking
|
||||
// on an A→B→C→D schedule gets the correct payment window anchored to B, not A.
|
||||
const createdAt = booking.createdAt as Date;
|
||||
const originStop = (booking.schedule as any).stopTimes?.find(
|
||||
(s: any) => s.stationId === (booking as any).originStationId,
|
||||
);
|
||||
const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
|
||||
const paymentDeadline = computePaymentDeadline(createdAt, dep);
|
||||
if (now < paymentDeadline) continue;
|
||||
|
||||
@@ -206,7 +282,7 @@ export class TasksService {
|
||||
// this they'd otherwise keep the seat locked for up to MAX_PAYMENT_HOURS even
|
||||
// though the booking is now cancelled. Scoped to this booking's own schedule,
|
||||
// since the same physical Seat row is reused across other recurring dates.
|
||||
const seatIds = booking.seats.map(s => s.seatId);
|
||||
const seatIds = booking.seats.map((s: any) => s.seatId);
|
||||
if (seatIds.length > 0) {
|
||||
await this.prisma.seatHold.deleteMany({
|
||||
where: { scheduleId: booking.scheduleId, seatIds: { hasSome: seatIds } },
|
||||
@@ -258,84 +334,75 @@ export class TasksService {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 1 min: poll the payment service for any PENDING_PAYMENT bookings
|
||||
// whose payment intent has moved to SUCCEEDED on the gateway but whose
|
||||
// confirmation event was never delivered (missed RabbitMQ message, network
|
||||
// blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running
|
||||
// it for an already-confirmed booking is safe.
|
||||
//
|
||||
// Processes at most 50 bookings per cycle to avoid hammering the payment
|
||||
// service; the next tick picks up the remainder.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/1 * * * *')
|
||||
async syncPaymentStatuses() {
|
||||
const BATCH_SIZE = 50;
|
||||
// ── Cancel PackageBookings whose payment deadline has passed ──────────────
|
||||
private async cancelExpiredPendingPackageBookings(now: Date) {
|
||||
const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
|
||||
const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
const expiredBookings = await this.prisma.packageBooking.findMany({
|
||||
where: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } },
|
||||
OR: [
|
||||
{ createdAt: { lte: twoHoursAgo } },
|
||||
{ package: { outboundSchedule: { departureAt: { lte: departureCutoff } } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
package: {
|
||||
include: {
|
||||
outboundSchedule: {
|
||||
include: { route: { select: { checkinMinutesBefore: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { paymentIntent: true },
|
||||
take: BATCH_SIZE,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
if (bookings.length === 0) return;
|
||||
|
||||
let confirmed = 0;
|
||||
let failed = 0;
|
||||
let errored = 0;
|
||||
|
||||
for (const booking of bookings) {
|
||||
if (!booking.paymentIntent) continue;
|
||||
let cancelledCount = 0;
|
||||
|
||||
for (const booking of expiredBookings) {
|
||||
try {
|
||||
const snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
booking.id,
|
||||
);
|
||||
const createdAt = booking.createdAt as Date;
|
||||
const dep = (booking.package as any).outboundSchedule.departureAt as Date;
|
||||
const checkinMinutes = (booking.package as any).outboundSchedule.route?.checkinMinutesBefore ?? CUTOFF_MINUTES;
|
||||
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
|
||||
if (now < paymentDeadline) continue;
|
||||
|
||||
if (!snapshot) continue;
|
||||
// Revert the tier's seat counters that were incremented when the booking was created.
|
||||
const seatsReserved = booking.adultCount + Math.max(0, booking.childCount - booking.adultCount);
|
||||
await this.prisma.packagePriceTier.update({
|
||||
where: { id: booking.priceTierId },
|
||||
data: {
|
||||
bookedSeats: { decrement: seatsReserved },
|
||||
availableSeats: { increment: seatsReserved },
|
||||
},
|
||||
});
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
const result = await this.paymentsService.finalizePaymentSuccess({
|
||||
intentId: booking.paymentIntent.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
if (!result.alreadyFinalized) {
|
||||
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
|
||||
confirmed++;
|
||||
}
|
||||
} else if (
|
||||
snapshot.status === ProviderPaymentStatus.FAILED ||
|
||||
snapshot.status === ProviderPaymentStatus.CANCELLED
|
||||
) {
|
||||
// The payment deadline enforcer will cancel the booking when its
|
||||
// window expires; log now so operations can see failed intents early.
|
||||
this.logger.warn(
|
||||
`Payment sync: ${booking.bookingRef} intent is ${snapshot.status} — ` +
|
||||
`booking will be auto-cancelled at payment deadline`,
|
||||
);
|
||||
failed++;
|
||||
await this.prisma.packageBooking.update({
|
||||
where: { id: booking.id },
|
||||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
|
||||
const message =
|
||||
`EDR: Your package booking ${booking.bookingRef} ` +
|
||||
`(departs ${fmtTime(dep)}) has been cancelled ` +
|
||||
`because payment was not completed by ${fmtTime(paymentDeadline)}.`;
|
||||
|
||||
if (booking.contactPhone) {
|
||||
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
|
||||
}
|
||||
// REQUIRES_ACTION / PROCESSING → still pending, retry next cycle
|
||||
|
||||
this.logger.log(`Auto-cancelled package booking: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`);
|
||||
cancelledCount++;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Payment sync error for ${booking.bookingRef}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
`Auto-cancel failed for package booking ${(booking as any).bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
errored++;
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmed > 0 || failed > 0 || errored > 0) {
|
||||
this.logger.log(
|
||||
`Payment sync run: ${bookings.length} checked, ` +
|
||||
`${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`,
|
||||
);
|
||||
if (cancelledCount > 0) {
|
||||
this.logger.log(`Auto-cancelled ${cancelledCount} expired pending package booking(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@ApiTags('Tickets')
|
||||
@@ -10,6 +9,17 @@ import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
export class TicketsController {
|
||||
constructor(private service: TicketsService) {}
|
||||
|
||||
@Post('generate-missing')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Generate tickets for all confirmed bookings that are missing them',
|
||||
description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed counts.',
|
||||
})
|
||||
generateMissing() {
|
||||
return this.service.generateMissing();
|
||||
}
|
||||
|
||||
@Post('smart-assign/:bookingId')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@@ -27,8 +37,8 @@ export class TicketsController {
|
||||
@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.'
|
||||
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);
|
||||
@@ -45,14 +55,15 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'List all tickets with optional filters' })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
@ApiQuery({ name: 'originStationId', required: false })
|
||||
@ApiQuery({ name: 'destinationStationId', required: false })
|
||||
@ApiQuery({ name: 'arrivalDate', required: false })
|
||||
@ApiQuery({ name: 'departureDate', required: false })
|
||||
@ApiQuery({ name: 'dateFrom', required: false })
|
||||
@ApiQuery({ name: 'dateTo', required: false })
|
||||
@ApiQuery({ name: 'coachId', required: false })
|
||||
@@ -64,6 +75,7 @@ export class TicketsController {
|
||||
@Query('originStationId') originStationId?: string,
|
||||
@Query('destinationStationId') destinationStationId?: string,
|
||||
@Query('arrivalDate') arrivalDate?: string,
|
||||
@Query('departureDate') departureDate?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('coachId') coachId?: string,
|
||||
@@ -76,6 +88,7 @@ export class TicketsController {
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
arrivalDate,
|
||||
departureDate,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
coachId,
|
||||
@@ -85,8 +98,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get('by-order/:merchantOrderId')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Get ticket by merchant order ID',
|
||||
description: 'Looks up the booking ID from the PaymentIntent using merchantOrderId, then returns the full ticket information.'
|
||||
@@ -103,8 +116,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post('scan-board/:qrCodeOrRef')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Scan QR code or booking ref and automatically board ticket',
|
||||
description: 'Scans ticket QR code or booking reference and automatically boards the passenger. Handles errors like expired tickets, already used tickets, etc. Designed for mobile boarding interface.'
|
||||
@@ -128,8 +141,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post(':bookingRef/validate')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Validate ticket at gate with audit logging',
|
||||
description: 'Validates ticket QR/barcode at station gate. For round-trip bookings, supply `leg` (OUTBOUND or RETURN) to record which leg is being used. Defaults to OUTBOUND if omitted. Records validation in audit log with timestamp, gate, and validator.'
|
||||
@@ -159,24 +172,24 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get(':ticketId/validation-logs')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Get validation logs for ticket' })
|
||||
getValidationLogs(@Param('ticketId') ticketId: string) {
|
||||
return this.service.getValidationLogs(ticketId);
|
||||
}
|
||||
|
||||
@Get('offline/export')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Export tickets for offline validation' })
|
||||
exportOfflineData(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.exportOfflineData(scheduleId);
|
||||
}
|
||||
|
||||
@Post('validate/offline')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Batch import offline validations',
|
||||
description: 'Processes validations collected offline. Each entry may include an optional `leg` field (OUTBOUND | RETURN) for round-trip tickets. Deduplication is per bookingRef+leg combination so both legs of the same booking can be submitted in one batch.'
|
||||
@@ -207,9 +220,9 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Delete ticket (admin only)',
|
||||
description: 'Permanently deletes a ticket record and removes associated seat blocks'
|
||||
})
|
||||
@@ -218,8 +231,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Patch(':id/restore')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' })
|
||||
restore(@Param('id') id: string) {
|
||||
return this.service.restore(id);
|
||||
|
||||
@@ -27,7 +27,7 @@ export class TicketsService {
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) {
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; departureDate?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) {
|
||||
const where: any = {};
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
@@ -41,10 +41,16 @@ export class TicketsService {
|
||||
where.status = filters.status;
|
||||
}
|
||||
if (filters.originStationId) {
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
|
||||
where.booking = { ...where.booking, originStationId: filters.originStationId };
|
||||
}
|
||||
if (filters.destinationStationId) {
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } };
|
||||
where.booking = { ...where.booking, destinationStationId: filters.destinationStationId };
|
||||
}
|
||||
if (filters.departureDate) {
|
||||
const start = new Date(filters.departureDate);
|
||||
const end = new Date(filters.departureDate);
|
||||
end.setDate(end.getDate() + 1);
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, departureAt: { gte: start, lt: end } } };
|
||||
}
|
||||
if (filters.arrivalDate) {
|
||||
const start = new Date(filters.arrivalDate);
|
||||
@@ -70,7 +76,7 @@ export class TicketsService {
|
||||
include: {
|
||||
booking: {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
passenger: { include: { travelerProfiles: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
@@ -146,6 +152,18 @@ export class TicketsService {
|
||||
contactPhone: t.booking?.contactPhone,
|
||||
returnSchedule: t.booking?.returnSchedule ?? null,
|
||||
seats: t.booking?.seats ?? [],
|
||||
originStation: (() => {
|
||||
const id = t.booking?.originStationId;
|
||||
if (!id) return t.booking?.schedule?.originStation ?? null;
|
||||
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
|
||||
return stop?.station ?? t.booking?.schedule?.originStation ?? null;
|
||||
})(),
|
||||
destinationStation: (() => {
|
||||
const id = t.booking?.destinationStationId;
|
||||
if (!id) return t.booking?.schedule?.destinationStation ?? null;
|
||||
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
|
||||
return stop?.station ?? t.booking?.schedule?.destinationStation ?? null;
|
||||
})(),
|
||||
},
|
||||
schedule: t.booking?.schedule,
|
||||
seat: t.seat ? {
|
||||
@@ -192,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() } },
|
||||
@@ -212,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(
|
||||
@@ -260,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 });
|
||||
}
|
||||
|
||||
@@ -334,54 +360,125 @@ export class TicketsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for seat conflicts before deleting existing tickets or issuing new ones
|
||||
await this.prisma.ticket.deleteMany({ where: { bookingId } });
|
||||
|
||||
// Remove any SeatBlock rows left over from a previous generate() run for this
|
||||
// booking — they reference the old ticket IDs which are now deleted, and would
|
||||
// otherwise cause the conflict check below to see this booking's own seats as
|
||||
// blocked by another booking.
|
||||
const seatIds = (booking as any).seats.map((bs: any) => bs.seatId);
|
||||
const conflictingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
seatId: { in: seatIds },
|
||||
booking: {
|
||||
id: { not: bookingId },
|
||||
status: { in: ['CONFIRMED', 'BOARDED'] },
|
||||
},
|
||||
},
|
||||
include: { seat: true },
|
||||
await this.prisma.seatBlock.deleteMany({
|
||||
where: { seatId: { in: seatIds }, blockedBy: 'SYSTEM' },
|
||||
});
|
||||
if (conflictingSeats.length > 0) {
|
||||
const labels = [...new Set(conflictingSeats.map((s: any) => s.seat.seatNumber))].join(', ');
|
||||
|
||||
// Check for seat conflicts — only seats confirmed/boarded by a *different* booking
|
||||
// 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<string, { originSeq: number; destSeq: number }>();
|
||||
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: {
|
||||
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 } },
|
||||
},
|
||||
});
|
||||
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.ticket.deleteMany({ where: { bookingId } });
|
||||
|
||||
// Generate one ticket per unique passenger (grouped by passengerName)
|
||||
// Generate one ticket per passenger per leg.
|
||||
// Round-trip / transit bookings have seats on multiple legs — each leg needs its own
|
||||
// ticket so the voucher can match by (passengerName, leg) and gate scanners can
|
||||
// validate each leg independently.
|
||||
const tickets = [];
|
||||
|
||||
// Group seats by passenger
|
||||
const passengerSeatsMap = new Map<string, any[]>();
|
||||
|
||||
// Group seats by (passengerName, leg)
|
||||
const passengerLegSeatsMap = new Map<string, any[]>();
|
||||
for (const bookingSeat of (booking as any).seats) {
|
||||
const key = bookingSeat.passengerName;
|
||||
if (!passengerSeatsMap.has(key)) {
|
||||
passengerSeatsMap.set(key, []);
|
||||
const key = `${bookingSeat.passengerName}|${bookingSeat.leg ?? 1}`;
|
||||
if (!passengerLegSeatsMap.has(key)) {
|
||||
passengerLegSeatsMap.set(key, []);
|
||||
}
|
||||
passengerSeatsMap.get(key)!.push(bookingSeat);
|
||||
passengerLegSeatsMap.get(key)!.push(bookingSeat);
|
||||
}
|
||||
|
||||
// Create one ticket per passenger
|
||||
for (const [passengerName, passengerSeats] of passengerSeatsMap.entries()) {
|
||||
// Use first seat for primary data
|
||||
const primarySeat = passengerSeats[0];
|
||||
|
||||
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
|
||||
// Create one ticket per (passenger, leg)
|
||||
for (const [key, legSeats] of passengerLegSeatsMap.entries()) {
|
||||
const [passengerName] = key.split('|');
|
||||
const primarySeat = legSeats[0];
|
||||
const leg = primarySeat.leg ?? 1;
|
||||
|
||||
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}L${leg}`;
|
||||
|
||||
// Re-encode QR with ticketNumber included
|
||||
const qrDataWithTicket = JSON.stringify({
|
||||
ref: booking.bookingRef,
|
||||
ticketNumber: barcodePayload,
|
||||
type: booking.bookingType,
|
||||
passenger: passengerName,
|
||||
seats: passengerSeats.map(ps => ({
|
||||
leg,
|
||||
seats: legSeats.map(ps => ({
|
||||
seat: ps.seat?.seatNumber,
|
||||
coach: ps.seat?.coach?.number,
|
||||
leg: ps.leg || 1,
|
||||
@@ -396,7 +493,7 @@ export class TicketsService {
|
||||
bookingRef: booking.bookingRef,
|
||||
passengerName,
|
||||
seatId: primarySeat.seatId,
|
||||
leg: primarySeat.leg || 1,
|
||||
leg,
|
||||
scheduleId: primarySeat.scheduleId || booking.scheduleId,
|
||||
qrPayload: qrPayloadFinal,
|
||||
barcodePayload,
|
||||
@@ -808,6 +905,34 @@ export class TicketsService {
|
||||
};
|
||||
}
|
||||
|
||||
async generateMissing(): Promise<{ processed: number; generated: number; failed: number; details: any[] }> {
|
||||
const confirmedWithNoTickets = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'CONFIRMED',
|
||||
tickets: { none: {} },
|
||||
paymentIntent: { status: 'SUCCEEDED' },
|
||||
},
|
||||
select: { id: true, bookingRef: true },
|
||||
});
|
||||
|
||||
const details: any[] = [];
|
||||
let generated = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const booking of confirmedWithNoTickets) {
|
||||
try {
|
||||
await this.smartAssignAndGenerate(booking.id);
|
||||
generated++;
|
||||
details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'generated' });
|
||||
} catch (err) {
|
||||
failed++;
|
||||
details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'failed', error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return { processed: confirmedWithNoTickets.length, generated, failed, details };
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
Reference in New Issue
Block a user