mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
Merge branch 'dev' into tests
This commit is contained in:
@@ -575,10 +575,13 @@ export class BookingsController {
|
||||
})
|
||||
@ApiResponse({ status: 404, description: "Schedule or seat hold not found" })
|
||||
create(@Req() req: any, @Body() dto: CreateBookingDto) {
|
||||
// Always resolve passengerId from the authenticated JWT — never trust the request body
|
||||
// Always resolve identity from the authenticated JWT — never trust the request body.
|
||||
// Routed through the unified GuestBookingService: because req.user.id is present, it
|
||||
// resolves the existing passenger from the token and layers on the authenticated-only
|
||||
// behaviours (iam.users contact, loyalty, audit, package inventory, seat-vs-hold guard).
|
||||
const iamUserId = req.user?.id;
|
||||
if (!iamUserId) throw new UnauthorizedException();
|
||||
return this.service.create({ ...dto, passengerId: iamUserId });
|
||||
return this.guestService.createGuestBooking(dto as unknown as CreateGuestBookingDto, req);
|
||||
}
|
||||
|
||||
@Get(":id/usage")
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Injectable, BadRequestException, NotFoundException, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { PassengerAuthService } from '../auth/passenger-auth.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
@@ -87,11 +90,13 @@ export class GuestBookingService {
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private seatsService: SeatsService,
|
||||
private verifaydaService: VerifaydaService,
|
||||
private currencyService: CurrencyService,
|
||||
private passengerAuthService: PassengerAuthService,
|
||||
private fareEngine: FareEngineService,
|
||||
private auditService: AuditService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
private paymentsService: PaymentsService,
|
||||
private auditService: AuditService,
|
||||
@@ -138,6 +143,7 @@ export class GuestBookingService {
|
||||
}
|
||||
|
||||
private async createGuestOneWayBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
const authUserId: string | null = req?.user?.id ?? null;
|
||||
// Validate hold
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||||
if (!hold || hold.expiresAt < new Date()) {
|
||||
@@ -319,6 +325,7 @@ export class GuestBookingService {
|
||||
// Resolve or create the guest Passenger record
|
||||
const firstPassenger = passengersData[0];
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger, req);
|
||||
const contact = await this.resolveActorContact(req, firstPassenger);
|
||||
|
||||
// Save passenger details for future use (if requested)
|
||||
if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) {
|
||||
@@ -360,8 +367,8 @@ export class GuestBookingService {
|
||||
bookingType: 'ONE_WAY',
|
||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: firstPassenger.email || null,
|
||||
contactPhone: firstPassenger.phone || null,
|
||||
contactEmail: contact.contactEmail,
|
||||
contactPhone: contact.contactPhone,
|
||||
seats: {
|
||||
create: passengersWithFares.map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
@@ -385,11 +392,18 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
// Save passenger details as traveler profiles
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
// Save passenger details as traveler profiles — guest bookings only.
|
||||
// Authenticated passengers already have a profile, matching the old BookingsService.
|
||||
if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
// Confirm seats
|
||||
await this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id));
|
||||
|
||||
// Authenticated-only side effect: audit the booking creation.
|
||||
if (authUserId) {
|
||||
await this.auditService.log({ userId: guestPassengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ONE_WAY', totalMinor: resolvedTotalMinor } });
|
||||
}
|
||||
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
return {
|
||||
@@ -652,6 +666,7 @@ export class GuestBookingService {
|
||||
}
|
||||
|
||||
private async createGuestRoundTripBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
const authUserId: string | null = req?.user?.id ?? null;
|
||||
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
|
||||
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
|
||||
}
|
||||
@@ -852,6 +867,7 @@ export class GuestBookingService {
|
||||
|
||||
// Create or resolve guest passenger (same as one-way)
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||
const contact = await this.resolveActorContact(req, passengersData[0]);
|
||||
|
||||
// Create booking with outbound seats; return seats confirmed separately
|
||||
const outboundSeatIds = dto.passengers.map(p => p.seatId).filter((id): id is string => !!id);
|
||||
@@ -880,8 +896,8 @@ export class GuestBookingService {
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: passengersData[0]?.email || null,
|
||||
contactPhone: passengersData[0]?.phone || null,
|
||||
contactEmail: contact.contactEmail,
|
||||
contactPhone: contact.contactPhone,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersWithFares.map((p) => ({
|
||||
@@ -923,12 +939,19 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
// Traveler profiles: guest bookings only (authenticated passengers already have one).
|
||||
if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(outboundSeatIds),
|
||||
this.seatsService.confirmSeats(returnSeatIds),
|
||||
]);
|
||||
|
||||
// Authenticated-only side effect: audit the booking creation.
|
||||
if (authUserId) {
|
||||
await this.auditService.log({ userId: guestPassengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ROUND_TRIP', totalMinor } });
|
||||
}
|
||||
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
return {
|
||||
@@ -954,6 +977,7 @@ export class GuestBookingService {
|
||||
}
|
||||
|
||||
private async createGuestTransitBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
const authUserId: string | null = req?.user?.id ?? null;
|
||||
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) {
|
||||
throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
|
||||
}
|
||||
@@ -1059,6 +1083,7 @@ export class GuestBookingService {
|
||||
: totalMinor;
|
||||
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||
const contact = await this.resolveActorContact(req, passengersData[0]);
|
||||
|
||||
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
|
||||
const booking = await this.prisma.booking.create({
|
||||
@@ -1081,8 +1106,8 @@ export class GuestBookingService {
|
||||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||||
leg2SeatClassId: leg2SeatClassId,
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: passengersData[0]?.email || null,
|
||||
contactPhone: passengersData[0]?.phone || null,
|
||||
contactEmail: contact.contactEmail,
|
||||
contactPhone: contact.contactPhone,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map(p => ({
|
||||
@@ -1124,7 +1149,8 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
// Traveler profiles: guest bookings only (authenticated passengers already have one).
|
||||
if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)),
|
||||
@@ -1150,6 +1176,7 @@ export class GuestBookingService {
|
||||
}
|
||||
|
||||
private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
const authUserId: string | null = req?.user?.id ?? null;
|
||||
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
|
||||
!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
|
||||
!dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
|
||||
@@ -1261,6 +1288,7 @@ export class GuestBookingService {
|
||||
: totalMinor;
|
||||
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||
const contact = await this.resolveActorContact(req, passengersData[0]);
|
||||
|
||||
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({
|
||||
seat: { connect: { id: seatId } },
|
||||
@@ -1302,8 +1330,8 @@ export class GuestBookingService {
|
||||
returnLeg2SeatClassId: retL2ClassId,
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: passengersData[0]?.email || null,
|
||||
contactPhone: passengersData[0]?.phone || null,
|
||||
contactEmail: contact.contactEmail,
|
||||
contactPhone: contact.contactPhone,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)),
|
||||
@@ -1319,7 +1347,8 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
// Traveler profiles: guest bookings only (authenticated passengers already have one).
|
||||
if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)),
|
||||
@@ -1348,11 +1377,40 @@ export class GuestBookingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the booking contact. Authenticated callers get their contact from iam.users
|
||||
* (matching the old BookingsService.resolveIamContact); guests fall back to the first
|
||||
* passenger's inline phone/email exactly as before.
|
||||
*/
|
||||
private async resolveActorContact(
|
||||
req: any,
|
||||
firstPassenger: any,
|
||||
): Promise<{ contactEmail: string | null; contactPhone: string | null }> {
|
||||
const iamUserId = req?.user?.id;
|
||||
if (iamUserId) {
|
||||
const rows = await this.dataSource.query<{ email: string; phone_number: string | null }[]>(
|
||||
`SELECT email, phone_number FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||
[iamUserId],
|
||||
);
|
||||
return { contactEmail: rows[0]?.email ?? null, contactPhone: rows[0]?.phone_number ?? null };
|
||||
}
|
||||
return { contactEmail: firstPassenger?.email || null, contactPhone: firstPassenger?.phone || null };
|
||||
}
|
||||
|
||||
private async resolveGuestPassenger(
|
||||
dto: Pick<CreateGuestBookingDto, 'createAccount' | 'password' | 'deviceId'>,
|
||||
firstPassenger: any,
|
||||
req?: any,
|
||||
): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> {
|
||||
// Authenticated caller: resolve the existing passenger from the JWT subject.
|
||||
// Never trust a client-supplied passengerId — identity comes from the token only.
|
||||
const authUserId = req?.user?.id;
|
||||
if (authUserId) {
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId: authUserId }, select: { id: true } });
|
||||
if (!passenger) throw new NotFoundException('Passenger profile not found for this account');
|
||||
return { guestPassengerId: passenger.id, iamUserId: authUserId, createdAccount: false };
|
||||
}
|
||||
|
||||
if (dto.createAccount && firstPassenger.email && dto.password) {
|
||||
const guestName = firstPassenger.passengerName ?? 'Guest';
|
||||
const result = await this.passengerAuthService.registerWithPassword(
|
||||
|
||||
Reference in New Issue
Block a user