Merge branch 'dev' of github.com:Tria-plc/edr-platform into dev

This commit is contained in:
hagiye
2026-06-26 23:48:28 +03:00
32 changed files with 949 additions and 145 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

@@ -66,7 +66,9 @@ function decodePrivateJwk(base64: string): FaydaJwk {
export default registerAs('fayda', (): FaydaConfig => {
const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true';
const scope = process.env.FAYDA_SCOPE ?? 'openid profile email';
// `profile` covers name/birthdate/gender/picture; `email`, `phone`, `address`
// are needed so the matching essential claims aren't rejected as out-of-scope.
const scope = process.env.FAYDA_SCOPE ?? 'openid profile email phone address';
const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code';
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);

View File

@@ -314,7 +314,7 @@ Payment providers send notifications to:
.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("Passenger Auth", "Passenger registration, login, OTP, password reset, Fayda password setup, 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")
.addTag("Config", "System settings, feature flags, and configuration management")
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")

View File

@@ -3,10 +3,10 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nes
import { Throttle, SkipThrottle } from '@nestjs/throttler';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { PassengerAuthService } from './passenger-auth.service';
import { RegisterDto, LoginDto } from './auth.dto';
import { RegisterDto, LoginDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Auth')
@ApiTags('Passenger Auth')
@Controller('auth')
@Throttle({ auth: { limit: 5, ttl: 60_000 } })
export class AuthController {
@@ -118,4 +118,24 @@ export class AuthController {
resetPassword(@Param('id') id: string, @Body() body: { tempPassword: string }) {
return this.passengerAuthService.resetUserPassword(id, body.tempPassword);
}
@Post('fayda/request-password-setup')
@IsPublic()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Send OTP to phone for Fayda-verified account password setup' })
@ApiResponse({ status: 200, description: 'OTP sent to registered phone number' })
@ApiBody({ type: FaydaRequestPasswordSetupDto })
requestFaydaPasswordSetup(@Body() dto: FaydaRequestPasswordSetupDto, @Request() req: any) {
return this.passengerAuthService.requestFaydaPasswordSetup(dto.phoneNumber, req);
}
@Post('fayda/verify-and-login')
@IsPublic()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Verify OTP and receive session token for Fayda-verified account' })
@ApiResponse({ status: 200, description: 'Returns token + requiresPassword flag. Use token with POST /v1/auth/set-fayda-password.' })
@ApiBody({ type: FaydaVerifyAndLoginDto })
verifyFaydaAndLogin(@Body() dto: FaydaVerifyAndLoginDto) {
return this.passengerAuthService.verifyFaydaAndLogin(dto.phoneNumber, dto.otp);
}
}

View File

@@ -1,6 +1,6 @@
import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class NameDto {
@ApiProperty({ example: 'ቀለሙ ቀጸላ' })
@@ -49,3 +49,19 @@ export class LoginDto {
@IsString()
password: string;
}
export class FaydaRequestPasswordSetupDto {
@ApiProperty({ example: '+251911234567', description: 'Phone number of the Fayda-verified account' })
@IsString()
phoneNumber: string;
}
export class FaydaVerifyAndLoginDto {
@ApiProperty({ example: '+251911234567' })
@IsString()
phoneNumber: string;
@ApiProperty({ example: '123456', description: '6-digit OTP received via SMS' })
@IsString()
otp: string;
}

View File

@@ -2,6 +2,7 @@ import {
Injectable,
ConflictException,
InternalServerErrorException,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { ModuleRef, ContextIdFactory } from '@nestjs/core';
@@ -10,6 +11,7 @@ import { DataSource } from 'typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AuthService as IamAuthService } from '@tria-plc/iamapi-common/module/auth/services/auth.service';
import { EUserType } from '@tria-plc/api-common/utils/enums/user.enum';
import { EOtpType } from '@tria-plc/iamapi-common/enums/otp.enum';
import { PrismaService } from '../../common/prisma.service';
import { RegisterDto, LoginDto } from './auth.dto';
@@ -19,10 +21,13 @@ type IamUserRow = {
name: { en: string; am: string } | null;
phone_number: string | null;
metadata: Record<string, any> | null;
verified_by: string | null;
};
@Injectable()
export class PassengerAuthService {
private readonly logger = new Logger(PassengerAuthService.name);
constructor(
private readonly prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
@@ -165,7 +170,7 @@ export class PassengerAuthService {
include: { loyalty: true, wallet: true },
}),
this.dataSource.query<IamUserRow[]>(
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`,
`SELECT id, email, name, phone_number, metadata, verified_by FROM iam.users WHERE id = $1 LIMIT 1`,
[iamUserId],
),
]);
@@ -178,7 +183,7 @@ export class PassengerAuthService {
email: iam?.email ?? null,
phone: iam?.phone_number ?? null,
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
faydaVerified: iam?.metadata?.faydaVerified ?? false,
faydaVerified: iam?.verified_by === 'fayda',
createdAt: passenger.createdAt,
passenger: {
id: passenger.id,
@@ -396,6 +401,121 @@ export class PassengerAuthService {
return { success: true, message: 'Password reset successfully' };
}
async requestFaydaPasswordSetup(phoneNumber: string, req: any): Promise<{ sent: boolean }> {
const phone = this.standardizePhone(phoneNumber);
const users = await this.dataSource.query<{ id: string; email: string }[]>(
`SELECT id, email FROM iam.users WHERE phone_number = $1 AND verified_by = 'fayda' LIMIT 1`,
[phone],
);
this.logger.log(`requestFaydaPasswordSetup: phone=${phone} found=${users.length > 0}`);
// Return success regardless to avoid phone enumeration
if (!users.length) return { sent: true };
const u = users[0];
const iamAuthService = await this.resolveIamAuthService(req);
await iamAuthService.generateVerificationCode({
email: u.email,
phoneNumber: phone,
type: EOtpType.SET_PASSWORD,
});
return { sent: true };
}
async verifyFaydaAndLogin(
phoneNumber: string,
otp: string,
): Promise<{ token: string; refreshToken: string; requiresPassword: boolean; iamUserId: string }> {
const phone = this.standardizePhone(phoneNumber);
const users = await this.dataSource.query<{
id: string;
email: string;
name: { en: string; am: string } | null;
username: string;
phone_number: string | null;
has_set_password: boolean;
}[]>(
`SELECT id, email, name, username, phone_number, has_set_password
FROM iam.users WHERE phone_number = $1 AND verified_by = 'fayda' LIMIT 1`,
[phone],
);
if (!users.length) throw new UnauthorizedException('Invalid phone number or OTP');
const u = users[0];
const verifications = await this.dataSource.query<{
id: string; verification_code: string; attempt_count: number;
}[]>(
`SELECT id, verification_code, attempt_count FROM iam.user_verifications
WHERE user_id = $1 AND otp_type = 'set-password' AND "isUsed" = false AND expires_at > NOW()
ORDER BY created_at DESC LIMIT 1`,
[u.id],
);
if (!verifications.length) throw new UnauthorizedException('Invalid phone number or OTP');
const v = verifications[0];
if (v.attempt_count >= 5) {
await this.dataSource.query(
`UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id],
);
throw new UnauthorizedException('Too many attempts. Request a new code.');
}
await this.dataSource.query(
`UPDATE iam.user_verifications SET attempt_count = attempt_count + 1 WHERE id = $1`, [v.id],
);
const { verifyPassword } = await import('@tria-plc/api-common/utils/argon');
const valid = await verifyPassword(otp, v.verification_code);
if (!valid) throw new UnauthorizedException('Invalid phone number or OTP');
await this.dataSource.query(
`UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id],
);
const userInfo = {
id: u.id,
email: u.email ?? '',
name: u.name ?? { en: '', am: '' },
userType: 'individual',
status: 'accepted',
hasSetPassword: u.has_set_password,
isPhoneNumberVerified: false,
hasFinishedRegistration: false,
hasFinishedDMSOnboarding: false,
username: u.username,
phoneNumber: u.phone_number ?? '',
roles: [],
permissions: [],
employee: [],
};
const sessions = await this.dataSource.query<{ id: string }[]>(
`INSERT INTO iam.sessions
(id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
VALUES (gen_random_uuid(), $1, 'fayda-otp-setup', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3)
ON CONFLICT (user_id, device) DO UPDATE
SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo",
expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW()
RETURNING id`,
[u.email ?? '', JSON.stringify(userInfo), u.id],
);
const { generateToken, generateRefreshToken } = await import('@tria-plc/api-common/utils/token');
const token = generateToken({ id: sessions[0].id });
const refreshToken = generateRefreshToken({ id: sessions[0].id });
return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id };
}
private standardizePhone(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}`;
}
private async compensateIamSignup(email: string): Promise<void> {
try {
const rows = await this.dataSource.query<{ id: string }[]>(

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,

View File

@@ -74,6 +74,7 @@ export class VerifaydaController {
purpose: dto.purpose ?? 'VERIFY',
platform: dto.platform ?? 'WEB',
userId: req.user?.id,
wantsPasswordSetup: dto.wantsPasswordSetup ?? false,
});
return { authorizationUrl };
}

View File

@@ -21,6 +21,17 @@ export class StartVerificationDto {
@IsOptional()
@IsIn(['WEB', 'MOBILE'])
platform?: 'WEB' | 'MOBILE';
@ApiPropertyOptional({
type: Boolean,
default: false,
description:
'Set to true when the user opts in to full account registration (checkbox). ' +
'When true, the /complete response includes a short-lived token and promptPasswordSetup=true ' +
'so the frontend can immediately prompt for a password via POST /v1/auth/set-fayda-password.',
})
@IsOptional()
wantsPasswordSetup?: boolean;
}
export class CompleteVerificationResultDto {
@@ -29,9 +40,12 @@ export class CompleteVerificationResultDto {
@ApiProperty() verified: boolean;
@ApiPropertyOptional({ description: 'JWT (LOGIN flow only).' })
@ApiPropertyOptional({ description: 'JWT. LOGIN: session token for the authenticated user. VERIFY: short-lived token for calling /v1/auth/set-fayda-password.' })
token?: string;
@ApiPropertyOptional()
refreshToken?: string;
@ApiPropertyOptional({
description: 'Authenticated user summary (LOGIN flow only; same shape as /auth/login).',
})
@@ -62,6 +76,19 @@ export class CompleteVerificationResultDto {
@ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' })
userDataSaved?: boolean;
@ApiPropertyOptional({ description: 'IAM user ID of the verified identity (VERIFY flow).' })
iamUserId?: string;
@ApiPropertyOptional({ description: 'True when the IAM account has not yet set a password (VERIFY flow).' })
requiresPassword?: boolean;
@ApiPropertyOptional({
description:
'True when the user opted in to immediate password setup (wantsPasswordSetup=true at start) ' +
'AND they have not yet set a password. Frontend should navigate to the set-password screen.',
})
promptPasswordSetup?: boolean;
}
export class VerifaydaCallbackDto {

View File

@@ -8,6 +8,7 @@ import {
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { generateToken, generateRefreshToken } from '@tria-plc/api-common/utils/token';
import axios, { AxiosInstance } from 'axios';
import { PrismaService } from '../../common/prisma.service';
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
@@ -47,6 +48,7 @@ export interface StartVerificationInput {
purpose: VerifaydaPurpose;
platform?: FaydaPlatform;
userId?: string; // iamUserId of the authenticated user, if any
wantsPasswordSetup?: boolean;
}
export interface FaydaUserSummary {
@@ -66,6 +68,10 @@ export interface CompleteVerificationResult {
purpose: VerifaydaPurpose;
verified: boolean;
token?: string;
refreshToken?: string;
requiresPassword?: boolean;
promptPasswordSetup?: boolean;
iamUserId?: string;
user?: FaydaUserSummary;
fullName?: string;
email?: string;
@@ -145,6 +151,7 @@ export class VerifaydaService {
codeVerifier,
purpose: input.purpose,
platform: input.platform ?? 'WEB',
saveToAccount: input.wantsPasswordSetup ?? false,
iamUserId: input.userId ?? null,
expiresAt,
},
@@ -220,8 +227,18 @@ export class VerifaydaService {
const login = await this.issueLoginToken(userId);
result = { purpose: 'LOGIN', verified: true, ...login };
} else {
// VERIFY — prove identity, save to IAM, return verified attributes.
const { userDataSaved } = await this.upsertIamUser(normalized);
// VERIFY — prove identity, save to IAM, return verified attributes + short-lived token.
const { iamUserId, userDataSaved } = await this.upsertIamUser(normalized);
let sessionToken: { token: string; refreshToken: string; requiresPassword: boolean } | undefined;
if (iamUserId) {
try {
sessionToken = await this.createFaydaSession(iamUserId);
} catch (err) {
this.logger.warn(`Fayda session creation failed: ${(err as Error).message}`);
}
}
result = {
purpose: 'VERIFY',
verified: true,
@@ -231,6 +248,11 @@ export class VerifaydaService {
birthdate: normalized.birthdate,
gender: normalized.gender,
userDataSaved,
iamUserId: iamUserId ?? undefined,
token: sessionToken?.token,
refreshToken: sessionToken?.refreshToken,
requiresPassword: sessionToken?.requiresPassword,
promptPasswordSetup: session.saveToAccount && (sessionToken?.requiresPassword ?? false),
};
}
@@ -298,14 +320,19 @@ export class VerifaydaService {
claims_locales: this.faydaConfig.claimsLocales,
});
// Every claim is marked essential so eSignet shows them locked/pre-checked
// on the consent screen — the user cannot toggle any off; they either
// consent to all of them or the whole flow is cancelled (?error=...).
const claims = {
userinfo: {
name: { essential: true },
phone_number: { essential: true },
email: { essential: false },
email: { essential: true },
birthdate: { essential: true },
gender: { essential: false },
picture: { essential: false },
gender: { essential: true },
address: { essential: true },
nationality: { essential: true },
picture: { essential: true },
},
id_token: {},
};
@@ -447,12 +474,16 @@ export class VerifaydaService {
phoneNumber: normalized.rawPhoneNumber ?? '',
};
// Step 1 — already verified with same Fayda sub
// Step 1 — already linked to this Fayda sub; ensure verified_by is set
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) {
await this.dataSource.query(
`UPDATE iam.users SET verified_by = 'fayda', updated_at = NOW() WHERE id = $1`,
[bySub[0].id],
);
return { iamUserId: bySub[0].id, userDataSaved: true };
}
@@ -497,7 +528,7 @@ export class VerifaydaService {
created_at, updated_at
) VALUES (
gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb,
'individual', 'accepted', true, false,
'individual', 'submitted', true, false,
false, 'fayda',
NOW(), NOW()
) RETURNING id`,
@@ -516,6 +547,60 @@ export class VerifaydaService {
}
}
private async createFaydaSession(
iamUserId: string,
): Promise<{ token: string; refreshToken: string; requiresPassword: boolean }> {
const rows = await this.dataSource.query<{
id: string;
email: string;
name: { en: string; am: string } | null;
username: string;
phone_number: string | null;
has_set_password: boolean;
status: string;
}[]>(
`SELECT id, email, name, username, phone_number, has_set_password, status
FROM iam.users WHERE id = $1 LIMIT 1`,
[iamUserId],
);
if (!rows.length) throw new Error(`IAM user ${iamUserId} not found`);
const u = rows[0];
const userInfo = {
id: u.id,
email: u.email ?? '',
name: u.name ?? { en: '', am: '' },
userType: 'individual',
status: u.status,
hasSetPassword: u.has_set_password,
isPhoneNumberVerified: false,
hasFinishedRegistration: false,
hasFinishedDMSOnboarding: false,
username: u.username,
phoneNumber: u.phone_number ?? '',
roles: [],
permissions: [],
employee: [],
};
const sessions = await this.dataSource.query<{ id: string }[]>(
`INSERT INTO iam.sessions
(id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
VALUES (gen_random_uuid(), $1, 'fayda-verify', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3)
ON CONFLICT (user_id, device) DO UPDATE
SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo",
expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW()
RETURNING id`,
[u.email ?? '', JSON.stringify(userInfo), iamUserId],
);
const sessionId = sessions[0].id;
const token = generateToken({ id: sessionId });
const refreshToken = generateRefreshToken({ id: sessionId });
return { token, refreshToken, requiresPassword: !u.has_set_password };
}
private async markSessionFailed(
state: string,
errorCode: string,

View File

@@ -47,8 +47,14 @@ export default function BookingsPage() {
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ['bookings', filters],
queryFn: () => bookingsApi.getAll(filters),
queryKey: ['bookings', filters, extraFilters],
queryFn: () => bookingsApi.getAll({
...filters,
...(extraFilters.bookingType && { bookingType: extraFilters.bookingType }),
...(extraFilters.paymentStatus && { paymentStatus: extraFilters.paymentStatus }),
...(extraFilters.dateFrom && { dateFrom: extraFilters.dateFrom }),
...(extraFilters.dateTo && { dateTo: extraFilters.dateTo }),
}),
});
const cancelMutation = useMutation({
@@ -257,8 +263,8 @@ export default function BookingsPage() {
<select className="input" value={extraFilters.paymentStatus}
onChange={(e) => setExtraFilters({ ...extraFilters, paymentStatus: e.target.value })}>
<option value="">All Payments</option>
<option value="PENDING">Pending</option>
<option value="PAID">Paid</option>
<option value="PENDING">Pending</option>
<option value="FAILED">Failed</option>
<option value="REFUNDED">Refunded</option>
</select>

View File

@@ -20,14 +20,21 @@ const STATUS_VARIANT: Record<string, any> = {
export default function ExcessBaggagePage() {
const queryClient = useQueryClient();
const [filters, setFilters] = useState({ status: '', bookingRef: '', page: '1' });
const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' });
const [showExtraFilters, setShowExtraFilters] = useState(false);
const [waiveModal, setWaiveModal] = useState<any>(null);
const [waiveReason, setWaiveReason] = useState('');
const [waiveError, setWaiveError] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['excess-baggage', filters],
queryFn: () => excessBaggageApi.getAll({ status: filters.status || undefined, bookingRef: filters.bookingRef || undefined, page: filters.page }),
queryFn: () => excessBaggageApi.getAll({
status: filters.status || undefined,
bookingRef: filters.bookingRef || undefined,
dateFrom: filters.dateFrom || undefined,
dateTo: filters.dateTo || undefined,
page: filters.page,
}),
});
const waiveMutation = useMutation({
@@ -104,14 +111,14 @@ export default function ExcessBaggagePage() {
icon: Send,
variant: 'secondary' as const,
onClick: (c: any) => resendMutation.mutate(c.id),
hidden: (c: any) => c.status !== 'PENDING',
show: (c: any) => c.status === 'PENDING',
},
{
label: 'Waive',
icon: RefreshCw,
variant: 'secondary' as const,
onClick: (c: any) => { setWaiveModal(c); setWaiveReason(''); setWaiveError(null); },
hidden: (c: any) => ['PAID', 'CASH_COLLECTED', 'WAIVED'].includes(c.status),
show: (c: any) => !['PAID', 'CASH_COLLECTED', 'WAIVED'].includes(c.status),
},
];
@@ -125,31 +132,41 @@ export default function ExcessBaggagePage() {
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Booking Ref</label>
<input
className="input"
placeholder="Search by booking ref…"
value={filters.bookingRef}
onChange={(e) => setFilters({ ...filters, bookingRef: e.target.value, page: '1' })}
/>
</div>
<div>
<label className="label">Status</label>
<select
className="input"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value, page: '1' })}
>
<option value="">All</option>
<div className="mb-4 space-y-3">
<div className="flex flex-wrap gap-3">
<div className="flex-1 min-w-48">
<input className="input" placeholder="Search by booking ref…"
value={filters.bookingRef}
onChange={(e) => setFilters({ ...filters, bookingRef: e.target.value, page: '1' })} />
</div>
<select className="input w-44" value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value, page: '1' })}>
<option value="">All Status</option>
<option value="PENDING">Pending</option>
<option value="PAID">Paid</option>
<option value="CASH_COLLECTED">Cash Collected</option>
<option value="EXPIRED">Expired</option>
<option value="WAIVED">Waived</option>
</select>
<button type="button" className="input w-auto px-4 text-sm font-medium text-primary border-primary/40"
onClick={() => setShowExtraFilters(v => !v)}>
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
</button>
</div>
{showExtraFilters && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
<div>
<label className="label">Date From</label>
<input type="date" className="input" value={filters.dateFrom}
onChange={(e) => setFilters({ ...filters, dateFrom: e.target.value, page: '1' })} />
</div>
<div>
<label className="label">Date To</label>
<input type="date" className="input" value={filters.dateTo}
onChange={(e) => setFilters({ ...filters, dateTo: e.target.value, page: '1' })} />
</div>
</div>
)}
</div>
</div>

View File

@@ -0,0 +1,7 @@
'use client';
import DashboardLayout from '../dashboard/layout';
export default function PackageInquiriesLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,193 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { packageInquiriesApi, packagesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
const STATUSES = ['NEW', 'CONTACTED', 'CONVERTED', 'CLOSED'];
const statusVariant: Record<string, string> = {
NEW: 'info',
CONTACTED: 'warning',
CONVERTED: 'success',
CLOSED: 'default',
};
export default function PackageInquiriesPage() {
const [filters, setFilters] = useState({ packageId: '', status: '' });
const [deleteConfirm, setDeleteConfirm] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['package-inquiries', filters],
queryFn: () => packageInquiriesApi.getAll({ ...filters, pageSize: 50 }),
});
const { data: packagesData } = useQuery({
queryKey: ['packages-all-simple'],
queryFn: () => packagesApi.getAll({ pageSize: 100 }),
});
const packages: any[] = packagesData?.items || [];
const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
packageInquiriesApi.updateStatus(id, status),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['package-inquiries'] }),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => packageInquiriesApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['package-inquiries'] });
setDeleteConfirm(null);
setDeleteError(null);
},
onError: (e: any) => setDeleteError(e?.response?.data?.message || e?.message || 'Failed to delete'),
});
const columns = [
{
key: 'contact',
label: 'Contact',
render: (row: any) => (
<div>
<div className="font-semibold">{row.contactName}</div>
<div className="text-xs text-muted-foreground">{row.contactEmail || row.contactPhone || '—'}</div>
</div>
),
},
{
key: 'package',
label: 'Package',
render: (row: any) => (
<div>
<div className="font-medium">{row.package?.name || '—'}</div>
<div className="text-xs text-muted-foreground font-mono">{row.package?.code}</div>
</div>
),
},
{
key: 'priceTier',
label: 'Price Tier',
render: (row: any) => row.priceTier ? (
<div>
<div className="text-sm font-medium">{row.priceTier.label}</div>
<div className="text-xs text-muted-foreground">{formatCurrency(row.priceTier.priceMinor, 'ETB')} / person</div>
</div>
) : <span className="text-muted-foreground text-sm"></span>,
},
{
key: 'travelerCount',
label: 'Travelers',
render: (row: any) => (
<span className="font-semibold">{row.travelerCount}</span>
),
},
{
key: 'enquiredAt',
label: 'Enquired At',
render: (row: any) => (
<span className="text-sm">{formatDateTime(row.enquiredAt)}</span>
),
},
{
key: 'notes',
label: 'Notes',
render: (row: any) => (
<span className="text-sm text-muted-foreground line-clamp-2 max-w-xs">{row.notes || '—'}</span>
),
},
{
key: 'status',
label: 'Status',
render: (row: any) => (
<select
className="input py-1 text-xs"
value={row.status}
onChange={(e) => statusMutation.mutate({ id: row.id, status: e.target.value })}
>
{STATUSES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
),
},
];
const actions = [
{
label: 'Delete',
onClick: (row: any) => { setDeleteConfirm(row); setDeleteError(null); },
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Package Inquiries</h1>
<p className="text-muted-foreground">Manage incoming package inquiries</p>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Package</label>
<select
className="input"
value={filters.packageId}
onChange={(e) => setFilters({ ...filters, packageId: e.target.value })}
>
<option value="">All Packages</option>
{packages.map((p: any) => (
<option key={p.id} value={p.id}>{p.name} ({p.code})</option>
))}
</select>
</div>
<div>
<label className="label">Status</label>
<select
className="input"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
>
<option value="">All Statuses</option>
{STATUSES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</div>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No inquiries found"
/>
<ConfirmDialog
isOpen={!!deleteConfirm}
onClose={() => { setDeleteConfirm(null); setDeleteError(null); }}
onConfirm={() => deleteMutation.mutate(deleteConfirm.id)}
title="Delete Inquiry"
message={`Delete inquiry from ${deleteConfirm?.contactName}? This cannot be undone.`}
confirmText="Delete"
isDanger
isLoading={deleteMutation.isPending}
error={deleteError ?? undefined}
/>
</div>
);
}

View File

@@ -29,6 +29,11 @@ const emptyForm = {
export default function PackagesPage() {
const [page] = useState(1);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [showExtraFilters, setShowExtraFilters] = useState(false);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [form, setForm] = useState(emptyForm);
const [modalMode, setModalMode] = useState<'create' | 'edit' | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
@@ -282,6 +287,15 @@ export default function PackagesPage() {
const isPending = createMutation.isPending || updateMutation.isPending;
const allItems: any[] = data?.items || [];
const filteredItems = allItems.filter((p) => {
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) && !p.code.toLowerCase().includes(search.toLowerCase())) return false;
if (statusFilter && p.status !== statusFilter) return false;
if (dateFrom && new Date(p.validFrom).toISOString().split('T')[0] < dateFrom) return false;
if (dateTo && new Date(p.validUntil).toISOString().split('T')[0] > dateTo) return false;
return true;
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -292,13 +306,47 @@ export default function PackagesPage() {
<ActionButton icon={Plus} onClick={openCreate}>New Package</ActionButton>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No packages found"
/>
<div className="card">
<div className="mb-4 space-y-3">
<div className="flex flex-wrap gap-3">
<div className="flex-1 min-w-48">
<input type="text" placeholder="Search by name or code..." className="input"
value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<select className="input w-44" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">All Status</option>
<option value="DRAFT">Draft</option>
<option value="ACTIVE">Active</option>
<option value="SOLD_OUT">Sold Out</option>
<option value="EXPIRED">Expired</option>
<option value="CANCELLED">Cancelled</option>
</select>
<button type="button" className="input w-auto px-4 text-sm font-medium text-primary border-primary/40"
onClick={() => setShowExtraFilters(v => !v)}>
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
</button>
</div>
{showExtraFilters && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
<div>
<label className="label">Valid From</label>
<input type="date" className="input" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Valid Until</label>
<input type="date" className="input" value={dateTo} onChange={(e) => setDateTo(e.target.value)} />
</div>
</div>
)}
</div>
<DataTable
data={filteredItems}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No packages found"
/>
</div>
{/* View Modal */}
<Modal isOpen={!!viewPackage} onClose={() => setViewPackage(null)} title="Package Details" size="lg">

View File

@@ -63,8 +63,14 @@ export default function PassengersPage() {
});
const { data, isLoading, error } = useQuery({
queryKey: ['passengers', filters],
queryFn: () => passengersApi.getAll(filters),
queryKey: ['passengers', filters, extraFilters],
queryFn: () => passengersApi.getAll({
...filters,
...(extraFilters.gender && { gender: extraFilters.gender }),
...(extraFilters.nationality && { nationality: extraFilters.nationality }),
...(extraFilters.dateFrom && { dateFrom: extraFilters.dateFrom }),
...(extraFilters.dateTo && { dateTo: extraFilters.dateTo }),
}),
});
const PASSENGER_COLS = [

View File

@@ -78,6 +78,8 @@ export default function TicketsPage() {
originStationId: filters.originStationId || undefined,
destinationStationId: filters.destinationStationId || undefined,
arrivalDate: filters.arrivalDate || undefined,
dateFrom: filters.dateFrom || undefined,
dateTo: filters.dateTo || undefined,
skip: 0,
take: 50,
}),
@@ -483,7 +485,7 @@ export default function TicketsPage() {
Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
<div>
<label className="label">Search</label>
<input
@@ -529,20 +531,40 @@ export default function TicketsPage() {
onChange={(e) => setFilters({ ...filters, arrivalDate: e.target.value })}
/>
</div>
<div>
<label className="label">Status</label>
<select
className="input"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
>
<option value="">All Status</option>
<option value="ACTIVE">Active</option>
<option value="USED">Used</option>
<option value="CANCELLED">Cancelled</option>
</select>
<div className="flex items-end">
<button type="button" className="input w-full px-4 text-sm font-medium text-primary border-primary/40"
onClick={() => setShowExtraFilters(v => !v)}>
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
</button>
</div>
</div>
{showExtraFilters && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-3">
<div>
<label className="label">Status</label>
<select
className="input"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
>
<option value="">All Status</option>
<option value="ACTIVE">Active</option>
<option value="USED">Used</option>
<option value="CANCELLED">Cancelled</option>
</select>
</div>
<div>
<label className="label">Issued From</label>
<input type="date" className="input" value={filters.dateFrom}
onChange={(e) => setFilters({ ...filters, dateFrom: e.target.value })} />
</div>
<div>
<label className="label">Issued To</label>
<input type="date" className="input" value={filters.dateTo}
onChange={(e) => setFilters({ ...filters, dateTo: e.target.value })} />
</div>
</div>
)}
</div>
{/* Tickets Table */}

View File

@@ -60,6 +60,7 @@ const navigationSections = [
title: 'Tourism',
items: [
{ name: 'Packages', href: '/packages', icon: Package },
{ name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare },
]
},
{

View File

@@ -397,6 +397,22 @@ export const packagesApi = {
deleteTier: (tierId: string) => apiClient.delete(`/packages/tiers/${tierId}`),
};
// Package Inquiries API
export const packageInquiriesApi = {
getAll: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
) as Record<string, string>;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/packages/inquiries${query ? `?${query}` : ''}`);
if (response?.data) return Array.isArray(response.data) ? { items: response.data } : response;
return Array.isArray(response) ? { items: response } : response;
},
create: (data: any) => apiClient.post<any>('/packages/inquiries', data),
updateStatus: (id: string, status: string) => apiClient.patch<any>(`/packages/inquiries/${id}/status`, { status }),
remove: (id: string) => apiClient.delete(`/packages/inquiries/${id}`),
};
// Excess Baggage API
export const excessBaggageApi = {
logCharge: (data: any) => apiClient.post<any>('/agents/excess-baggage', data),

52
pnpm-lock.yaml generated
View File

@@ -479,10 +479,10 @@ importers:
version: 8.1.6
'@tria-plc/api-common':
specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz
version: file:local-packages/tria-plc-api-common-1.4.3.tgz(e6b80acddd4bb7fc40e438635b24d1bc)
version: file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6)
'@tria-plc/iamapi-common':
specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz
version: file:local-packages/tria-plc-iamapi-common-0.7.4.tgz(c97ba831ddde82920910406ab5262991)
specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.6.tgz
version: file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(c97ba831ddde82920910406ab5262991)
'@types/bcrypt':
specifier: ^6.0.0
version: 6.0.0
@@ -907,7 +907,7 @@ importers:
version: 9.1.2(eslint@8.57.1)
eslint-plugin-import:
specifier: ^2.31.0
version: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
version: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-plugin-react:
specifier: ^7.37.1
version: 7.37.5(eslint@8.57.1)
@@ -4065,28 +4065,6 @@ packages:
rxjs: ^7.8.0
typeorm: ^0.3.0
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.4.tgz':
resolution: {integrity: sha512-6Ot921laEp3rZZBXDFX+gL7nPKEyHIRJaHSIP1i+seG20+PCCGA/QHDglcJXSgF5ccnpxBdlxmI/BZbYu7LV/A==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.4.tgz}
version: 0.7.4
engines: {node: '>=20'}
peerDependencies:
'@nestjs/axios': ^4.0.0
'@nestjs/common': ^11.0.0
'@nestjs/core': ^11.0.0
'@nestjs/jwt': ^11.0.0
'@nestjs/microservices': ^11.0.0
'@nestjs/passport': ^11.0.0
'@nestjs/swagger': ^11.0.0
'@nestjs/throttler': ^6.0.0
'@nestjs/typeorm': ^11.0.0
'@tria-plc/api-common': '*'
axios: ^1.9.0
class-transformer: ^0.5.1
class-validator: ^0.14.1
reflect-metadata: ^0.2.0
rxjs: ^7.8.0
typeorm: ^0.3.0
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz':
resolution: {integrity: sha512-AEYNqqP3Iu26N09LdZ6hBFSKqceKdIkedWNFMe3rau5CoflyrzWdoBUIcnFFbQlR9lRywj03HDkxK+MKNlExLA==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.6.tgz}
version: 0.7.6
@@ -15213,7 +15191,7 @@ snapshots:
'@tootallnate/quickjs-emscripten@0.23.0': {}
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(e6b80acddd4bb7fc40e438635b24d1bc)':
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6)':
dependencies:
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -15224,7 +15202,7 @@ snapshots:
'@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
'@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.4.tgz(c97ba831ddde82920910406ab5262991)
'@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(c97ba831ddde82920910406ab5262991)
argon2: 0.43.1
axios: 1.17.0
change-case: 5.4.4
@@ -15301,7 +15279,7 @@ snapshots:
- debug
- supports-color
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.4.tgz(c97ba831ddde82920910406ab5262991)':
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e)':
dependencies:
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -15309,10 +15287,10 @@ snapshots:
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
'@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(e6b80acddd4bb7fc40e438635b24d1bc)
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e)
api-common: 1.2.2
argon2: 0.43.1
axios: 1.17.0
@@ -15336,7 +15314,7 @@ snapshots:
- '@faker-js/faker'
- supports-color
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e)':
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(c97ba831ddde82920910406ab5262991)':
dependencies:
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -15344,10 +15322,10 @@ snapshots:
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e)
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6)
api-common: 1.2.2
argon2: 0.43.1
axios: 1.17.0
@@ -17874,7 +17852,7 @@ snapshots:
eslint: 8.57.1
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1)
@@ -17908,7 +17886,7 @@ snapshots:
tinyglobby: 0.2.17
unrs-resolver: 1.12.2
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
@@ -17923,7 +17901,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9