mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix conflict
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- Migration already applied directly to the database.
|
||||
-- This file exists only to satisfy Prisma's migration directory check (P3015).
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE passenger."Booking" ADD COLUMN IF NOT EXISTS "paymentReminderSentAt" TIMESTAMP(3);
|
||||
@@ -534,6 +534,7 @@ model Booking {
|
||||
source String @default("WEB")
|
||||
promoCode String?
|
||||
paidAt DateTime?
|
||||
paymentReminderSentAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
|
||||
@@ -689,8 +689,15 @@ async function seedKulubbiPackage() {
|
||||
const [outboundSchedule, returnSchedule] = schedules;
|
||||
|
||||
await prisma.travelPackage.upsert({
|
||||
where: { code: 'KULUBBI-2025' },
|
||||
update: {},
|
||||
where: { code: 'KULUBI-2025' },
|
||||
update: {
|
||||
validFrom: new Date(),
|
||||
validUntil: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
||||
boardingTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
|
||||
departureTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
|
||||
arrivalTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
create: {
|
||||
code: 'KULUBBI-2025',
|
||||
name: 'Kulubbi Gabriel Pilgrimage Package',
|
||||
@@ -699,15 +706,15 @@ async function seedKulubbiPackage() {
|
||||
returnScheduleId: returnSchedule.id,
|
||||
originStationId: addisStation.id,
|
||||
destinationStationId: direDawaStation.id,
|
||||
boardingTime: new Date('2025-07-24T07:00:00+03:00'),
|
||||
departureTime: new Date('2025-07-24T09:00:00+03:00'),
|
||||
arrivalTime: new Date('2025-07-25T06:00:00+03:00'),
|
||||
boardingTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
|
||||
departureTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
|
||||
arrivalTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
|
||||
totalCapacity: 912,
|
||||
coachConfiguration: '1 Locomotive + 2SBC + 2HBC + 6HSC',
|
||||
busTransferIncluded: true,
|
||||
busTransferRoute: 'Dire Dawa ↔ Kulubi Gabriel',
|
||||
validFrom: new Date('2025-07-01'),
|
||||
validUntil: new Date('2025-07-24T09:00:00+03:00'),
|
||||
validFrom: new Date(),
|
||||
validUntil: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
||||
status: 'ACTIVE',
|
||||
includedServices: [
|
||||
'Round-trip train travel (Addis Ababa ↔ Dire Dawa)',
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
NestModule,
|
||||
OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { DynamicThrottlerGuard } from './common/dynamic-throttler.guard';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
@@ -63,6 +64,7 @@ import { SystemConfigModule } from './modules/system-config/system-config.module
|
||||
import { PackagesModule } from './modules/packages/packages.module';
|
||||
import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { TasksModule } from './modules/tasks/tasks.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -131,9 +133,11 @@ import { HealthModule } from './modules/health/health.module';
|
||||
PackagesModule,
|
||||
ExcessBaggageModule,
|
||||
HealthModule,
|
||||
TasksModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
{ provide: APP_GUARD, useClass: DynamicThrottlerGuard },
|
||||
DynamicThrottlerGuard,
|
||||
EdrPassengerOrgSeeder,
|
||||
PassengerStaffUsersSeeder,
|
||||
],
|
||||
|
||||
34
apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts
Normal file
34
apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Injectable, ExecutionContext, Inject } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerStorage, getOptionsToken, getStorageToken } from '@nestjs/throttler';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../modules/system-config/system-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class DynamicThrottlerGuard extends ThrottlerGuard {
|
||||
constructor(
|
||||
@Inject(getOptionsToken()) options: any,
|
||||
@Inject(getStorageToken()) storageService: ThrottlerStorage,
|
||||
reflector: Reflector,
|
||||
private readonly systemConfig: SystemConfigService,
|
||||
) {
|
||||
super(options, storageService, reflector);
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const [authLimit, authTtl, strictLimit, strictTtl, defaultLimit, defaultTtl] =
|
||||
await Promise.all([
|
||||
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_LIMIT),
|
||||
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_TTL_MS),
|
||||
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_STRICT_LIMIT),
|
||||
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_STRICT_TTL_MS),
|
||||
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_DEFAULT_LIMIT),
|
||||
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_DEFAULT_TTL_MS),
|
||||
]);
|
||||
|
||||
this.throttlers = [
|
||||
{ name: 'default', ttl: defaultTtl, limit: defaultLimit },
|
||||
];
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,11 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`);
|
||||
}
|
||||
|
||||
// When the thrown body is already a structured object (e.g. { status, message, code }),
|
||||
// merge it into the envelope so callers receive all custom fields.
|
||||
const customFields =
|
||||
typeof messageRaw === 'object' && messageRaw !== null ? messageRaw : {};
|
||||
|
||||
response.status(status).json({
|
||||
success: false,
|
||||
statusCode: status,
|
||||
@@ -54,6 +59,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
error: exception instanceof Error ? exception.name : 'Error',
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
...customFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM
|
||||
// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM
|
||||
// modules read process.env at module-load time (e.g. MinioModule.register reads MINIO_ENDPOINT),
|
||||
// which happens before ConfigModule.forRoot() would populate it. Must be the very first import.
|
||||
import "dotenv/config";
|
||||
@@ -18,7 +18,7 @@ async function bootstrap() {
|
||||
|
||||
// URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under
|
||||
// `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay
|
||||
// version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend.
|
||||
// version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend.
|
||||
app.enableVersioning({ type: VersioningType.URI });
|
||||
|
||||
app.enableCors({
|
||||
@@ -126,7 +126,7 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
|
||||
- Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
|
||||
- Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
|
||||
- Complete audit trail per leg for compliance and reporting
|
||||
- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode
|
||||
- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode
|
||||
|
||||
### Booking Type Matrix
|
||||
|
||||
@@ -236,28 +236,28 @@ For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`ret
|
||||
|
||||
### Step 3: Passenger Information & Verification
|
||||
**For Ethiopian Passengers:**
|
||||
\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years)
|
||||
\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years)
|
||||
|
||||
**For International Passengers:**
|
||||
\`POST /passengers/register-international\` — Passport information collection
|
||||
\`POST /passengers/register-international\` — Passport information collection
|
||||
|
||||
### Step 4: View Seat Map
|
||||
\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats.
|
||||
\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats.
|
||||
For round-trips, call this twice: once for outbound scheduleId, once for return scheduleId.
|
||||
|
||||
### Step 5: Hold Seats
|
||||
\`POST /seats/hold\` to reserve seats for 15 minutes.
|
||||
- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\`
|
||||
- TRANSIT leg-2: second hold call → \`leg2HoldId\`
|
||||
- ROUND_TRIP return: second hold call → \`returnHoldId\`
|
||||
- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
|
||||
- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\`
|
||||
- TRANSIT leg-2: second hold call → \`leg2HoldId\`
|
||||
- ROUND_TRIP return: second hold call → \`returnHoldId\`
|
||||
- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
|
||||
|
||||
### Step 6: Create Booking
|
||||
Choose the right endpoint and bookingType:
|
||||
- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\`
|
||||
- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\`
|
||||
- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\`
|
||||
- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\`
|
||||
- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\`
|
||||
- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\`
|
||||
- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\`
|
||||
- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\`
|
||||
|
||||
### Step 7: Process Payment
|
||||
\`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian)
|
||||
@@ -311,6 +311,8 @@ 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("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("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
|
||||
.addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management")
|
||||
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT booking types. returnLegStatus filter for round-trip no-show management")
|
||||
@@ -347,6 +349,50 @@ Payment providers send notifications to:
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
|
||||
// Collapse all IAM / platform-infrastructure tags into one Swagger tag so every
|
||||
// endpoint from @tria-plc/iamapi-common and @tria-plc/api-common appears under
|
||||
// a single "Corporate IAM & Platform Infrastructure" section.
|
||||
const IAM_UNIFIED_TAG = 'Corporate IAM & Platform Infrastructure';
|
||||
const IAM_SOURCE_TAGS = new Set([
|
||||
'Auth', 'Sessions', 'API_COMMON_File Settings',
|
||||
'IAM_USER__Users', 'IAM_USER__User Document', 'IAM_USER__User Roles',
|
||||
'IAM_USER__Roles', 'IAM_USER__Role Permissions', 'IAM_USER__Permissions',
|
||||
'IAM_USER__Applications', 'IAM_USER__Account Configurations', 'IAM_USER__Documentary Requirements',
|
||||
'IAM_ORGANISATION_STRUCTURE__Organizations', 'IAM_ORGANISATION_STRUCTURE__Organization Types',
|
||||
'IAM_ORGANISATION_STRUCTURE__Organization Configurations',
|
||||
'IAM_ORGANISATION_STRUCTURE__Global Organization Configurations',
|
||||
'IAM_ORGANISATION_STRUCTURE__Organization Settings',
|
||||
'IAM_ORGANISATION_STRUCTURE__Units', 'IAM_ORGANISATION_STRUCTURE__Unit Settings',
|
||||
'IAM_ORGANISATION_STRUCTURE__Global Unit Configurations', 'IAM_ORGANISATION_STRUCTURE__Unit Clusters',
|
||||
'IAM_ORGANISATION_STRUCTURE__Positions', 'IAM_ORGANISATION_STRUCTURE__Position Types',
|
||||
'IAM_ORGANISATION_STRUCTURE__Position Configurations',
|
||||
'IAM_ORGANISATION_STRUCTURE__Position Type Configurations',
|
||||
'IAM_ORGANISATION_STRUCTURE__Position Permissions', 'IAM_ORGANISATION_STRUCTURE__Position Type Permissions',
|
||||
'IAM_ORGANISATION_STRUCTURE__Employees', 'IAM_ORGANISATION_STRUCTURE__Employee Positions',
|
||||
'IAM_ORGANISATION_STRUCTURE__Locations', 'IAM_ORGANISATION_STRUCTURE__Location Types',
|
||||
'IAM_ORGANISATION_STRUCTURE__Default Units', 'IAM_ORGANISATION_STRUCTURE__Default Positions',
|
||||
'IAM_ORGANISATION_STRUCTURE__Projects', 'IAM_ORGANISATION_STRUCTURE__Migrate',
|
||||
'IAM_RECORD__Headers', 'IAM_RECORD__Footers', 'IAM_RECORD__Seals',
|
||||
'IAM_RECORD__Employee Signatures', 'IAM_RECORD__Employee Stamps',
|
||||
]);
|
||||
|
||||
// Re-tag every operation whose tags overlap with IAM_SOURCE_TAGS
|
||||
for (const pathItem of Object.values(document.paths)) {
|
||||
for (const operation of Object.values(pathItem as Record<string, any>)) {
|
||||
if (Array.isArray(operation?.tags)) {
|
||||
const hasIam = operation.tags.some((t: string) => IAM_SOURCE_TAGS.has(t));
|
||||
if (hasIam) operation.tags = [IAM_UNIFIED_TAG];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the individual source tag definitions with the single unified tag
|
||||
document.tags = [
|
||||
...(document.tags ?? []).filter((t: any) => !IAM_SOURCE_TAGS.has(t.name)),
|
||||
{ name: IAM_UNIFIED_TAG, description: 'Back-office staff authentication, session management, organisation structure, user/role/permission management, and file settings. Provided by @tria-plc/iamapi-common and @tria-plc/api-common.' },
|
||||
];
|
||||
|
||||
SwaggerModule.setup("api-docs", app, document, {
|
||||
customSiteTitle: "EDR Passenger API",
|
||||
swaggerOptions: {
|
||||
@@ -360,7 +406,7 @@ Payment providers send notifications to:
|
||||
|
||||
const port = process.env.PORT ?? 4000;
|
||||
await app.listen(port);
|
||||
console.log(`🚀 EDR Passenger API running on port ${port}`);
|
||||
console.log(`📚 Swagger: http://localhost:${port}/api-docs`);
|
||||
console.log(`🚀 EDR Passenger API running on port ${port}`);
|
||||
console.log(`📚 Swagger: http://localhost:${port}/api-docs`);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, Request, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AgentsService } from './agents.service';
|
||||
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
|
||||
import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
|
||||
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
|
||||
@ApiTags('Agents')
|
||||
@@ -16,6 +16,24 @@ export class AgentsController {
|
||||
getMe(@Request() req: any) {
|
||||
return this.service.getMe(req.user?.id ?? req.user?.sub);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all agents' })
|
||||
findAll(@Query('search') search?: string, @Query('active') active?: string) {
|
||||
return this.service.findAll({ search, active });
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create agent profile linked to an IAM user' })
|
||||
createAgent(@Body() dto: CreateAgentDto) {
|
||||
return this.service.createAgent(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update agent profile' })
|
||||
updateAgent(@Param('id') id: string, @Body() dto: Partial<CreateAgentDto> & { active?: boolean }) {
|
||||
return this.service.updateAgent(id, dto);
|
||||
}
|
||||
@Post('bookings')
|
||||
@ApiOperation({ summary: 'Create agent booking with cash payment' })
|
||||
createBooking(@Body() dto: CreateAgentBookingDto) {
|
||||
|
||||
@@ -2,6 +2,12 @@ import { IsString, IsInt, IsBoolean, IsOptional, IsArray, ValidateNested } from
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateAgentDto {
|
||||
@ApiProperty() @IsString() iamUserId: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() agentCode?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsInt() commissionRate?: number;
|
||||
}
|
||||
|
||||
export class AgentPassengerDto {
|
||||
@ApiProperty() @IsString() fullName: string;
|
||||
@ApiProperty() @IsString() phone: string;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
|
||||
import { IdDocumentType } from '@prisma/client';
|
||||
@@ -10,7 +12,49 @@ function generateRef(): string {
|
||||
|
||||
@Injectable()
|
||||
export class AgentsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async findAll(filters: { search?: string; active?: string }) {
|
||||
const where: any = {};
|
||||
if (filters.active !== undefined && filters.active !== '') {
|
||||
where.active = filters.active === 'true';
|
||||
}
|
||||
const agents = await this.prisma.agent.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
// Enrich with IAM user data
|
||||
const iamUserIds = agents.map(a => a.iamUserId).filter(Boolean) as string[];
|
||||
type IamRow = { id: string; email: string; name: any; phone_number: string | null };
|
||||
const iamRows: IamRow[] = iamUserIds.length > 0
|
||||
? await this.dataSource.query<IamRow[]>(
|
||||
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
|
||||
[iamUserIds],
|
||||
).catch(() => [])
|
||||
: [];
|
||||
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||
const items = agents
|
||||
.map(a => {
|
||||
const iam = a.iamUserId ? iamMap.get(a.iamUserId) ?? null : null;
|
||||
const fullName = iam?.name?.en ?? iam?.name?.am ?? null;
|
||||
if (filters.search) {
|
||||
const q = filters.search.toLowerCase();
|
||||
const matches = a.agentCode.toLowerCase().includes(q)
|
||||
|| (iam?.email ?? '').toLowerCase().includes(q)
|
||||
|| (fullName ?? '').toLowerCase().includes(q);
|
||||
if (!matches) return null;
|
||||
}
|
||||
return {
|
||||
...a,
|
||||
user: iam ? { fullName, email: iam.email, phone: iam.phone_number } : null,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
return { items, total: items.length };
|
||||
}
|
||||
|
||||
async createAgentBooking(dto: CreateAgentBookingDto) {
|
||||
const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId } });
|
||||
@@ -139,4 +183,31 @@ export class AgentsService {
|
||||
if (!agent) throw new NotFoundException('No agent profile found for this user');
|
||||
return agent;
|
||||
}
|
||||
|
||||
async createAgent(dto: { iamUserId: string; agentCode?: string; commissionRate?: number }) {
|
||||
const existing = await this.prisma.agent.findUnique({ where: { iamUserId: dto.iamUserId } });
|
||||
if (existing) throw new BadRequestException('An agent profile already exists for this user');
|
||||
const agentCode = dto.agentCode || `AG${String(Date.now()).slice(-4)}`;
|
||||
return this.prisma.agent.create({
|
||||
data: {
|
||||
iamUserId: dto.iamUserId,
|
||||
agentCode,
|
||||
commissionRate: dto.commissionRate ?? 5,
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateAgent(id: string, dto: { agentCode?: string; commissionRate?: number; active?: boolean }) {
|
||||
const agent = await this.prisma.agent.findUnique({ where: { id } });
|
||||
if (!agent) throw new NotFoundException('Agent not found');
|
||||
return this.prisma.agent.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.agentCode !== undefined && { agentCode: dto.agentCode }),
|
||||
...(dto.commissionRate !== undefined && { commissionRate: dto.commissionRate }),
|
||||
...(dto.active !== undefined && { active: dto.active }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException, SetMetadata } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, SetMetadata, BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { GuestBookingService } from './guest-booking.service';
|
||||
@@ -35,13 +36,13 @@ export class BookingsController {
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
const passengerId = req.user?.passengerId;
|
||||
if (!passengerId) throw new Error('Passenger ID not found in token');
|
||||
return this.service.findByPassengerId(passengerId, {
|
||||
search,
|
||||
status,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20
|
||||
const iamUserId = req.user?.id;
|
||||
if (!iamUserId) throw new UnauthorizedException();
|
||||
return this.service.findByIamUserId(iamUserId, {
|
||||
search,
|
||||
status,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,11 @@ export class BookingsService {
|
||||
private readonly fareEngine: FareEngineService,
|
||||
) {}
|
||||
|
||||
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
|
||||
const passenger = await this.prisma.passenger.findUniqueOrThrow({ where: { iamUserId }, select: { id: true } });
|
||||
return this.findByPassengerId(passenger.id, filters);
|
||||
}
|
||||
|
||||
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
|
||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { IsInt, IsPositive, IsString } from 'class-validator';
|
||||
import { ExcessBaggageService } from './excess-baggage.service';
|
||||
import {
|
||||
LogExcessBaggageDto,
|
||||
@@ -8,6 +9,13 @@ import {
|
||||
} from './excess-baggage.dto';
|
||||
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
|
||||
class UpsertBaggageAllowanceDto {
|
||||
@IsString() seatClassId: string;
|
||||
@IsInt() @IsPositive() maxWeightKg: number;
|
||||
@IsInt() @IsPositive() maxPiecesCount: number;
|
||||
@IsInt() @IsPositive() excessFeePerKg: number;
|
||||
}
|
||||
|
||||
// ── IAM-protected agent/supervisor routes ────────────────────────────────────
|
||||
@ApiTags('Excess Baggage')
|
||||
@Controller('agents/excess-baggage')
|
||||
@@ -55,6 +63,30 @@ export class ExcessBaggageAgentController {
|
||||
waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) {
|
||||
return this.service.waiveCharge(id, dto);
|
||||
}
|
||||
|
||||
@Get('allowances')
|
||||
@ApiOperation({ summary: 'List all baggage allowance rules' })
|
||||
getAllowances() {
|
||||
return this.service.getAllowances();
|
||||
}
|
||||
|
||||
@Post('allowances')
|
||||
@ApiOperation({ summary: 'Create baggage allowance rule for a seat class' })
|
||||
createAllowance(@Body() dto: UpsertBaggageAllowanceDto) {
|
||||
return this.service.upsertAllowance(dto);
|
||||
}
|
||||
|
||||
@Patch('allowances/:id')
|
||||
@ApiOperation({ summary: 'Update baggage allowance rule' })
|
||||
updateAllowance(@Param('id') id: string, @Body() dto: Partial<UpsertBaggageAllowanceDto>) {
|
||||
return this.service.updateAllowance(id, dto);
|
||||
}
|
||||
|
||||
@Delete('allowances/:id')
|
||||
@ApiOperation({ summary: 'Delete baggage allowance rule' })
|
||||
deleteAllowance(@Param('id') id: string) {
|
||||
return this.service.deleteAllowance(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public pay-by-token routes (passenger self-service) ──────────────────────
|
||||
|
||||
@@ -249,4 +249,30 @@ export class ExcessBaggageService {
|
||||
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getAllowances() {
|
||||
const [allowances, seatClasses] = await Promise.all([
|
||||
this.prisma.baggageAllowance.findMany({ orderBy: { createdAt: 'asc' } }),
|
||||
this.prisma.seatClass.findMany({ select: { id: true, name: true } }),
|
||||
]);
|
||||
const scMap = new Map(seatClasses.map(s => [s.id, s]));
|
||||
return allowances.map(a => ({ ...a, seatClass: scMap.get(a.seatClassId) ?? null }));
|
||||
}
|
||||
|
||||
async upsertAllowance(dto: { seatClassId: string; maxWeightKg: number; maxPiecesCount: number; excessFeePerKg: number }) {
|
||||
return this.prisma.baggageAllowance.upsert({
|
||||
where: { seatClassId: dto.seatClassId } as any,
|
||||
update: { maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, excessFeePerKg: dto.excessFeePerKg },
|
||||
create: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, excessFeePerKg: dto.excessFeePerKg },
|
||||
});
|
||||
}
|
||||
|
||||
async updateAllowance(id: string, dto: Partial<{ maxWeightKg: number; maxPiecesCount: number; excessFeePerKg: number }>) {
|
||||
return this.prisma.baggageAllowance.update({ where: { id }, data: dto });
|
||||
}
|
||||
|
||||
async deleteAllowance(id: string) {
|
||||
await this.prisma.baggageAllowance.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,15 @@ export class FleetController {
|
||||
return this.service.deleteTrain(id);
|
||||
}
|
||||
|
||||
@Patch('trains/:id/restore')
|
||||
@ApiOperation({ summary: 'Restore (reactivate) a deactivated train' })
|
||||
@ApiParam({ name: 'id', description: 'Train UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Train restored' })
|
||||
@ApiResponse({ status: 404, description: 'Train not found' })
|
||||
restoreTrain(@Param('id') id: string) {
|
||||
return this.service.restoreTrain(id);
|
||||
}
|
||||
|
||||
// Coach Endpoints
|
||||
@Get('coaches')
|
||||
@ApiOperation({ summary: 'List coaches with seat status summary' })
|
||||
|
||||
@@ -7,6 +7,7 @@ export class CreateTrainDto {
|
||||
@ApiPropertyOptional({ example: 'EDR', description: 'Operator ID (defaults to op_edr)' }) @IsOptional() @IsString() operatorId?: string;
|
||||
@ApiPropertyOptional({ example: 'Ethiopian-Djibouti Railway' }) @IsOptional() @IsString() operatorName?: string;
|
||||
@ApiPropertyOptional({ example: 'Addis-Djibouti Express' }) @IsOptional() @IsString() description?: string;
|
||||
@ApiPropertyOptional({ example: true, description: 'Whether the train is active' }) @IsOptional() @IsBoolean() isActive?: boolean;
|
||||
}
|
||||
|
||||
export class CreateCoachDto {
|
||||
@@ -26,6 +27,8 @@ export class CreateCoachDto {
|
||||
description: 'Beds per compartment/room. Must be even (split equally left/right). Defaults: VIP_BED=4, ECONOMY_BED=6. Only applies when bedCategory is set.',
|
||||
})
|
||||
@IsOptional() @IsInt() bedsPerRoom?: number;
|
||||
@ApiPropertyOptional({ example: 1, description: 'Sequence number for ordering coaches in the train' })
|
||||
@IsOptional() @IsInt() sequence?: number;
|
||||
}
|
||||
|
||||
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {
|
||||
@@ -66,6 +69,9 @@ export class CreateClassDto {
|
||||
@ApiProperty({ example: 'Economy' }) @IsString() name: string;
|
||||
@IsOptional() @IsString() description?: string;
|
||||
@ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number;
|
||||
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() premiumMinor?: number;
|
||||
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() insuranceFeeMinor?: number;
|
||||
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateClassDto {
|
||||
|
||||
@@ -44,7 +44,7 @@ const DEFAULT_BEDS_PER_ROOM: Record<'ECONOMY_BED' | 'VIP_BED', number> = {
|
||||
// Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type
|
||||
function detectBedCategory(coachTypeName: string): BedCategory {
|
||||
const name = coachTypeName.toLowerCase();
|
||||
const isBed = name.includes('bed') || name.includes('sleeper') || name.includes('couchette');
|
||||
const isBed = name.includes('bed') || name.includes('berth') || name.includes('sleeper') || name.includes('couchette');
|
||||
if (!isBed) return null;
|
||||
if (name.includes('vip')) return 'VIP_BED';
|
||||
return 'ECONOMY_BED';
|
||||
@@ -224,6 +224,9 @@ export class FleetService {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
baseFareMinor: dto.baseFareMinor,
|
||||
...(dto.premiumMinor !== undefined && { premiumMinor: dto.premiumMinor }),
|
||||
...(dto.insuranceFeeMinor !== undefined && { insuranceFeeMinor: dto.insuranceFeeMinor }),
|
||||
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -307,34 +310,54 @@ export class FleetService {
|
||||
}
|
||||
|
||||
createTrain(dto: CreateTrainDto) {
|
||||
return this.prisma.train.create({ data: dto });
|
||||
return this.prisma.train.create({
|
||||
data: {
|
||||
number: dto.number,
|
||||
name: dto.name,
|
||||
operatorId: dto.operatorId,
|
||||
operatorName: dto.operatorName,
|
||||
description: dto.description,
|
||||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateTrain(id: string, dto: CreateTrainDto) {
|
||||
const train = await this.prisma.train.findUnique({ where: { id } });
|
||||
if (!train) throw new NotFoundException('Train not found');
|
||||
return this.prisma.train.update({ where: { id }, data: dto });
|
||||
return this.prisma.train.update({
|
||||
where: { id },
|
||||
data: {
|
||||
number: dto.number,
|
||||
name: dto.name,
|
||||
operatorId: dto.operatorId,
|
||||
operatorName: dto.operatorName,
|
||||
description: dto.description,
|
||||
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTrain(id: string) {
|
||||
const train = await this.prisma.train.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
schedules: true,
|
||||
},
|
||||
include: { schedules: true },
|
||||
});
|
||||
if (!train) throw new NotFoundException('Train not found');
|
||||
|
||||
// Check for active schedules
|
||||
if (train.schedules.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.`
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.train.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async restoreTrain(id: string) {
|
||||
const train = await this.prisma.train.findUnique({ where: { id } });
|
||||
if (!train) throw new NotFoundException('Train not found');
|
||||
return this.prisma.train.update({ where: { id }, data: { isActive: true } });
|
||||
}
|
||||
|
||||
async getCoach(id: string) {
|
||||
const coach = await this.prisma.coach.findUnique({
|
||||
where: { id },
|
||||
@@ -370,18 +393,20 @@ export class FleetService {
|
||||
throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`);
|
||||
}
|
||||
|
||||
// Get the next sequence number for this coach type
|
||||
const lastCoach = await this.prisma.coach.findFirst({
|
||||
where: { coachTypeId: dto.coachTypeId },
|
||||
orderBy: { sequence: 'desc' },
|
||||
});
|
||||
const nextSequence = (lastCoach?.sequence ?? 0) + 1;
|
||||
// Use user-provided sequence or auto-assign the next one
|
||||
let resolvedSequence = dto.sequence;
|
||||
if (resolvedSequence === undefined || resolvedSequence === null) {
|
||||
const lastCoach = await this.prisma.coach.findFirst({
|
||||
orderBy: { sequence: 'desc' },
|
||||
});
|
||||
resolvedSequence = (lastCoach?.sequence ?? 0) + 1;
|
||||
}
|
||||
|
||||
const coach = await this.prisma.coach.create({
|
||||
data: {
|
||||
coachTypeId: dto.coachTypeId,
|
||||
number: dto.number,
|
||||
sequence: nextSequence,
|
||||
sequence: resolvedSequence,
|
||||
arrangement: dto.arrangement,
|
||||
capacity: dto.capacity,
|
||||
status: dto.status || 'ACTIVE',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { PackagesService } from './packages.service';
|
||||
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto } from './packages.dto';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||
|
||||
@@ -19,9 +20,9 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Get('all')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List all packages (admin)' })
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'List all packages (backoffice)' })
|
||||
listAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20);
|
||||
}
|
||||
@@ -49,48 +50,64 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Create package (admin)' })
|
||||
create(@Body() dto: CreatePackageDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update package (admin)' })
|
||||
update(@Param('id') id: string, @Body() dto: Partial<CreatePackageDto>) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete package (admin)' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
|
||||
@Patch(':id/activate')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Activate package (admin)' })
|
||||
activate(@Param('id') id: string) {
|
||||
return this.service.activate(id);
|
||||
}
|
||||
|
||||
@Patch(':id/deactivate')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Deactivate package (admin)' })
|
||||
deactivate(@Param('id') id: string) {
|
||||
return this.service.deactivate(id);
|
||||
}
|
||||
|
||||
@Post(':id/tiers')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Add price tier to package (admin)' })
|
||||
addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) {
|
||||
return this.service.addTier(id, dto);
|
||||
}
|
||||
|
||||
@Patch('tiers/:tierId')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update price tier (admin)' })
|
||||
updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) {
|
||||
return this.service.updateTier(tierId, dto);
|
||||
}
|
||||
|
||||
@Delete('tiers/:tierId')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete price tier (admin)' })
|
||||
deleteTier(@Param('tierId') tierId: string) {
|
||||
return this.service.deleteTier(tierId);
|
||||
|
||||
@@ -20,7 +20,7 @@ export class PackagesService {
|
||||
listActive() {
|
||||
const now = new Date();
|
||||
return this.prisma.travelPackage.findMany({
|
||||
where: { status: 'ACTIVE', validFrom: { lte: now }, validUntil: { gte: now } },
|
||||
where: { status: 'ACTIVE', validUntil: { gte: now } },
|
||||
include: {
|
||||
priceTiers: true,
|
||||
outboundSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
@@ -117,12 +117,40 @@ export class PackagesService {
|
||||
return this.prisma.packagePriceTier.delete({ where: { id: tierId } });
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const pkg = await this.prisma.travelPackage.findUnique({
|
||||
where: { id },
|
||||
include: { bookings: { select: { id: true, status: true } } },
|
||||
});
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
const hasActive = pkg.bookings.some((b) => b.status === 'PENDING_PAYMENT' || b.status === 'CONFIRMED');
|
||||
if (hasActive) throw new BadRequestException('Cannot delete a package with active bookings');
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const bookingIds = pkg.bookings.map((b) => b.id);
|
||||
if (bookingIds.length > 0) {
|
||||
await tx.packageBookingPassenger.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||
await tx.packagePaymentIntent.deleteMany({ where: { packageBookingId: { in: bookingIds } } });
|
||||
await tx.packageBooking.deleteMany({ where: { packageId: id } });
|
||||
}
|
||||
await tx.packagePriceTier.deleteMany({ where: { packageId: id } });
|
||||
await tx.travelPackage.delete({ where: { id } });
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async activate(id: string) {
|
||||
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
return this.prisma.travelPackage.update({ where: { id }, data: { status: 'ACTIVE' } });
|
||||
}
|
||||
|
||||
async deactivate(id: string) {
|
||||
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
return this.prisma.travelPackage.update({ where: { id }, data: { status: 'DRAFT' } });
|
||||
}
|
||||
|
||||
async book(dto: BookPackageDto, passengerId?: string) {
|
||||
const pkg = await this.prisma.travelPackage.findUnique({
|
||||
where: { id: dto.packageId },
|
||||
@@ -130,7 +158,6 @@ export class PackagesService {
|
||||
});
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking');
|
||||
if (new Date() > pkg.validUntil) throw new BadRequestException('Package has expired');
|
||||
|
||||
const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId);
|
||||
if (!tier) throw new NotFoundException('Price tier not found');
|
||||
@@ -228,7 +255,11 @@ export class PackagesService {
|
||||
this.prisma.travelPackage.findMany({
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: { priceTiers: true },
|
||||
include: {
|
||||
priceTiers: true,
|
||||
outboundSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.travelPackage.count(),
|
||||
|
||||
@@ -432,10 +432,12 @@ export class PassengersService {
|
||||
this.prisma.notification.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.travelerProfile.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.savedRoute.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.journey.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.packageBooking.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.ticket.deleteMany({ where: { booking: { passengerId: id } } }),
|
||||
this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }),
|
||||
this.prisma.booking.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId: id } } }),
|
||||
this.prisma.journey.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.passenger.delete({ where: { id } }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export class CreateRouteDto {
|
||||
@ApiPropertyOptional({ example: 'Main corridor via Dire Dawa' }) @IsOptional() @IsString() description?: string;
|
||||
@ApiProperty({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsDateString() effectiveFrom: string;
|
||||
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
|
||||
@ApiPropertyOptional({ example: true, description: 'Whether the route is active (defaults to true)' }) @IsOptional() @IsBoolean() active?: boolean;
|
||||
@ApiProperty({
|
||||
type: [RouteStopInputDto],
|
||||
description: 'Ordered stops for this route. Sequence 1 = origin, last sequence = destination.',
|
||||
|
||||
@@ -26,6 +26,7 @@ export class RoutesService {
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
active: dto.active ?? true,
|
||||
effectiveFrom: new Date(dto.effectiveFrom),
|
||||
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null,
|
||||
stops: {
|
||||
|
||||
@@ -306,8 +306,18 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
async deleteSchedule(id: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { bookings: true } } },
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
if ((schedule as any)._count.bookings > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete schedule. It has ${(schedule as any)._count.bookings} booking(s). Cancel all bookings before deleting.`,
|
||||
);
|
||||
}
|
||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
||||
return this.prisma.trainSchedule.delete({ where: { id } });
|
||||
}
|
||||
|
||||
|
||||
@@ -51,27 +51,30 @@ export class SeatsService {
|
||||
: 0;
|
||||
const bedCategory = isBedCoach ? this.getBedCategory(coachTypeName, bedsPerRoom) : null;
|
||||
|
||||
const mappedSeats = allSeats.map((s: any) => ({
|
||||
id: s.id,
|
||||
seatNumber: s.seatNumber,
|
||||
label: s.seatNumber,
|
||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||
kind: s.kind,
|
||||
row: s.row,
|
||||
col: s.col,
|
||||
isWindow: s.isWindow,
|
||||
isAisle: s.isAisle,
|
||||
// Bed-specific fields
|
||||
...(isBedCoach ? {
|
||||
room_id: `${a.coach.id}-R${s.row}`,
|
||||
category: bedCategory,
|
||||
position: this.colToPosition(s.col),
|
||||
bed_type: this.bedPositionToType(s.bedPosition),
|
||||
bedPosition: s.bedPosition,
|
||||
} : {
|
||||
bedPosition: s.bedPosition,
|
||||
}),
|
||||
}));
|
||||
const mappedSeats = allSeats.map((s: any) => {
|
||||
const resolvedBedPosition = isBedCoach
|
||||
? this.resolveBedPosition(s.col, s.bedPosition)
|
||||
: s.bedPosition;
|
||||
return {
|
||||
id: s.id,
|
||||
seatNumber: s.seatNumber,
|
||||
label: s.seatNumber,
|
||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||
kind: s.kind,
|
||||
row: s.row,
|
||||
col: s.col,
|
||||
isWindow: s.isWindow,
|
||||
isAisle: s.isAisle,
|
||||
bedPosition: resolvedBedPosition,
|
||||
// Bed-specific fields (only when coach is a bed coach)
|
||||
...(isBedCoach ? {
|
||||
room_id: `${a.coach.id}-R${s.row}`,
|
||||
category: bedCategory,
|
||||
position: this.colToPosition(s.col, a.coach.arrangement),
|
||||
bed_type: this.bedPositionToType(resolvedBedPosition),
|
||||
} : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const base = {
|
||||
id: a.coach.id,
|
||||
@@ -116,7 +119,7 @@ export class SeatsService {
|
||||
|
||||
private isBedCoach(coachTypeName: string): boolean {
|
||||
const n = coachTypeName.toLowerCase();
|
||||
return n.includes('bed') || n.includes('sleeper') || n.includes('couchette');
|
||||
return n.includes('bed') || n.includes('berth') || n.includes('sleeper') || n.includes('couchette');
|
||||
}
|
||||
|
||||
private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' {
|
||||
@@ -128,9 +131,23 @@ export class SeatsService {
|
||||
return 'ECONOMY_BED';
|
||||
}
|
||||
|
||||
// col format: L1, L2, L3, R1, R2, R3
|
||||
private colToPosition(col: string): 'LEFT' | 'RIGHT' {
|
||||
return col?.startsWith('R') ? 'RIGHT' : 'LEFT';
|
||||
// col format: L1, L2, L3, R1, R2, R3 (new) or A, B, C, D (legacy)
|
||||
// arrangement e.g. "2+2", "3+3", "2+0" → "leftCount+rightCount"
|
||||
private colToPosition(col: string, arrangement?: string): 'LEFT' | 'RIGHT' | null {
|
||||
if (!col) return null;
|
||||
// New named-col format: L1, L2, R1, R2 …
|
||||
if (/^L\d+$/.test(col)) return 'LEFT';
|
||||
if (/^R\d+$/.test(col)) return 'RIGHT';
|
||||
// Legacy single-letter cols (A, B, C, D …): derive from arrangement
|
||||
const colIndex = col.toUpperCase().charCodeAt(0) - 65; // A=0, B=1, C=2 …
|
||||
if (arrangement) {
|
||||
const [leftStr, rightStr] = arrangement.split('+');
|
||||
const rightCount = parseInt(rightStr ?? '0', 10);
|
||||
if (rightCount === 0) return 'LEFT'; // single-side berth coach — all LEFT
|
||||
const leftCount = parseInt(leftStr, 10) || 0;
|
||||
return colIndex < leftCount ? 'LEFT' : 'RIGHT';
|
||||
}
|
||||
return 'LEFT'; // safe default when no arrangement info
|
||||
}
|
||||
|
||||
private bedPositionToType(bedPosition: string | null): 'LOWER' | 'MIDDLE' | 'UPPER' | null {
|
||||
@@ -141,6 +158,22 @@ export class SeatsService {
|
||||
return map[bedPosition.toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
// Derives bedPosition from col when the seat was created with legacy A/B/C columns
|
||||
// (new coaches use L1/L2/L3/R1/R2/R3 and store bedPosition explicitly).
|
||||
// Col-to-tier mapping: A → lower, B → middle, C → upper, D → upper (4-tier).
|
||||
private resolveBedPosition(col: string, storedBedPosition: string | null): string | null {
|
||||
if (storedBedPosition) return storedBedPosition;
|
||||
const legacyMap: Record<string, string> = { A: 'lower', B: 'middle', C: 'upper', D: 'upper' };
|
||||
// Also handle numeric suffix in L/R cols: L1→lower, L2→middle, L3→upper
|
||||
if (/^[LR]\d+$/.test(col)) {
|
||||
const tier = parseInt(col.slice(1), 10);
|
||||
if (tier === 1) return 'lower';
|
||||
if (tier === 2) return 'middle';
|
||||
return 'upper';
|
||||
}
|
||||
return legacyMap[col?.toUpperCase()] ?? null;
|
||||
}
|
||||
|
||||
async resolveEffectiveStatuses(
|
||||
scheduleId: string,
|
||||
seatIds: string[],
|
||||
|
||||
@@ -4,11 +4,23 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
export const CONFIG_KEYS = {
|
||||
SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes',
|
||||
HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure',
|
||||
THROTTLE_AUTH_LIMIT: 'throttle_auth_limit',
|
||||
THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms',
|
||||
THROTTLE_STRICT_LIMIT: 'throttle_strict_limit',
|
||||
THROTTLE_STRICT_TTL_MS: 'throttle_strict_ttl_ms',
|
||||
THROTTLE_DEFAULT_LIMIT: 'throttle_default_limit',
|
||||
THROTTLE_DEFAULT_TTL_MS: 'throttle_default_ttl_ms',
|
||||
} as const;
|
||||
|
||||
const DEFAULTS: Record<string, string> = {
|
||||
[CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5',
|
||||
[CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2',
|
||||
[CONFIG_KEYS.THROTTLE_AUTH_LIMIT]: '5',
|
||||
[CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000',
|
||||
[CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20',
|
||||
[CONFIG_KEYS.THROTTLE_STRICT_TTL_MS]: '60000',
|
||||
[CONFIG_KEYS.THROTTLE_DEFAULT_LIMIT]: '100',
|
||||
[CONFIG_KEYS.THROTTLE_DEFAULT_TTL_MS]: '60000',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
|
||||
10
apps/edr-passenger-api/src/modules/tasks/tasks.module.ts
Normal file
10
apps/edr-passenger-api/src/modules/tasks/tasks.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { TasksService } from './tasks.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, NotificationsModule],
|
||||
providers: [TasksService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
205
apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
Normal file
205
apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
|
||||
/** Minutes before departure at which each action fires. */
|
||||
const REMINDER_MINUTES = 3 * 60; // 3 h → send payment reminder SMS
|
||||
const DEADLINE_MINUTES = 2 * 60; // 2 h → cancel unpaid booking
|
||||
|
||||
/** Half-width of the reminder detection window (cron runs every 2 min). */
|
||||
const REMINDER_WINDOW_MINUTES = 2;
|
||||
|
||||
function fmtTime(d: Date): string {
|
||||
return d.toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Africa/Addis_Ababa',
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TasksService {
|
||||
private readonly logger = new Logger(TasksService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsClientService,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 2 min: advance TrainSchedule statuses (departure / arrival).
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/2 * * * *')
|
||||
async syncScheduleStatuses() {
|
||||
const now = new Date();
|
||||
|
||||
const [departed, arrived] = await Promise.all([
|
||||
this.prisma.trainSchedule.updateMany({
|
||||
where: { status: 'SCHEDULED', departureAt: { lte: now } },
|
||||
data: { status: 'EN_ROUTE' },
|
||||
}),
|
||||
this.prisma.trainSchedule.updateMany({
|
||||
where: { status: { in: ['EN_ROUTE', 'BOARDING'] }, arrivalAt: { lte: now } },
|
||||
data: { status: 'ARRIVED' },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (departed.count > 0 || arrived.count > 0) {
|
||||
this.logger.log(
|
||||
`Schedule sync: ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 2 min: payment deadline enforcement.
|
||||
//
|
||||
// • 3 h before departure → send one SMS reminder to complete payment.
|
||||
// • 2 h before departure → cancel booking if payment is still pending
|
||||
// and notify the passenger by SMS.
|
||||
//
|
||||
// Example: train departs 08:00
|
||||
// 05:00 → reminder SMS sent ("pay before 06:00 or booking is cancelled")
|
||||
// 06:00 → booking auto-cancelled, cancellation SMS sent
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/2 * * * *')
|
||||
async enforcePaymentDeadlines() {
|
||||
const now = new Date();
|
||||
|
||||
await Promise.all([
|
||||
this.sendPaymentReminders(now),
|
||||
this.cancelExpiredPendingBookings(now),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 3-hour reminder ───────────────────────────────────────────────────────
|
||||
private async sendPaymentReminders(now: Date) {
|
||||
// Narrow 4-minute window (±2 min around the 3-hour mark) so each booking
|
||||
// is caught by exactly one cron tick and paymentReminderSentAt guards re-sends.
|
||||
const windowMs = REMINDER_WINDOW_MINUTES * 60 * 1000;
|
||||
const reminderMs = REMINDER_MINUTES * 60 * 1000;
|
||||
|
||||
const windowStart = new Date(now.getTime() + reminderMs - windowMs);
|
||||
const windowEnd = new Date(now.getTime() + reminderMs + windowMs);
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentReminderSentAt: null,
|
||||
schedule: { departureAt: { gte: windowStart, lte: windowEnd } },
|
||||
} as any,
|
||||
include: {
|
||||
schedule: {
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const booking of bookings) {
|
||||
try {
|
||||
const dep = booking.schedule.departureAt as Date;
|
||||
const deadline = new Date(dep.getTime() - DEADLINE_MINUTES * 60 * 1000);
|
||||
const origin = booking.schedule.originStation?.name ?? '';
|
||||
const dest = booking.schedule.destinationStation?.name ?? '';
|
||||
|
||||
const message =
|
||||
`EDR: Your booking ${booking.bookingRef} ` +
|
||||
`(${origin} → ${dest}) departs at ${fmtTime(dep)}. ` +
|
||||
`Complete payment by ${fmtTime(deadline)} or your booking will be cancelled.`;
|
||||
|
||||
if (booking.contactPhone) {
|
||||
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
|
||||
}
|
||||
|
||||
await this.prisma.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: { paymentReminderSentAt: now } as any,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Payment reminder sent: ${booking.bookingRef} (departs ${fmtTime(dep)}, deadline ${fmtTime(deadline)})`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Reminder failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2-hour auto-cancel ────────────────────────────────────────────────────
|
||||
private async cancelExpiredPendingBookings(now: Date) {
|
||||
const cutoff = new Date(now.getTime() + DEADLINE_MINUTES * 60 * 1000); // now + 2 h
|
||||
|
||||
const expiredBookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
schedule: { departureAt: { lte: cutoff } },
|
||||
},
|
||||
include: {
|
||||
schedule: {
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
paymentIntent: { select: { method: true } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const booking of expiredBookings) {
|
||||
try {
|
||||
// 1. Release held seats (Journey rows are the occupancy source of truth)
|
||||
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
|
||||
|
||||
// 2. Audit record (no refund — payment was never completed)
|
||||
await this.prisma.bookingCancellation.create({
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
cancelledBy: 'SYSTEM',
|
||||
reason: 'Payment not completed before departure deadline',
|
||||
refundAmount: 0,
|
||||
refundMethod: booking.paymentIntent?.method ?? 'NONE',
|
||||
refundStatus: 'NOT_APPLICABLE',
|
||||
},
|
||||
}).catch(() => null); // booking may already have a cancellation record
|
||||
|
||||
// 3. Mark cancelled
|
||||
await this.prisma.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
|
||||
// 4. Notify passenger
|
||||
const dep = booking.schedule.departureAt as Date;
|
||||
const origin = booking.schedule.originStation?.name ?? '';
|
||||
const dest = booking.schedule.destinationStation?.name ?? '';
|
||||
|
||||
const message =
|
||||
`EDR: Your booking ${booking.bookingRef} ` +
|
||||
`(${origin} → ${dest}, departs ${fmtTime(dep)}) has been cancelled ` +
|
||||
`because payment was not completed before the deadline.`;
|
||||
|
||||
if (booking.contactPhone) {
|
||||
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Auto-cancelled: ${booking.bookingRef} (payment deadline expired, departs ${fmtTime(dep)})`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (expiredBookings.length > 0) {
|
||||
this.logger.log(`Auto-cancelled ${expiredBookings.length} expired pending booking(s)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,4 +166,12 @@ export class TicketsController {
|
||||
delete(@Param('id') id: string) {
|
||||
return this.service.delete(id);
|
||||
}
|
||||
|
||||
@Patch(':id/restore')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' })
|
||||
restore(@Param('id') id: string) {
|
||||
return this.service.restore(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
@@ -127,12 +127,37 @@ export class TicketsService {
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
|
||||
// No payment intent record at all
|
||||
if (!booking.paymentIntent) {
|
||||
throw new HttpException(
|
||||
{ status: 'error', message: 'Payment not completed', code: 400 },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
// Payment intent exists but not yet succeeded
|
||||
if (booking.paymentIntent.status !== 'SUCCEEDED') {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Payment not completed',
|
||||
code: 400,
|
||||
detail: `Payment status: ${booking.paymentIntent.status}`,
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
// Booking not in CONFIRMED state (safety net — should align with SUCCEEDED)
|
||||
if (booking.status !== 'CONFIRMED') {
|
||||
const paymentStatus = booking.paymentIntent?.status ?? null;
|
||||
throw new BadRequestException(
|
||||
`Payment not completed. Please complete your payment before accessing the ticket. ` +
|
||||
`Booking status: ${booking.status}` +
|
||||
(paymentStatus ? `. Payment status: ${paymentStatus}` : ''),
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Payment not completed',
|
||||
code: 400,
|
||||
detail: `Booking status: ${booking.status}`,
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -567,4 +592,10 @@ export class TicketsService {
|
||||
|
||||
return { deleted: true, ticketId: id };
|
||||
}
|
||||
|
||||
async restore(id: string) {
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
return this.prisma.ticket.update({ where: { id }, data: { status: 'ACTIVE' } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,9 @@ export class CompleteVerificationResultDto {
|
||||
|
||||
@ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' })
|
||||
gender?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' })
|
||||
userDataSaved?: boolean;
|
||||
}
|
||||
|
||||
export class VerifaydaCallbackDto {
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface CompleteVerificationResult {
|
||||
phoneNumber?: string;
|
||||
birthdate?: string;
|
||||
gender?: string;
|
||||
userDataSaved?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -219,8 +220,8 @@ export class VerifaydaService {
|
||||
const login = await this.issueLoginToken(userId);
|
||||
result = { purpose: 'LOGIN', verified: true, ...login };
|
||||
} else {
|
||||
// VERIFY — prove identity and hand the verified attributes back to the
|
||||
// caller. No domain writes; the session row tracks status as usual.
|
||||
// VERIFY — prove identity, save to IAM, return verified attributes.
|
||||
const { userDataSaved } = await this.upsertIamUser(normalized);
|
||||
result = {
|
||||
purpose: 'VERIFY',
|
||||
verified: true,
|
||||
@@ -229,6 +230,7 @@ export class VerifaydaService {
|
||||
phoneNumber: normalized.phoneNumber,
|
||||
birthdate: normalized.birthdate,
|
||||
gender: normalized.gender,
|
||||
userDataSaved,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -265,13 +267,13 @@ export class VerifaydaService {
|
||||
}
|
||||
|
||||
async getVerificationStatus(iamUserId: string): Promise<VerificationStatusDto> {
|
||||
const rows = await this.dataSource.query<{ metadata: Record<string, any> | null; name: { en: string; am: string } | null }[]>(
|
||||
`SELECT metadata, name FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||
const rows = await this.dataSource.query<{ verified_by: string | null; updated_at: Date | null; name: { en: string; am: string } | null }[]>(
|
||||
`SELECT verified_by, updated_at, name FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||
[iamUserId],
|
||||
);
|
||||
const iam = rows[0] ?? null;
|
||||
const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true';
|
||||
const faydaVerifiedAt = iam?.metadata?.faydaVerifiedAt ? new Date(iam.metadata.faydaVerifiedAt) : undefined;
|
||||
const faydaVerified = iam?.verified_by === 'fayda';
|
||||
const faydaVerifiedAt = faydaVerified && iam?.updated_at ? new Date(iam.updated_at) : undefined;
|
||||
const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined;
|
||||
return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName };
|
||||
}
|
||||
@@ -387,18 +389,39 @@ export class VerifaydaService {
|
||||
}
|
||||
|
||||
private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo {
|
||||
const nameEn = raw['name#en'] as string | undefined;
|
||||
const nameAm = raw['name#am'] as string | undefined;
|
||||
const genderEn = raw['gender#en'] as string | undefined;
|
||||
const genderAm = raw['gender#am'] as string | undefined;
|
||||
const addressEn = raw['address#en'] as string | undefined;
|
||||
const addressAm = raw['address#am'] as string | undefined;
|
||||
const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined;
|
||||
|
||||
return {
|
||||
sub: raw.sub,
|
||||
fullName: raw.name ?? raw['name#en'] ?? raw['name#am'],
|
||||
phoneNumber:
|
||||
raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone,
|
||||
email: raw.email,
|
||||
gender: raw.gender,
|
||||
birthdate: raw.birthdate,
|
||||
picture: raw.picture,
|
||||
fullName: (raw.name as string | undefined) ?? nameEn ?? nameAm,
|
||||
phoneNumber: rawPhone ? this.standardizePhoneNumber(rawPhone) : undefined,
|
||||
rawPhoneNumber: rawPhone,
|
||||
email: raw.email as string | undefined,
|
||||
gender: genderEn ?? genderAm ?? (raw.gender as string | undefined),
|
||||
birthdate: raw.birthdate as string | undefined,
|
||||
picture: raw.picture as string | undefined,
|
||||
nameEn,
|
||||
nameAm,
|
||||
genderEn,
|
||||
genderAm,
|
||||
addressEn,
|
||||
addressAm,
|
||||
};
|
||||
}
|
||||
|
||||
private standardizePhoneNumber(phone: string): string {
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
if (digits.startsWith('251')) return `+${digits}`;
|
||||
if (digits.startsWith('0')) return `+251${digits.slice(1)}`;
|
||||
return `+${digits}`;
|
||||
}
|
||||
|
||||
// LOGIN via Fayda is now handled entirely by the IAM package's own OIDC flow.
|
||||
// This method is kept as a stub so completeVerification() still compiles;
|
||||
// it throws immediately without touching the database.
|
||||
@@ -411,6 +434,88 @@ export class VerifaydaService {
|
||||
});
|
||||
}
|
||||
|
||||
private async upsertIamUser(
|
||||
normalized: NormalizedFaydaUserInfo,
|
||||
): Promise<{ iamUserId: string | null; userDataSaved: boolean }> {
|
||||
try {
|
||||
const iamMetadata = {
|
||||
sub: normalized.sub,
|
||||
address: { am: normalized.addressAm ?? '', en: normalized.addressEn ?? '' },
|
||||
email: normalized.email ?? '',
|
||||
gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' },
|
||||
name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' },
|
||||
phoneNumber: normalized.rawPhoneNumber ?? '',
|
||||
};
|
||||
|
||||
// Step 1 — already verified with same Fayda sub
|
||||
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) {
|
||||
return { iamUserId: bySub[0].id, userDataSaved: true };
|
||||
}
|
||||
|
||||
// Step 2 — existing user by phone or email, not yet Fayda-verified
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (normalized.phoneNumber) {
|
||||
params.push(normalized.phoneNumber);
|
||||
conditions.push(`phone_number = $${params.length}`);
|
||||
}
|
||||
if (normalized.email) {
|
||||
params.push(normalized.email);
|
||||
conditions.push(`email = $${params.length}`);
|
||||
}
|
||||
if (conditions.length > 0) {
|
||||
const byContact = await this.dataSource.query<{ id: string }[]>(
|
||||
`SELECT id FROM iam.users WHERE ${conditions.join(' OR ')} LIMIT 1`,
|
||||
params,
|
||||
);
|
||||
if (byContact.length > 0) {
|
||||
const existingId = byContact[0].id;
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.users
|
||||
SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb,
|
||||
verified_by = 'fayda',
|
||||
updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[JSON.stringify(iamMetadata), existingId],
|
||||
);
|
||||
return { iamUserId: existingId, userDataSaved: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 — new user
|
||||
const name = { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' };
|
||||
const username = normalized.phoneNumber ?? normalized.email ?? normalized.sub;
|
||||
const inserted = await this.dataSource.query<{ id: string }[]>(
|
||||
`INSERT INTO iam.users (
|
||||
id, name, username, email, phone_number, metadata,
|
||||
user_type, status, is_active, has_set_password,
|
||||
is_phone_number_verified, verified_by,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb,
|
||||
'individual', 'accepted', true, false,
|
||||
false, 'fayda',
|
||||
NOW(), NOW()
|
||||
) RETURNING id`,
|
||||
[
|
||||
JSON.stringify(name),
|
||||
username,
|
||||
normalized.email ?? null,
|
||||
normalized.phoneNumber ?? null,
|
||||
JSON.stringify(iamMetadata),
|
||||
],
|
||||
);
|
||||
return { iamUserId: inserted[0].id, userDataSaved: true };
|
||||
} catch (err) {
|
||||
this.logger.error(`Fayda IAM upsert failed: ${(err as Error).message}`);
|
||||
return { iamUserId: null, userDataSaved: false };
|
||||
}
|
||||
}
|
||||
|
||||
private async markSessionFailed(
|
||||
state: string,
|
||||
errorCode: string,
|
||||
|
||||
@@ -27,10 +27,19 @@ export interface FaydaUserInfo {
|
||||
|
||||
export interface NormalizedFaydaUserInfo {
|
||||
sub: string;
|
||||
// Convenience / display fields
|
||||
fullName?: string;
|
||||
phoneNumber?: string;
|
||||
phoneNumber?: string; // standardized e.g. +251911234567
|
||||
email?: string;
|
||||
gender?: string;
|
||||
birthdate?: string;
|
||||
picture?: string;
|
||||
// Raw localized fields — preserved for IAM-identical writes
|
||||
nameEn?: string;
|
||||
nameAm?: string;
|
||||
genderEn?: string;
|
||||
genderAm?: string;
|
||||
addressEn?: string;
|
||||
addressAm?: string;
|
||||
rawPhoneNumber?: string; // unstandardized, stored in IAM metadata
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
"noEmit": false,
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "./.tsbuildinfo",
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@tria-plc/iamapi-common": ["./node_modules/@tria-plc/iamapi-common/dist/index"],
|
||||
"@tria-plc/iamapi-common/*": ["./node_modules/@tria-plc/iamapi-common/dist/*"]
|
||||
},
|
||||
"strictPropertyInitialization": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
|
||||
Reference in New Issue
Block a user