Merge pull request #975 from Tria-plc/dev

syncing
This commit is contained in:
Nathnael Wondisha
2026-07-27 14:19:45 +03:00
committed by GitHub
261 changed files with 15418 additions and 1956 deletions

View File

@@ -181,6 +181,17 @@ GITHUB_PACKAGE_TOKEN=<your-github-packages-token>
# Login endpoint for backoffice users: POST /v1/auth/login
SEED_EDR_PASSENGER_ORG=false
SEED_PASSENGER_STAFF=false
# IAM baseline shared with edr-freight-api (roles, IAM app + permissions, position
# types, organization types + default units, org/unit settings, super admin).
# Replaces the seeder that used to ship inside @tria-plc/iamapi-common — see
# packages/iam-seed. Seeds by DEFAULT when unset; every write is insert-only.
# Set to false to opt out.
SEED_IAM_BASELINE=true
# Super-admin account seeded by the above. Shared across the apps on this schema.
SUPER_ADMIN_EMAIL=superadmin@tria.com
SUPER_ADMIN_PHONE=
# Falls back to DEFAULT_PASSWORD when empty.
SUPER_ADMIN_DEFAULT_PASSWORD=
# Plain-text password set on seeded staff accounts. Defaults to '12345678' if unset.
DEFAULT_PASSWORD=Admin@1234

View File

