Package inquiry, UAT related update

This commit is contained in:
Stephanos A
2026-06-26 13:57:26 +03:00
parent eefbe6e8ed
commit 86c7b2e4eb
23 changed files with 648 additions and 93 deletions

View File

@@ -1417,6 +1417,7 @@ model TravelPackage {
returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id])
priceTiers PackagePriceTier[]
bookings PackageBooking[]
inquiries PackageInquiry[]
@@index([status, validFrom])
@@schema("passenger")
@@ -1434,6 +1435,7 @@ model PackagePriceTier {
package TravelPackage @relation(fields: [packageId], references: [id])
bookings PackageBooking[]
inquiries PackageInquiry[]
@@unique([packageId, seatType])
@@schema("passenger")
@@ -1501,3 +1503,24 @@ model PackagePaymentIntent {
@@schema("passenger")
}
model PackageInquiry {
id String @id @default(uuid())
packageId String
priceTierId String?
travelerCount Int
contactName String
contactEmail String?
contactPhone String?
notes String?
status String @default("NEW")
enquiredAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
package TravelPackage @relation(fields: [packageId], references: [id])
priceTier PackagePriceTier? @relation(fields: [priceTierId], references: [id])
@@index([packageId])
@@schema("passenger")
}

View File

@@ -80,15 +80,23 @@ export class BookingsController {
@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' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
@ApiQuery({ name: 'returnLegStatus', required: false, description: 'Filter round-trip leg usage: NEITHER_USED | OUTBOUND_ONLY | INBOUND_ONLY | BOTH_USED | NOT_APPLICABLE' })
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'status', required: false })
@ApiQuery({ name: 'returnLegStatus', required: false })
@ApiQuery({ name: 'bookingType', required: false })
@ApiQuery({ name: 'paymentStatus', required: false })
@ApiQuery({ name: 'dateFrom', required: false })
@ApiQuery({ name: 'dateTo', required: false })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
findAll(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('returnLegStatus') returnLegStatus?: string,
@Query('bookingType') bookingType?: string,
@Query('paymentStatus') paymentStatus?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
@@ -96,6 +104,10 @@ export class BookingsController {
search,
status,
returnLegStatus,
bookingType,
paymentStatus,
dateFrom,
dateTo,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20
});

View File

