mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #282 from Tria-plc/alpha
Merge request for passenger apps updates
This commit is contained in:
@@ -496,7 +496,7 @@ async function seedPaymentMethods() {
|
||||
{ type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA' },
|
||||
{ type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA' },
|
||||
{ type: 'EBIRR', displayName: 'eBirr', region: 'ETHIOPIA' },
|
||||
{ type: 'WAAFI', displayName: 'Waffi', region: 'DJIBOUTI' },
|
||||
{ type: 'WAAFI', displayName: 'Waafi', region: 'DJIBOUTI' },
|
||||
{ type: 'CARD', displayName: 'Credit/Debit Card', region: 'GLOBAL' },
|
||||
{ type: 'WALLET', displayName: 'Wallet', region: 'GLOBAL' },
|
||||
];
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, 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';
|
||||
// IAM auth: validate the IAM session token via @tria-plc/api-common's DB-backed JwtGuard.
|
||||
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
|
||||
@ApiTags('Agents')
|
||||
@Controller('agents')
|
||||
// TODO(iam-authz): restrict per route via @UseGuards(PermissionGuard([...])) once the IAM
|
||||
// role→permission mapping (EIamPermissionKey) is confirmed. For now: authenticated IAM users only.
|
||||
@UseGuards(IamJwtGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
export class AgentsController {
|
||||
constructor(private service: AgentsService) {}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: 'Get agent profile for logged-in IAM user' })
|
||||
getMe(@Request() req: any) {
|
||||
return this.service.getMe(req.user?.id ?? req.user?.sub);
|
||||
}
|
||||
@Post('bookings')
|
||||
@ApiOperation({ summary: 'Create agent booking with cash payment' })
|
||||
createBooking(@Body() dto: CreateAgentBookingDto) {
|
||||
|
||||
@@ -133,4 +133,10 @@ export class AgentsService {
|
||||
take: 20
|
||||
});
|
||||
}
|
||||
|
||||
async getMe(iamUserId: string) {
|
||||
const agent = await this.prisma.agent.findUnique({ where: { iamUserId } });
|
||||
if (!agent) throw new NotFoundException('No agent profile found for this user');
|
||||
return agent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException, SetMetadata } 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';
|
||||
@@ -47,7 +46,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get('by-device')
|
||||
@IsPublic()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Get bookings by device ID',
|
||||
description: 'Returns all bookings associated with a device ID (for guest users). Includes saved passenger details and booking history.'
|
||||
@@ -76,8 +75,8 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'List all bookings with filters (Admin/Agent)',
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.'
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' })
|
||||
@@ -102,7 +101,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post('guest')
|
||||
@IsPublic()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Create guest booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT (no login required)',
|
||||
description: `Creates a booking without requiring login. Supports all four booking types.
|
||||
@@ -256,7 +255,8 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get('saved-passengers')
|
||||
@ApiOperation({
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Get saved passenger profiles',
|
||||
description: 'Retrieve saved passenger details by userId (if logged in) or deviceId (for guest users)'
|
||||
})
|
||||
@@ -417,8 +417,8 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(':id/usage')
|
||||
@ApiOperation({
|
||||
summary: 'Check if booking is in use',
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
description: 'Returns list of modules/data that reference this booking'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Usage information retrieved' })
|
||||
@@ -428,7 +428,8 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(':bookingRef')
|
||||
@ApiOperation({
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Get booking details by reference (no auth required)',
|
||||
description: 'Returns booking with passenger categories, Verifayda verification status, and multi-currency amounts. Works for both guest and authenticated bookings.'
|
||||
})
|
||||
@@ -452,8 +453,8 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Delete booking (admin only)',
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
description: 'Permanently deletes a booking record'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Booking deleted successfully' })
|
||||
@@ -463,8 +464,8 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Update booking details',
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
description: 'Updates booking information for admin/agent operations'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Booking updated successfully' })
|
||||
|
||||
@@ -1027,9 +1027,10 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
async getByRef(bookingRef: string) {
|
||||
async getByRef(bookingRefOrId: string) {
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId);
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef },
|
||||
where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException, Patch, Delete } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException, Patch, Delete, SetMetadata } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { SkipThrottle, Throttle } from '@nestjs/throttler';
|
||||
import { PassengersService } from './passengers.service';
|
||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
@@ -19,6 +19,7 @@ export class PassengersController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'List all passengers with filters (Admin/Agent)',
|
||||
description: 'Returns paginated list of passengers with search filters'
|
||||
@@ -86,6 +87,7 @@ export class PassengersController {
|
||||
}
|
||||
|
||||
@Post('verify-fayda')
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Verify Ethiopian national ID via Verifayda 2.0',
|
||||
description: `**Standalone endpoint for pre-verification of Ethiopian national IDs**
|
||||
@@ -155,6 +157,7 @@ Pre-verify national ID to auto-fill passenger registration form before submissio
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
@SetMetadata('isPublic', true)
|
||||
@UseGuards(OptionalJwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
@@ -249,6 +252,7 @@ The API automatically detects:
|
||||
}
|
||||
|
||||
@Post('save-details')
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Bulk save passenger details from booking flow',
|
||||
description: `**Endpoint for saving multiple passengers in a single booking**
|
||||
@@ -347,6 +351,7 @@ Returns saved passenger details with generated IDs and confirmation.`,
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Update passenger details',
|
||||
description: 'Updates passenger information for admin/agent operations'
|
||||
@@ -358,6 +363,7 @@ Returns saved passenger details with generated IDs and confirmation.`,
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Delete passenger (admin only)',
|
||||
description: 'Permanently deletes a passenger record and associated data'
|
||||
@@ -369,6 +375,7 @@ Returns saved passenger details with generated IDs and confirmation.`,
|
||||
}
|
||||
|
||||
@Get(':id/usage')
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Check if passenger is in use',
|
||||
description: 'Returns list of modules/data that reference this passenger'
|
||||
|
||||
@@ -32,46 +32,20 @@ export class PassengersService {
|
||||
const { search, verified, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
let iamUserIdFilter: string[] | null = null;
|
||||
const where: any = {};
|
||||
|
||||
if (search || verified !== undefined) {
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (search) {
|
||||
conditions.push(`(
|
||||
u.email ILIKE $${idx} OR
|
||||
u.phone_number ILIKE $${idx} OR
|
||||
(u.name->>'en') ILIKE $${idx} OR
|
||||
(u.name->>'am') ILIKE $${idx}
|
||||
)`);
|
||||
params.push(`%${search}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
if (verified !== undefined) {
|
||||
if (verified) {
|
||||
conditions.push(`u.metadata->>'faydaVerified' = 'true'`);
|
||||
} else {
|
||||
conditions.push(`(u.metadata IS NULL OR u.metadata->>'faydaVerified' IS DISTINCT FROM 'true')`);
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await this.dataSource.query<{ id: string }[]>(
|
||||
`SELECT u.id FROM iam.users u WHERE ${conditions.join(' AND ')}`,
|
||||
params,
|
||||
);
|
||||
iamUserIdFilter = rows.map(r => r.id);
|
||||
|
||||
if (iamUserIdFilter.length === 0) {
|
||||
return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
|
||||
}
|
||||
if (search) {
|
||||
where.user = {
|
||||
OR: [
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ phone: { contains: search, mode: 'insensitive' } },
|
||||
{ fullName: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const where: any = {};
|
||||
if (iamUserIdFilter) {
|
||||
where.iamUserId = { in: iamUserIdFilter };
|
||||
if (verified !== undefined) {
|
||||
where.user = { ...(where.user ?? {}), faydaVerified: verified };
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
@@ -81,8 +55,22 @@ export class PassengersService {
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: true,
|
||||
loyalty: true,
|
||||
wallet: true,
|
||||
_count: { select: { bookings: true } },
|
||||
bookings: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
select: {
|
||||
contactEmail: true,
|
||||
contactPhone: true,
|
||||
seats: { take: 1, orderBy: { id: 'asc' }, select: {
|
||||
passengerName: true, dateOfBirth: true, passportNumber: true,
|
||||
passportCountry: true, idDocumentType: true, verifaydaVerified: true, faydaVerifiedAt: true,
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.passenger.count({ where }),
|
||||
@@ -97,16 +85,68 @@ export class PassengersService {
|
||||
: [];
|
||||
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||
|
||||
// Collect guest contact details for bulk SavedPassengerProfile lookup
|
||||
const guestContacts = items
|
||||
.filter(p => !(p as any).user && !p.iamUserId)
|
||||
.map(p => (p as any).bookings?.[0])
|
||||
.filter(Boolean);
|
||||
const guestEmails = guestContacts.map((b: any) => b.contactEmail).filter(Boolean) as string[];
|
||||
const guestPhones = guestContacts.map((b: any) => b.contactPhone).filter(Boolean) as string[];
|
||||
|
||||
const savedProfiles = (guestEmails.length || guestPhones.length)
|
||||
? await this.prisma.savedPassengerProfile.findMany({
|
||||
where: { OR: [
|
||||
...(guestEmails.length ? [{ email: { in: guestEmails } }] : []),
|
||||
...(guestPhones.length ? [{ phone: { in: guestPhones } }] : []),
|
||||
]},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
: [];
|
||||
|
||||
// Index by email then phone for O(1) lookup
|
||||
const profileByEmail = new Map(savedProfiles.filter(s => s.email).map(s => [s.email!, s]));
|
||||
const profileByPhone = new Map(savedProfiles.filter(s => s.phone).map(s => [s.phone!, s]));
|
||||
|
||||
return {
|
||||
items: items.map(passenger => {
|
||||
const localUser = (passenger as any).user ?? null;
|
||||
const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
|
||||
const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true';
|
||||
const faydaVerified = localUser?.faydaVerified === true
|
||||
|| iam?.metadata?.faydaVerified === true
|
||||
|| iam?.metadata?.faydaVerified === 'true';
|
||||
const guestBooking = !localUser && !iam ? (passenger as any).bookings?.[0] : null;
|
||||
const guestSeat = guestBooking?.seats?.[0] ?? null;
|
||||
const savedProfile = guestBooking
|
||||
? (profileByEmail.get(guestBooking.contactEmail) ?? profileByPhone.get(guestBooking.contactPhone) ?? null)
|
||||
: null;
|
||||
return {
|
||||
id: passenger.id,
|
||||
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
|
||||
email: iam?.email ?? null,
|
||||
phone: iam?.phone_number ?? null,
|
||||
fullName: localUser?.fullName ?? iam?.name?.en ?? iam?.name?.am ?? savedProfile?.passengerName ?? guestSeat?.passengerName ?? null,
|
||||
email: localUser?.email ?? iam?.email ?? savedProfile?.email ?? guestBooking?.contactEmail ?? null,
|
||||
phone: localUser?.phone ?? iam?.phone_number ?? savedProfile?.phone ?? guestBooking?.contactPhone ?? null,
|
||||
gender: localUser?.gender ?? iam?.metadata?.gender ?? null,
|
||||
dateOfBirth: localUser?.dateOfBirth
|
||||
? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth)
|
||||
: (iam?.metadata?.dateOfBirth ?? (savedProfile?.dateOfBirth
|
||||
? new Date(savedProfile.dateOfBirth).toISOString().split('T')[0]
|
||||
: (guestSeat?.dateOfBirth ? new Date(guestSeat.dateOfBirth).toISOString().split('T')[0] : null))),
|
||||
nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? savedProfile?.nationality ?? null,
|
||||
nationalityCode: localUser?.nationalityCode ?? iam?.metadata?.nationalityCode ?? null,
|
||||
faydaVerified,
|
||||
faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? guestSeat?.faydaVerifiedAt ?? null,
|
||||
passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? savedProfile?.passportNumber ?? guestSeat?.passportNumber ?? null,
|
||||
passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? savedProfile?.passportCountry ?? guestSeat?.passportCountry ?? null,
|
||||
passportExpiryDate: localUser?.passportExpiryDate ?? iam?.metadata?.passportExpiryDate ?? null,
|
||||
idDocumentType: savedProfile?.idDocumentType ?? guestSeat?.idDocumentType ?? null,
|
||||
verified: faydaVerified,
|
||||
lastLoginAt: localUser?.lastLoginAt ?? null,
|
||||
role: localUser?.role ?? null,
|
||||
loyalty: passenger.loyalty
|
||||
? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: (passenger.loyalty as any).lifetimePoints ?? 0 }
|
||||
: null,
|
||||
wallet: (passenger as any).wallet
|
||||
? { balanceMinor: (passenger as any).wallet.balanceMinor, currency: (passenger as any).wallet.currency ?? 'ETB' }
|
||||
: null,
|
||||
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
|
||||
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
|
||||
totalBookings: passenger._count.bookings,
|
||||
@@ -384,7 +424,21 @@ export class PassengersService {
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||
|
||||
await this.prisma.passenger.delete({ where: { id } });
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId: id } } }),
|
||||
this.prisma.loyaltyAccount.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId: id } } }),
|
||||
this.prisma.walletAccount.deleteMany({ where: { passengerId: id } }),
|
||||
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.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }),
|
||||
this.prisma.booking.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.passenger.delete({ where: { id } }),
|
||||
]);
|
||||
|
||||
return { deleted: true, passengerId: id };
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
SetMetadata,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
ApiOkResponse,
|
||||
ApiProduces,
|
||||
} from "@nestjs/swagger";
|
||||
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
|
||||
|
||||
import { SkipThrottle, Throttle } from "@nestjs/throttler";
|
||||
import { Response } from "express";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
@@ -65,7 +66,7 @@ export class PaymentsController {
|
||||
}
|
||||
|
||||
@Post("initiate")
|
||||
@IsPublic()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: "Initiate payment with nationality-based payment methods",
|
||||
description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion`,
|
||||
@@ -75,14 +76,14 @@ export class PaymentsController {
|
||||
}
|
||||
|
||||
@Get("intents/:bookingId")
|
||||
@IsPublic()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({ summary: "Get payment intent status for a booking" })
|
||||
getIntent(@Param("bookingId") bookingId: string) {
|
||||
return this.service.getIntentByBookingId(bookingId);
|
||||
}
|
||||
|
||||
@Get("waafi/return")
|
||||
@IsPublic()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"DEMO ONLY — confirm a Waafi payment from the browser-return params and return JSON for the " +
|
||||
@@ -123,7 +124,7 @@ export class PaymentsController {
|
||||
}
|
||||
|
||||
@Get("methods")
|
||||
@IsPublic()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: "List payment systems supported by the platform",
|
||||
description:
|
||||
@@ -136,7 +137,7 @@ export class PaymentsController {
|
||||
}
|
||||
|
||||
@Get("checkout")
|
||||
@IsPublic()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: "Browser checkout redirect",
|
||||
description:
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Post,
|
||||
Patch,
|
||||
Query,
|
||||
SetMetadata,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
} from "@nestjs/swagger";
|
||||
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
|
||||
import { SeatsService } from "./seats.service";
|
||||
import { HoldSeatsDto } from "./seats.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
@@ -30,7 +30,7 @@ export class SeatsController {
|
||||
|
||||
// ── Seat Map ──────────────────────────────────────────────────────────────
|
||||
@Get("seatmap/:scheduleId")
|
||||
@IsPublic()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: "Get seat map filtered by coach type",
|
||||
description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches.`,
|
||||
@@ -104,6 +104,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
}
|
||||
|
||||
@Post("hold")
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Hold seats for 15 minutes before booking (Public - Guest booking supported)",
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Body, Controller, Get, Patch, SetMetadata, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import { SystemConfigService } from './system-config.service';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { Roles } from '../../common/roles.decorator';
|
||||
|
||||
@ApiTags('System Config')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
@Controller('system-config')
|
||||
@ApiTags('Config')
|
||||
@Controller('config')
|
||||
export class SystemConfigController {
|
||||
constructor(private service: SystemConfigService) {}
|
||||
|
||||
@Get('fayda-status')
|
||||
@SetMetadata('isPublic', true)
|
||||
@SkipThrottle()
|
||||
@ApiOperation({ summary: 'Get Fayda verification enabled status (public)' })
|
||||
getFaydaStatus() {
|
||||
const enabled = process.env.VERIFAYDA_ENABLED !== 'false';
|
||||
return { enabled };
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiOperation({ summary: 'Get all system config (admin)' })
|
||||
getAll() {
|
||||
return this.service.getAll();
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiOperation({ summary: 'Update system config (admin)' })
|
||||
update(@Body() body: Record<string, string>) {
|
||||
return this.service.updateMany(body);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
@@ -9,7 +9,8 @@ export class TicketsController {
|
||||
constructor(private service: TicketsService) {}
|
||||
|
||||
@Post('generate/:bookingId')
|
||||
@ApiOperation({
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Generate ticket for booking (confirmation page)',
|
||||
description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.'
|
||||
})
|
||||
@@ -18,6 +19,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Patch('update-seats/:bookingId')
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Update ticket seats before final confirmation',
|
||||
description: 'Allows users to change selected seats after ticket generation. Removes old seat blocks and creates new ones for updated seats.'
|
||||
@@ -69,9 +71,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get(':bookingRef')
|
||||
@ApiOperation({
|
||||
summary: 'Get ticket with QR code and passenger details (public)',
|
||||
})
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({ summary: 'Get ticket with QR code and passenger details (public)' })
|
||||
getByRef(@Param('bookingRef') ref: string) {
|
||||
return this.service.getByRef(ref);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ export class TicketsService {
|
||||
ticketNumber: t.barcodePayload,
|
||||
bookingRef: t.bookingRef,
|
||||
booking: {
|
||||
id: t.booking.id,
|
||||
bookingRef: t.booking.bookingRef,
|
||||
status: t.booking.status,
|
||||
bookingType: t.booking.bookingType,
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function PassengersPage() {
|
||||
case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : '';
|
||||
case 'gender': return p.gender || '';
|
||||
case 'nationality': return p.nationality || '';
|
||||
case 'verified': return p.nationalId ? 'Yes' : 'No';
|
||||
case 'verified': return p.faydaVerified ? 'Yes' : 'No';
|
||||
default: return '';
|
||||
}
|
||||
});
|
||||
@@ -117,15 +117,15 @@ export default function PassengersPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone },
|
||||
{ key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' },
|
||||
{ key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || 'N/A' },
|
||||
{ key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' },
|
||||
{ key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' },
|
||||
{ key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' },
|
||||
{
|
||||
key: 'verified', label: 'Status',
|
||||
render: (p: any) => (
|
||||
<Badge variant="status" status={p.nationalId ? 'CONFIRMED' : 'PENDING'}>
|
||||
{p.nationalId ? 'Verified' : 'Unverified'}
|
||||
<Badge variant="status" status={p.faydaVerified ? 'CONFIRMED' : 'PENDING'}>
|
||||
{p.faydaVerified ? 'Verified' : 'Unverified'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
@@ -192,7 +192,7 @@ export default function PassengersPage() {
|
||||
{selectedPassenger && (() => {
|
||||
const p = selectedPassenger;
|
||||
const isVerified = !!p.faydaVerified || !!p.nationalId;
|
||||
const tier = p.passenger?.loyalty?.tier || p.loyalty?.tier;
|
||||
const tier = p.loyalty?.tier || p.loyaltyTier;
|
||||
const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE;
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,7 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { ticketsApi, apiClient, stationsApi, excessBaggageApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
|
||||
@@ -25,15 +26,23 @@ export default function TicketsPage() {
|
||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState<any>(null);
|
||||
|
||||
const { user } = useAuthStore();
|
||||
|
||||
// Excess baggage state
|
||||
const [excessModalOpen, setExcessModalOpen] = useState(false);
|
||||
const [excessTicket, setExcessTicket] = useState<any>(null);
|
||||
const [excessKg, setExcessKg] = useState('');
|
||||
const [excessCollectCash, setExcessCollectCash] = useState(false);
|
||||
const [excessAgentId, setExcessAgentId] = useState('');
|
||||
const [excessError, setExcessError] = useState<string | null>(null);
|
||||
const [excessResult, setExcessResult] = useState<any>(null);
|
||||
|
||||
const { data: agentData } = useQuery({
|
||||
queryKey: ['agent-me'],
|
||||
queryFn: () => apiClient.get<any>('/agents/me'),
|
||||
enabled: !!user,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
@@ -105,7 +114,6 @@ export default function TicketsPage() {
|
||||
setExcessTicket(ticket);
|
||||
setExcessKg('');
|
||||
setExcessCollectCash(false);
|
||||
setExcessAgentId('');
|
||||
setExcessError(null);
|
||||
setExcessResult(null);
|
||||
setExcessModalOpen(true);
|
||||
@@ -114,9 +122,11 @@ export default function TicketsPage() {
|
||||
const handleExcessSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!excessTicket) return;
|
||||
const agentId = agentData?.id;
|
||||
if (!agentId) { setExcessError('No agent profile found for your account'); return; }
|
||||
await excessMutation.mutateAsync({
|
||||
bookingId: excessTicket.bookingId,
|
||||
agentId: excessAgentId,
|
||||
bookingId: excessTicket.booking?.id ?? excessTicket.bookingId,
|
||||
agentId,
|
||||
excessWeightKg: parseInt(excessKg),
|
||||
collectCash: excessCollectCash,
|
||||
});
|
||||
@@ -371,6 +381,13 @@ export default function TicketsPage() {
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'Baggage',
|
||||
onClick: openExcessModal,
|
||||
variant: 'secondary' as const,
|
||||
icon: Package,
|
||||
show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status),
|
||||
},
|
||||
{
|
||||
label: 'Board',
|
||||
onClick: handleBoard,
|
||||
@@ -411,13 +428,6 @@ export default function TicketsPage() {
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
},
|
||||
{
|
||||
label: 'Excess Baggage',
|
||||
onClick: openExcessModal,
|
||||
variant: 'secondary' as const,
|
||||
icon: Package,
|
||||
show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status),
|
||||
},
|
||||
];
|
||||
|
||||
const stations = stationsData?.items || [];
|
||||
@@ -736,16 +746,16 @@ export default function TicketsPage() {
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Booking: <span className="font-semibold text-foreground">{excessTicket?.booking?.bookingRef}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Agent ID</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Enter your agent ID"
|
||||
value={excessAgentId}
|
||||
onChange={(e) => setExcessAgentId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{agentData && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Agent: <span className="font-semibold text-foreground">{agentData.agentCode}</span>
|
||||
</div>
|
||||
)}
|
||||
{!agentData && (
|
||||
<div className="text-sm text-amber-600 dark:text-amber-400">
|
||||
⚠ No agent profile linked to your account.
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="label">Excess weight (kg)</label>
|
||||
<input
|
||||
|
||||
@@ -413,6 +413,6 @@ export const excessBaggageApi = {
|
||||
|
||||
// System Config API
|
||||
export const systemConfigApi = {
|
||||
getAll: () => apiClient.get<Record<string, string>>('/system-config'),
|
||||
update: (data: Record<string, string>) => apiClient.patch<Record<string, string>>('/system-config', data),
|
||||
getAll: () => apiClient.get<Record<string, string>>('/config'),
|
||||
update: (data: Record<string, string>) => apiClient.patch<Record<string, string>>('/config', data),
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react';
|
||||
@@ -29,10 +29,6 @@ export default function ConfirmationPage() {
|
||||
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
|
||||
const confirmAttempted = useRef(false);
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: () => apiClient.patch(`/bookings/${bookingId}/confirm`, { status: 'SUCCEEDED' }),
|
||||
});
|
||||
|
||||
const { data: _booking } = useQuery<BookingWithTicket>({
|
||||
queryKey: ['booking', bookingId],
|
||||
queryFn: async (): Promise<BookingWithTicket> => {
|
||||
@@ -54,13 +50,20 @@ export default function ConfirmationPage() {
|
||||
useEffect(() => {
|
||||
if (bookingId && !confirmAttempted.current) {
|
||||
confirmAttempted.current = true;
|
||||
confirmMutation.mutate();
|
||||
|
||||
apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => {
|
||||
console.error('Failed to generate ticket:', err);
|
||||
|
||||
// Only generate ticket if booking is already CONFIRMED (e.g. wallet payment)
|
||||
// For other payment methods, ticket is generated by the payment webhook after payment completes
|
||||
apiClient.get(`/bookings/${bookingId}`).then((data: any) => {
|
||||
if (data?.status === 'CONFIRMED') {
|
||||
apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => {
|
||||
console.error('Failed to generate ticket:', err);
|
||||
});
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error('Failed to fetch booking status:', err);
|
||||
});
|
||||
}
|
||||
}, [bookingId, confirmMutation]);
|
||||
}, [bookingId]);
|
||||
|
||||
const copyPNR = () => {
|
||||
if (pnr) {
|
||||
|
||||
@@ -380,6 +380,7 @@ export default function PassengersPage() {
|
||||
const router = useRouter();
|
||||
const { searchCriteria, setPassengers, setCreateAccount } = useBookingStore();
|
||||
const { user, isAuthenticated, updateUser } = useAuthStore();
|
||||
const isInitialized = useAuthStore((s) => s.isInitialized);
|
||||
const [faydaEnabled, setFaydaEnabled] = useState(true);
|
||||
const [verificationStatus, setVerificationStatus] = useState<Record<number, 'success' | 'error'>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -435,8 +436,8 @@ export default function PassengersPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const populateForm = async () => {
|
||||
if (!isInitialized) return;
|
||||
if (!isAuthenticated || !user?.id || !searchCriteria) {
|
||||
console.log('Missing required data for population');
|
||||
setFormInitialized(true);
|
||||
return;
|
||||
}
|
||||
@@ -475,7 +476,7 @@ export default function PassengersPage() {
|
||||
};
|
||||
|
||||
populateForm();
|
||||
}, [isAuthenticated, user, searchCriteria, setValue]);
|
||||
}, [isInitialized, isAuthenticated, user, searchCriteria, setValue]);
|
||||
|
||||
const openFaydaVerification = async (index: number) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
@@ -60,21 +60,14 @@ export default function PaymentPage() {
|
||||
|
||||
const paymentMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
// For TELEBIRR and WAAFI, use the initiate endpoint
|
||||
if (data.method === 'TELEBIRR' || data.method === 'WAAFI') {
|
||||
const response = await apiClient.post('/payments/initiate', {
|
||||
// For all payment methods, use the initiate endpoint
|
||||
try {
|
||||
return await apiClient.post("/payments/initiate", {
|
||||
bookingId: data.bookingId,
|
||||
method: data.method,
|
||||
paymentMethodId: data.paymentMethodId,
|
||||
platform: 'web'
|
||||
platform: 'web',
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// For other payment methods, try the regular payment intent API
|
||||
try {
|
||||
return await apiClient.post("/payments/intent", data);
|
||||
} catch (error) {
|
||||
console.log("Payment API not available, using mock payment");
|
||||
// Mock payment response
|
||||
|
||||
@@ -15,21 +15,21 @@ class ApiClient {
|
||||
|
||||
this.client.interceptors.request.use((config) => {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
||||
if (token) {
|
||||
if (token && token !== 'null' && token !== 'undefined') {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
const PUBLIC_PREFIXES = ['/config/', '/auth/login', '/auth/register', '/passengers/me'];
|
||||
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Don't redirect if it's a login or register request (invalid credentials)
|
||||
const isAuthEndpoint = error.config?.url?.includes('/auth/login') ||
|
||||
error.config?.url?.includes('/auth/register');
|
||||
|
||||
if (!isAuthEndpoint && typeof window !== 'undefined') {
|
||||
const url: string = error.config?.url || '';
|
||||
const isPublic = PUBLIC_PREFIXES.some((p) => url.includes(p));
|
||||
if (!isPublic && typeof window !== 'undefined') {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
window.location.href = '/login';
|
||||
|
||||
Reference in New Issue
Block a user