mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into tests
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
const logger = new Logger('ScheduleTimesUtils');
|
||||
|
||||
export type StopForTiming = {
|
||||
sequence: number;
|
||||
distanceKm: number | null;
|
||||
travelMinutesToStop: number | null;
|
||||
checkinMinutesBefore: number | null;
|
||||
};
|
||||
|
||||
export type PlannedStopTime = {
|
||||
sequence: number;
|
||||
plannedArrivalAt: string | undefined;
|
||||
plannedDepartureAt: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes each stop's planned arrival/departure time by walking the route in sequence order.
|
||||
*
|
||||
* Model per intermediate stop:
|
||||
* arrival = departureCursor + travelMinutesToStop (falls back to distance interpolation)
|
||||
* departure = arrival + checkinMinutesBefore (dwell time; 0 if null)
|
||||
* next-stop travel starts from this departure, not from arrival.
|
||||
*
|
||||
* This means booking for stop B closes at B.departureAt − checkinMinutesBefore = B.arrivalAt,
|
||||
* i.e. the train must not yet have arrived at the stop for a booking to succeed.
|
||||
*
|
||||
* The last stop is always locked to arr so schedule.arrivalAt stays authoritative.
|
||||
*/
|
||||
export function computePlannedStopTimes(
|
||||
route: { id: string; stops: StopForTiming[] },
|
||||
dep: Date,
|
||||
arr: Date,
|
||||
): PlannedStopTime[] {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
// cursor tracks the DEPARTURE time from the most-recently processed stop.
|
||||
let departureCursor = dep;
|
||||
|
||||
return route.stops.map((stop, index) => {
|
||||
if (index === 0) {
|
||||
// Origin: train starts here, no arrival.
|
||||
departureCursor = dep;
|
||||
return { sequence: stop.sequence, plannedArrivalAt: undefined, plannedDepartureAt: dep.toISOString() };
|
||||
}
|
||||
|
||||
if (index === route.stops.length - 1) {
|
||||
// Final destination: arrival is authoritative; no departure.
|
||||
return { sequence: stop.sequence, plannedArrivalAt: arr.toISOString(), plannedDepartureAt: undefined };
|
||||
}
|
||||
|
||||
// Intermediate stop: compute arrival from the previous stop's departure.
|
||||
let arrivalAt: Date;
|
||||
if (stop.travelMinutesToStop != null) {
|
||||
arrivalAt = new Date(departureCursor.getTime() + stop.travelMinutesToStop * 60_000);
|
||||
} else {
|
||||
const stopDistance = stop.distanceKm || 0;
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
arrivalAt = new Date(dep.getTime() + totalDuration * progress);
|
||||
logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`);
|
||||
}
|
||||
|
||||
// Dwell at this stop = checkinMinutesBefore (the boarding window).
|
||||
const dwell = stop.checkinMinutesBefore ?? 0;
|
||||
const departureAt = new Date(arrivalAt.getTime() + dwell * 60_000);
|
||||
departureCursor = departureAt;
|
||||
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: arrivalAt.toISOString(),
|
||||
plannedDepartureAt: departureAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -575,10 +575,13 @@ export class BookingsController {
|
||||
})
|
||||
@ApiResponse({ status: 404, description: "Schedule or seat hold not found" })
|
||||
create(@Req() req: any, @Body() dto: CreateBookingDto) {
|
||||
// Always resolve passengerId from the authenticated JWT — never trust the request body
|
||||
// Always resolve identity from the authenticated JWT — never trust the request body.
|
||||
// Routed through the unified GuestBookingService: because req.user.id is present, it
|
||||
// resolves the existing passenger from the token and layers on the authenticated-only
|
||||
// behaviours (iam.users contact, loyalty, audit, package inventory, seat-vs-hold guard).
|
||||
const iamUserId = req.user?.id;
|
||||
if (!iamUserId) throw new UnauthorizedException();
|
||||
return this.service.create({ ...dto, passengerId: iamUserId });
|
||||
return this.guestService.createGuestBooking(dto as unknown as CreateGuestBookingDto, req);
|
||||
}
|
||||
|
||||
@Get(":id/usage")
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Injectable, BadRequestException, NotFoundException, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { PassengerAuthService } from '../auth/passenger-auth.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
@@ -87,11 +90,13 @@ export class GuestBookingService {
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private seatsService: SeatsService,
|
||||
private verifaydaService: VerifaydaService,
|
||||
private currencyService: CurrencyService,
|
||||
private passengerAuthService: PassengerAuthService,
|
||||
private fareEngine: FareEngineService,
|
||||
private auditService: AuditService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
private paymentsService: PaymentsService,
|
||||
private auditService: AuditService,
|
||||
@@ -138,6 +143,7 @@ export class GuestBookingService {
|
||||
}
|
||||
|
||||
private async createGuestOneWayBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
const authUserId: string | null = req?.user?.id ?? null;
|
||||
// Validate hold
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||||
if (!hold || hold.expiresAt < new Date()) {
|
||||
@@ -319,6 +325,7 @@ export class GuestBookingService {
|
||||
// Resolve or create the guest Passenger record
|
||||
const firstPassenger = passengersData[0];
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger, req);
|
||||
const contact = await this.resolveActorContact(req, firstPassenger);
|
||||
|
||||
// Save passenger details for future use (if requested)
|
||||
if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) {
|
||||
@@ -360,8 +367,8 @@ export class GuestBookingService {
|
||||
bookingType: 'ONE_WAY',
|
||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: firstPassenger.email || null,
|
||||
contactPhone: firstPassenger.phone || null,
|
||||
contactEmail: contact.contactEmail,
|
||||
contactPhone: contact.contactPhone,
|
||||
seats: {
|
||||
create: passengersWithFares.map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
@@ -385,11 +392,18 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
// Save passenger details as traveler profiles
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
// Save passenger details as traveler profiles — guest bookings only.
|
||||
// Authenticated passengers already have a profile, matching the old BookingsService.
|
||||
if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
// Confirm seats
|
||||
await this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id));
|
||||
|
||||
// Authenticated-only side effect: audit the booking creation.
|
||||
if (authUserId) {
|
||||
await this.auditService.log({ userId: guestPassengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ONE_WAY', totalMinor: resolvedTotalMinor } });
|
||||
}
|
||||
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
return {
|
||||
@@ -652,6 +666,7 @@ export class GuestBookingService {
|
||||
}
|
||||
|
||||
private async createGuestRoundTripBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
const authUserId: string | null = req?.user?.id ?? null;
|
||||
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
|
||||
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
|
||||
}
|
||||
@@ -852,6 +867,7 @@ export class GuestBookingService {
|
||||
|
||||
// Create or resolve guest passenger (same as one-way)
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||
const contact = await this.resolveActorContact(req, passengersData[0]);
|
||||
|
||||
// Create booking with outbound seats; return seats confirmed separately
|
||||
const outboundSeatIds = dto.passengers.map(p => p.seatId).filter((id): id is string => !!id);
|
||||
@@ -880,8 +896,8 @@ export class GuestBookingService {
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: passengersData[0]?.email || null,
|
||||
contactPhone: passengersData[0]?.phone || null,
|
||||
contactEmail: contact.contactEmail,
|
||||
contactPhone: contact.contactPhone,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersWithFares.map((p) => ({
|
||||
@@ -923,12 +939,19 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
// Traveler profiles: guest bookings only (authenticated passengers already have one).
|
||||
if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(outboundSeatIds),
|
||||
this.seatsService.confirmSeats(returnSeatIds),
|
||||
]);
|
||||
|
||||
// Authenticated-only side effect: audit the booking creation.
|
||||
if (authUserId) {
|
||||
await this.auditService.log({ userId: guestPassengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ROUND_TRIP', totalMinor } });
|
||||
}
|
||||
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
return {
|
||||
@@ -954,6 +977,7 @@ export class GuestBookingService {
|
||||
}
|
||||
|
||||
private async createGuestTransitBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
const authUserId: string | null = req?.user?.id ?? null;
|
||||
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) {
|
||||
throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
|
||||
}
|
||||
@@ -1059,6 +1083,7 @@ export class GuestBookingService {
|
||||
: totalMinor;
|
||||
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||
const contact = await this.resolveActorContact(req, passengersData[0]);
|
||||
|
||||
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
|
||||
const booking = await this.prisma.booking.create({
|
||||
@@ -1081,8 +1106,8 @@ export class GuestBookingService {
|
||||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||||
leg2SeatClassId: leg2SeatClassId,
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: passengersData[0]?.email || null,
|
||||
contactPhone: passengersData[0]?.phone || null,
|
||||
contactEmail: contact.contactEmail,
|
||||
contactPhone: contact.contactPhone,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map(p => ({
|
||||
@@ -1124,7 +1149,8 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
// Traveler profiles: guest bookings only (authenticated passengers already have one).
|
||||
if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)),
|
||||
@@ -1150,6 +1176,7 @@ export class GuestBookingService {
|
||||
}
|
||||
|
||||
private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
const authUserId: string | null = req?.user?.id ?? null;
|
||||
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
|
||||
!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
|
||||
!dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
|
||||
@@ -1261,6 +1288,7 @@ export class GuestBookingService {
|
||||
: totalMinor;
|
||||
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||
const contact = await this.resolveActorContact(req, passengersData[0]);
|
||||
|
||||
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({
|
||||
seat: { connect: { id: seatId } },
|
||||
@@ -1302,8 +1330,8 @@ export class GuestBookingService {
|
||||
returnLeg2SeatClassId: retL2ClassId,
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
userAgent: dto.deviceId,
|
||||
contactEmail: passengersData[0]?.email || null,
|
||||
contactPhone: passengersData[0]?.phone || null,
|
||||
contactEmail: contact.contactEmail,
|
||||
contactPhone: contact.contactPhone,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)),
|
||||
@@ -1319,7 +1347,8 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
// Traveler profiles: guest bookings only (authenticated passengers already have one).
|
||||
if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)),
|
||||
@@ -1348,11 +1377,40 @@ export class GuestBookingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the booking contact. Authenticated callers get their contact from iam.users
|
||||
* (matching the old BookingsService.resolveIamContact); guests fall back to the first
|
||||
* passenger's inline phone/email exactly as before.
|
||||
*/
|
||||
private async resolveActorContact(
|
||||
req: any,
|
||||
firstPassenger: any,
|
||||
): Promise<{ contactEmail: string | null; contactPhone: string | null }> {
|
||||
const iamUserId = req?.user?.id;
|
||||
if (iamUserId) {
|
||||
const rows = await this.dataSource.query<{ email: string; phone_number: string | null }[]>(
|
||||
`SELECT email, phone_number FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||
[iamUserId],
|
||||
);
|
||||
return { contactEmail: rows[0]?.email ?? null, contactPhone: rows[0]?.phone_number ?? null };
|
||||
}
|
||||
return { contactEmail: firstPassenger?.email || null, contactPhone: firstPassenger?.phone || null };
|
||||
}
|
||||
|
||||
private async resolveGuestPassenger(
|
||||
dto: Pick<CreateGuestBookingDto, 'createAccount' | 'password' | 'deviceId'>,
|
||||
firstPassenger: any,
|
||||
req?: any,
|
||||
): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> {
|
||||
// Authenticated caller: resolve the existing passenger from the JWT subject.
|
||||
// Never trust a client-supplied passengerId — identity comes from the token only.
|
||||
const authUserId = req?.user?.id;
|
||||
if (authUserId) {
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId: authUserId }, select: { id: true } });
|
||||
if (!passenger) throw new NotFoundException('Passenger profile not found for this account');
|
||||
return { guestPassengerId: passenger.id, iamUserId: authUserId, createdAccount: false };
|
||||
}
|
||||
|
||||
if (dto.createAccount && firstPassenger.email && dto.password) {
|
||||
const guestName = firstPassenger.passengerName ?? 'Guest';
|
||||
const result = await this.passengerAuthService.registerWithPassword(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 } }),
|
||||
|
||||
@@ -242,6 +242,61 @@ export class PaymentsService {
|
||||
return this.initiateWalletPayment(booking);
|
||||
}
|
||||
|
||||
// Double-charge guard for payment-method switches. Before opening a fresh charge over
|
||||
// this booking, reconcile any still-open intent against the authoritative provider
|
||||
// status — the booking-status check above only blocks once the booking is CONFIRMED,
|
||||
// which leaves a window where the first attempt actually paid but the mark-paid
|
||||
// webhook/poll hasn't landed yet.
|
||||
const existingIntent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
if (existingIntent && NON_TERMINAL_STATUSES.includes(existingIntent.status)) {
|
||||
let snapshot: PaymentIntentSnapshot | null = null;
|
||||
try {
|
||||
snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
booking.id,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`payment reconcile before initiate failed for booking ${booking.id}: ${message}; treating existing intent as still open`,
|
||||
);
|
||||
}
|
||||
|
||||
// The previous attempt actually paid (provider SUCCEEDED, event just late):
|
||||
// converge the booking now and return it — never charge a second time.
|
||||
if (snapshot?.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
let intent = await this.syncIntentProjection(booking.id, snapshot);
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||||
where: { id: intent.id },
|
||||
});
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
|
||||
// Still pending at the provider (REQUIRES_ACTION/PROCESSING) — or the payment
|
||||
// service was unreachable and the local status is non-terminal. Block the switch:
|
||||
// return the existing intent so the payer completes or waits out the open attempt
|
||||
// rather than opening a second concurrent charge.
|
||||
if (
|
||||
!snapshot ||
|
||||
snapshot.status === ProviderPaymentStatus.REQUIRES_ACTION ||
|
||||
snapshot.status === ProviderPaymentStatus.PROCESSING
|
||||
) {
|
||||
const intent = snapshot
|
||||
? await this.syncIntentProjection(booking.id, snapshot)
|
||||
: existingIntent;
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
// Otherwise the provider reports FAILED/CANCELLED — fall through and initiate
|
||||
// the newly selected method below.
|
||||
}
|
||||
|
||||
const { returnUrl, failureUrl } = this.resolveReturnUrls(
|
||||
method,
|
||||
requestOrigin,
|
||||
|
||||
@@ -53,6 +53,12 @@ export class ReportsController {
|
||||
return this.service.getSeatStatusReport(scheduleId);
|
||||
}
|
||||
|
||||
@Get("boarding")
|
||||
@ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" })
|
||||
getBoardingReport(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.getBoardingReport(scheduleId);
|
||||
}
|
||||
|
||||
@Get("payments")
|
||||
@ApiOperation({ summary: "Payments collected for a schedule" })
|
||||
getPaymentsReport(@Query('scheduleId') scheduleId: string) {
|
||||
|
||||
@@ -1022,6 +1022,119 @@ export class ReportsService {
|
||||
return { total: rows.length, rows };
|
||||
}
|
||||
|
||||
async getBoardingReport(scheduleId: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: {
|
||||
id: true,
|
||||
departureAt: true,
|
||||
arrivalAt: true,
|
||||
train: { select: { number: true, name: true } },
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
if (!schedule) return null;
|
||||
|
||||
const tickets = await this.prisma.ticket.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||
{ leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||
{ scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
bookingRef: true,
|
||||
passengerName: true,
|
||||
boardedAt: true,
|
||||
validatorId: true,
|
||||
status: true,
|
||||
booking: {
|
||||
select: {
|
||||
status: true,
|
||||
originStationId: true,
|
||||
destinationStationId: true,
|
||||
},
|
||||
},
|
||||
seat: {
|
||||
select: {
|
||||
seatNumber: true,
|
||||
bedPosition: true,
|
||||
coach: {
|
||||
select: {
|
||||
number: true,
|
||||
coachType: { select: { name: true, seatClasses: { select: { name: true, bedPosition: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }],
|
||||
});
|
||||
|
||||
const stationIds = [...new Set(
|
||||
tickets.flatMap(t => [t.booking.originStationId, t.booking.destinationStationId]).filter(Boolean) as string[],
|
||||
)];
|
||||
const stations = stationIds.length > 0
|
||||
? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } })
|
||||
: [];
|
||||
const stationName = new Map(stations.map(s => [s.id, s.name]));
|
||||
|
||||
const resolveSeatClass = (seat: any): string | null => {
|
||||
const classes = seat?.coach?.coachType?.seatClasses ?? [];
|
||||
const matched = seat?.bedPosition
|
||||
? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition.toLowerCase())
|
||||
: null;
|
||||
return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? null;
|
||||
};
|
||||
|
||||
const rows = tickets.map(t => ({
|
||||
bookingRef: t.bookingRef,
|
||||
passengerName: t.passengerName,
|
||||
coachNumber: t.seat?.coach?.number ?? null,
|
||||
seatNumber: t.seat?.seatNumber ?? null,
|
||||
seatClassName: resolveSeatClass(t.seat),
|
||||
origin: t.booking.originStationId ? (stationName.get(t.booking.originStationId) ?? null) : null,
|
||||
destination: t.booking.destinationStationId ? (stationName.get(t.booking.destinationStationId) ?? null) : null,
|
||||
boarded: !!t.boardedAt,
|
||||
boardedAt: t.boardedAt ?? null,
|
||||
validatorId: t.validatorId ?? null,
|
||||
bookingStatus: t.booking.status,
|
||||
}));
|
||||
|
||||
const boardedCount = rows.filter(r => r.boarded).length;
|
||||
const notBoardedCount = rows.length - boardedCount;
|
||||
|
||||
const byCoach = new Map<string, { coachNumber: string; total: number; boarded: number }>();
|
||||
for (const r of rows) {
|
||||
const key = r.coachNumber ?? 'Unknown';
|
||||
if (!byCoach.has(key)) byCoach.set(key, { coachNumber: key, total: 0, boarded: 0 });
|
||||
byCoach.get(key)!.total++;
|
||||
if (r.boarded) byCoach.get(key)!.boarded++;
|
||||
}
|
||||
|
||||
return {
|
||||
schedule: {
|
||||
id: schedule.id,
|
||||
trainName: (schedule.train as any)?.name ?? (schedule.train as any)?.number,
|
||||
origin: (schedule.originStation as any)?.name,
|
||||
destination: (schedule.destinationStation as any)?.name,
|
||||
departureAt: schedule.departureAt,
|
||||
arrivalAt: schedule.arrivalAt,
|
||||
},
|
||||
summary: {
|
||||
total: rows.length,
|
||||
boardedCount,
|
||||
notBoardedCount,
|
||||
boardingRate: rows.length > 0 ? +((boardedCount / rows.length) * 100).toFixed(1) : 0,
|
||||
},
|
||||
byCoach: [...byCoach.values()].sort((a, b) => a.coachNumber.localeCompare(b.coachNumber)),
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
async getReport(reportId: string) {
|
||||
return this.prisma.operationalReport.findUnique({
|
||||
where: { id: reportId },
|
||||
|
||||
@@ -148,6 +148,20 @@ export class RoutesService {
|
||||
travelMinutesToStop: s.travelMinutesToStop ?? null,
|
||||
})),
|
||||
});
|
||||
|
||||
// Propagate new stop timing to all future schedules on this route so that
|
||||
// per-stop check-in cutoffs reflect the updated travelMinutesToStop values.
|
||||
const futureSchedules = await this.prisma.trainSchedule.findMany({
|
||||
where: { routeId: id, status: { in: ['SCHEDULED', 'BOARDING'] }, departureAt: { gt: new Date() } },
|
||||
select: { id: true, departureAt: true, arrivalAt: true },
|
||||
});
|
||||
const stopsForTiming = dto.stops
|
||||
.map(s => ({ sequence: s.sequence, distanceKm: s.distanceKm ?? null, travelMinutesToStop: s.travelMinutesToStop ?? null, checkinMinutesBefore: s.checkinMinutesBefore ?? null }))
|
||||
.sort((a, b) => a.sequence - b.sequence);
|
||||
for (const sched of futureSchedules) {
|
||||
const times = computePlannedStopTimes({ id, stops: stopsForTiming }, new Date(sched.departureAt), new Date(sched.arrivalAt));
|
||||
await this.applyRouteToSchedule(id, sched.id, Object.fromEntries(times.map(t => [t.sequence, t])));
|
||||
}
|
||||
}
|
||||
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Route', entityId: id, newData: { name: dto.name, active: dto.active } });
|
||||
|
||||
@@ -154,6 +154,12 @@ export class SchedulesController {
|
||||
@ApiQuery({ name: 'cascade', required: false, type: Boolean })
|
||||
deleteSchedule(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteSchedule(id, cascade === 'true'); }
|
||||
|
||||
@Post(':id/recalculate-stops')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Recompute TripStopTime records from current route travelMinutesToStop values' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
recalculateStops(@Param('id') id: string) { return this.service.recalculateStopTimes(id); }
|
||||
|
||||
@Get(':id/stops')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'List all stops for a schedule' })
|
||||
|
||||
@@ -702,6 +702,24 @@ export class SchedulesService {
|
||||
return { synced, errors };
|
||||
}
|
||||
|
||||
async recalculateStopTimes(scheduleId: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
include: { route: { include: { stops: { orderBy: { sequence: 'asc' } } } } },
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
if (!schedule.routeId || !schedule.route) throw new BadRequestException('Schedule has no associated route');
|
||||
|
||||
const plannedTimes = computePlannedStopTimes(
|
||||
schedule.route,
|
||||
new Date(schedule.departureAt),
|
||||
new Date(schedule.arrivalAt),
|
||||
);
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
await this.routesService.applyRouteToSchedule(schedule.routeId, scheduleId, plannedTimesMap);
|
||||
return { recalculated: true, scheduleId, stopCount: plannedTimes.length };
|
||||
}
|
||||
|
||||
async assignCoaches(scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
@@ -761,7 +761,7 @@ export class TicketsService {
|
||||
if (ticket.validatedAt) {
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
|
||||
}
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
|
||||
this.fireBoardingPassNotification(booking, ticket, null);
|
||||
await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: 'ONE_WAY' } });
|
||||
@@ -780,7 +780,7 @@ export class TicketsService {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
|
||||
throw new BadRequestException(`${resolvedLeg} already validated`);
|
||||
}
|
||||
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
|
||||
await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } });
|
||||
@@ -813,7 +813,7 @@ export class TicketsService {
|
||||
|
||||
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
|
||||
if (!ticket.validatedAt) {
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } });
|
||||
}
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user