@@ -28,6 +28,10 @@ interface BookingFilters {
search?: string;
status?: string;
returnLegStatus?: string;
bookingType?: string;
paymentStatus?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
pageSize?: number;
}
@@ -195,7 +199,7 @@ export class BookingsService {
}
async findAll(filters: BookingFilters = {}) {
const { search, status, returnLegStatus, page = 1, pageSize = 20 } = filters;
const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
@@ -227,6 +231,23 @@ export class BookingsService {
if (status) where.status = status;
if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
if (bookingType) where.bookingType = bookingType;
if (dateFrom || dateTo) {
where.createdAt = {
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}),
};
}
if (paymentStatus) {
const statusMap: Record<string, string> = {
PAID: 'SUCCEEDED',
PENDING: 'REQUIRES_ACTION',
FAILED: 'FAILED',
REFUNDED: 'REFUNDED',
};
const mapped = statusMap[paymentStatus] ?? paymentStatus;
where.paymentIntent = { is: { status: mapped } };
}
const [items, total] = await Promise.all([
this.prisma.booking.findMany({

View File

@@ -35,12 +35,16 @@ export class ExcessBaggageAgentController {
getAll(
@Query('status') status?: string,
@Query('bookingRef') bookingRef?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.getAll({
status,
bookingRef,
dateFrom,
dateTo,
page: page ? parseInt(page) : undefined,
pageSize: pageSize ? parseInt(pageSize) : undefined,
});

View File

@@ -7,6 +7,8 @@ import {
import { PrismaService } from '../../common/prisma.service';
import { PaymentClientService } from '../payments/payment-client.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { EmailClientService } from '../notifications/email-client.service';
import {
LogExcessBaggageDto,
WaiveChargeDto,
@@ -30,6 +32,8 @@ export class ExcessBaggageService {
private prisma: PrismaService,
private paymentClient: PaymentClientService,
private notifications: NotificationsService,
private smsClient: SmsClientService,
private emailClient: EmailClientService,
) {}
async logCharge(dto: LogExcessBaggageDto) {
@@ -101,23 +105,27 @@ export class ExcessBaggageService {
const amountStr = (charge.totalMinor / 100).toFixed(2);
const msg = `EDR: Excess baggage charge of ${amountStr} ETB for booking ${booking.bookingRef}. Pay here: ${payUrl} (valid 30 min)`;
const recipient = phone ?? email ?? booking.passengerId;
try {
await this.notifications['deliverSms'](recipient, msg);
} catch (err) {
this.logger.warn(`SMS send failed for excess baggage charge ${charge.id}: ${err}`);
if (phone) {
try {
await this.smsClient.sendSms({ to: phone, message: msg });
} catch (err) {
this.logger.warn(`SMS send failed for excess baggage charge ${charge.id}: ${err}`);
}
}
if (email) {
try {
await this.notifications['deliverEmail'](
recipient,
`EDR — Excess baggage payment required (${booking.bookingRef})`,
msg,
);
await this.emailClient.sendEmail({
to: email,
subject: `EDR — Excess baggage payment required (${booking.bookingRef})`,
text: msg,
});
} catch (err) {
this.logger.warn(`Email send failed for excess baggage charge ${charge.id}: ${err}`);
}
}
if (!phone && !email) {
this.logger.warn(`No contact info to send excess baggage payment link for charge ${charge.id}`);
}
}
async getCharge(id: string) {
@@ -227,14 +235,22 @@ export class ExcessBaggageService {
async getAll(filters: {
status?: string;
bookingRef?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
pageSize?: number;
}) {
const { status, bookingRef, page = 1, pageSize = 20 } = filters;
const { status, bookingRef, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
if (status) where.status = status;
if (bookingRef) where.booking = { bookingRef: { contains: bookingRef, mode: 'insensitive' } };
if (dateFrom || dateTo) {
where.createdAt = {
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}),
};
}
const [items, total] = await Promise.all([
this.prisma.excessBaggageCharge.findMany({

View File

@@ -5,6 +5,7 @@ import {
OnApplicationBootstrap,
} from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import * as sgMail from "@sendgrid/mail";
import { SendEmail } from "./dtos/email.dto";
@Injectable()
@@ -14,9 +15,15 @@ export class EmailClientService implements OnApplicationBootstrap {
constructor(
@Inject("EMAIL_SERVICE")
private readonly emailServiceClient: ClientProxy,
) {}
) {
const apiKey = process.env.SENDGRID_API_KEY;
if (apiKey) sgMail.setApiKey(apiKey);
}
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
private get sendgridEnabled() {
return !!process.env.SENDGRID_API_KEY;
}
async onApplicationBootstrap() {
if (!this.enabled) return;
@@ -29,22 +36,38 @@ export class EmailClientService implements OnApplicationBootstrap {
}
async sendEmail(dto: SendEmail): Promise<{ queued: boolean }> {
if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped EMAIL`);
return { queued: false };
if (this.enabled) {
this.emailServiceClient.emit("send-email", {
...dto,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
this.logger.log(
`EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
);
this.logger.debug(
`EMAIL payload to=${dto.to} subject="${dto.subject ?? ""}"`,
);
return { queued: true };
}
this.emailServiceClient.emit("send-email", {
...dto,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
// Fire-and-forget enqueue: this confirms the message was handed to RabbitMQ, NOT delivered.
this.logger.log(
`EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
);
// Recipient + content are PII — keep them at debug level only.
this.logger.debug(
`EMAIL payload to=${dto.to} subject="${dto.subject ?? ""}" body="${dto.text ?? dto.body ?? dto.html ?? ""}"`,
);
return { queued: true };
if (this.sendgridEnabled) {
try {
await sgMail.send({
to: dto.to,
from: process.env.SENDGRID_FROM_EMAIL ?? "noreply@edr-platform.com",
subject: dto.subject ?? "EDR Notification",
text: dto.text ?? dto.body ?? "",
...(dto.html ? { html: dto.html } : {}),
});
this.logger.log(`EMAIL sent via SendGrid to=${dto.to}`);
return { queued: true };
} catch (err: any) {
this.logger.error(`SendGrid send failed to=${dto.to}: ${err?.message}`);
return { queued: false };
}
}
this.logger.warn(`EMAIL not sent (no transport) — to=${dto.to} subject="${dto.subject ?? ""}"`);
return { queued: false };
}
}

View File

@@ -32,7 +32,7 @@ export class SmsClientService implements OnApplicationBootstrap {
async sendSms(dto: SingleMessageDto): Promise<{ queued: boolean }> {
if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped SMS`);
this.logger.warn(`SMS not sent (RabbitMQ disabled)to=${dto.to} message="${dto.message}"`);
return { queued: false };
}
this.smsClient.emit("send-sms", {
@@ -51,7 +51,7 @@ export class SmsClientService implements OnApplicationBootstrap {
async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> {
if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`);
this.logger.warn(`BULK SMS not sent (RabbitMQ disabled)${dto.messages?.length ?? 0} messages skipped`);
return { queued: false };
}
const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from }));

View File

@@ -2,7 +2,7 @@ import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request,
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 { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto } from './packages.dto';
import { IamGuard } from '../../common/iam-adapter';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
@@ -12,6 +12,42 @@ import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
export class PackagesController {
constructor(private readonly service: PackagesService) {}
@Post('inquiries')
@IsPublic()
@ApiOperation({ summary: 'Submit a package inquiry (public)' })
createInquiry(@Body() dto: CreateInquiryDto) {
return this.service.createInquiry(dto);
}
@Get('inquiries')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all inquiries (backoffice)' })
listInquiries(
@Query('packageId') packageId?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.listInquiries({ packageId, status, page: page ? +page : 1, pageSize: pageSize ? +pageSize : 20 });
}
@Patch('inquiries/:id/status')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update inquiry status (backoffice)' })
updateInquiryStatus(@Param('id') id: string, @Body() dto: UpdateInquiryStatusDto) {
return this.service.updateInquiryStatus(id, dto.status);
}
@Delete('inquiries/:id')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete inquiry (backoffice)' })
deleteInquiry(@Param('id') id: string) {
return this.service.deleteInquiry(id);
}
@Get()
@IsPublic()
@ApiOperation({ summary: 'List active packages' })

View File

@@ -16,6 +16,20 @@ export class CreatePriceTierDto {
@IsInt() @Min(0) availableSeats: number;
}
export class CreateInquiryDto {
@ApiProperty() @IsUUID() packageId: string;
@ApiPropertyOptional() @IsOptional() @IsUUID() priceTierId?: string;
@ApiProperty({ example: 2 }) @IsInt() @Min(1) travelerCount: number;
@ApiProperty() @IsString() contactName: string;
@ApiPropertyOptional() @IsOptional() @IsString() contactEmail?: string;
@ApiPropertyOptional() @IsOptional() @IsString() contactPhone?: string;
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
}
export class UpdateInquiryStatusDto {
@ApiProperty({ example: 'CONTACTED' }) @IsString() status: string;
}
export class UpdatePriceTierDto {
@ApiPropertyOptional() @IsOptional() @IsString() seatType?: string;
@ApiPropertyOptional() @IsOptional() @IsString() label?: string;

View File

@@ -1,7 +1,7 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CurrencyService } from '../currency/currency.service';
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto } from './packages.dto';
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto';
import { Currency } from '@prisma/client';
function generateRef(): string {
@@ -17,6 +17,53 @@ export class PackagesService {
private readonly currencyService: CurrencyService,
) {}
async createInquiry(dto: CreateInquiryDto) {
return this.prisma.packageInquiry.create({
data: {
packageId: dto.packageId,
priceTierId: dto.priceTierId ?? null,
travelerCount: dto.travelerCount,
contactName: dto.contactName,
contactEmail: dto.contactEmail ?? null,
contactPhone: dto.contactPhone ?? null,
notes: dto.notes ?? null,
enquiredAt: new Date(),
},
include: { package: { select: { id: true, name: true, code: true } }, priceTier: { select: { id: true, label: true } } },
});
}
async listInquiries({ packageId, status, page = 1, pageSize = 20 }: { packageId?: string; status?: string; page?: number; pageSize?: number }) {
const where: any = {};
if (packageId) where.packageId = packageId;
if (status) where.status = status;
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([
this.prisma.packageInquiry.findMany({
where,
include: { package: { select: { id: true, name: true, code: true } }, priceTier: { select: { id: true, label: true, priceMinor: true } } },
orderBy: { enquiredAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.packageInquiry.count({ where }),
]);
return { items, total, page, pageSize };
}
async updateInquiryStatus(id: string, status: string) {
const inquiry = await this.prisma.packageInquiry.findUnique({ where: { id } });
if (!inquiry) throw new NotFoundException('Inquiry not found');
return this.prisma.packageInquiry.update({ where: { id }, data: { status } });
}
async deleteInquiry(id: string) {
const inquiry = await this.prisma.packageInquiry.findUnique({ where: { id } });
if (!inquiry) throw new NotFoundException('Inquiry not found');
await this.prisma.packageInquiry.delete({ where: { id } });
return { deleted: true };
}
listActive() {
const now = new Date();
return this.prisma.travelPackage.findMany({

View File

@@ -24,19 +24,31 @@ export class PassengersController {
summary: 'List all passengers with filters (Admin/Agent)',
description: 'Returns paginated list of passengers with search filters'
})
@ApiQuery({ name: 'search', required: false, description: 'Search by name, email, or phone' })
@ApiQuery({ name: 'verified', required: false, description: 'Filter by verification status' })
@ApiQuery({ name: 'page', required: false, description: 'Page number' })
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'verified', required: false })
@ApiQuery({ name: 'gender', required: false })
@ApiQuery({ name: 'nationality', required: false })
@ApiQuery({ name: 'dateFrom', required: false })
@ApiQuery({ name: 'dateTo', required: false })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
findAll(
@Query('search') search?: string,
@Query('verified') verified?: string,
@Query('gender') gender?: string,
@Query('nationality') nationality?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.findAll({
search,
verified: verified ? verified === 'true' : undefined,
verified: verified ? verified === 'true' : undefined,
gender,
nationality,
dateFrom,
dateTo,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20
});

View File

@@ -8,6 +8,10 @@ import { VerifaydaService } from '../verifayda/verifayda.service';
interface PassengerFilters {
search?: string;
verified?: boolean;
gender?: string;
nationality?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
pageSize?: number;
}
@@ -29,7 +33,7 @@ export class PassengersService {
) {}
async findAll(filters: PassengerFilters = {}) {
const { search, verified, page = 1, pageSize = 20 } = filters;
const { search, verified, gender, nationality, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
@@ -48,6 +52,21 @@ export class PassengersService {
where.user = { ...(where.user ?? {}), faydaVerified: verified };
}
if (gender) {
where.user = { ...(where.user ?? {}), gender };
}
if (nationality) {
where.user = { ...(where.user ?? {}), nationality: { contains: nationality, mode: 'insensitive' } };
}
if (dateFrom || dateTo) {
where.createdAt = {
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}),
};
}
const [items, total] = await Promise.all([
this.prisma.passenger.findMany({
where,

View File

@@ -37,6 +37,8 @@ export class TicketsController {
@ApiQuery({ name: 'originStationId', required: false })
@ApiQuery({ name: 'destinationStationId', required: false })
@ApiQuery({ name: 'arrivalDate', required: false })
@ApiQuery({ name: 'dateFrom', required: false })
@ApiQuery({ name: 'dateTo', required: false })
@ApiQuery({ name: 'skip', required: false })
@ApiQuery({ name: 'take', required: false })
listTickets(
@@ -45,6 +47,8 @@ export class TicketsController {
@Query('originStationId') originStationId?: string,
@Query('destinationStationId') destinationStationId?: string,
@Query('arrivalDate') arrivalDate?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('skip') skip?: string,
@Query('take') take?: string,
) {
@@ -54,6 +58,8 @@ export class TicketsController {
originStationId,
destinationStationId,
arrivalDate,
dateFrom,
dateTo,
skip: skip ? parseInt(skip) : 0,
take: take ? parseInt(take) : 50,
});

View File

@@ -21,7 +21,7 @@ export class TicketsService {
@InjectDataSource() private readonly dataSource: DataSource,
) {}
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) {
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; skip: number; take: number }) {
const where: any = {};
if (filters.search) {
where.OR = [
@@ -31,7 +31,7 @@ export class TicketsService {
];
}
if (filters.status) {
where.booking = { ...where.booking, status: filters.status };
where.status = filters.status;
}
if (filters.originStationId) {
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
@@ -45,6 +45,12 @@ export class TicketsService {
end.setDate(end.getDate() + 1);
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } };
}
if (filters.dateFrom || filters.dateTo) {
where.issuedAt = {
...(filters.dateFrom ? { gte: new Date(filters.dateFrom) } : {}),
...(filters.dateTo ? { lte: new Date(new Date(filters.dateTo).setHours(23, 59, 59, 999)) } : {}),
};
}
const [tickets, total] = await Promise.all([
this.prisma.ticket.findMany({
where,