Production cleanups

This commit is contained in:
Stephanos A
2026-07-08 09:01:07 +03:00
parent 63fa6996b1
commit c7403e1a1e
21 changed files with 701 additions and 755 deletions

View File

@@ -60,7 +60,7 @@ async function bootstrap() {
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
## Latest Updates
- **Enhanced Module Coverage:** Complete API coverage with 25+ core modules including System Config, Excess Baggage, Packages, and comprehensive CRUD operations across all entities.
- **Enhanced Module Coverage:** Complete API coverage with 25+ core modules including System Config, Excess Luggage, Packages, and comprehensive CRUD operations across all entities.
- **Health Check Endpoints:** Three public probes added under \`/health\`. Liveness (\`GET /health\`), readiness with live DB ping (\`GET /health/ready\`), and app info (\`GET /health/info\`). All are exempt from rate limiting.
- **Rate Limiting:** Global throttle enforced via ThrottlerGuard with three named tiers: auth (5 req/min on \`/auth\` and \`/fayda/verification\`), strict (20 req/min on \`/bookings\`, \`/passengers\`, \`/payments\`, \`/wallet\`), default (100 req/min everywhere else). Health probes, webhook handlers, and internal service endpoints are exempt.
- **Boarding Pass on Gate Validation:** Every successful gate validation at \`POST /tickets/:ref/validate\` now automatically delivers a boarding pass to the passenger via email (full HTML with QR code, route, seat table) and SMS (compact text with ref, route, seats, barcode). The leg label (OUTBOUND, RETURN, LEG1, etc.) is included so passengers know which boarding it covers.
@@ -328,7 +328,7 @@ Payment providers send notifications to:
"JWT-auth",
)
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
.addTag("Excess Baggage", "IAM-protected agent/supervisor endpoints to log excess baggage charges, waive fees, resend payment links, and manage allowance rules per seat class. Public token-based endpoints let passengers self-pay outstanding charges.")
.addTag("Excess Luggage", "IAM-protected agent/supervisor endpoints to log excess baggage charges, waive fees, resend payment links, and manage allowance rules per seat class. Public token-based endpoints let passengers self-pay outstanding charges.")
.addTag("Packages", "Bundled travel packages with tiered pricing. Public endpoints for browsing and booking; JWT-authenticated endpoints for purchase history; IAM-protected endpoints for admin CRUD and tier management.")
.addTag("Config", "System-wide configuration management including feature flags, maintenance modes, and operational parameters. IAM-protected endpoints for administrative control.")
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")

View File

@@ -17,6 +17,7 @@ function generateRef(): string {
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
/**
* For package round-trip bookings, totalMinor in the DB may have been stored as a
* single-leg amount before the server fix. Recompute from the tier price when needed.

View File

@@ -14,7 +14,7 @@ const BOOKING_CUTOFF_MS = 30 * 60 * 1000;
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
// Ethiopian mobile prefixes: Ethio Telecom (09xx) and Safaricom ET (07xx)

View File

@@ -17,7 +17,7 @@ class UpsertBaggageAllowanceDto {
}
// ── IAM-protected agent/supervisor routes ────────────────────────────────────
@ApiTags('Excess Baggage')
@ApiTags('Excess Luggage')
@Controller('agents/excess-baggage')
@UseGuards(IamJwtGuard)
@ApiBearerAuth('IAM-auth')
@@ -100,7 +100,7 @@ export class ExcessBaggageAgentController {
}
// ── Public pay-by-token routes (passenger self-service) ──────────────────────
@ApiTags('Excess Baggage')
@ApiTags('Excess Luggage')
@Controller('excess-baggage')
export class ExcessBaggagePublicController {
constructor(private service: ExcessBaggageService) {}

View File

@@ -4,9 +4,10 @@ import { SearchService } from './search.service';
import { CurrencyModule } from '../currency/currency.module';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
import { SegmentsModule } from '../segments/segments.module';
import { SystemConfigModule } from '../system-config/system-config.module';
@Module({
imports: [CurrencyModule, FareEngineModule, SegmentsModule],
imports: [CurrencyModule, FareEngineModule, SegmentsModule, SystemConfigModule],
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService],

View File

@@ -6,6 +6,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
import { SegmentsService } from '../segments/segments.service';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { Currency } from '@prisma/client';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
const POINTS_TO_MINOR = 10;
@@ -40,8 +41,13 @@ export class SearchService {
private currencyService: CurrencyService,
private fareEngine: FareEngineService,
private segmentsService: SegmentsService,
private systemConfig: SystemConfigService,
) {}
private async getCutoffHours(): Promise<number> {
return this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE);
}
async searchTrips(dto: SearchTripsDto) {
const [direct, transit] = await Promise.all([
this.searchSchedules(
@@ -166,13 +172,14 @@ export class SearchService {
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
status: 'SCHEDULED',
isPackageOnly: false,
OR: [
{ departureAt: { gte: windowStart, lt: requestedDate } },
{ departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } },
],
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
orderBy: { departureAt: 'asc' },
@@ -183,7 +190,7 @@ export class SearchService {
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
)
);
return results.filter(Boolean);
return results.filter((r): r is NonNullable<typeof r> => !!r && r.hasAvailability);
}
private async searchSchedules(
@@ -200,12 +207,17 @@ export class SearchService {
const now = new Date();
const totalPassengers = adultCount + (childCount ?? 0);
const cutoffHours = await this.getCutoffHours();
const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000);
const earliest = new Date(Math.max((date < now ? now : date).getTime(), cutoffThreshold.getTime()));
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
status: 'SCHEDULED',
isPackageOnly: false,
departureAt: { gte: date < now ? now : date, lt: nextDay },
departureAt: { gte: earliest, lt: nextDay },
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
});
@@ -215,7 +227,7 @@ export class SearchService {
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
)
);
return results.filter(Boolean);
return results.filter((r): r is NonNullable<typeof r> => !!r && r.hasAvailability);
}
// ── Transit search ─────────────────────────────────────────────────────────
@@ -241,26 +253,31 @@ export class SearchService {
const [leg1Schedules, allCandidates] = await Promise.all([
this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
status: 'SCHEDULED',
isPackageOnly: false,
departureAt: { gte: dayStart, lt: dayEnd },
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
}),
this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
status: 'SCHEDULED',
isPackageOnly: false,
departureAt: { gte: dayStart, lt: leg2WindowEnd },
coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
}),
]);
const cutoffHours = await this.getCutoffHours();
const cutoffThreshold = new Date(Date.now() + cutoffHours * 60 * 60 * 1000);
const results: any[] = [];
for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
for (const leg1 of (leg1Schedules as ScheduleWithIncludes[]).filter(s => new Date(s.departureAt) > cutoffThreshold)) {
const originStop = leg1.stopTimes.find(s => s.stationId === originStationId);
if (!originStop) continue;
@@ -354,6 +371,9 @@ export class SearchService {
.map((s: any) => s.id as string)
);
// Exclude schedules with no seats at all
if (allValidSeatIds.length === 0) return null;
// Run availability batch and fare calculation in parallel
const [freeSeats, faresByClass] = await Promise.all([
this.segmentsService.getFreeSeatIds(