Merge branch 'dev' into reschedule

This commit is contained in:
Abubeker Yasin
2026-08-28 16:36:51 +03:00
637 changed files with 53614 additions and 8442 deletions

View File

@@ -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")

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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;