mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 09:35:44 +00:00
@@ -261,10 +261,26 @@ export class PassengerAuthService {
|
|||||||
|
|
||||||
if (!passenger) throw new Error('Passenger not found');
|
if (!passenger) throw new Error('Passenger not found');
|
||||||
const iam = iamRows[0];
|
const iam = iamRows[0];
|
||||||
|
const meta = iam?.metadata ?? {};
|
||||||
const faydaVerified = iam?.verified_by === 'fayda';
|
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 {
|
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,
|
iamUserId,
|
||||||
// Top-level passengerId keeps the profile shape consistent with the login
|
// Top-level passengerId keeps the profile shape consistent with the login
|
||||||
// response so the web User object always carries it (the JWT does not).
|
// response so the web User object always carries it (the JWT does not).
|
||||||
@@ -272,8 +288,11 @@ export class PassengerAuthService {
|
|||||||
email: iam?.email ?? null,
|
email: iam?.email ?? null,
|
||||||
phone: iam?.phone_number ?? null,
|
phone: iam?.phone_number ?? null,
|
||||||
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
|
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
|
||||||
|
gender,
|
||||||
|
dateOfBirth,
|
||||||
nationality,
|
nationality,
|
||||||
faydaVerified,
|
faydaVerified,
|
||||||
|
faydaSub: meta.sub ?? null,
|
||||||
preferredCurrency: resolvePreferredCurrency(nationality, faydaVerified),
|
preferredCurrency: resolvePreferredCurrency(nationality, faydaVerified),
|
||||||
createdAt: passenger.createdAt,
|
createdAt: passenger.createdAt,
|
||||||
passenger: {
|
passenger: {
|
||||||
|
|||||||
@@ -557,10 +557,13 @@ export class BookingsController {
|
|||||||
})
|
})
|
||||||
@ApiResponse({ status: 404, description: "Schedule or seat hold not found" })
|
@ApiResponse({ status: 404, description: "Schedule or seat hold not found" })
|
||||||
create(@Req() req: any, @Body() dto: CreateBookingDto) {
|
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;
|
const iamUserId = req.user?.id;
|
||||||
if (!iamUserId) throw new UnauthorizedException();
|
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")
|
@Get(":id/usage")
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Injectable, BadRequestException, NotFoundException, Logger } from '@nestjs/common';
|
import { Injectable, BadRequestException, NotFoundException, Logger } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { SeatsService } from '../seats/seats.service';
|
import { SeatsService } from '../seats/seats.service';
|
||||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||||
import { CurrencyService } from '../currency/currency.service';
|
import { CurrencyService } from '../currency/currency.service';
|
||||||
import { PassengerAuthService } from '../auth/passenger-auth.service';
|
import { PassengerAuthService } from '../auth/passenger-auth.service';
|
||||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||||
|
import { AuditService } from '../../common/audit.service';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { PaymentsService } from '../payments/payments.service';
|
import { PaymentsService } from '../payments/payments.service';
|
||||||
import { AuditService } from '../../common/audit.service';
|
import { AuditService } from '../../common/audit.service';
|
||||||
@@ -87,11 +90,13 @@ export class GuestBookingService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
private seatsService: SeatsService,
|
private seatsService: SeatsService,
|
||||||
private verifaydaService: VerifaydaService,
|
private verifaydaService: VerifaydaService,
|
||||||
private currencyService: CurrencyService,
|
private currencyService: CurrencyService,
|
||||||
private passengerAuthService: PassengerAuthService,
|
private passengerAuthService: PassengerAuthService,
|
||||||
private fareEngine: FareEngineService,
|
private fareEngine: FareEngineService,
|
||||||
|
private auditService: AuditService,
|
||||||
private eventEmitter: EventEmitter2,
|
private eventEmitter: EventEmitter2,
|
||||||
private paymentsService: PaymentsService,
|
private paymentsService: PaymentsService,
|
||||||
private auditService: AuditService,
|
private auditService: AuditService,
|
||||||
@@ -138,6 +143,7 @@ export class GuestBookingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async createGuestOneWayBooking(dto: CreateGuestBookingDto, req?: any) {
|
private async createGuestOneWayBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||||
|
const authUserId: string | null = req?.user?.id ?? null;
|
||||||
// Validate hold
|
// Validate hold
|
||||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||||||
if (!hold || hold.expiresAt < new Date()) {
|
if (!hold || hold.expiresAt < new Date()) {
|
||||||
@@ -319,6 +325,7 @@ export class GuestBookingService {
|
|||||||
// Resolve or create the guest Passenger record
|
// Resolve or create the guest Passenger record
|
||||||
const firstPassenger = passengersData[0];
|
const firstPassenger = passengersData[0];
|
||||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger, req);
|
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)
|
// Save passenger details for future use (if requested)
|
||||||
if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) {
|
if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) {
|
||||||
@@ -360,8 +367,8 @@ export class GuestBookingService {
|
|||||||
bookingType: 'ONE_WAY',
|
bookingType: 'ONE_WAY',
|
||||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||||
userAgent: dto.deviceId,
|
userAgent: dto.deviceId,
|
||||||
contactEmail: firstPassenger.email || null,
|
contactEmail: contact.contactEmail,
|
||||||
contactPhone: firstPassenger.phone || null,
|
contactPhone: contact.contactPhone,
|
||||||
seats: {
|
seats: {
|
||||||
create: passengersWithFares.map((p) => ({
|
create: passengersWithFares.map((p) => ({
|
||||||
seat: { connect: { id: p.seatId } },
|
seat: { connect: { id: p.seatId } },
|
||||||
@@ -385,11 +392,18 @@ export class GuestBookingService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save passenger details as traveler profiles
|
// Save passenger details as traveler profiles — guest bookings only.
|
||||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
// Authenticated passengers already have a profile, matching the old BookingsService.
|
||||||
|
if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||||
|
|
||||||
// Confirm seats
|
// Confirm seats
|
||||||
await this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id));
|
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 });
|
this.eventEmitter.emit('booking.created', { booking });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -652,6 +666,7 @@ export class GuestBookingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async createGuestRoundTripBooking(dto: CreateGuestBookingDto, req?: any) {
|
private async createGuestRoundTripBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||||
|
const authUserId: string | null = req?.user?.id ?? null;
|
||||||
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
|
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
|
||||||
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
|
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)
|
// Create or resolve guest passenger (same as one-way)
|
||||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
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
|
// Create booking with outbound seats; return seats confirmed separately
|
||||||
const outboundSeatIds = dto.passengers.map(p => p.seatId).filter((id): id is string => !!id);
|
const outboundSeatIds = dto.passengers.map(p => p.seatId).filter((id): id is string => !!id);
|
||||||
@@ -880,8 +896,8 @@ export class GuestBookingService {
|
|||||||
returnLegStatus: 'NEITHER_USED',
|
returnLegStatus: 'NEITHER_USED',
|
||||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||||
userAgent: dto.deviceId,
|
userAgent: dto.deviceId,
|
||||||
contactEmail: passengersData[0]?.email || null,
|
contactEmail: contact.contactEmail,
|
||||||
contactPhone: passengersData[0]?.phone || null,
|
contactPhone: contact.contactPhone,
|
||||||
seats: {
|
seats: {
|
||||||
create: [
|
create: [
|
||||||
...passengersWithFares.map((p) => ({
|
...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([
|
await Promise.all([
|
||||||
this.seatsService.confirmSeats(outboundSeatIds),
|
this.seatsService.confirmSeats(outboundSeatIds),
|
||||||
this.seatsService.confirmSeats(returnSeatIds),
|
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 });
|
this.eventEmitter.emit('booking.created', { booking });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -954,6 +977,7 @@ export class GuestBookingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async createGuestTransitBooking(dto: CreateGuestBookingDto, req?: any) {
|
private async createGuestTransitBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||||
|
const authUserId: string | null = req?.user?.id ?? null;
|
||||||
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) {
|
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) {
|
||||||
throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
|
throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
|
||||||
}
|
}
|
||||||
@@ -1059,6 +1083,7 @@ export class GuestBookingService {
|
|||||||
: totalMinor;
|
: totalMinor;
|
||||||
|
|
||||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
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
|
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
|
||||||
const booking = await this.prisma.booking.create({
|
const booking = await this.prisma.booking.create({
|
||||||
@@ -1081,8 +1106,8 @@ export class GuestBookingService {
|
|||||||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||||||
leg2SeatClassId: leg2SeatClassId,
|
leg2SeatClassId: leg2SeatClassId,
|
||||||
userAgent: dto.deviceId,
|
userAgent: dto.deviceId,
|
||||||
contactEmail: passengersData[0]?.email || null,
|
contactEmail: contact.contactEmail,
|
||||||
contactPhone: passengersData[0]?.phone || null,
|
contactPhone: contact.contactPhone,
|
||||||
seats: {
|
seats: {
|
||||||
create: [
|
create: [
|
||||||
...passengersData.map(p => ({
|
...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([
|
await Promise.all([
|
||||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)),
|
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) {
|
private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||||
|
const authUserId: string | null = req?.user?.id ?? null;
|
||||||
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
|
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
|
||||||
!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
|
!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
|
||||||
!dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
|
!dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
|
||||||
@@ -1261,6 +1288,7 @@ export class GuestBookingService {
|
|||||||
: totalMinor;
|
: totalMinor;
|
||||||
|
|
||||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
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) => ({
|
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({
|
||||||
seat: { connect: { id: seatId } },
|
seat: { connect: { id: seatId } },
|
||||||
@@ -1302,8 +1330,8 @@ export class GuestBookingService {
|
|||||||
returnLeg2SeatClassId: retL2ClassId,
|
returnLeg2SeatClassId: retL2ClassId,
|
||||||
returnLegStatus: 'NEITHER_USED',
|
returnLegStatus: 'NEITHER_USED',
|
||||||
userAgent: dto.deviceId,
|
userAgent: dto.deviceId,
|
||||||
contactEmail: passengersData[0]?.email || null,
|
contactEmail: contact.contactEmail,
|
||||||
contactPhone: passengersData[0]?.phone || null,
|
contactPhone: contact.contactPhone,
|
||||||
seats: {
|
seats: {
|
||||||
create: [
|
create: [
|
||||||
...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)),
|
...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([
|
await Promise.all([
|
||||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)),
|
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(
|
private async resolveGuestPassenger(
|
||||||
dto: Pick<CreateGuestBookingDto, 'createAccount' | 'password' | 'deviceId'>,
|
dto: Pick<CreateGuestBookingDto, 'createAccount' | 'password' | 'deviceId'>,
|
||||||
firstPassenger: any,
|
firstPassenger: any,
|
||||||
req?: any,
|
req?: any,
|
||||||
): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> {
|
): 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) {
|
if (dto.createAccount && firstPassenger.email && dto.password) {
|
||||||
const guestName = firstPassenger.passengerName ?? 'Guest';
|
const guestName = firstPassenger.passengerName ?? 'Guest';
|
||||||
const result = await this.passengerAuthService.registerWithPassword(
|
const result = await this.passengerAuthService.registerWithPassword(
|
||||||
|
|||||||
@@ -254,11 +254,10 @@ export class PassengersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const passenger = await this.prisma.passenger.findUnique({
|
// Identity (name, DOB, gender, nationality, Fayda status) lives on the IAM user record,
|
||||||
where: { iamUserId: req.user.id },
|
// 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.
|
||||||
if (!passenger) return null;
|
return await this.service.getMyProfile(req.user.id);
|
||||||
return this.service.getProfile(passenger.id);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type IamUserRow = {
|
|||||||
name: { en: string; am: string } | null;
|
name: { en: string; am: string } | null;
|
||||||
phone_number: string | null;
|
phone_number: string | null;
|
||||||
metadata: Record<string, any> | null;
|
metadata: Record<string, any> | null;
|
||||||
|
verified_by?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@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) {
|
async getStats(passengerId: string) {
|
||||||
const [totalTrips, totalSpendResult, loyalty] = await Promise.all([
|
const [totalTrips, totalSpendResult, loyalty] = await Promise.all([
|
||||||
this.prisma.booking.count({ where: { passengerId, status: 'BOARDED' as any } }),
|
this.prisma.booking.count({ where: { passengerId, status: 'BOARDED' as any } }),
|
||||||
|
|||||||
@@ -512,17 +512,30 @@ export class VerifaydaService {
|
|||||||
gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' },
|
gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' },
|
||||||
name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' },
|
name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' },
|
||||||
phoneNumber: normalized.rawPhoneNumber ?? '',
|
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 }[]>(
|
const bySub = await this.dataSource.query<{ id: string }[]>(
|
||||||
`SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`,
|
`SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`,
|
||||||
[normalized.sub],
|
[normalized.sub],
|
||||||
);
|
);
|
||||||
if (bySub.length > 0) {
|
if (bySub.length > 0) {
|
||||||
await this.dataSource.query(
|
await this.dataSource.query(
|
||||||
`UPDATE iam.users SET verified_by = 'fayda', updated_at = NOW() WHERE id = $1`,
|
`UPDATE iam.users
|
||||||
[bySub[0].id],
|
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 };
|
return { iamUserId: bySub[0].id, userDataSaved: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import { useBookingStore } from '@/lib/booking-store';
|
import { useBookingStore } from '@/lib/booking-store';
|
||||||
import { UserPlus, ChevronLeft } from 'lucide-react';
|
import { UserPlus, ChevronLeft, LogIn } from 'lucide-react';
|
||||||
// import { LogIn } from 'lucide-react'; // TODO: re-enable auth — used by commented-out SignIn/Register button
|
|
||||||
|
|
||||||
function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) {
|
function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
@@ -96,7 +95,6 @@ export default function AuthCheckPage() {
|
|||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* TODO: re-enable auth — SignIn or Register button commented out until auth integration
|
|
||||||
<Tooltip content={[
|
<Tooltip content={[
|
||||||
'Saved passenger details',
|
'Saved passenger details',
|
||||||
'View booking history',
|
'View booking history',
|
||||||
@@ -110,7 +108,6 @@ export default function AuthCheckPage() {
|
|||||||
SignIn or Register
|
SignIn or Register
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
*/}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 text-center">
|
||||||
|
|||||||
@@ -933,27 +933,60 @@ function PassengersForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch passenger profile from backend
|
// Fetch passenger profile from backend. This may be null (e.g. no Passenger row linked
|
||||||
const passengerData: any = await apiClient.get(`/passengers/me`);
|
// yet) — in that case fall back to the `user` object, which /auth/profile hydrates from
|
||||||
|
// the same IAM record (name, gender, DOB, nationality, Fayda status). Merging the two
|
||||||
|
// means an already-verified passenger's form still prefills from whichever source has
|
||||||
|
// the data, rather than being left blank.
|
||||||
|
const passengerData: any = (await apiClient.get(`/passengers/me`)) || {};
|
||||||
|
|
||||||
if (!passengerData) {
|
const pick = (a: any, b: any) => (a !== undefined && a !== null && a !== '' ? a : b);
|
||||||
|
|
||||||
|
// Nationality for THIS booking is the one chosen at search — the passengers-page
|
||||||
|
// nationality field is read-only. It, not the account's stored nationality, decides
|
||||||
|
// whether the Fayda gate applies, so a logged-in user who picked "Other"/"Djiboutian"
|
||||||
|
// isn't wrongly forced into Fayda (Fayda is only for Ethiopian nationals).
|
||||||
|
const nationality = searchCriteria?.nationality || pick(passengerData.nationality, user.nationality) || 'ETHIOPIAN';
|
||||||
|
const isEthiopian = String(nationality).toUpperCase() === 'ETHIOPIAN';
|
||||||
|
const isVerified = Boolean(pick(passengerData.faydaVerified, user.faydaVerified));
|
||||||
|
|
||||||
|
// A logged-in but NOT Fayda-verified Ethiopian must pass the Fayda gate exactly like a
|
||||||
|
// guest. Prefilling their identity and expanding the form would let them submit the
|
||||||
|
// booking without ever verifying — only a verified passenger may pass. When Fayda is
|
||||||
|
// globally disabled there is no gate, so the restriction doesn't apply.
|
||||||
|
const mustVerifyFayda = isEthiopian && !isVerified && faydaEnabled;
|
||||||
|
|
||||||
|
// Nationality + contact aren't identity-verifying, so they're safe to prefill either way.
|
||||||
|
setValue('passengers.0.nationality', nationality);
|
||||||
|
const phoneVal = pick(passengerData.phone, user.phone);
|
||||||
|
if (phoneVal) setValue('passengers.0.phone', phoneVal);
|
||||||
|
const emailVal = pick(passengerData.email, user.email);
|
||||||
|
if (emailVal) setValue('passengers.0.email', emailVal);
|
||||||
|
|
||||||
|
if (mustVerifyFayda) {
|
||||||
|
// Force the Fayda gate: leave name/DOB/gender empty and keep the form collapsed so the
|
||||||
|
// "Verify with Fayda" screen is shown instead of an editable, pre-filled form.
|
||||||
|
setValue('passengers.0.faydaVerified', false);
|
||||||
|
setValue('passengers.0.formExpanded', false);
|
||||||
setFormInitialized(true);
|
setFormInitialized(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only populate first passenger
|
// Verified Ethiopian, or a non-Ethiopian (passport flow): prefill everything and expand.
|
||||||
setValue('passengers.0.name', passengerData?.fullName || user.fullName || '');
|
setValue('passengers.0.name', pick(passengerData.fullName, user.fullName) || '');
|
||||||
setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || '');
|
setValue('passengers.0.dateOfBirth', pick(passengerData.dateOfBirth, user.dateOfBirth) || '');
|
||||||
if (passengerData?.gender || user.gender) setValue('passengers.0.gender', (passengerData?.gender || user.gender) as any);
|
const genderVal = pick(passengerData.gender, user.gender);
|
||||||
setValue('passengers.0.nationality', passengerData?.nationality || user.nationality || 'ETHIOPIAN');
|
if (genderVal) setValue('passengers.0.gender', genderVal as any);
|
||||||
if (passengerData?.phone || user.phone) setValue('passengers.0.phone', passengerData?.phone || user.phone || '');
|
const passportNumberVal = pick(passengerData.passportNumber, user.passportNumber);
|
||||||
if (passengerData?.email || user.email) setValue('passengers.0.email', passengerData?.email || user.email || '');
|
if (passportNumberVal) setValue('passengers.0.passportNumber', passportNumberVal);
|
||||||
if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber);
|
setValue('passengers.0.passportCountry', pick(passengerData.passportCountry, user.passportCountry) || (searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : ''));
|
||||||
setValue('passengers.0.passportCountry', passengerData?.passportCountry || (searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : ''));
|
const passportIssueVal = pick(passengerData.passportIssueDate, user.passportIssueDate);
|
||||||
if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate);
|
if (passportIssueVal) setValue('passengers.0.passportIssueDate', passportIssueVal);
|
||||||
if (passengerData?.passportExpiryDate) setValue('passengers.0.passportExpiryDate', passengerData.passportExpiryDate);
|
const passportExpiryVal = pick(passengerData.passportExpiryDate, user.passportExpiryDate);
|
||||||
if (passengerData?.passportIssuingAuthority) setValue('passengers.0.passportIssuingAuthority', passengerData.passportIssuingAuthority);
|
if (passportExpiryVal) setValue('passengers.0.passportExpiryDate', passportExpiryVal);
|
||||||
setValue('passengers.0.faydaVerified', passengerData?.faydaVerified || user.faydaVerified || false);
|
const passportAuthVal = pick(passengerData.passportIssuingAuthority, user.passportIssuingAuthority);
|
||||||
|
if (passportAuthVal) setValue('passengers.0.passportIssuingAuthority', passportAuthVal);
|
||||||
|
setValue('passengers.0.faydaVerified', isVerified);
|
||||||
setValue('passengers.0.formExpanded', true);
|
setValue('passengers.0.formExpanded', true);
|
||||||
|
|
||||||
setFormInitialized(true);
|
setFormInitialized(true);
|
||||||
@@ -1126,10 +1159,21 @@ function PassengersForm() {
|
|||||||
// Identity fields sourced from a completed Fayda verification are locked — the
|
// Identity fields sourced from a completed Fayda verification are locked — the
|
||||||
// passenger can't edit the verified name / date of birth / gender.
|
// passenger can't edit the verified name / date of birth / gender.
|
||||||
const isFaydaLocked = !!passengers[index]?.faydaVerified;
|
const isFaydaLocked = !!passengers[index]?.faydaVerified;
|
||||||
|
// ...but lock each field only when it actually carries a value. A verified profile
|
||||||
|
// can be missing a field (e.g. a Fayda *login* record whose metadata has no date of
|
||||||
|
// birth) — locking an empty, required input would strand the user with no way to
|
||||||
|
// fill it or submit. A missing field stays editable so they can complete it.
|
||||||
|
const isNameLocked = isFaydaLocked && !!passengers[index]?.name;
|
||||||
|
const isDobLocked = isFaydaLocked && !!passengers[index]?.dateOfBirth;
|
||||||
|
const isGenderLocked = isFaydaLocked && !!passengers[index]?.gender;
|
||||||
// Contact fields lock only when Fayda actually supplied them; a value Fayda left
|
// Contact fields lock only when Fayda actually supplied them; a value Fayda left
|
||||||
// blank stays editable so the passenger can add their own phone/email.
|
// blank stays editable so the passenger can add their own phone/email.
|
||||||
const isPhoneLocked = !!passengers[index]?.faydaPhoneLocked;
|
// A logged-in, already-verified primary passenger's contact details also come from
|
||||||
const isEmailLocked = !!passengers[index]?.faydaEmailLocked;
|
// their verified profile — lock those too, alongside name/DOB/gender. Only lock a
|
||||||
|
// field that actually has a value, so an incomplete profile can't strand the user
|
||||||
|
// on an unfillable required field.
|
||||||
|
const isPhoneLocked = !!passengers[index]?.faydaPhoneLocked || (isLoggedInAndVerified && !!passengers[index]?.phone);
|
||||||
|
const isEmailLocked = !!passengers[index]?.faydaEmailLocked || (isLoggedInAndVerified && !!passengers[index]?.email);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={field.id} className="card">
|
<div key={field.id} className="card">
|
||||||
@@ -1227,8 +1271,8 @@ function PassengersForm() {
|
|||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.name`)}
|
{...register(`passengers.${index}.name`)}
|
||||||
readOnly={isFaydaLocked}
|
readOnly={isNameLocked}
|
||||||
className={`input-field ${isFaydaLocked ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''} ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
|
className={`input-field ${isNameLocked ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''} ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
|
||||||
placeholder="Full name as per ID"
|
placeholder="Full name as per ID"
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.name && (
|
{errors.passengers?.[index]?.name && (
|
||||||
@@ -1244,14 +1288,14 @@ function PassengersForm() {
|
|||||||
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
||||||
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
||||||
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
|
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
|
||||||
disabled={isFaydaLocked}
|
disabled={isDobLocked}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gender */}
|
{/* Gender */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
||||||
{isFaydaLocked ? (
|
{isGenderLocked ? (
|
||||||
<input
|
<input
|
||||||
value={passengers[index]?.gender || ''}
|
value={passengers[index]?.gender || ''}
|
||||||
readOnly
|
readOnly
|
||||||
@@ -1344,14 +1388,14 @@ function PassengersForm() {
|
|||||||
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
||||||
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
||||||
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
|
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
|
||||||
disabled={isFaydaLocked}
|
disabled={isDobLocked}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gender */}
|
{/* Gender */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
||||||
{isFaydaLocked ? (
|
{isGenderLocked ? (
|
||||||
<input
|
<input
|
||||||
value={passengers[index]?.gender || ''}
|
value={passengers[index]?.gender || ''}
|
||||||
readOnly
|
readOnly
|
||||||
@@ -1398,6 +1442,7 @@ function PassengersForm() {
|
|||||||
onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
|
onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
|
||||||
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
|
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
|
||||||
error={errors.passengers?.[index]?.phone?.message}
|
error={errors.passengers?.[index]?.phone?.message}
|
||||||
|
disabled={isPhoneLocked}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -192,22 +192,20 @@ export default function AppSidebar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// TODO: re-enable auth — Sign in / Register links commented out until auth integration
|
<div className="flex items-center gap-2 px-1 pt-1">
|
||||||
// <div className="flex items-center gap-2 px-1 pt-1">
|
<Link
|
||||||
// <Link
|
href="/login"
|
||||||
// href="/login"
|
className="flex-1 text-center px-3 py-2 text-sm font-medium text-gray-100 hover:bg-white/10 rounded-lg transition-colors"
|
||||||
// className="flex-1 text-center px-3 py-2 text-sm font-medium text-gray-100 hover:bg-white/10 rounded-lg transition-colors"
|
>
|
||||||
// >
|
Sign in
|
||||||
// Sign in
|
</Link>
|
||||||
// </Link>
|
<Link
|
||||||
// <Link
|
href="/register"
|
||||||
// href="/register"
|
className="flex-1 text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
// className="flex-1 text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
|
>
|
||||||
// >
|
Register
|
||||||
// Register
|
</Link>
|
||||||
// </Link>
|
</div>
|
||||||
// </div>
|
|
||||||
null
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Home, Phone, Ticket } from 'lucide-react';
|
import { Home, Phone, Ticket, User } from 'lucide-react';
|
||||||
// import { User } from 'lucide-react'; // TODO: re-enable auth — used by commented-out Sign in tab
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
// import { useAuthStore } from '@/lib/auth-store'; // TODO: re-enable auth
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
|
||||||
// The linear, one-screen-at-a-time booking flow — each of these pages already
|
// The linear, one-screen-at-a-time booking flow — each of these pages already
|
||||||
// has its own sticky mobile CTA bar (and the mobile step strip at the top),
|
// has its own sticky mobile CTA bar (and the mobile step strip at the top),
|
||||||
@@ -22,7 +21,7 @@ const LINEAR_FLOW_PREFIXES = [
|
|||||||
|
|
||||||
export default function BottomTabBar() {
|
export default function BottomTabBar() {
|
||||||
const pathname = usePathname() ?? '';
|
const pathname = usePathname() ?? '';
|
||||||
// const isAuthenticated = useAuthStore((s) => s.isAuthenticated); // TODO: re-enable auth
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
|
||||||
const isInLinearFlow = LINEAR_FLOW_PREFIXES.some((p) => pathname.startsWith(p));
|
const isInLinearFlow = LINEAR_FLOW_PREFIXES.some((p) => pathname.startsWith(p));
|
||||||
if (isInLinearFlow) return null;
|
if (isInLinearFlow) return null;
|
||||||
@@ -31,13 +30,12 @@ export default function BottomTabBar() {
|
|||||||
{ href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' },
|
{ href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' },
|
||||||
{ href: '/booking/lookup', label: 'Bookings', icon: Ticket, match: (p: string) => p.startsWith('/booking/lookup') || p.startsWith('/booking/detail') },
|
{ href: '/booking/lookup', label: 'Bookings', icon: Ticket, match: (p: string) => p.startsWith('/booking/lookup') || p.startsWith('/booking/detail') },
|
||||||
{ href: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') },
|
{ href: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') },
|
||||||
// TODO: re-enable auth — auth login/register tab commented out until auth integration
|
{
|
||||||
// {
|
href: isAuthenticated ? '/profile' : '/login',
|
||||||
// href: isAuthenticated ? '/profile' : '/login',
|
label: isAuthenticated ? 'Account' : 'Sign in',
|
||||||
// label: isAuthenticated ? 'Account' : 'Sign in',
|
icon: User,
|
||||||
// icon: User,
|
match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'),
|
||||||
// match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'),
|
},
|
||||||
// },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -296,9 +296,10 @@ export class IntentsService {
|
|||||||
* expiresAt, so opening a fresh session would leave two concurrently-payable
|
* expiresAt, so opening a fresh session would leave two concurrently-payable
|
||||||
* sessions and invite a double charge (observed in prod: a superseded Telebirr
|
* sessions and invite a double charge (observed in prod: a superseded Telebirr
|
||||||
* session was paid after cancellation, orphaning the capture).
|
* session was paid after cancellation, orphaning the capture).
|
||||||
* - Expired, or the requested amount/currency changed: retired (CANCELLED, no
|
* - Expired, or the requested amount/currency or platform (web↔mobile) changed:
|
||||||
* notification — nothing was paid; a payment.failed here would wrongly fail the
|
* retired (CANCELLED, no notification — nothing was paid; a payment.failed here
|
||||||
* domain order mid-retry) and null is returned so the caller opens a fresh session.
|
* would wrongly fail the domain order mid-retry) and null is returned so the caller
|
||||||
|
* opens a fresh session with the correct amount/clientAction for the new platform.
|
||||||
*
|
*
|
||||||
* When the status query itself errors, the existing intent is reused unchanged:
|
* When the status query itself errors, the existing intent is reused unchanged:
|
||||||
* superseding blind could leave two live sessions and a double charge.
|
* superseding blind could leave two live sessions and a double charge.
|
||||||
@@ -332,8 +333,15 @@ export class IntentsService {
|
|||||||
const chargeChanged =
|
const chargeChanged =
|
||||||
intent.amountMinor !== request.amountMinor ||
|
intent.amountMinor !== request.amountMinor ||
|
||||||
intent.currency !== request.currency;
|
intent.currency !== request.currency;
|
||||||
|
// A web↔mobile switch needs a different clientAction shape (e.g. Telebirr:
|
||||||
|
// REDIRECT for web vs LAUNCH_APP for the native app), so reusing the stored
|
||||||
|
// session would hand the payer the wrong launch method and break the return.
|
||||||
|
// Detect the stored session's platform from its clientAction and retire on a switch.
|
||||||
|
const storedIsMobileLaunch = intent.clientAction?.type === "LAUNCH_APP";
|
||||||
|
const requestedMobile = (request.platform ?? "web") === "mobile";
|
||||||
|
const platformChanged = storedIsMobileLaunch !== requestedMobile;
|
||||||
|
|
||||||
if (!expired && !chargeChanged) {
|
if (!expired && !chargeChanged && !platformChanged) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`intent ${intent.id} reused (live ${intent.provider} session, unpaid, not expired) for ` +
|
`intent ${intent.id} reused (live ${intent.provider} session, unpaid, not expired) for ` +
|
||||||
`${request.service}/${request.referenceType}/${request.referenceId}`,
|
`${request.service}/${request.referenceType}/${request.referenceId}`,
|
||||||
@@ -346,7 +354,7 @@ export class IntentsService {
|
|||||||
failureCode: expired ? "EXPIRED" : "SUPERSEDED",
|
failureCode: expired ? "EXPIRED" : "SUPERSEDED",
|
||||||
failureMessage: expired
|
failureMessage: expired
|
||||||
? "Provider session expired before the payer acted"
|
? "Provider session expired before the payer acted"
|
||||||
: "Payer re-initiated with a changed amount; previous session superseded",
|
: "Payer re-initiated with a changed amount or platform; previous session superseded",
|
||||||
});
|
});
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`intent ${intent.id} retired (${expired ? "EXPIRED" : "SUPERSEDED"}) — fresh session will be opened`,
|
`intent ${intent.id} retired (${expired ? "EXPIRED" : "SUPERSEDED"}) — fresh session will be opened`,
|
||||||
|
|||||||
Reference in New Issue
Block a user