mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
Adding all the tests and fixes to the passengers app
This commit is contained in:
@@ -863,6 +863,9 @@ export class BookingsService {
|
||||
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
||||
let resolvedTotalMinor: number;
|
||||
let displayTotalMinor: number;
|
||||
// True when the total came from a client-summed subtotal (per-seat sum or reviewedTotalMinor),
|
||||
// which the portal computes UNDISCOUNTED — the promo must still be applied to it (H-13).
|
||||
let usedClientSubtotal = false;
|
||||
|
||||
if (allFaresProvided && !dto.packageId) {
|
||||
// Server has every passenger's berth fare — sum is the authoritative display total.
|
||||
@@ -870,6 +873,7 @@ export class BookingsService {
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
usedClientSubtotal = true;
|
||||
if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor !== displayTotalMinor) {
|
||||
this.logger.warn(`createOneWayBooking: reviewedTotalMinor=${dto.reviewedTotalMinor} ignored — using server-computed sum=${displayTotalMinor}`);
|
||||
}
|
||||
@@ -891,13 +895,35 @@ export class BookingsService {
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
usedClientSubtotal = true;
|
||||
} else {
|
||||
resolvedTotalMinor = fareCalculation.totalMinor;
|
||||
displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency)
|
||||
: resolvedTotalMinor;
|
||||
}
|
||||
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} displayTotalMinor=${displayTotalMinor} displayCurrency=${displayCurrency} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
|
||||
|
||||
// H-13 fix: the portal sums UNDISCOUNTED per-passenger fares into the total it sends, silently
|
||||
// dropping the promo the fare engine recognized (the discount lives only in the fare-breakdown).
|
||||
// When the total came from that client subtotal, apply the authoritative promo discount so the
|
||||
// customer is charged the discounted price. No-op when no promo applies (discountMinor === 0).
|
||||
// The fallback branch above already books fareCalculation.totalMinor (discount included), so it is
|
||||
// excluded via usedClientSubtotal to avoid double-subtracting.
|
||||
if (usedClientSubtotal && fareCalculation.discountMinor > 0) {
|
||||
resolvedTotalMinor = Math.max(0, resolvedTotalMinor - fareCalculation.discountMinor);
|
||||
const discountDisplayMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(fareCalculation.discountMinor, Currency.ETB, displayCurrency)
|
||||
: fareCalculation.discountMinor;
|
||||
displayTotalMinor = Math.max(0, displayTotalMinor - discountDisplayMinor);
|
||||
}
|
||||
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} displayTotalMinor=${displayTotalMinor} displayCurrency=${displayCurrency} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} discountMinor=${fareCalculation.discountMinor} fareEngine=${fareCalculation.totalMinor})`);
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative fare. resolvedTotalMinor
|
||||
// is the ETB charge basis; fareCalculation.totalMinor is the authoritative ETB fare (already net
|
||||
// of promo/loyalty/free-child). A client that forges seatFareMinor / reviewedTotalMinor below it
|
||||
// is rejected. Floor (not equality) so legitimate berth surcharges — which only raise the total —
|
||||
// still pass; the tolerance absorbs FX-conversion rounding.
|
||||
this.assertTotalNotUnderAuthoritative(resolvedTotalMinor, fareCalculation.totalMinor, 'createOneWayBooking');
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
@@ -1028,6 +1054,9 @@ export class BookingsService {
|
||||
loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
|
||||
}
|
||||
// C-1 guard: authoritative ETB fare for both legs, captured before the client-driven branches
|
||||
// below may overwrite totalMinor with a per-seat sum or reviewedTotalMinor.
|
||||
const authoritativeTotalMinor = totalMinor;
|
||||
const taxesMinor = 0;
|
||||
|
||||
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
|
||||
@@ -1093,6 +1122,9 @@ export class BookingsService {
|
||||
: dto.reviewedTotalMinor;
|
||||
}
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
|
||||
this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createRoundTripBooking');
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
@@ -1675,6 +1707,21 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* C-1 protection: reject a booking whose ETB charge basis is below the server-recomputed
|
||||
* authoritative fare. A floor (not equality) so legitimate berth surcharges — which only raise
|
||||
* the total — still pass; a 1% tolerance absorbs FX-conversion rounding. A forged seatFareMinor /
|
||||
* reviewedTotalMinor that lowers the charge (e.g. to 1 or 0) is refused with a 400 and nothing is
|
||||
* persisted.
|
||||
*/
|
||||
private assertTotalNotUnderAuthoritative(resolvedTotalMinor: number, authoritativeMinor: number, context: string): void {
|
||||
const tolerance = Math.max(1, Math.round(authoritativeMinor * 0.01));
|
||||
if (resolvedTotalMinor < authoritativeMinor - tolerance) {
|
||||
this.logger.warn(`${context}: rejecting booking — resolvedTotalMinor=${resolvedTotalMinor} below authoritative fare=${authoritativeMinor}`);
|
||||
throw new BadRequestException('Booking total does not match the authoritative fare');
|
||||
}
|
||||
}
|
||||
|
||||
private async calculateFare(
|
||||
scheduleId: string,
|
||||
seatClassId: string,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, BadRequestException, NotFoundException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
@@ -42,6 +42,8 @@ function calculateAge(dateOfBirth: Date): number {
|
||||
|
||||
@Injectable()
|
||||
export class GuestBookingService {
|
||||
private readonly logger = new Logger(GuestBookingService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private seatsService: SeatsService,
|
||||
@@ -52,6 +54,21 @@ export class GuestBookingService {
|
||||
private eventEmitter: EventEmitter2,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* C-1 protection (guest path): reject a booking whose ETB charge basis is below the
|
||||
* server-recomputed authoritative fare. A floor (not equality) so legitimate berth surcharges —
|
||||
* which only raise the total — still pass; a 1% tolerance absorbs FX-conversion rounding. A forged
|
||||
* seatFareMinor / reviewedTotalMinor that lowers the charge (e.g. to 0) is refused with a 400 and
|
||||
* nothing is persisted.
|
||||
*/
|
||||
private assertTotalNotUnderAuthoritative(resolvedTotalMinor: number, authoritativeMinor: number, context: string): void {
|
||||
const tolerance = Math.max(1, Math.round(authoritativeMinor * 0.01));
|
||||
if (resolvedTotalMinor < authoritativeMinor - tolerance) {
|
||||
this.logger.warn(`${context}: rejecting booking — resolvedTotalMinor=${resolvedTotalMinor} below authoritative fare=${authoritativeMinor}`);
|
||||
throw new BadRequestException('Booking total does not match the authoritative fare');
|
||||
}
|
||||
}
|
||||
|
||||
async createGuestBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
// Enrich passengers with phone/email from SavedPassengerProfile when not supplied inline.
|
||||
// The portal calls /passengers/save-details before booking but doesn't re-send contact
|
||||
@@ -250,6 +267,9 @@ export class GuestBookingService {
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative ETB fare (net of promo).
|
||||
this.assertTotalNotUnderAuthoritative(resolvedTotalMinor, Math.max(0, totalBaseFareMinor - discountMinor), 'createGuestBooking');
|
||||
|
||||
// Resolve or create the guest Passenger record
|
||||
const firstPassenger = passengersData[0];
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger, req);
|
||||
@@ -485,6 +505,9 @@ export class GuestBookingService {
|
||||
|
||||
const taxesMinor = 0;
|
||||
let totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
|
||||
// C-1 guard: authoritative ETB fare for both legs, captured before the client-driven branches
|
||||
// below may overwrite totalMinor with a per-seat sum or reviewedTotalMinor.
|
||||
const authoritativeTotalMinor = totalMinor;
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
let displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
@@ -540,6 +563,9 @@ export class GuestBookingService {
|
||||
: displayTotalMinor;
|
||||
}
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
|
||||
this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createGuestRoundTripBooking');
|
||||
|
||||
// Create or resolve guest passenger (same as one-way)
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||
|
||||
|
||||
@@ -140,10 +140,14 @@ export class CurrencyService {
|
||||
});
|
||||
|
||||
if (!exchangeRate) {
|
||||
this.logger.warn(
|
||||
`No exchange rate found for ${fromCurrency} to ${toCurrency}, using 1.0`,
|
||||
// H-2: fail closed. Never price at parity (1.0) when a required rate is absent — a silent 1.0
|
||||
// substitution underprices international fares ~100×. Reject the quote/booking instead.
|
||||
this.logger.error(
|
||||
`No exchange rate configured for ${fromCurrency}->${toCurrency}; refusing to price at parity`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
`No exchange rate configured for ${fromCurrency}->${toCurrency}`,
|
||||
);
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - exchangeRate.effectiveDate.getTime();
|
||||
|
||||
@@ -23,6 +23,8 @@ export class CurrencyController {
|
||||
}
|
||||
|
||||
@Put()
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Upsert an exchange rate for today' })
|
||||
@ApiResponse({ status: 200, description: 'Rate created or updated for today\'s effective date' })
|
||||
upsert(@Body() dto: UpsertExchangeRateDto) {
|
||||
@@ -30,6 +32,8 @@ export class CurrencyController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update an exchange rate by ID' })
|
||||
@ApiParam({ name: 'id', description: 'CurrencyExchangeRate UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Rate updated' })
|
||||
|
||||
@@ -969,6 +969,19 @@ export class PaymentsService {
|
||||
return { processed: false, reason: "booking-not-found" };
|
||||
}
|
||||
|
||||
// C-4 guard: a settlement must cover what the passenger was quoted. Compare the provider-settled
|
||||
// amount against the booking's display-currency total (the amount the customer agreed to pay);
|
||||
// a short payment must NOT confirm the booking. Amount-only — the display↔charge currency
|
||||
// divergence is tracked separately under the USD/DJF findings. The 1% tolerance absorbs rounding.
|
||||
const expectedMinor = booking.displayTotalMinor ?? booking.totalMinor;
|
||||
const shortPayTolerance = Math.max(1, Math.round(expectedMinor * 0.01));
|
||||
if (event.amountMinor < expectedMinor - shortPayTolerance) {
|
||||
this.logger.error(
|
||||
`mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMinor} ${booking.displayCurrency}; not confirming`,
|
||||
);
|
||||
return { processed: false, reason: "amount-mismatch" };
|
||||
}
|
||||
|
||||
// Local intent row is a projection during the strangler migration: reuse it when the
|
||||
// legacy initiate path created one, otherwise materialize it from the event.
|
||||
let intent = await this.prisma.paymentIntent.findUnique({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsOptional, IsInt, IsBoolean } from 'class-validator';
|
||||
import { IsString, IsOptional, IsInt, IsBoolean, Min, Max } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreatePromotionDto {
|
||||
@@ -15,14 +15,17 @@ export class CreatePromotionDto {
|
||||
@IsString()
|
||||
subtitle?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 15 })
|
||||
@ApiPropertyOptional({ example: 15, description: 'Percentage discount, bounded 0..100' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
percentOff?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 5000 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
amountOffMinor?: number;
|
||||
|
||||
@ApiProperty({ example: '2026-12-31T23:59:59Z' })
|
||||
|
||||
@@ -103,6 +103,8 @@ export class SchedulesService {
|
||||
const dep = parseEthiopianTime(dto.departureAt);
|
||||
const arr = parseEthiopianTime(dto.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
// M-4: a new schedule cannot depart in the past — the backoffice form does not enforce this.
|
||||
if (dep.getTime() < Date.now()) throw new BadRequestException('departureAt must be in the future');
|
||||
|
||||
const [train, route] = await Promise.all([
|
||||
this.prisma.train.findUnique({ where: { id: dto.trainId } }),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsInt, IsBoolean, IsOptional, IsIn } from 'class-validator';
|
||||
import { IsString, IsInt, IsBoolean, IsOptional, IsIn, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
|
||||
export class CreateSeatClassDto {
|
||||
@@ -27,11 +27,13 @@ export class CreateSeatClassDto {
|
||||
|
||||
@ApiProperty({ example: 3000, description: 'Per-km rate in minor units (tariff decimal × 100000)' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
basePrice: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 1200, description: 'Flat insurance fee in minor units' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
insuranceFeeMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: true })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Get, Patch, SetMetadata, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { SystemConfigService } from './system-config.service';
|
||||
import { UpdateSystemConfigDto } from './system-config.dto';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { Roles } from '../../common/roles.decorator';
|
||||
|
||||
@@ -31,7 +32,12 @@ export class SystemConfigController {
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiOperation({ summary: 'Update system config (admin)' })
|
||||
update(@Body() body: Record<string, string>) {
|
||||
return this.service.updateMany(body);
|
||||
update(@Body() dto: UpdateSystemConfigDto) {
|
||||
// The DTO validates/coerces each known key to a positive integer; persist back as strings.
|
||||
const entries: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(dto)) {
|
||||
if (value !== undefined) entries[key] = String(value);
|
||||
}
|
||||
return this.service.updateMany(entries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IsInt, IsOptional, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* Whitelisted, typed body for `PATCH /config`. Config is persisted as string key/values, but every
|
||||
* known key is a positive integer (durations, hour windows, throttle limits/TTLs). Values arrive as
|
||||
* strings from the backoffice form; `@Type(() => Number)` coerces them so the numeric/range checks
|
||||
* apply (M-3 — the endpoint previously stored any raw string, e.g. `seat_hold_duration_minutes: -1`).
|
||||
* Unknown keys are stripped by the global whitelisting ValidationPipe.
|
||||
*/
|
||||
export class UpdateSystemConfigDto {
|
||||
@ApiPropertyOptional({ example: 5, description: 'Seat-hold duration in minutes (1..60)' })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(60)
|
||||
seat_hold_duration_minutes?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 2 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
|
||||
hold_cutoff_hours_before_departure?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 4 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
|
||||
boarding_window_hours_before_departure?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 5 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_auth_limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 60000 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_auth_ttl_ms?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 20 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_strict_limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 60000 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_strict_ttl_ms?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 100 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_default_limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 60000 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_default_ttl_ms?: number;
|
||||
}
|
||||
@@ -1,29 +1,34 @@
|
||||
/**
|
||||
* Auth/authorization gaps (matrix Suite J), proven via route guard metadata — no boot needed.
|
||||
* J1 🔴 The exchange-rate controller's write routes (PUT upsert, PATCH update) carry NO guard,
|
||||
* so USD/ETB/DJF rates — which every international fare multiplies by — can be rewritten by
|
||||
* an unauthenticated caller. Only DELETE is guarded (@PassengerAdmin). fare-engine/currency.controller.ts:25,32,42
|
||||
* Auth/authorization gaps (matrix Suite J), via route guard metadata — no boot needed.
|
||||
*
|
||||
* C-8 🔴 The exchange-rate write routes (PUT upsert, PATCH update) carry no METHOD-LEVEL guard, so
|
||||
* they get only the global JwtGuard (authentication) and NOT @PassengerAdmin (authorization)
|
||||
* — unlike DELETE, which is admin-gated. Net effect (verified live in
|
||||
* e2e-ui .../pb-config-propagation.spec.ts BC-11): anonymous → 401, but ANY authenticated
|
||||
* user incl. a passenger → 200 rewrites live FX. fare-engine/currency.controller.ts:25,32,42
|
||||
*
|
||||
* NOTE: this metadata check proves the missing ADMIN guard, NOT "unauthenticated" — a global
|
||||
* APP_GUARD=JwtGuard (SharedAuthModule) still requires a valid token. The earlier "unauthenticated
|
||||
* FX write" reading was a false positive corrected by the live BC-11 test.
|
||||
*/
|
||||
import "reflect-metadata";
|
||||
import { CurrencyController } from "../src/modules/fare-engine/currency.controller";
|
||||
|
||||
// Nest stores @UseGuards under the "__guards__" metadata key on the route handler.
|
||||
const GUARDS_METADATA = "__guards__";
|
||||
|
||||
function guardsOn(handler: unknown): unknown[] {
|
||||
return (Reflect.getMetadata(GUARDS_METADATA, handler as object) as unknown[]) ?? [];
|
||||
}
|
||||
|
||||
describe("Auth gaps (Suite J)", () => {
|
||||
it("J1 🔴 PUT upsert exchange-rate has NO guard (unauthenticated FX write)", () => {
|
||||
it("C-8 🔴 PUT upsert exchange-rate has NO admin guard (only the global JwtGuard applies)", () => {
|
||||
expect(guardsOn(CurrencyController.prototype.upsert)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("J1 🔴 PATCH update exchange-rate has NO guard (unauthenticated FX write)", () => {
|
||||
it("C-8 🔴 PATCH update exchange-rate has NO admin guard (only the global JwtGuard applies)", () => {
|
||||
expect(guardsOn(CurrencyController.prototype.update)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("J1 control: DELETE exchange-rate IS guarded — proving the omission on writes is not global", () => {
|
||||
it("C-8 control: DELETE exchange-rate IS admin-gated — proving writes should be too", () => {
|
||||
expect(guardsOn(CurrencyController.prototype.remove).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* C-9-UI 🔴 Authenticated POST /bookings is broken: the controller overrides passengerId with the
|
||||
* JWT user id (`bookings.controller.ts:528-532`, "never trust the request body"), but the service
|
||||
* only resolves an iamUserId → Passenger when it is NON-UUID (`bookings.service.ts:773`). Real IAM
|
||||
* ids are UUIDs, and registration creates `Passenger.id ≠ iamUserId` (`passenger-auth.service.ts:225`),
|
||||
* so `booking.create` uses the iamUserId directly as passengerId → foreign-key violation.
|
||||
*
|
||||
* This reproduces the controller's behavior by calling BookingsService.create with passengerId set to
|
||||
* a UUID iamUserId (not the Passenger.id), exactly as the authed controller does. It also shows the
|
||||
* CONTROL: passing the real Passenger.id succeeds — proving the resolution gap, not a fixture problem.
|
||||
*/
|
||||
import { BookingsService } from "../src/modules/bookings/bookings.service";
|
||||
import { getTestPrisma, disconnectTestPrisma } from "./setup/prisma";
|
||||
import { truncateAllPassenger, seedCore, IDS } from "./fixtures/seed-core";
|
||||
|
||||
function asyncStub(): any {
|
||||
return new Proxy({}, { get: () => async () => undefined });
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
async function buildBookableGraph(prisma: any, passengerId: string, iamUserId: string) {
|
||||
const passenger = await prisma.passenger.create({ data: { id: passengerId, iamUserId } });
|
||||
const train = await prisma.train.create({ data: { number: `AB-${++seq}`, name: "T" } });
|
||||
const schedule = await prisma.trainSchedule.create({
|
||||
data: {
|
||||
trainId: train.id,
|
||||
routeId: IDS.route,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
departureAt: new Date(Date.now() + 86_400_000),
|
||||
arrivalAt: new Date(Date.now() + 90_000_000),
|
||||
durationMinutes: 60,
|
||||
},
|
||||
});
|
||||
await prisma.tripStopTime.createMany({
|
||||
data: [
|
||||
{ scheduleId: schedule.id, stationId: IDS.stationA, sequence: 1 },
|
||||
{ scheduleId: schedule.id, stationId: IDS.stationB, sequence: 2 },
|
||||
],
|
||||
});
|
||||
const coach = await prisma.coach.create({ data: { coachTypeId: IDS.coachType, number: `AB-${seq}` } });
|
||||
const seat = await prisma.seat.create({ data: { coachId: coach.id, seatNumber: "1A", row: 1, col: "1" } });
|
||||
await prisma.fareRule.create({
|
||||
data: { tripId: schedule.id, seatClassId: IDS.seatClassLocal, baseFareMinor: 30_000, currency: "ETB", validFrom: new Date("2020-01-01") },
|
||||
});
|
||||
const hold = await prisma.seatHold.create({
|
||||
data: { scheduleId: schedule.id, seatIds: [seat.id], passengerId, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
return { schedule, seat, hold };
|
||||
}
|
||||
|
||||
function dtoFor(passengerId: string, schedule: any, hold: any, seat: any) {
|
||||
return {
|
||||
passengerId, // the controller passes req.user.id here (the iamUserId)
|
||||
scheduleId: schedule.id,
|
||||
holdId: hold.id,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
seatClassId: IDS.seatClassLocal,
|
||||
bookingType: "ONE_WAY",
|
||||
passengers: [
|
||||
{
|
||||
seatId: seat.id,
|
||||
passengerName: "Auth User",
|
||||
dateOfBirth: new Date("1990-01-01"),
|
||||
idDocumentType: "PASSPORT",
|
||||
passportNumber: "P1",
|
||||
passportCountry: "ET",
|
||||
nationality: "Ethiopian",
|
||||
seatFareMinor: 30_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("Authenticated booking passengerId resolution (regression)", () => {
|
||||
const prisma = getTestPrisma();
|
||||
let bookings: BookingsService;
|
||||
|
||||
beforeAll(() => {
|
||||
bookings = new BookingsService(
|
||||
prisma as any,
|
||||
{ query: async () => [] } as any, // dataSource (resolveIamContact raw SQL → [])
|
||||
asyncStub(), // seatsService
|
||||
{ emit: () => true } as any,
|
||||
asyncStub(), // verifaydaService (PASSPORT skips)
|
||||
asyncStub(), // currencyService (ETB skips)
|
||||
asyncStub(), // fareEngine (FareRule short-circuits)
|
||||
asyncStub(), // auditService
|
||||
);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await truncateAllPassenger(prisma);
|
||||
await seedCore(prisma);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await disconnectTestPrisma();
|
||||
});
|
||||
|
||||
it("🔴 create() with a UUID iamUserId (as the authed controller passes) FAILS the passenger FK", async () => {
|
||||
const passengerId = "aaaaaaaa-0000-4000-8000-000000000001"; // real Passenger.id
|
||||
const iamUserId = "bbbbbbbb-0000-4000-8000-000000000002"; // UUID iamUserId ≠ Passenger.id
|
||||
const { schedule, hold, seat } = await buildBookableGraph(prisma, passengerId, iamUserId);
|
||||
|
||||
// The controller calls service.create({ ...dto, passengerId: req.user.id }) — i.e. the iamUserId.
|
||||
await expect(
|
||||
bookings.create(dtoFor(iamUserId, schedule, hold, seat) as any),
|
||||
).rejects.toThrow(); // Prisma P2003 on Booking_passengerId_fkey
|
||||
|
||||
expect(await prisma.booking.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("control: create() with the real Passenger.id succeeds — proving the gap is the id, not the fixture", async () => {
|
||||
const passengerId = "aaaaaaaa-0000-4000-8000-000000000003";
|
||||
const iamUserId = "bbbbbbbb-0000-4000-8000-000000000004";
|
||||
const { schedule, hold, seat } = await buildBookableGraph(prisma, passengerId, iamUserId);
|
||||
|
||||
const booking: any = await bookings.create(dtoFor(passengerId, schedule, hold, seat) as any);
|
||||
expect(booking.id).toBeTruthy();
|
||||
expect(booking.passengerId).toBe(passengerId);
|
||||
});
|
||||
});
|
||||
89
apps/edr-passenger-api/test/fixtures/seed-passenger-session.ts
vendored
Normal file
89
apps/edr-passenger-api/test/fixtures/seed-passenger-session.ts
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Seeds a passenger IAM user + session directly (no OTP flow) and mints an access token the portal
|
||||
* accepts. The API JwtGuard verifies the JWT signature (JWT_ACCESS_TOKEN_SECRET) and looks up the
|
||||
* session by its `id` claim; the portal then calls /auth/profile which needs a Passenger row linked
|
||||
* by iamUserId. Returns { token, profile } for the Playwright passenger storageState.
|
||||
*
|
||||
* Run standalone to validate: `npx ts-node test/fixtures/seed-passenger-session.ts` (prints the
|
||||
* token and the /auth/profile status via the running API on :4000).
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { SignJWT } from "jose";
|
||||
import { UI_IDS } from "./seed-ui";
|
||||
|
||||
export const PASSENGER_USER_ID = "11111111-0000-4000-8000-000000000001";
|
||||
export const PASSENGER_SESSION_ID = "11111111-0000-4000-8000-000000000002";
|
||||
const EMAIL = "test.passenger@edr.local";
|
||||
const USERNAME = "test_passenger";
|
||||
|
||||
export async function seedPassengerSession(prisma: PrismaClient): Promise<{ token: string }> {
|
||||
const secret = process.env.JWT_ACCESS_TOKEN_SECRET;
|
||||
if (!secret) throw new Error("JWT_ACCESS_TOKEN_SECRET is required to mint the passenger token");
|
||||
|
||||
const userInfo = {
|
||||
id: PASSENGER_USER_ID,
|
||||
name: { en: "Test Passenger" },
|
||||
email: EMAIL,
|
||||
roles: [] as unknown[],
|
||||
status: "accepted",
|
||||
employee: [] as unknown[],
|
||||
userType: "individual",
|
||||
username: USERNAME,
|
||||
permissions: [] as unknown[],
|
||||
};
|
||||
const expiry = new Date(Date.now() + 7 * 86400_000);
|
||||
|
||||
// iam.users (delete-then-insert so re-seeding is idempotent).
|
||||
await prisma.$executeRawUnsafe(`DELETE FROM iam.sessions WHERE id = $1::uuid`, PASSENGER_SESSION_ID);
|
||||
await prisma.$executeRawUnsafe(`DELETE FROM iam.users WHERE id = $1::uuid`, PASSENGER_USER_ID);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO iam.users (created_at, id, name, username, email, user_type, status, is_active, has_set_password, is_phone_number_verified, verified_by)
|
||||
VALUES (now(), $1::uuid, $2::jsonb, $3, $4, 'individual', 'accepted', true, true, true, 'SYSTEM')`,
|
||||
PASSENGER_USER_ID,
|
||||
JSON.stringify(userInfo.name),
|
||||
USERNAME,
|
||||
EMAIL,
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO iam.sessions (created_at, id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
|
||||
VALUES (now(), $1::uuid, $2, 'e2e', $3::jsonb, $4, 0, 'ACTIVE', $5::uuid)`,
|
||||
PASSENGER_SESSION_ID,
|
||||
EMAIL,
|
||||
JSON.stringify(userInfo),
|
||||
expiry,
|
||||
PASSENGER_USER_ID,
|
||||
);
|
||||
|
||||
// Link the seeded Passenger row to this IAM user so /auth/profile resolves.
|
||||
await prisma.passenger.update({
|
||||
where: { id: UI_IDS.passenger },
|
||||
data: { iamUserId: PASSENGER_USER_ID },
|
||||
});
|
||||
|
||||
const token = await new SignJWT({ id: PASSENGER_SESSION_ID })
|
||||
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("7d")
|
||||
.sign(new TextEncoder().encode(secret));
|
||||
|
||||
return { token };
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
(async () => {
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
const { token } = await seedPassengerSession(prisma);
|
||||
const api = process.env.API_URL ?? "http://localhost:4000";
|
||||
const res = await fetch(`${api}/auth/profile`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[passenger-session] /auth/profile -> HTTP ${res.status}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log((await res.text()).slice(0, 400));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
})();
|
||||
}
|
||||
197
apps/edr-passenger-api/test/fixtures/seed-ui.ts
vendored
Normal file
197
apps/edr-passenger-api/test/fixtures/seed-ui.ts
vendored
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* UI E2E seed — extends seed-core with a BOOKABLE trip + payment methods + promos so the portal
|
||||
* search/booking flow and the backoffice config flow have real data to drive. Runnable standalone
|
||||
* (`ts-node test/fixtures/seed-ui.ts`) or importable (`seedUi(prisma)`) from the Playwright
|
||||
* global-setup. Reads DATABASE_URL from the environment (the UI stack points at the 5544 test DB).
|
||||
*
|
||||
* Searchability: a schedule shows in POST /search when it is SCHEDULED, not package-only, has a
|
||||
* future departure on the searched date, operational coaches with AVAILABLE seats, and a resolvable
|
||||
* fare (seat-class distance formula using the seeded route-stop distances + USD→ETB rate).
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { IDS, resetAndSeedCore } from "./seed-core";
|
||||
|
||||
export const UI_IDS = {
|
||||
train: "00000000-0000-4000-8000-000000000100",
|
||||
schedule: "00000000-0000-4000-8000-000000000101",
|
||||
coach: "00000000-0000-4000-8000-000000000102",
|
||||
// Passenger.id is set EQUAL to the IAM user id. The bookings controller overrides passengerId
|
||||
// with the JWT user id (req.user.id), and the service only resolves iamUserId→passenger when it
|
||||
// is NON-UUID — since IAM ids are UUIDs, it uses the id directly, so Passenger.id must equal it.
|
||||
passenger: "11111111-0000-4000-8000-000000000001",
|
||||
promoValid: "PROMO10",
|
||||
promoExpired: "EXPIRED50",
|
||||
// Return leg C→A on the same calendar date, for ROUND_TRIP scenarios (UA-6). The route stops are
|
||||
// symmetric in distance (A=0, C=250) so the reverse leg prices identically to the outbound.
|
||||
returnSchedule: "00000000-0000-4000-8000-000000000201",
|
||||
returnCoach: "00000000-0000-4000-8000-000000000202",
|
||||
} as const;
|
||||
|
||||
/** Days-from-now the sample trip departs (tests search on this calendar date, Addis TZ). */
|
||||
export const DEPART_IN_DAYS = 2;
|
||||
|
||||
export function sampleDepartAt(): Date {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() + DEPART_IN_DAYS);
|
||||
d.setUTCHours(6, 0, 0, 0); // 06:00Z ~ 09:00 Addis — safely same calendar day either TZ
|
||||
return d;
|
||||
}
|
||||
|
||||
/** The date string a test passes to POST /search for the sample trip (YYYY-MM-DD). */
|
||||
export function sampleDepartDate(): string {
|
||||
return sampleDepartAt().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export async function seedUi(prisma: PrismaClient): Promise<void> {
|
||||
await resetAndSeedCore(prisma);
|
||||
|
||||
// Give the two seed-core seat classes the exact names the portal review flow maps by.
|
||||
await prisma.seatClass.update({
|
||||
where: { id: IDS.seatClassLocal },
|
||||
data: { name: "Economy Regular" },
|
||||
});
|
||||
await prisma.seatClass.update({
|
||||
where: { id: IDS.seatClassIntl },
|
||||
data: { name: "Economy Regular Intl" },
|
||||
});
|
||||
|
||||
// Enabled payment methods — the portal pay page renders ONLY enabled PaymentMethod rows.
|
||||
await prisma.paymentMethod.createMany({
|
||||
data: [
|
||||
{ type: "WALLET", displayName: "Wallet", currency: "ETB", enabled: true, isDefault: true, sortOrder: 0 },
|
||||
{ type: "TELEBIRR", displayName: "telebirr", currency: "ETB", enabled: true, sortOrder: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const departAt = sampleDepartAt();
|
||||
const arriveAt = new Date(departAt.getTime() + 4 * 3600_000);
|
||||
|
||||
await prisma.train.create({
|
||||
data: { id: UI_IDS.train, number: "UI-100", name: "UI Test Express" },
|
||||
});
|
||||
|
||||
await prisma.trainSchedule.create({
|
||||
data: {
|
||||
id: UI_IDS.schedule,
|
||||
trainId: UI_IDS.train,
|
||||
routeId: IDS.route,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationC,
|
||||
departureAt: departAt,
|
||||
arrivalAt: arriveAt,
|
||||
durationMinutes: 240,
|
||||
status: "SCHEDULED",
|
||||
stopsCount: 3,
|
||||
isPackageOnly: false,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.tripStopTime.createMany({
|
||||
data: [
|
||||
{ scheduleId: UI_IDS.schedule, stationId: IDS.stationA, sequence: 1, plannedDepartureAt: departAt, status: "OPEN" },
|
||||
{ scheduleId: UI_IDS.schedule, stationId: IDS.stationB, sequence: 2, plannedDepartureAt: new Date(departAt.getTime() + 2 * 3600_000), status: "OPEN" },
|
||||
{ scheduleId: UI_IDS.schedule, stationId: IDS.stationC, sequence: 3, plannedArrivalAt: arriveAt, status: "OPEN" },
|
||||
],
|
||||
});
|
||||
|
||||
// Enough seats that a full suite run (many bookings share one seeded DB, seats are not released
|
||||
// between specs) never exhausts availability: 12 rows × 4 cols = 48 seats.
|
||||
const SEAT_ROWS = 12;
|
||||
await prisma.coach.create({
|
||||
data: { id: UI_IDS.coach, coachTypeId: IDS.coachType, number: "UI-C1", capacity: SEAT_ROWS * 4, sequence: 1, status: "ACTIVE" },
|
||||
});
|
||||
await prisma.coachAssignment.create({
|
||||
data: { scheduleId: UI_IDS.schedule, coachId: UI_IDS.coach, positionNumber: 1, isOperational: true },
|
||||
});
|
||||
await prisma.seat.createMany({ data: buildSeats(UI_IDS.coach, SEAT_ROWS) });
|
||||
|
||||
// Logged-in passenger with a funded wallet + loyalty (used by the portal storageState + WALLET pay).
|
||||
await prisma.passenger.create({ data: { id: UI_IDS.passenger } });
|
||||
await prisma.walletAccount.create({
|
||||
data: { passengerId: UI_IDS.passenger, balanceMinor: 100_000_000 },
|
||||
});
|
||||
await prisma.loyaltyAccount.create({
|
||||
data: { passengerId: UI_IDS.passenger, pointsBalance: 500 },
|
||||
});
|
||||
|
||||
// Promotions (schema field names, NOT the backoffice UI names). Unique exact codes.
|
||||
await prisma.promotion.createMany({
|
||||
data: [
|
||||
{ title: "10% off", code: UI_IDS.promoValid, percentOff: 10, validUntil: new Date(Date.now() + 30 * 86400_000), active: true },
|
||||
{ title: "Expired", code: UI_IDS.promoExpired, percentOff: 50, validUntil: new Date(Date.now() - 86400_000), active: true },
|
||||
],
|
||||
});
|
||||
|
||||
// One baggage allowance (for excess-baggage flows later).
|
||||
await prisma.baggageAllowance.create({
|
||||
data: { seatClassId: IDS.seatClassLocal, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 50 },
|
||||
});
|
||||
|
||||
// ── Return leg (C→A) for ROUND_TRIP (UA-6) ──────────────────────────────────
|
||||
// Same train, same day, departs after the outbound arrives. Distances are symmetric
|
||||
// (A=0km … C=250km) so the reverse leg prices the same as the outbound.
|
||||
const returnDepart = new Date(departAt.getTime() + 8 * 3600_000); // 8h after outbound departs
|
||||
const returnArrive = new Date(returnDepart.getTime() + 4 * 3600_000);
|
||||
await prisma.trainSchedule.create({
|
||||
data: {
|
||||
id: UI_IDS.returnSchedule,
|
||||
trainId: UI_IDS.train,
|
||||
routeId: IDS.route,
|
||||
originStationId: IDS.stationC,
|
||||
destinationStationId: IDS.stationA,
|
||||
departureAt: returnDepart,
|
||||
arrivalAt: returnArrive,
|
||||
durationMinutes: 240,
|
||||
status: "SCHEDULED",
|
||||
stopsCount: 3,
|
||||
isPackageOnly: false,
|
||||
},
|
||||
});
|
||||
await prisma.tripStopTime.createMany({
|
||||
data: [
|
||||
{ scheduleId: UI_IDS.returnSchedule, stationId: IDS.stationC, sequence: 1, plannedDepartureAt: returnDepart, status: "OPEN" },
|
||||
{ scheduleId: UI_IDS.returnSchedule, stationId: IDS.stationB, sequence: 2, plannedDepartureAt: new Date(returnDepart.getTime() + 2 * 3600_000), status: "OPEN" },
|
||||
{ scheduleId: UI_IDS.returnSchedule, stationId: IDS.stationA, sequence: 3, plannedArrivalAt: returnArrive, status: "OPEN" },
|
||||
],
|
||||
});
|
||||
await prisma.coach.create({
|
||||
data: { id: UI_IDS.returnCoach, coachTypeId: IDS.coachType, number: "UI-C2", capacity: 48, sequence: 1, status: "ACTIVE" },
|
||||
});
|
||||
await prisma.coachAssignment.create({
|
||||
data: { scheduleId: UI_IDS.returnSchedule, coachId: UI_IDS.returnCoach, positionNumber: 1, isOperational: true },
|
||||
});
|
||||
await prisma.seat.createMany({ data: buildSeats(UI_IDS.returnCoach, 12) });
|
||||
}
|
||||
|
||||
/** Build `rows × 4` seats (cols A–D) for a coach. */
|
||||
function buildSeats(coachId: string, rows: number) {
|
||||
const cols = ["A", "B", "C", "D"];
|
||||
const seats: Array<{ coachId: string; seatNumber: string; row: number; col: string; isWindow: boolean; isAisle: boolean }> = [];
|
||||
for (let row = 1; row <= rows; row++) {
|
||||
for (const col of cols) {
|
||||
seats.push({
|
||||
coachId,
|
||||
seatNumber: `${row}${col}`,
|
||||
row,
|
||||
col,
|
||||
isWindow: col === "A" || col === "D",
|
||||
isAisle: col === "B" || col === "C",
|
||||
});
|
||||
}
|
||||
}
|
||||
return seats;
|
||||
}
|
||||
|
||||
// Standalone runner
|
||||
if (require.main === module) {
|
||||
(async () => {
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
await seedUi(prisma);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[seed-ui] done. Sample trip ${IDS.stationA}→${IDS.stationC} on ${sampleDepartDate()} (schedule ${UI_IDS.schedule}).`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -596,6 +596,7 @@ export default function PaymentPage() {
|
||||
return (
|
||||
<button
|
||||
key={method.id}
|
||||
data-testid={`pay-method-${method.type}`}
|
||||
onClick={() => setSelectedMethod(method.type)}
|
||||
disabled={isProcessing || !method.enabled}
|
||||
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
|
||||
|
||||
@@ -484,6 +484,7 @@ export default function ResultsPage() {
|
||||
return (
|
||||
<div
|
||||
key={coachType.coachId}
|
||||
data-testid="coach-option"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={selectThisCoach}
|
||||
@@ -633,6 +634,7 @@ export default function ResultsPage() {
|
||||
{isSelected && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="continue-passenger-details"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(classModal, isOutbound);
|
||||
@@ -829,6 +831,7 @@ export default function ResultsPage() {
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
data-testid="result-select-btn"
|
||||
onClick={() =>
|
||||
setClassModal({ ...schedule, isOutbound } as any)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user