mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 15:58:18 +00:00
Merge branch 'dev' into reschedule
This commit is contained in:
@@ -36,6 +36,7 @@ import {
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
import { SeatsService } from "../seats/seats.service";
|
||||
|
||||
@ApiTags("Booking")
|
||||
@Controller("bookings")
|
||||
@@ -44,6 +45,7 @@ export class BookingsController {
|
||||
constructor(
|
||||
private service: BookingsService,
|
||||
private guestService: GuestBookingService,
|
||||
private seatsService: SeatsService,
|
||||
) {}
|
||||
|
||||
@Get("my")
|
||||
@@ -358,6 +360,33 @@ export class BookingsController {
|
||||
return this.guestService.createGuestBooking(dto, req);
|
||||
}
|
||||
|
||||
@Post("group")
|
||||
@PassengerStaff([PASSENGER_PERMS.bookings.manage])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Create a group booking — staff bulk/group reservation, one PNR for the whole group",
|
||||
description: `Staff-only entry point for bulk/group bookings (e.g. tour groups booked via an uploaded passenger list and auto-assigned seats from POST /seats/auto-assign-hold).
|
||||
|
||||
Same body shape as POST /bookings/guest (CreateGuestBookingDto) and the same underlying pipeline — fare engine, ADULT/CHILD age pricing — just gated to staff. Supports ONE_WAY (default) and ROUND_TRIP via \`bookingType\`; for ROUND_TRIP, supply \`returnScheduleId\`/\`returnHoldId\`/\`returnOriginStationId\`/\`returnDestinationStationId\`/\`returnSeatClassId\` and each passenger's \`returnSeatId\`, exactly as POST /bookings/guest does.
|
||||
|
||||
Skips Verifayda national-ID verification: the roster comes from a staff-uploaded spreadsheet, not a live Fayda identity flow, so there is nothing to verify an ID number against. Passenger fields (name, DOB, nationality) are trusted exactly as uploaded.
|
||||
|
||||
Deliberately does NOT forward the staff caller's identity into booking creation: the acting staff member is not a Passenger, so the underlying guest-booking flow (which tries to resolve an authenticated caller as an existing Passenger profile) would reject the request. The booking is created exactly like a guest booking — a fresh passenger record, contact info from the first passenger in the list — with staff authorization enforced only at this route.
|
||||
|
||||
If booking creation fails after the seats were already held, every hold involved (outbound and, for ROUND_TRIP, return) is released immediately so the seats don't sit locked for the rest of the hold TTL.`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: "Group booking created successfully with fareBreakdown" })
|
||||
@ApiResponse({ status: 400, description: "Missing required seat IDs" })
|
||||
async createGroup(@Body() dto: CreateGuestBookingDto) {
|
||||
try {
|
||||
return await this.guestService.createGuestBooking({ ...dto, bookingType: dto.bookingType || "ONE_WAY", skipIdentityVerification: true });
|
||||
} catch (err) {
|
||||
const holdIdsToRelease = [dto.holdId, dto.returnHoldId].filter((id): id is string => !!id);
|
||||
await Promise.allSettled(holdIdsToRelease.map((id) => this.seatsService.releaseHold(id)));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@Post("reservations/:seatId/issue")
|
||||
@PassengerStaffStrict(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
|
||||
import { normalizePhoneVariants } from '../../common/utils/phone.utils';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
import { JourneyDirection } from '../seats/seats.dto';
|
||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||
@@ -47,39 +48,6 @@ function resolvePackageRoundTripTotal(
|
||||
return adultCount * adultFareMinor + paidChildren * adultFareMinor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all plausible normalised variants of a raw phone string so that the
|
||||
* DB query matches regardless of how the number was stored (local 09… vs international +251…).
|
||||
* Returns an empty array when the input is clearly invalid (< 7 digits).
|
||||
*/
|
||||
function normalizePhoneVariants(raw: string): string[] {
|
||||
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
|
||||
const stripped = raw.replace(/[^\d+]/g, '');
|
||||
const digits = stripped.replace(/^\+/, '');
|
||||
if (digits.length < 7) return [];
|
||||
|
||||
const variants = new Set<string>([stripped]);
|
||||
|
||||
if (stripped.startsWith('+251') && digits.length === 12) {
|
||||
// +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX
|
||||
variants.add(digits); // 251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('251') && digits.length === 12) {
|
||||
// 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX
|
||||
variants.add('+' + stripped); // +251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('0') && digits.length === 10) {
|
||||
// 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +)
|
||||
variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX
|
||||
variants.add('251' + digits.slice(1)); // 251XXXXXXXXX
|
||||
} else if (!stripped.startsWith('+') && digits.length >= 9) {
|
||||
// bare international digits without +
|
||||
variants.add('+' + digits);
|
||||
}
|
||||
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
function calculateAge(dateOfBirth: Date): number {
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
||||
|
||||
@@ -168,6 +168,15 @@ export class CreateGuestBookingDto {
|
||||
|
||||
@ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
|
||||
@IsOptional() @IsNumber() reviewedTotalMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Skip Verifayda national-ID verification and trust passenger fields as given (name, DOB, nationality). ' +
|
||||
'For staff-entered/bulk-uploaded rosters (e.g. group bookings) where there is no live Fayda identity ' +
|
||||
'flow to verify against — calling Verifayda for typed-in ID numbers either returns dev-mode mock data ' +
|
||||
'(overwriting the real name) or, once configured, would reject the whole booking on a non-match.',
|
||||
})
|
||||
@IsOptional() @IsBoolean() skipIdentityVerification?: boolean;
|
||||
}
|
||||
|
||||
export class SavedPassengerProfileDto {
|
||||
|
||||
@@ -196,7 +196,7 @@ export class GuestBookingService {
|
||||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
|
||||
|
||||
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||||
if (passenger.idDocumentNumber) {
|
||||
if (passenger.idDocumentNumber && !dto.skipIdentityVerification) {
|
||||
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
||||
if (!verification.verified) {
|
||||
throw new BadRequestException(
|
||||
@@ -392,7 +392,10 @@ export class GuestBookingService {
|
||||
contactEmail: contact.contactEmail,
|
||||
contactPhone: contact.contactPhone,
|
||||
seats: {
|
||||
create: passengersWithFares.map((p) => ({
|
||||
// A free child (no seatId — see passengersWithFares above) has nothing to connect to;
|
||||
// `connect: { id: undefined }` throws PrismaClientValidationError immediately if this
|
||||
// filter is missing, so it's never optional here despite the map below looking safe.
|
||||
create: passengersWithFares.filter((p) => p.seatId).map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: p.passengerName,
|
||||
@@ -763,7 +766,7 @@ export class GuestBookingService {
|
||||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
|
||||
|
||||
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||||
if (passenger.idDocumentNumber) {
|
||||
if (passenger.idDocumentNumber && !dto.skipIdentityVerification) {
|
||||
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
||||
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
|
||||
passengerName = verification.passengerData?.fullName || passengerName;
|
||||
|
||||
Reference in New Issue
Block a user