@@ -27,6 +27,7 @@
"prisma:verify": "ts-node prisma/verify-backfill.ts"
},
"dependencies": {
"@edr/iam-seed": "workspace:*",
"@edr/types": "workspace:*",
"@golevelup/nestjs-rabbitmq": "^5.5.0",
"@nestjs/axios": "^4.0.1",

View File

@@ -4,8 +4,8 @@ import { ConfigModule, ConfigService } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule";
import { EventEmitterModule } from "@nestjs/event-emitter";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
import { IamModule as TriaIamModule } from "@tria-plc/iamapi-common/iam.module";
import { DataSeeder } from "@tria-plc/iamapi-common/db/seed/seeder";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
import {
EDR_PASSENGER_APPLICATION,
@@ -103,6 +103,17 @@ import { EOtpType } from "@tria-plc/iamapi-common";
`Set your EDR Passenger password using this link: ${route}`,
},
}),
// Replaces the package's DataSeeder. Shared with edr-freight-api, which
// seeds the same `iam` schema — see packages/iam-seed.
IamSeedModule.forRoot({
superAdmin: {
username: "superadmin",
name: { am: "ሱፐር አድሚን", en: "Super Admin" },
roleKey: "super_admin",
organizationKey: "edr",
fallbackEmail: "superadmin@tria.com",
},
}),
SharedAuthModule,
PrismaModule,
AuditModule,
@@ -151,18 +162,23 @@ import { EOtpType } from "@tria-plc/iamapi-common";
export class AppModule implements OnApplicationBootstrap {
private readonly logger = new Logger(AppModule.name);
constructor(
private readonly seeder: DataSeeder,
private readonly iamBaselineSeeder: IamBaselineSeeder,
private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder,
private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder,
private readonly segmentFareSeeder: SegmentFareSeeder,
) {}
async onApplicationBootstrap() {
// Runs first so the roles it seeds exist before EdrPassengerOrgSeeder links
// super_admin permissions. Its own super-admin account attaches to the `edr`
// organization, which that seeder creates — so on a brand-new database the
// account lands on the next boot; it logs a warning and skips until then.
// Non-fatal internally, but the wrapper stays for symmetry with the rest.
try {
await this.seeder.run();
await this.iamBaselineSeeder.run();
} catch (err) {
this.logger.error(
"[DataSeeder] Seed failed (non-fatal):",
"[IamBaselineSeeder] Seed failed (non-fatal):",
(err as Error).message,
);
}

View File

@@ -261,10 +261,26 @@ export class PassengerAuthService {
if (!passenger) throw new Error('Passenger not found');
const iam = iamRows[0];
const meta = iam?.metadata ?? {};
const faydaVerified = iam?.verified_by === 'fayda';
const nationality = iam?.metadata?.nationality ?? null;
// A Fayda-verified holder is an Ethiopian national ID holder, so default nationality to
// Ethiopian when the metadata doesn't carry it explicitly.
const nationality = meta.nationality ?? (faydaVerified ? 'ETHIOPIAN' : null);
// Fayda stores gender as { am, en }; tolerate a legacy plain string too.
const gender =
meta.gender && typeof meta.gender === 'object'
? (meta.gender.en ?? meta.gender.am ?? null)
: (meta.gender ?? null);
// birthdate is persisted as ISO by the Fayda upsert; tolerate a "/"-separated legacy value.
const rawDob = meta.dateOfBirth ?? meta.birthdate ?? null;
const dateOfBirth = rawDob ? String(rawDob).replace(/\//g, '-') : null;
return {
// The web User object keys on `id` (the IAM user id) — the login response returns it, so
// this profile refresh MUST too, otherwise fetchProfile() overwrites the logged-in user
// with an id-less object and everything guarded on `user.id` (passenger-form prefill,
// save-details userId) silently breaks.
id: iamUserId,
iamUserId,
// Top-level passengerId keeps the profile shape consistent with the login
// response so the web User object always carries it (the JWT does not).
@@ -272,8 +288,11 @@ export class PassengerAuthService {
email: iam?.email ?? null,
phone: iam?.phone_number ?? null,
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
gender,
dateOfBirth,
nationality,
faydaVerified,
faydaSub: meta.sub ?? null,
preferredCurrency: resolvePreferredCurrency(nationality, faydaVerified),
createdAt: passenger.createdAt,
passenger: {

View File

@@ -557,10 +557,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")

View File

@@ -1,13 +1,15 @@
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';
import { SmsClientService } from '../notifications/sms-client.service';
import { CreateGuestBookingDto, SavedPassengerProfileDto, IssueReservationBookingDto, ReservationBookingKind } from './guest-booking.dto';
import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
@@ -87,14 +89,15 @@ 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,
private smsClient: SmsClientService,
) { }
@@ -138,6 +141,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 +323,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 +365,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 +390,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 {
@@ -590,8 +602,22 @@ export class GuestBookingService {
// Release the reservation using the SAME scope it was created with (global vs
// schedule-scoped) — unblockSeat already correctly resets Seat.status for a global
// block; reimplementing that here would risk missing that reset.
// block; reimplementing that here would risk missing that reset. This alone leaves a
// window where the seat has no SeatBlock, no SeatHold, and no JourneySegment (the
// latter is only created on payment success — see PaymentsService.createJourneySegments)
// — i.e. fully available to the public — the instant this returns, since confirmSeats()
// is a no-op with no existing hold to extend. holdSeats() immediately re-reserves the
// seat with the same createdBy segment-range metadata the search/hold-conflict checks
// already rely on (getSeatAvailabilityMap); confirmSeats() then extends that hold to the
// real payment deadline (same mechanism createGuestOneWayBooking uses), so the seat stays
// unavailable to everyone else until the passenger pays or the hold/booking expires.
await this.seatsService.unblockSeat(seatId, seatBlock.scheduleId ?? undefined);
await this.seatsService.holdSeats({
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
passengers: [{ passengerId: guestPassengerId, seatId }],
});
await this.seatsService.confirmSeats([seatId]);
this.eventEmitter.emit('booking.created', { booking });
@@ -638,6 +664,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');
}
@@ -838,6 +865,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);
@@ -866,8 +894,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) => ({
@@ -909,12 +937,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 {
@@ -940,6 +975,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');
}
@@ -1045,6 +1081,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({
@@ -1067,8 +1104,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 => ({
@@ -1110,7 +1147,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)),
@@ -1136,6 +1174,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) {
@@ -1247,6 +1286,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 } },
@@ -1288,8 +1328,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)),
@@ -1305,7 +1345,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)),
@@ -1334,11 +1375,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(

View File

@@ -254,11 +254,10 @@ export class PassengersController {
}
try {
const passenger = await this.prisma.passenger.findUnique({
where: { iamUserId: req.user.id },
});
if (!passenger) return null;
return this.service.getProfile(passenger.id);
// Identity (name, DOB, gender, nationality, Fayda status) lives on the IAM user record,
// not the Passenger row — return the full booking-form payload built from it so an
// already-verified passenger's form can prefill and lock. See getMyProfile.
return await this.service.getMyProfile(req.user.id);
} catch (error) {
return null;
}

View File

@@ -22,6 +22,7 @@ type IamUserRow = {
name: { en: string; am: string } | null;
phone_number: string | null;
metadata: Record<string, any> | null;
verified_by?: string | null;
};
@Injectable()
@@ -237,6 +238,66 @@ export class PassengersService {
};
}
/**
* Full passenger-form payload for the logged-in user, sourced from the IAM user record
* (iam.users) — where the Fayda-verified identity actually lives — rather than the sparse
* Passenger row. The booking passenger form (/booking/passengers) calls this via
* GET /passengers/me to prefill (and lock) an already-verified passenger's details.
*
* Identity fields don't depend on a Passenger row existing; only `id` (used later to tag the
* primary passenger on the booking) does, and it's null if no Passenger row is linked yet.
*/
async getMyProfile(iamUserId: string) {
const [passenger, iamRows] = await Promise.all([
this.prisma.passenger.findUnique({ where: { iamUserId } }),
this.dataSource.query<IamUserRow[]>(
`SELECT id, email, name, phone_number, metadata, verified_by FROM iam.users WHERE id = $1 LIMIT 1`,
[iamUserId],
),
]);
const iam = iamRows[0] ?? null;
if (!iam && !passenger) return null;
const meta = iam?.metadata ?? {};
const faydaVerified =
iam?.verified_by === 'fayda' ||
meta.faydaVerified === true ||
meta.faydaVerified === 'true';
// Fayda writes gender as { am, en }; older/manual records may store a plain string.
const gender =
meta.gender && typeof meta.gender === 'object'
? (meta.gender.en ?? meta.gender.am ?? null)
: (meta.gender ?? null);
const fullName = iam?.name?.en ?? iam?.name?.am ?? null;
// Stored as ISO by the Fayda upsert; tolerate a "/"-separated legacy value.
const rawDob = meta.dateOfBirth ?? meta.birthdate ?? null;
const dateOfBirth = rawDob ? String(rawDob).replace(/\//g, '-') : null;
// Nationality isn't always in metadata; a Fayda-verified holder is Ethiopian by definition.
const nationality = meta.nationality ?? (faydaVerified ? 'ETHIOPIAN' : null);
return {
id: passenger?.id ?? null,
fullName,
email: iam?.email ?? meta.email ?? null,
phone: iam?.phone_number ?? meta.phoneNumber ?? null,
gender,
dateOfBirth,
nationality,
faydaVerified,
faydaSub: meta.sub ?? null,
passportNumber: meta.passportNumber ?? null,
passportCountry: meta.passportCountry ?? null,
passportIssueDate: meta.passportIssueDate ?? null,
passportExpiryDate: meta.passportExpiryDate ?? null,
passportIssuingAuthority: meta.passportIssuingAuthority ?? null,
};
}
async getStats(passengerId: string) {
const [totalTrips, totalSpendResult, loyalty] = await Promise.all([
this.prisma.booking.count({ where: { passengerId, status: 'BOARDED' as any } }),

View File

@@ -12,6 +12,7 @@ import { SegmentsService } from "../segments/segments.service";
import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto";
import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils";
import { Currency, Prisma } from "@prisma/client";
import { Passenger } from "@edr/types";
const POINTS_TO_MINOR = 10;
@@ -102,19 +103,23 @@ export class SearchService {
const outbound = [...direct, ...transit];
if (outbound.length === 0 && dto.journeyType !== "ROUND_TRIP") {
const alternativesOutbound = await this.searchAlternatives(
dto.originStationId,
dto.destinationStationId,
dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
);
const [alternativesOutbound, outboundReason] = await Promise.all([
this.searchAlternatives(
dto.originStationId,
dto.destinationStationId,
dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
),
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date),
]);
return {
journeyType: "ONE_WAY",
outbound: [],
alternativeOutbound: alternativesOutbound,
requestedDate: dto.date,
outboundReason,
};
}
@@ -157,7 +162,7 @@ export class SearchService {
const returnDate = dto.returnDate ?? dto.date;
if (outbound.length === 0 || inbound.length === 0) {
const [alternativeOutbound, alternativeInbound] = await Promise.all([
const [alternativeOutbound, alternativeInbound, outboundReason, inboundReason] = await Promise.all([
outbound.length === 0
? this.searchAlternatives(
dto.originStationId,
@@ -178,6 +183,12 @@ export class SearchService {
dto.nationality,
)
: Promise.resolve([]),
outbound.length === 0
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date)
: Promise.resolve(undefined),
inbound.length === 0
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate)
: Promise.resolve(undefined),
]);
return {
journeyType: "ROUND_TRIP",
@@ -187,6 +198,8 @@ export class SearchService {
alternativeInbound,
requestedDate: dto.date,
requestedReturnDate: returnDate,
outboundReason,
inboundReason,
};
}
@@ -344,6 +357,101 @@ export class SearchService {
);
}
/**
* Only called when searchSchedules/searchTransitOptions found zero bookable results for a
* leg — classifies WHY, cheaply, by re-querying without the filters that already excluded
* everything. Priority order (most specific/actionable first): a station pair EDR never
* connects at all beats "nothing on this exact date", which beats "something exists but
* every option is cancelled/package-only/past cutoff/full" — see SearchEmptyReasonCode.
*/
private async classifyEmptySearch(
originStationId: string,
destinationStationId: string,
dateStr: string,
): Promise<Passenger.ISearchEmptyReason> {
const [origin, destination] = await Promise.all([
this.prisma.station.findUnique({ where: { id: originStationId }, select: { name: true } }),
this.prisma.station.findUnique({ where: { id: destinationStationId }, select: { name: true } }),
]);
const originStationName = origin?.name ?? "the origin station";
const destinationStationName = destination?.name ?? "the destination station";
const withCode = (code: Passenger.SearchEmptyReasonCode) => ({
code,
originStationName,
destinationStationName,
});
// 1. Does any active route connect these two stations, in this direction, at all —
// ignoring date entirely?
const candidateRoutes = await this.prisma.route.findMany({
where: { active: true, stops: { some: { stationId: originStationId } } },
select: { stops: { select: { stationId: true, sequence: true } } },
});
const routeExists = candidateRoutes.some((r) => {
const o = r.stops.find((s) => s.stationId === originStationId);
const d = r.stops.find((s) => s.stationId === destinationStationId);
return !!o && !!d && o.sequence < d.sequence;
});
if (!routeExists) return withCode(Passenger.SearchEmptyReasonCode.NoRoute);
// 2. A route exists — is there any schedule at all on the requested date for this pair
// (regardless of status/package/coach/cutoff — those are checked next)?
const [y, m, d] = dateStr.split("-").map(Number);
const date = new Date(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
);
const nextDay = new Date(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
);
const dayCandidates = await this.prisma.trainSchedule.findMany({
where: { departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } } },
select: {
status: true,
isPackageOnly: true,
departureAt: true,
route: {
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
},
stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } },
coachAssignments: { select: { id: true } },
},
});
const sameDayForPair = dayCandidates.filter((s) => {
const o = s.stopTimes.find((st) => st.stationId === originStationId);
const dst = s.stopTimes.find((st) => st.stationId === destinationStationId);
return !!o && !!dst && o.sequence < dst.sequence;
});
if (sameDayForPair.length === 0) return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
// 3. Schedules exist that date — narrow to ones that would otherwise be bookable
// (right status, not package-only, has at least one coach assigned).
const bookable = sameDayForPair.filter(
(s) =>
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
!s.isPackageOnly &&
s.coachAssignments.length > 0,
);
if (bookable.length === 0) {
if (sameDayForPair.every((s) => s.status === "CANCELLED"))
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
if (sameDayForPair.every((s) => s.isPackageOnly))
return withCode(Passenger.SearchEmptyReasonCode.PackageOnly);
return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
}
// 4. Bookable schedules exist — did every one of them already pass its check-in cutoff
// for this origin stop?
const allCutoffPassed = bookable.every((s) => {
const originStop = s.stopTimes.find((st) => st.stationId === originStationId) ?? null;
return Date.now() >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime();
});
if (allCutoffPassed) return withCode(Passenger.SearchEmptyReasonCode.CheckinClosed);
// 5. A bookable, still-open schedule exists for this pair/date — the only remaining reason
// searchSchedules dropped it is zero/insufficient seat availability.
return withCode(Passenger.SearchEmptyReasonCode.FullyBooked);
}
// ── Transit search ─────────────────────────────────────────────────────────
private readonly MIN_CONNECTION_MINUTES = 30;
private readonly MAX_CONNECTION_MINUTES = 360;

View File

@@ -512,17 +512,30 @@ export class VerifaydaService {
gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' },
name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' },
phoneNumber: normalized.rawPhoneNumber ?? '',
// Persist the identity fields the passenger booking form needs. Fayda returns
// these on every verification but they were previously dropped, leaving the
// logged-in/verified form with nothing to prefill. birthdate arrives as
// YYYY/MM/DD — store it as the ISO YYYY-MM-DD the form expects. A Fayda-verified
// holder is an Ethiopian national ID holder, so nationality is always Ethiopian.
dateOfBirth: normalized.birthdate ? normalized.birthdate.replace(/\//g, '-') : '',
nationality: 'ETHIOPIAN',
};
// Step 1 — already linked to this Fayda sub; ensure verified_by is set
// Step 1 — already linked to this Fayda sub; refresh metadata (backfills the newly
// persisted dateOfBirth/nationality for users linked before this change) and ensure
// verified_by is set.
const bySub = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`,
[normalized.sub],
);
if (bySub.length > 0) {
await this.dataSource.query(
`UPDATE iam.users SET verified_by = 'fayda', updated_at = NOW() WHERE id = $1`,
[bySub[0].id],
`UPDATE iam.users
SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb,
verified_by = 'fayda',
updated_at = NOW()
WHERE id = $2`,
[JSON.stringify(iamMetadata), bySub[0].id],
);
return { iamUserId: bySub[0].id, userDataSaved: true };
}

View File

@@ -294,6 +294,37 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
expect(byToken.schedule.origin.id).toBe(IDS.stationA);
});
it("PASSENGER path: the seat stays reserved (not publicly available) after the payment link is sent", async () => {
// Regression for: unblockSeat() released the reservation's SeatBlock and confirmSeats()
// was a no-op with no SeatHold to extend, so the seat had no SeatBlock, no SeatHold, and
// no JourneySegment (only created on payment success) the instant the payment link went
// out — fully bookable by the general public before the traveler had even paid.
await resetAndSeedCore(harness.prisma);
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-STAYS-BLOCKED-${Date.now()}`, departureAt: dep, arrivalAt: arr });
await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);
const result: any = await guestBookingService.issueBookingFromReservation(
seats[0].id,
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
"staff-user-4",
);
expect(result.booking.status).toBe("PENDING_PAYMENT");
// A member of the public trying to hold the exact same seat/leg must be rejected —
// proves the seat is covered by a real SeatHold (or equivalent), not silently free.
await expect(
seatsService.holdSeats({
scheduleId: schedule.id,
originStationId: IDS.stationA,
destinationStationId: IDS.stationB,
passengers: [{ passengerId: "someone-else", seatId: seats[0].id }],
} as any),
).rejects.toThrow(/already (held|booked)/i);
});
it("requires a phone number for a PASSENGER booking", async () => {
await resetAndSeedCore(harness.prisma);
const dep = new Date(Date.now() + 3 * 60 * 60_000);