diff --git a/apps/edr-passenger-api/prisma/migrations/20260704132103_add_package_fields_to_booking/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260704132103_add_package_fields_to_booking/migration.sql new file mode 100644 index 000000000..365c15559 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260704132103_add_package_fields_to_booking/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Booking" ADD COLUMN "packageId" TEXT, +ADD COLUMN "priceTierId" TEXT; + +-- AddForeignKey +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_priceTierId_fkey" FOREIGN KEY ("priceTierId") REFERENCES "PackagePriceTier"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260704212551_add_fraud_alert_acknowledged_at/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260704212551_add_fraud_alert_acknowledged_at/migration.sql new file mode 100644 index 000000000..51e09889a --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260704212551_add_fraud_alert_acknowledged_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "FraudAlert" ADD COLUMN "acknowledgedAt" TIMESTAMP(3); diff --git a/apps/edr-passenger-api/prisma/migrations/20260705000000_add_app_releases/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260705000000_add_app_releases/migration.sql new file mode 100644 index 000000000..0ce87d7de --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260705000000_add_app_releases/migration.sql @@ -0,0 +1,13 @@ +CREATE TABLE "passenger"."AppRelease" ( + "id" TEXT NOT NULL, + "os" TEXT NOT NULL, + "version" TEXT NOT NULL, + "forceUpdate" BOOLEAN NOT NULL DEFAULT false, + "storeLink" TEXT, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "AppRelease_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AppRelease_os_version_key" ON "passenger"."AppRelease"("os", "version"); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 68b3ac234..ba3571ab1 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -506,6 +506,8 @@ model Booking { bookingRef String @unique passengerId String scheduleId String + packageId String? + priceTierId String? bookingType String @default("ONE_WAY") status BookingStatus @default(DRAFT) currency String @default("ETB") @@ -544,6 +546,8 @@ model Booking { passenger Passenger @relation(fields: [passengerId], references: [id]) schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id]) returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id]) + package TravelPackage? @relation(fields: [packageId], references: [id]) + priceTier PackagePriceTier? @relation(fields: [priceTierId], references: [id]) seats BookingSeat[] paymentIntent PaymentIntent? tickets Ticket[] @@ -1301,6 +1305,7 @@ model FraudAlert { context Json severity String @default("MEDIUM") acknowledged Boolean @default(false) + acknowledgedAt DateTime? createdAt DateTime @default(now()) @@index([iamUserId, createdAt]) @@index([acknowledged]) @@ -1428,7 +1433,8 @@ model TravelPackage { outboundSchedule TrainSchedule @relation("PackageOutbound", fields: [outboundScheduleId], references: [id]) returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id]) priceTiers PackagePriceTier[] - bookings PackageBooking[] + bookings Booking[] + packageBookings PackageBooking[] inquiries PackageInquiry[] @@index([status, validFrom]) @@ -1446,7 +1452,8 @@ model PackagePriceTier { bookedSeats Int @default(0) package TravelPackage @relation(fields: [packageId], references: [id]) - bookings PackageBooking[] + bookings Booking[] + packageBookings PackageBooking[] inquiries PackageInquiry[] @@unique([packageId, seatType]) @@ -1536,3 +1543,17 @@ model PackageInquiry { @@index([packageId]) @@schema("passenger") } + +model AppRelease { + id String @id @default(uuid()) + os String // "android" | "ios" + version String + forceUpdate Boolean @default(false) + storeLink String? + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([os, version]) + @@schema("passenger") +} diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 0e9f85079..313ce34b0 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -854,26 +854,64 @@ async function runStep(name: string, step: () => Promise): Promise Promise]> = [ - ['System Users', seedSystemUsers], - ['Stations', seedStations], - ['Coach Types & Classes', seedCoachTypesAndClasses], - ['Route', seedRoute], - ['Coaches', seedCoaches], - ['Trips', seedTrips], - ['Fare Rules', seedFareRules], - ['Currency', seedCurrency], - ['Payment Methods', seedPaymentMethods], - ['Segment Fares', seedSegmentFares], - ['Notification Templates', seedNotificationTemplates], - ['Menu & Food', seedMenuAndFood], - ['Promotions', seedPromotions], - ['FAQ', seedFAQ], - ['Fraud Rules', seedFraudRules], - ['Kulubbi Package', seedKulubbiPackage], + // ['System Users', seedSystemUsers], + // ['Stations', seedStations], + // ['Coach Types & Classes', seedCoachTypesAndClasses], + // ['Route', seedRoute], + // ['Coaches', seedCoaches], + // ['Trips', seedTrips], + // ['Fare Rules', seedFareRules], + // ['Currency', seedCurrency], + // ['Payment Methods', seedPaymentMethods], + // ['Segment Fares', seedSegmentFares], + // ['Notification Templates', seedNotificationTemplates], + // ['Menu & Food', seedMenuAndFood], + // ['Promotions', seedPromotions], + // ['FAQ', seedFAQ], + // ['Fraud Rules', seedFraudRules], + // ['Kulubbi Package', seedKulubbiPackage], + // ['Package Bookings', seedPackageBookings], ]; let failed = 0; diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index ba7a18086..65381f519 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -61,6 +61,7 @@ import { PackagesModule } from './modules/packages/packages.module'; import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module'; import { HealthModule } from './modules/health/health.module'; import { TasksModule } from './modules/tasks/tasks.module'; +import { AppReleasesModule } from './modules/app-releases/app-releases.module'; @Module({ imports: [ @@ -130,6 +131,7 @@ import { TasksModule } from './modules/tasks/tasks.module'; ExcessBaggageModule, HealthModule, TasksModule, + AppReleasesModule, ], providers: [ { provide: APP_GUARD, useClass: DynamicThrottlerGuard }, diff --git a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts index 39d492b2c..0cc8d93b6 100644 --- a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts +++ b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts @@ -6,6 +6,7 @@ import { HttpStatus, Logger, } from '@nestjs/common'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; @Catch() export class HttpExceptionFilter implements ExceptionFilter { @@ -22,15 +23,27 @@ export class HttpExceptionFilter implements ExceptionFilter { const response = ctx.getResponse(); const request = ctx.getRequest(); + let prismaMessage: string | null = null; + if (exception instanceof PrismaClientKnownRequestError) { + if (exception.code === 'P2003') { + const field = (exception.meta?.field_name as string | undefined) ?? 'a related record'; + prismaMessage = `Cannot delete this record because it is still referenced by ${field}. Remove the related records first.`; + } else if (exception.code === 'P2025') { + prismaMessage = 'Record not found.'; + } + } + const status = exception instanceof HttpException ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; + : prismaMessage + ? HttpStatus.BAD_REQUEST + : HttpStatus.INTERNAL_SERVER_ERROR; const messageRaw = exception instanceof HttpException ? exception.getResponse() - : 'Internal server error'; + : prismaMessage ?? 'Internal server error'; const message = typeof messageRaw === 'string' diff --git a/apps/edr-passenger-api/src/modules/app-releases/app-releases.controller.ts b/apps/edr-passenger-api/src/modules/app-releases/app-releases.controller.ts new file mode 100644 index 000000000..7fbfa7de0 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/app-releases/app-releases.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, SetMetadata } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { AppReleasesService, AppReleaseDto } from './app-releases.service'; +import { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; + +@ApiTags('App Releases') +@Controller('app-releases') +export class AppReleasesController { + constructor(private service: AppReleasesService) {} + + @Get() + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'List all app releases (public)' }) + getAll() { + return this.service.getAll(); + } + + @Get('latest/:os') + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Get latest release for a given OS (public)' }) + @ApiParam({ name: 'os', enum: ['android', 'ios'] }) + getLatest(@Param('os') os: string) { + return this.service.getLatest(os); + } + + @Post() + @PassengerStaff(PASSENGER_PERMS.admin) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Create an app release (admin)' }) + create(@Body() dto: AppReleaseDto) { + return this.service.create(dto); + } + + @Patch(':id') + @PassengerStaff(PASSENGER_PERMS.admin) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Update an app release (admin)' }) + update(@Param('id') id: string, @Body() dto: Partial) { + return this.service.update(id, dto); + } + + @Delete(':id') + @PassengerStaff(PASSENGER_PERMS.admin) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Delete an app release (admin)' }) + remove(@Param('id') id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-passenger-api/src/modules/app-releases/app-releases.module.ts b/apps/edr-passenger-api/src/modules/app-releases/app-releases.module.ts new file mode 100644 index 000000000..89e1f733a --- /dev/null +++ b/apps/edr-passenger-api/src/modules/app-releases/app-releases.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AppReleasesController } from './app-releases.controller'; +import { AppReleasesService } from './app-releases.service'; +import { PrismaModule } from '../../common/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [AppReleasesController], + providers: [AppReleasesService], +}) +export class AppReleasesModule {} diff --git a/apps/edr-passenger-api/src/modules/app-releases/app-releases.service.ts b/apps/edr-passenger-api/src/modules/app-releases/app-releases.service.ts new file mode 100644 index 000000000..16b221ecc --- /dev/null +++ b/apps/edr-passenger-api/src/modules/app-releases/app-releases.service.ts @@ -0,0 +1,71 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PrismaService } from '../../common/prisma.service'; + +export class AppReleaseDto { + @ApiProperty({ enum: ['android', 'ios'] }) + @IsIn(['android', 'ios']) + os: string; + + @ApiProperty({ example: '1.2.3' }) + @IsString() + version: string; + + @ApiProperty({ default: false }) + @IsBoolean() + forceUpdate: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + storeLink?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} + +@Injectable() +export class AppReleasesService { + constructor(private prisma: PrismaService) {} + + private get db() { + return (this.prisma as any); + } + + getAll() { + return this.db.appRelease.findMany({ orderBy: [{ os: 'asc' }, { createdAt: 'desc' }] }); + } + + async getLatest(os: string) { + const release = await this.db.appRelease.findFirst({ + where: { os }, + orderBy: { createdAt: 'desc' }, + }); + if (!release) throw new NotFoundException(`No release found for ${os}`); + return release; + } + + async create(dto: AppReleaseDto) { + const existing = await this.db.appRelease.findUnique({ + where: { os_version: { os: dto.os, version: dto.version } }, + }); + if (existing) throw new ConflictException(`Release ${dto.os} ${dto.version} already exists`); + return this.db.appRelease.create({ data: dto }); + } + + async update(id: string, dto: Partial) { + const release = await this.db.appRelease.findUnique({ where: { id } }); + if (!release) throw new NotFoundException('App release not found'); + return this.db.appRelease.update({ where: { id }, data: dto }); + } + + async remove(id: string) { + const release = await this.db.appRelease.findUnique({ where: { id } }); + if (!release) throw new NotFoundException('App release not found'); + await this.db.appRelease.delete({ where: { id } }); + return { deleted: true, id }; + } +} diff --git a/apps/edr-passenger-api/src/modules/audit/audit.controller.ts b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts index 1202e4d45..3a1e94760 100644 --- a/apps/edr-passenger-api/src/modules/audit/audit.controller.ts +++ b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts @@ -30,8 +30,8 @@ export class AuditController { entityType: entityType || undefined, }; - const items = await this.auditService.getLogs(filters); - return { items }; + const result = await this.auditService.getLogs(filters); + return { items: result.data, total: result.total, limit: result.limit, offset: result.offset }; } @Get('logs/:id') diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 4062acddc..170ff84a3 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -133,6 +133,12 @@ export class CreateBookingDto { @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[]; + @ApiPropertyOptional({ description: 'Package ID — when set, fare is taken from the package price tier instead of the fare engine' }) + @IsOptional() @IsString() packageId?: string; + + @ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' }) + @IsOptional() @IsString() priceTierId?: string; + @ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' }) @IsOptional() @IsString() promoCode?: string; diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 2ca7d5ac4..1aba4ea97 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -202,9 +202,12 @@ export class BookingsService { async findAll(filters: BookingFilters = {}) { const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - + + const onlyPackages = bookingType === 'PACKAGE'; + const includePackageBookings = !returnLegStatus && bookingType !== 'ONE_WAY' && bookingType !== 'ROUND_TRIP' && bookingType !== 'TRANSIT' && bookingType !== 'ROUND_TRIP_TRANSIT'; + const where: any = {}; - + if (search) { const iamRows = await this.dataSource.query<{ id: string }[]>( `SELECT u.id FROM iam.users u @@ -229,10 +232,10 @@ export class BookingsService { { seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } }, ]; } - + if (status) where.status = status; if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus; - if (bookingType) where.bookingType = bookingType; + if (bookingType && !onlyPackages) where.bookingType = bookingType; if (dateFrom || dateTo) { where.createdAt = { ...(dateFrom ? { gte: new Date(dateFrom) } : {}), @@ -240,17 +243,125 @@ export class BookingsService { }; } if (paymentStatus) { - const statusMap: Record = { - PAID: 'SUCCEEDED', - PENDING: 'REQUIRES_ACTION', - FAILED: 'FAILED', - REFUNDED: 'REFUNDED', - }; + const statusMap: Record = { 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([ + + const pkgWhere: any = {}; + if (search) { + pkgWhere.OR = [ + { bookingRef: { contains: search, mode: 'insensitive' } }, + { contactEmail: { contains: search, mode: 'insensitive' } }, + { contactPhone: { contains: search, mode: 'insensitive' } }, + { passengers: { some: { passengerName: { contains: search, mode: 'insensitive' } } } }, + ]; + } + if (status) pkgWhere.status = status; + if (dateFrom || dateTo) pkgWhere.createdAt = where.createdAt; + if (paymentStatus) pkgWhere.paymentIntent = { is: { status: (where.paymentIntent as any)?.is?.status } }; + + if (onlyPackages) { + // Package bookings live in two places: + // 1. PackageBooking table (dedicated package bookings) + // 2. Booking table with packageId != null (round-trip bookings linked to a package) + const bookingPkgWhere: any = { packageId: { not: null } }; + if (status) bookingPkgWhere.status = status; + if (dateFrom || dateTo) bookingPkgWhere.createdAt = where.createdAt; + if (paymentStatus) bookingPkgWhere.paymentIntent = where.paymentIntent; + if (search) bookingPkgWhere.OR = where.OR; + + const [pkgItems, pkgTotal, regPkgItems, regPkgTotal] = await Promise.all([ + this.prisma.packageBooking.findMany({ + where: pkgWhere, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + include: { + package: { select: { id: true, name: true, code: true } }, + priceTier: { select: { id: true, label: true, seatType: true } }, + passengers: true, + paymentIntent: true, + }, + }), + this.prisma.packageBooking.count({ where: pkgWhere }), + this.prisma.booking.findMany({ + where: bookingPkgWhere, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + include: { + passenger: { select: { id: true, iamUserId: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + paymentIntent: true, + seats: { include: { seat: true } }, + }, + }), + this.prisma.booking.count({ where: bookingPkgWhere }), + ]); + + const iamUserIds = regPkgItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[]; + const iamRows = iamUserIds.length > 0 + ? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>( + `SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`, + [iamUserIds], + ) + : []; + const iamMap = new Map(iamRows.map(r => [r.id, r])); + + const mappedRegPkg = regPkgItems.map((booking: any) => { + const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; + const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory })); + const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); + return { + id: booking.id, bookingRef: booking.bookingRef, status: booking.status, + totalMinor: booking.totalMinor, currency: 'ETB', + displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, + contactEmail: booking.contactEmail, contactPhone: booking.contactPhone, + bookingType: booking.bookingType, packageId: booking.packageId, isPackageBooking: true, + returnLegStatus: (booking as any).returnLegStatus ?? null, + adultCount: booking.adultCount, childCount: booking.childCount, + createdAt: booking.createdAt, + passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null, + passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], + passengers: uniquePassengers, + schedule: booking.schedule ? { + train: booking.schedule.train, + originStation: booking.schedule.originStation, + destinationStation: booking.schedule.destinationStation, + departureAt: booking.schedule.departureAt, + } : null, + paymentIntent: booking.paymentIntent, + seatCount: booking.seats.length, + }; + }); + + const mappedPkg = pkgItems.map((b: any) => ({ + id: b.id, bookingRef: b.bookingRef, status: b.status, + totalMinor: b.totalMinor, currency: b.currency || 'ETB', + displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor, + contactEmail: b.contactEmail, contactPhone: b.contactPhone, + bookingType: 'PACKAGE', packageId: b.packageId, isPackageBooking: true, + packageName: b.package?.name, packageCode: b.package?.code, + returnLegStatus: null, adultCount: b.passengerCount, childCount: 0, + createdAt: b.createdAt, passenger: null, + passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [], + passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [], + schedule: null, paymentIntent: b.paymentIntent, seatCount: b.passengerCount, + })); + + const total = pkgTotal + regPkgTotal; + const allItems = [...mappedPkg, ...mappedRegPkg] + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, pageSize); + + return { + items: allItems, + meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) }, + }; + } + + const [regularItems, regularTotal, pkgItems, pkgTotal] = await Promise.all([ this.prisma.booking.findMany({ where, skip, @@ -264,9 +375,22 @@ export class BookingsService { }, }), this.prisma.booking.count({ where }), + includePackageBookings + ? this.prisma.packageBooking.findMany({ + where: pkgWhere, + orderBy: { createdAt: 'desc' }, + include: { + package: { select: { id: true, name: true, code: true } }, + priceTier: { select: { id: true, label: true, seatType: true } }, + passengers: true, + paymentIntent: true, + }, + }) + : Promise.resolve([] as any[]), + includePackageBookings ? this.prisma.packageBooking.count({ where: pkgWhere }) : Promise.resolve(0), ]); - const iamUserIds = items.map(b => b.passenger?.iamUserId).filter(Boolean) as string[]; + const iamUserIds = regularItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[]; const iamRows = iamUserIds.length > 0 ? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>( `SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`, @@ -275,59 +399,80 @@ export class BookingsService { : []; const iamMap = new Map(iamRows.map(r => [r.id, r])); + const mappedRegular = regularItems.map((booking: any) => { + const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; + const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory })); + const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); + return { + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: booking.totalMinor, + currency: 'ETB', + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + contactEmail: booking.contactEmail, + contactPhone: booking.contactPhone, + bookingType: booking.bookingType, + packageId: booking.packageId ?? null, + isPackageBooking: !!booking.packageId, + returnLegStatus: (booking as any).returnLegStatus ?? null, + adultCount: booking.adultCount, + childCount: booking.childCount, + createdAt: booking.createdAt, + passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null, + passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], + passengers: uniquePassengers, + schedule: { + train: booking.schedule.train, + originStation: booking.schedule.originStation, + destinationStation: booking.schedule.destinationStation, + departureAt: booking.schedule.departureAt, + }, + paymentIntent: booking.paymentIntent, + seatCount: booking.seats.length, + }; + }); + + const mappedPkg = pkgItems.map((b: any) => ({ + id: b.id, + bookingRef: b.bookingRef, + status: b.status, + totalMinor: b.totalMinor, + currency: b.currency || 'ETB', + displayCurrency: b.displayCurrency, + displayTotalMinor: b.displayTotalMinor, + contactEmail: b.contactEmail, + contactPhone: b.contactPhone, + bookingType: 'PACKAGE', + packageId: b.packageId, + isPackageBooking: true, + packageName: b.package?.name, + packageCode: b.package?.code, + returnLegStatus: null, + adultCount: b.passengerCount, + childCount: 0, + createdAt: b.createdAt, + passenger: null, + passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [], + passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [], + schedule: null, + paymentIntent: b.paymentIntent, + seatCount: b.passengerCount, + })); + + const total = regularTotal + pkgTotal; + const allItems = [...mappedRegular, ...mappedPkg] + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, pageSize); + return { - items: items.map(booking => { - const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; - // Build passenger list with categories - const passengerDetails = booking.seats.map((s: any) => ({ - name: s.passengerName, - category: s.passengerCategory // 'ADULT' or 'CHILD' - })); - // Get unique names with their categories - const uniquePassengers = Array.from( - new Map(passengerDetails.map(p => [p.name, p])).values() - ); - - return { - id: booking.id, - bookingRef: booking.bookingRef, - status: booking.status, - totalMinor: booking.totalMinor, - currency: 'ETB', - displayCurrency: booking.displayCurrency, - displayTotalMinor: booking.displayTotalMinor, - contactEmail: booking.contactEmail, - contactPhone: booking.contactPhone, - bookingType: booking.bookingType, - returnLegStatus: (booking as any).returnLegStatus ?? null, - adultCount: booking.adultCount, - childCount: booking.childCount, - createdAt: booking.createdAt, - passenger: iam - ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } - : null, - passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], - passengers: uniquePassengers, // Include category info - schedule: { - train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - }, - paymentIntent: booking.paymentIntent, - seatCount: booking.seats.length, - }; - }), - meta: { - page, - pageSize, - total, - totalPages: Math.ceil(total / pageSize), - }, + items: allItems, + meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) }, }; } - async create(dto: CreateBookingDto) { + async create(dto: CreateBookingDto) { if (dto.bookingType === 'ROUND_TRIP') return this.createRoundTripBooking(dto); if (dto.bookingType === 'TRANSIT') return this.createTransitBooking(dto); if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createRoundTripTransitBooking(dto); @@ -363,7 +508,9 @@ export class BookingsService { const passengersData = await this.processPassengers(dto.passengers as any[]); const { adultCount, childCount } = this.countPassengers(passengersData); - const fareCalculation = await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints); + const fareCalculation = dto.packageId && dto.priceTierId + ? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount) + : await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = fareCalculation.totalMinor; @@ -401,6 +548,7 @@ export class BookingsService { childCount, displayCurrency, displayTotalMinor, + ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), seats: { create: passengersWithFares.map(p => ({ seat: { connect: { id: p.seatId } }, @@ -421,6 +569,12 @@ export class BookingsService { }); await this.seatsService.confirmSeats(passengersData.map(p => p.seatId)); + if (dto.packageId && dto.priceTierId) { + await this.prisma.packagePriceTier.update({ + where: { id: dto.priceTierId }, + data: { bookedSeats: { increment: passengersData.length } }, + }); + } this.eventEmitter.emit('booking.created', { booking }); return { ...booking, fareBreakdown: fareCalculation }; } @@ -468,23 +622,38 @@ export class BookingsService { const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]); const { adultCount, childCount } = this.countPassengers(passengersData); - const [outboundFare, returnFare] = await Promise.all([ - this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount), - this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount) - ]); - - const combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor; + // Package bookings use fixed tier price split equally across both legs + let outboundFare: Awaited>; + let returnFare: Awaited>; + let combinedBaseFareMinor: number; let discountMinor = 0; - if (dto.promoCode) { - const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); - if (promo?.active && promo.validUntil > new Date()) { - discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); - } - } + let loyaltyMinor = 0; + let totalMinor: number; - const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; + if (dto.packageId && dto.priceTierId) { + const pkgFare = await this.calculatePackageFare(dto.priceTierId, adultCount, childCount); + // Split evenly across both legs for per-seat fare recording + const halfMinor = Math.round(pkgFare.baseFareMinor / 2); + outboundFare = { ...pkgFare, baseFareMinor: halfMinor, totalBaseFareMinor: Math.round(pkgFare.totalBaseFareMinor / 2) }; + returnFare = { ...pkgFare, baseFareMinor: pkgFare.baseFareMinor - halfMinor, totalBaseFareMinor: pkgFare.totalBaseFareMinor - Math.round(pkgFare.totalBaseFareMinor / 2) }; + combinedBaseFareMinor = pkgFare.totalBaseFareMinor; + totalMinor = pkgFare.totalMinor; + } else { + [outboundFare, returnFare] = await Promise.all([ + this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount), + this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount) + ]); + combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > new Date()) { + discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); + } + } + loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; + totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor); + } const taxesMinor = 0; - const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = totalMinor; @@ -541,6 +710,7 @@ export class BookingsService { returnHoldId: dto.returnHoldId, returnSeatClassId: dto.returnSeatClassId, returnLegStatus: 'NEITHER_USED', + ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), seats: { create: [ ...passengersWithFares.map(p => ({ @@ -586,6 +756,13 @@ export class BookingsService { this.seatsService.confirmSeats(returnSeatIds) ]); + if (dto.packageId && dto.priceTierId) { + await this.prisma.packagePriceTier.update({ + where: { id: dto.priceTierId }, + data: { bookedSeats: { increment: passengersData.length } }, + }); + } + this.eventEmitter.emit('booking.created', { booking }); return { @@ -1048,6 +1225,30 @@ export class BookingsService { return { adultCount, childCount }; } + private async calculatePackageFare( + priceTierId: string, + adultCount: number, + childCount: number, + ) { + const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } }); + const passengerCount = adultCount + childCount; + const totalBaseFareMinor = tier.priceMinor * passengerCount; + return { + baseFareMinor: tier.priceMinor, + adultCount, + adultFareMinor: tier.priceMinor * adultCount, + childCount, + freeChildrenCount: 0, + paidChildrenCount: childCount, + childFareMinor: tier.priceMinor * childCount, + totalBaseFareMinor, + discountMinor: 0, + loyaltyRedemptionMinor: 0, + taxesFeesMinor: 0, + totalMinor: totalBaseFareMinor, + }; + } + private async calculateFare( scheduleId: string, seatClassId: string, @@ -1182,7 +1383,64 @@ export class BookingsService { paymentIntent: true, tickets: { take: 1 }, }, }); - if (!booking) throw new NotFoundException('Booking not found'); + + if (!booking) { + // Fall back to PackageBooking + const pkgBooking = await this.prisma.packageBooking.findUnique({ + where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId }, + include: { + package: { include: { outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } }, returnSchedule: { include: { originStation: true, destinationStation: true } } } }, + priceTier: true, + passengers: true, + paymentIntent: true, + }, + }); + if (!pkgBooking) throw new NotFoundException('Booking not found'); + return { + id: pkgBooking.id, + bookingRef: pkgBooking.bookingRef, + status: pkgBooking.status, + totalMinor: pkgBooking.totalMinor, + currency: pkgBooking.currency || 'ETB', + adultCount: pkgBooking.passengerCount, + childCount: 0, + displayCurrency: pkgBooking.displayCurrency, + displayTotalMinor: pkgBooking.displayTotalMinor ?? undefined, + bookingType: 'PACKAGE', + packageId: pkgBooking.packageId, + priceTierId: pkgBooking.priceTierId, + packageName: (pkgBooking as any).package?.name, + packageCode: (pkgBooking as any).package?.code, + tierLabel: (pkgBooking as any).priceTier?.label, + isPackageBooking: true, + returnLegStatus: null, + contactEmail: pkgBooking.contactEmail, + contactPhone: pkgBooking.contactPhone, + createdAt: pkgBooking.createdAt, + schedule: (pkgBooking as any).package?.outboundSchedule ? { + id: (pkgBooking as any).package.outboundSchedule.id, + trainNumber: (pkgBooking as any).package.outboundSchedule.train?.number, + trainName: (pkgBooking as any).package.outboundSchedule.train?.name, + origin: (pkgBooking as any).package.outboundSchedule.originStation, + destination: (pkgBooking as any).package.outboundSchedule.destinationStation, + departureAt: (pkgBooking as any).package.outboundSchedule.departureAt, + arrivalAt: (pkgBooking as any).package.outboundSchedule.arrivalAt, + } : null, + passengers: (pkgBooking as any).passengers?.map((p: any) => ({ + fullName: p.passengerName, + category: 'ADULT', + leg: 1, + fareMinor: Math.round(pkgBooking.totalMinor / pkgBooking.passengerCount), + verifaydaVerified: false, + seat: null, + })), + payment: (pkgBooking as any).paymentIntent + ? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status } + : undefined, + ticket: undefined, + }; + } + return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, totalMinor: booking.totalMinor, currency: 'ETB', diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 6907d14a3..5b6b876d6 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -81,8 +81,10 @@ export class GuestBookingService { throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); } - const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId); - const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); + const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId) + ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined); + const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId) + ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; @@ -306,10 +308,16 @@ export class GuestBookingService { throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); } - const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId); - const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId); - const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId); - const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId); + const synth = (sched: any, stationId: string, seq: number) => { + const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation; + return { stationId, sequence: seq, station }; + }; + const obStops = outboundSchedule.stopTimes.length > 0 ? outboundSchedule.stopTimes : [synth(outboundSchedule, outboundSchedule.originStationId, 0), synth(outboundSchedule, outboundSchedule.destinationStationId, 1)]; + const retStops = returnSchedule.stopTimes.length > 0 ? returnSchedule.stopTimes : [synth(returnSchedule, returnSchedule.originStationId, 0), synth(returnSchedule, returnSchedule.destinationStationId, 1)]; + const outboundOriginStop = obStops.find((s: any) => s.stationId === dto.originStationId) ?? obStops[0]; + const outboundDestStop = obStops.find((s: any) => s.stationId === dto.destinationStationId) ?? obStops[obStops.length - 1]; + const returnOriginStop = retStops.find((s: any) => s.stationId === dto.returnOriginStationId) ?? retStops[0]; + const returnDestStop = retStops.find((s: any) => s.stationId === dto.returnDestinationStationId) ?? retStops[retStops.length - 1]; if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule'); if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule'); diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index 592aadacd..f7f2c4558 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -130,7 +130,7 @@ export class FareEngineService { const adultCount = dto.adultCount ?? 1; const childCount = dto.childCount ?? 0; - const freeChildrenCount = Math.min(childCount, 1); + const freeChildrenCount = Math.min(childCount, adultCount); const paidChildrenCount = Math.max(0, childCount - 1); // Subtotal includes: (distance-based fare + premium + insurance) × passengers @@ -169,7 +169,7 @@ export class FareEngineService { `Total fare/pax: ${farePerPassengerMinor} ETB minor`, ``, `Adults: ${adultCount} × ${farePerPassengerMinor} = ${adultSubtotal} ETB minor`, - `Children: ${childCount} (${freeChildrenCount} free + ${paidChildrenCount} paid)`, + `Children: ${childCount} (${freeChildrenCount} free [1 per adult] + ${paidChildrenCount} paid)`, ` Free child: ${freeChildrenCount} × ${premiumPerPassenger + insurancePerPassenger} = ${freeChildSubtotal} ETB minor`, ` Paid child: ${paidChildrenCount} × ${farePerPassengerMinor} = ${paidChildSubtotal} ETB minor`, ``, diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts index c53d3a5fb..87007f16a 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Param, Body, Query, Logger } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { FraudService, FraudRuleConfig } from './fraud.service'; import { PassengerStaff } from '../../common/passenger-guards'; @@ -48,6 +48,31 @@ export class FraudController { return { data: rule, message: 'Rule updated successfully' }; } + /** + * Acknowledge a fraud alert + */ + @Patch('alerts/:id/acknowledge') + @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) + @ApiOperation({ summary: 'Acknowledge a fraud alert' }) + async acknowledgeAlert(@Param('id') id: string) { + const alert = await this.fraudService.acknowledgeAlert(id); + return { data: alert, message: 'Alert acknowledged' }; + } + + /** + * Block user via userId + */ + @Post('users/:userId/block') + @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) + @ApiOperation({ summary: 'Block user by userId' }) + async blockUserById( + @Param('userId') userId: string, + @Body() body: { reason?: string; durationMinutes?: number }, + ) { + await this.fraudService.blockUserTemporarily(userId, body.durationMinutes ?? 60); + return { message: `User blocked for ${body.durationMinutes ?? 60} minutes` }; + } + /** * Block user temporarily */ diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.module.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.module.ts index a95078578..17b86705f 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.module.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { FraudService } from './fraud.service'; import { FraudController } from './fraud.controller'; @Module({ - imports: [HttpModule], + imports: [HttpModule, TypeOrmModule], providers: [FraudService], controllers: [FraudController], exports: [FraudService], diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts index a75db4449..2f988fd31 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts @@ -164,6 +164,16 @@ export class FraudService { this.logger.log(`Passenger (iamUserId=${iamUserId}) unblocked`); } + /** + * Acknowledge a fraud alert + */ + async acknowledgeAlert(id: string) { + return this.prisma.fraudAlert.update({ + where: { id }, + data: { acknowledged: true, acknowledgedAt: new Date() }, + }); + } + /** * Get all fraud alerts */ diff --git a/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts b/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts index 7095110e4..b4b1a63c6 100644 --- a/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts +++ b/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { Controller, Get, Param, Post, Delete, UseGuards, SetMetadata, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { LoyaltyService } from './loyalty.service'; import { JwtGuard } from '../../common/jwt.guard'; @@ -9,7 +9,9 @@ import { JwtGuard } from '../../common/jwt.guard'; @ApiBearerAuth('JWT-auth') export class LoyaltyController { constructor(private service: LoyaltyService) {} + @Get('accounts') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all loyalty accounts' }) getAccounts(@Query() q: any) { return this.service.getAccounts(q); } @Get(':passengerId') @ApiOperation({ summary: 'Get loyalty account with tier progress' }) getAccount(@Param('passengerId') id: string) { return this.service.getAccount(id); } @Get(':passengerId/rewards') @ApiOperation({ summary: 'Get available rewards' }) getRewards(@Param('passengerId') id: string) { return this.service.getRewards(id); } @Post(':passengerId/rewards/:rewardId/redeem') @ApiOperation({ summary: 'Redeem a loyalty reward' }) redeemReward(@Param('passengerId') pid: string, @Param('rewardId') rid: string) { return this.service.redeemReward(pid, rid); } + @Delete('accounts/:id') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete loyalty account' }) deleteAccount(@Param('id') id: string) { return this.service.deleteAccount(id); } } diff --git a/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts b/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts index 4cc69b214..22b919f6f 100644 --- a/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts +++ b/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts @@ -5,6 +5,42 @@ import { PrismaService } from '../../common/prisma.service'; export class LoyaltyService { constructor(private prisma: PrismaService) {} + async getAccounts(params: { search?: string; tier?: string; page?: string; pageSize?: string } = {}) { + const { search, tier, page = '1', pageSize = '20' } = params; + const skip = (parseInt(page) - 1) * parseInt(pageSize); + const where: any = {}; + if (tier) where.tier = tier; + if (search) { + where.passenger = { + OR: [ + { user: { fullName: { contains: search, mode: 'insensitive' } } }, + { user: { email: { contains: search, mode: 'insensitive' } } }, + ], + }; + } + const [items, total] = await Promise.all([ + this.prisma.loyaltyAccount.findMany({ + where, + skip, + take: parseInt(pageSize), + orderBy: { pointsBalance: 'desc' }, + include: { passenger: { include: { user: true } } }, + }), + this.prisma.loyaltyAccount.count({ where }), + ]); + return { + items: items.map(a => ({ + ...a, + passenger: a.passenger ? { + id: a.passenger.id, + fullName: (a.passenger as any).user?.fullName ?? null, + email: (a.passenger as any).user?.email ?? null, + phone: (a.passenger as any).user?.phone ?? null, + } : null, + })), + meta: { page: parseInt(page), pageSize: parseInt(pageSize), total, totalPages: Math.ceil(total / parseInt(pageSize)) }, + }; + } async getAccount(passengerId: string) { const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } }); if (!account) throw new NotFoundException('Loyalty account not found'); @@ -40,4 +76,15 @@ export class LoyaltyService { await this.prisma.loyaltyReward.update({ where: { id: rewardId }, data: { available: false } }); return { redeemed: true, pointsUsed: reward.costPoints, balanceAfter: newBalance }; } + + async deleteAccount(id: string) { + const account = await this.prisma.loyaltyAccount.findUnique({ where: { id } }); + if (!account) throw new NotFoundException('Loyalty account not found'); + await this.prisma.$transaction([ + this.prisma.loyaltyLedgerEntry.deleteMany({ where: { accountId: id } }), + this.prisma.loyaltyReward.deleteMany({ where: { accountId: id } }), + this.prisma.loyaltyAccount.delete({ where: { id } }), + ]); + return { deleted: true, accountId: id }; + } } diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts index c88bdc15d..f814eb3e7 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -1,8 +1,8 @@ import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } 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, CreateInquiryDto, UpdateInquiryStatusDto } from './packages.dto'; +import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto, PackageBookingContextDto } from './packages.dto'; import { IamGuard } from '../../common/iam-adapter'; import { JwtGuard } from '../../common/jwt.guard'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; @@ -63,6 +63,19 @@ export class PackagesController { return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20); } + @Get('bookings') + @UseGuards(IamGuard) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'List all package bookings (backoffice)' }) + listBookings( + @Query('packageId') packageId?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.service.listBookings({ packageId, status, page: page ? +page : 1, pageSize: pageSize ? +pageSize : 20 }); + } + @Get('my-bookings') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @@ -78,6 +91,21 @@ export class PackagesController { return this.service.getBookingByRef(ref); } + @Get(':id/booking-context') + @IsPublic() + @ApiOperation({ summary: 'Get booking context for self-service package booking' }) + @ApiQuery({ name: 'tierId', required: true }) + @ApiQuery({ name: 'adultCount', required: true }) + @ApiQuery({ name: 'childCount', required: false }) + getBookingContext( + @Param('id') id: string, + @Query('tierId') tierId: string, + @Query('adultCount') adultCount: string, + @Query('childCount') childCount?: string, + ) { + return this.service.getBookingContext(id, tierId, parseInt(adultCount), childCount ? parseInt(childCount) : 0); + } + @Get(':id') @IsPublic() @ApiOperation({ summary: 'Get package details' }) diff --git a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts index b4dac126d..074d7454d 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID } from 'class-validator'; +import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID, IsPositive } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; @@ -93,6 +93,12 @@ export class BookPackagePassengerDto { @ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string; } +export class PackageBookingContextDto { + @ApiProperty() @IsUUID() tierId: string; + @ApiProperty({ example: 1 }) @IsInt() @IsPositive() adultCount: number; + @ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() @Min(0) childCount?: number; +} + export class BookPackageDto { @ApiProperty() @IsUUID() packageId: string; @ApiProperty() @IsUUID() priceTierId: string; diff --git a/apps/edr-passenger-api/src/modules/packages/packages.module.ts b/apps/edr-passenger-api/src/modules/packages/packages.module.ts index f84a23781..32aec44fc 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.module.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.module.ts @@ -3,9 +3,10 @@ import { PrismaModule } from '../../common/prisma.module'; import { PackagesController } from './packages.controller'; import { PackagesService } from './packages.service'; import { CurrencyModule } from '../currency/currency.module'; +import { BookingsModule } from '../bookings/bookings.module'; @Module({ - imports: [PrismaModule, CurrencyModule], + imports: [PrismaModule, CurrencyModule, BookingsModule], controllers: [PackagesController], providers: [PackagesService], exports: [PackagesService], diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index f6c25611c..4a9f9382e 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -3,6 +3,8 @@ import { PrismaService } from '../../common/prisma.service'; import { CurrencyService } from '../currency/currency.service'; import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto'; import { Currency } from '@prisma/client'; +import { BookingsService } from '../bookings/bookings.service'; +import { GuestBookingService } from '../bookings/guest-booking.service'; function generateRef(): string { return 'PKG-' + Array.from({ length: 6 }, () => @@ -15,8 +17,94 @@ export class PackagesService { constructor( private readonly prisma: PrismaService, private readonly currencyService: CurrencyService, + private readonly bookingsService: BookingsService, + private readonly guestBookingService: GuestBookingService, ) {} + async getBookingContext(packageId: string, tierId: string, adultCount: number, childCount = 0) { + const pkg = await this.prisma.travelPackage.findUnique({ + where: { id: packageId }, + include: { + priceTiers: true, + outboundSchedule: { + include: { + originStation: true, + destinationStation: true, + coachAssignments: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } }, + }, + }, + returnSchedule: { include: { originStation: true, destinationStation: true } }, + }, + }); + if (!pkg || pkg.status !== 'ACTIVE') throw new NotFoundException('Package not available'); + + const tier = pkg.priceTiers.find(t => t.id === tierId); + if (!tier) throw new NotFoundException('Price tier not found'); + + const passengerCount = adultCount + childCount; + if (passengerCount < 1) throw new BadRequestException('At least one passenger required'); + + const remaining = tier.availableSeats - tier.bookedSeats; + if (passengerCount > remaining) + throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`); + + const totalMinor = tier.priceMinor * passengerCount; + + // Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches + let seatClassId: string | null = null; + let coachTypeId: string | null = null; + for (const a of pkg.outboundSchedule.coachAssignments) { + const sc = a.coach.coachType?.seatClasses?.find( + (s: any) => s.name.toLowerCase().includes(tier.seatType.toLowerCase()) || + tier.seatType.toLowerCase().includes(s.name.toLowerCase()), + ); + if (sc) { seatClassId = sc.id; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; } + } + // Fallback: use the first coach assignment's coachTypeId if no match found + if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) { + const first = pkg.outboundSchedule.coachAssignments[0]; + coachTypeId = first.coach.coachTypeId ?? first.coach.coachType?.id ?? null; + } + + return { + packageId: pkg.id, + packageName: pkg.name, + priceTierId: tier.id, + tierLabel: tier.label, + seatType: tier.seatType, + seatClassId, + coachTypeId, + adultCount, + childCount, + passengerCount, + pricePerPassengerMinor: tier.priceMinor, + totalMinor, + currency: tier.currency, + remainingSeats: remaining, + outboundSchedule: { + scheduleId: pkg.outboundScheduleId, + originStationId: pkg.originStationId, + destinationStationId: pkg.destinationStationId, + departureAt: pkg.outboundSchedule.departureAt, + arrivalAt: pkg.outboundSchedule.arrivalAt, + originStation: pkg.outboundSchedule.originStation, + destinationStation: pkg.outboundSchedule.destinationStation, + }, + returnSchedule: pkg.returnSchedule ? { + scheduleId: pkg.returnScheduleId, + originStationId: pkg.destinationStationId, + destinationStationId: pkg.originStationId, + departureAt: pkg.returnSchedule.departureAt, + arrivalAt: pkg.returnSchedule.arrivalAt, + originStation: pkg.returnSchedule.destinationStation, + destinationStation: pkg.returnSchedule.originStation, + } : null, + includedServices: pkg.includedServices, + busTransferIncluded: pkg.busTransferIncluded, + busTransferRoute: pkg.busTransferRoute, + }; + } + async createInquiry(dto: CreateInquiryDto) { return this.prisma.packageInquiry.create({ data: { @@ -74,7 +162,7 @@ export class PackagesService { returnSchedule: { include: { originStation: true, destinationStation: true } }, }, orderBy: { validFrom: 'asc' }, - }); + }).then(pkgs => pkgs.map(p => ({ ...p, journeyType: p.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' }))); } async getById(id: string) { @@ -87,7 +175,7 @@ export class PackagesService { }, }); if (!pkg) throw new NotFoundException('Package not found'); - return pkg; + return { ...pkg, journeyType: pkg.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' }; } create(dto: CreatePackageDto) { @@ -296,6 +384,29 @@ export class PackagesService { return booking; } + async listBookings({ 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.packageBooking.findMany({ + where, + include: { + package: { select: { id: true, name: true, code: true } }, + priceTier: { select: { id: true, label: true, seatType: true } }, + passengers: true, + paymentIntent: true, + }, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + }), + this.prisma.packageBooking.count({ where }), + ]); + return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }; + } + async listAll(page = 1, pageSize = 20) { const skip = (page - 1) * pageSize; const [items, total] = await Promise.all([ diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index ea952d98c..d41fcbfed 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -433,39 +433,49 @@ export class PassengersService { } async deletePassenger(id: string) { - const passenger = await this.prisma.passenger.findUnique({ + // id may be a TravelerProfile.id (from the list endpoint) or a Passenger.id + let passenger = await this.prisma.passenger.findUnique({ where: { id }, - include: { - user: true - } + include: { user: true }, }); - if (!passenger) throw new NotFoundException('Passenger not found'); + + if (!passenger) { + const profile = await this.prisma.travelerProfile.findUnique({ where: { id } }); + if (!profile?.passengerId) throw new NotFoundException('Passenger not found'); + passenger = await this.prisma.passenger.findUnique({ + where: { id: profile.passengerId }, + include: { user: true }, + }); + if (!passenger) throw new NotFoundException('Passenger not found'); + } + + const passengerId = passenger.id; // Check usage before allowing deletion - const usage = await this.checkPassengerUsage(id); + const usage = await this.checkPassengerUsage(passengerId); if (usage.isInUse && usage.constraints) { - const passengerName = (passenger as any).user?.fullName || `Passenger ${id.slice(-8)}`; + const passengerName = (passenger as any).user?.fullName || `Passenger ${passengerId.slice(-8)}`; throw new DeleteOperationException('Passenger', passengerName, usage.constraints); } 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.packageBooking.deleteMany({ where: { passengerId: id } }), - this.prisma.ticket.deleteMany({ where: { booking: { passengerId: id } } }), - this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }), - this.prisma.booking.deleteMany({ where: { passengerId: id } }), - this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId: id } } }), - this.prisma.journey.deleteMany({ where: { passengerId: id } }), - this.prisma.passenger.delete({ where: { id } }), + this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId } } }), + this.prisma.loyaltyAccount.deleteMany({ where: { passengerId } }), + this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId } } }), + this.prisma.walletAccount.deleteMany({ where: { passengerId } }), + this.prisma.notification.deleteMany({ where: { passengerId } }), + this.prisma.travelerProfile.deleteMany({ where: { passengerId } }), + this.prisma.savedRoute.deleteMany({ where: { passengerId } }), + this.prisma.packageBooking.deleteMany({ where: { passengerId } }), + this.prisma.ticket.deleteMany({ where: { booking: { passengerId } } }), + this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId } } }), + this.prisma.booking.deleteMany({ where: { passengerId } }), + this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId } } }), + this.prisma.journey.deleteMany({ where: { passengerId } }), + this.prisma.passenger.delete({ where: { id: passengerId } }), ]); - return { deleted: true, passengerId: id }; + return { deleted: true, passengerId }; } async checkPassengerUsage(id: string) { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 280ef2752..5be11434a 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, HttpStatus, Param, @@ -42,6 +43,14 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; export class PaymentsController { constructor(private service: PaymentsService) {} + @Delete(":id") + @PassengerStaff([PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ summary: "Delete a payment intent record (admin only)" }) + deletePayment(@Param("id") id: string) { + return this.service.deletePayment(id); + } + @Get("all") @PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin]) @ApiBearerAuth("IAM-auth") diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index af496a5a2..b79079c39 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -55,6 +55,13 @@ export class PaymentsService { private currencyService: CurrencyService, ) {} + async deletePayment(id: string) { + const intent = await this.prisma.paymentIntent.findUnique({ where: { id } }); + if (!intent) throw new NotFoundException('Payment intent not found'); + await this.prisma.paymentIntent.delete({ where: { id } }); + return { deleted: true, id }; + } + async getAll(filters: { search?: string; status?: string; diff --git a/apps/edr-passenger-api/src/modules/search/search.controller.ts b/apps/edr-passenger-api/src/modules/search/search.controller.ts index 6bb9d1960..384592dc9 100644 --- a/apps/edr-passenger-api/src/modules/search/search.controller.ts +++ b/apps/edr-passenger-api/src/modules/search/search.controller.ts @@ -1,8 +1,8 @@ -import { Body, Controller, Post } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Body, Controller, Post, Get, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SearchService } from './search.service'; -import { SearchTripsDto, FareQuoteDto } from './search.dto'; +import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto'; @ApiTags('Search') @Controller('search') @@ -66,4 +66,29 @@ Nationality-Based: getFareQuote(@Body() dto: FareQuoteDto) { return this.service.getFareQuote(dto); } + + @Get('fare-breakdown') + @ApiOperation({ + summary: 'Per-passenger fare breakdown for booking review page', + description: `Calculates a line-item fare for each individual passenger based on their date of birth, nationality, and chosen seat class. + +- Age is derived from dateOfBirth at request time (ADULT ≥5 yrs, CHILD <5 yrs) +- First CHILD in the list travels free (pays only premium + insurance fees) +- Each passenger can have a different seat class and nationality +- Returns per-passenger lines plus subtotal, discount, and grand total + +**passengers** must be a URL-encoded JSON array, e.g.: +\`[{"passengerName":"Abebe","dateOfBirth":"1985-03-15","seatClassId":"uuid","nationality":"Ethiopian"}]\``, + }) + @ApiQuery({ name: 'scheduleId', description: 'TrainSchedule UUID' }) + @ApiQuery({ name: 'originStationId', description: 'Origin station UUID' }) + @ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' }) + @ApiQuery({ name: 'passengers', description: 'URL-encoded JSON array of passengers: [{passengerName, dateOfBirth, seatClassId, nationality?}]' }) + @ApiQuery({ name: 'promoCode', required: false }) + @ApiQuery({ name: 'displayCurrency', required: false, enum: ['ETB', 'DJF', 'USD'] }) + @ApiResponse({ status: 200, description: 'Per-passenger fare lines with grand total' }) + @ApiResponse({ status: 404, description: 'Schedule not found' }) + getFareBreakdown(@Query() dto: FareBreakdownRequestDto) { + return this.service.getFareBreakdown(dto); + } } diff --git a/apps/edr-passenger-api/src/modules/search/search.dto.ts b/apps/edr-passenger-api/src/modules/search/search.dto.ts index 9eb035ef2..cfb1075ca 100644 --- a/apps/edr-passenger-api/src/modules/search/search.dto.ts +++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts @@ -75,6 +75,43 @@ export class CoachTypeOptionClass { @ApiProperty({ example: 35000 }) baseFareMinor: number; } +export class FareBreakdownPassengerDto { + @ApiProperty({ example: 'Abebe Kebede', description: 'Passenger name (for display only)' }) + @IsString() passengerName: string; + + @ApiProperty({ example: '1985-03-15', description: 'Date of birth — determines ADULT (≥5 yrs) or CHILD (<5 yrs)' }) + @IsDateString() dateOfBirth: string; + + @ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID for this passenger' }) + @IsString() seatClassId: string; + + @ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality — affects billing currency and seat class variant' }) + @IsOptional() @IsString() nationality?: string; +} + +export class FareBreakdownRequestDto { + @ApiProperty({ example: 'schedule-uuid' }) + @IsString() scheduleId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID (must be a stop on the schedule)' }) + @IsString() originStationId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' }) + @IsString() destinationStationId: string; + + @ApiProperty({ + example: '[{"passengerName":"Abebe","dateOfBirth":"1985-03-15","seatClassId":"uuid","nationality":"Ethiopian"}]', + description: 'URL-encoded JSON array of passengers. Each entry: { passengerName, dateOfBirth (YYYY-MM-DD), seatClassId, nationality? }', + }) + @IsString() passengers: string; + + @ApiPropertyOptional({ example: 'WEEKEND15' }) + @IsOptional() @IsString() promoCode?: string; + + @ApiPropertyOptional({ example: 'USD', enum: Currency }) + @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; +} + export class CoachTypeOption { @ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string; @ApiProperty({ example: 'Economy' }) coachTypeName: string; diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 6f3bc5a67..621e5487d 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; -import { SearchTripsDto, FareQuoteDto } from './search.dto'; +import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, FareBreakdownPassengerDto } from './search.dto'; import { CurrencyService } from '../currency/currency.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; import { SegmentsService } from '../segments/segments.service'; @@ -477,6 +477,124 @@ export class SearchService { }; } + async getFareBreakdown(dto: FareBreakdownRequestDto) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + select: { routeId: true, originStationId: true, destinationStationId: true }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation'); + + const now = new Date(); + const displayCurrency = dto.displayCurrency ?? Currency.ETB; + + let parsedPassengers: FareBreakdownPassengerDto[]; + try { + parsedPassengers = JSON.parse(dto.passengers as unknown as string); + } catch { + throw new NotFoundException('passengers must be a valid JSON array'); + } + + // Categorise passengers by age + const categorised = parsedPassengers.map(p => { + const ageMs = now.getTime() - new Date(p.dateOfBirth).getTime(); + const ageYears = ageMs / (1000 * 60 * 60 * 24 * 365.25); + return { ...p, category: (ageYears >= 5 ? 'ADULT' : 'CHILD') as 'ADULT' | 'CHILD', ageYears }; + }); + + const adultCount = categorised.filter(p => p.category === 'ADULT').length; + const childCount = categorised.filter(p => p.category === 'CHILD').length; + + // Ask the fare engine for the authoritative free-child count using the full group + // Use the first passenger's seatClassId as a representative — freeChildrenCount + // depends only on adultCount/childCount, not on seat class. + const groupFare = await this.fareEngine.calculate({ + routeId: schedule.routeId!, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + seatClassId: categorised[0].seatClassId, + nationality: categorised[0].nationality, + scheduleId: dto.scheduleId, + adultCount, + childCount, + }); + const freeChildrenAllowed = groupFare.freeChildrenCount; + + // Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup) + let freeChildrenUsed = 0; + const passengerLines = await Promise.all( + categorised.map(async (p) => { + const fare = await this.fareEngine.calculate({ + routeId: schedule.routeId!, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + seatClassId: p.seatClassId, + nationality: p.nationality, + scheduleId: dto.scheduleId, + adultCount: 1, + childCount: 0, + }); + + const isFree = p.category === 'CHILD' && freeChildrenUsed < freeChildrenAllowed; + if (isFree) freeChildrenUsed++; + + const fareMinor = isFree + ? fare.premiumPerPassenger + fare.insurancePerPassenger + : fare.farePerPassengerMinor; + const displayFareMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(fareMinor, Currency.ETB, displayCurrency) + : fareMinor; + + return { + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + category: p.category, + ageYears: Math.floor(p.ageYears), + seatClassId: fare.seatClassId, + seatClassName: fare.seatClassName, + nationality: p.nationality ?? null, + baseFareMinor: fare.baseFarePerPassengerMinor, + premiumMinor: fare.premiumPerPassenger, + insuranceFeeMinor: fare.insurancePerPassenger, + fareMinor, + isFree, + displayCurrency, + displayFareMinor, + }; + }), + ); + + let subtotalMinor = passengerLines.reduce((sum, l) => sum + l.fareMinor, 0); + + let discountMinor = 0; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > now) { + discountMinor = promo.percentOff + ? Math.round(subtotalMinor * promo.percentOff / 100) + : (promo.amountOffMinor ?? 0); + } + } + + const totalMinor = subtotalMinor - discountMinor; + const displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + + return { + scheduleId: dto.scheduleId, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + passengers: passengerLines, + subtotalMinor, + discountMinor, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor, + }; + } + private async calculateFaresForSegment( schedule: ScheduleWithIncludes, originStationId: string, diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts index 79151bdc9..63f5e1f29 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts @@ -1,5 +1,6 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; +import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; @Injectable() export class SeatClassesService { @@ -45,8 +46,24 @@ export class SeatClassesService { } async deleteSeatClass(id: string) { - const sc = await this.prisma.seatClass.findUnique({ where: { id } }); + const sc = await this.prisma.seatClass.findUnique({ + where: { id }, + include: { + _count: { select: { fareRules: true, routeFareRules: true, segmentFares: true } }, + }, + }); if (!sc) throw new NotFoundException('SeatClass not found'); + + const totalFareRules = + (sc as any)._count.fareRules + + (sc as any)._count.routeFareRules + + (sc as any)._count.segmentFares; + + if (totalFareRules > 0) + throw new DeleteOperationException('Seat Class', sc.name, [ + { entityName: 'fare rule', count: totalFareRules, action: 'delete' }, + ]); + return this.prisma.seatClass.delete({ where: { id } }); } } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index a8d5d724c..4a9783101 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -28,6 +28,25 @@ import { IamGuard } from "../../common/iam-adapter"; export class SeatsController { constructor(private service: SeatsService) {} + // ── Coach Availability ──────────────────────────────────────────────────── + @Get('coaches/:scheduleId') + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: 'List coaches with remaining seat counts for a schedule', + description: 'Returns each coach assigned to the schedule with total, available, held, and booked seat counts. Optionally scoped to a specific origin→destination leg.', + }) + @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) + @ApiQuery({ name: 'originStationId', required: false, description: 'Scope availability to this origin station' }) + @ApiQuery({ name: 'destinationStationId', required: false, description: 'Scope availability to this destination station' }) + @ApiResponse({ status: 200, description: 'Coaches with seat availability counts' }) + getCoachesWithAvailability( + @Param('scheduleId') scheduleId: string, + @Query('originStationId') originStationId?: string, + @Query('destinationStationId') destinationStationId?: string, + ) { + return this.service.getCoachesWithAvailability(scheduleId, originStationId, destinationStationId); + } + // ── Seat Map ────────────────────────────────────────────────────────────── @Get("seatmap/:scheduleId") @SetMetadata('isPublic', true) diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 1562a600b..5c237e6ba 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -367,7 +367,24 @@ export class SeatsService { where: { scheduleId: dto.scheduleId }, select: { stationId: true, sequence: true }, }); - const seqOf = (stationId: string) => stopTimes.find(s => s.stationId === stationId)?.sequence; + + // When no stop times exist, fall back to the schedule's own origin/destination + // with synthetic sequences so the hold can still be created. + let effectiveStopTimes = stopTimes; + if (stopTimes.length === 0) { + const sched = await tx.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + select: { originStationId: true, destinationStationId: true }, + }); + if (sched) { + effectiveStopTimes = [ + { stationId: sched.originStationId, sequence: 0 }, + { stationId: sched.destinationStationId, sequence: 1 }, + ]; + } + } + + const seqOf = (stationId: string) => effectiveStopTimes.find(s => s.stationId === stationId)?.sequence; const reqFrom = seqOf(dto.originStationId); const reqTo = seqOf(dto.destinationStationId); @@ -604,6 +621,56 @@ export class SeatsService { await this.prisma.journey.deleteMany({ where: { bookingId } as any }); } + async getCoachesWithAvailability(scheduleId: string, originStationId?: string, destinationStationId?: string) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { originStationId: true, destinationStationId: true }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + + const assignments = await this.prisma.coachAssignment.findMany({ + where: { scheduleId }, + include: { + coach: { + include: { + seats: { select: { id: true, status: true, seatNumber: true } }, + coachType: { include: { seatClasses: { select: { name: true } } } }, + }, + }, + }, + orderBy: { positionNumber: 'asc' }, + }); + + const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id)); + const effectiveStatuses = await this.resolveEffectiveStatuses( + scheduleId, + allSeatIds, + originStationId ?? schedule.originStationId, + destinationStationId ?? schedule.destinationStationId, + ); + + return assignments.map(a => { + const seats = a.coach.seats.filter(s => s.seatNumber && !s.seatNumber.startsWith('-')); + const totalSeats = seats.length; + const unavailable = seats.filter(s => { + const status = effectiveStatuses.get(s.id) ?? s.status; + return status === 'HELD' || status === 'BOOKED' || status === 'BLOCKED'; + }).length; + + return { + coachId: a.coach.id, + coachNumber: a.coach.number, + positionNumber: a.positionNumber, + coachTypeName: a.coach.coachType?.name ?? '', + seatClasses: a.coach.coachType?.seatClasses.map(sc => sc.name) ?? [], + totalSeats, + availableSeats: totalSeats - unavailable, + heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'HELD').length, + bookedSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'BOOKED').length, + }; + }); + } + async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise { const seats = await this.prisma.seat.findMany({ where: { diff --git a/apps/edr-passenger-api/src/modules/stations/stations.service.ts b/apps/edr-passenger-api/src/modules/stations/stations.service.ts index 9e9fc824d..ec2480b21 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.service.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.service.ts @@ -3,6 +3,7 @@ import { REQUEST } from '@nestjs/core'; import { PrismaService } from '../../common/prisma.service'; import { AuditService } from '../../common/audit.service'; import { CreateStationDto } from './stations.dto'; +import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; interface StationFilters { search?: string; @@ -96,7 +97,35 @@ export class StationsService { } async remove(id: string) { - const station = await this.findOne(id); + const station = await this.prisma.station.findUnique({ + where: { id }, + include: { + _count: { select: { stopTimes: true } }, + originSchedules: { take: 1, select: { id: true } }, + destinationSchedules: { take: 1, select: { id: true } }, + }, + }); + if (!station) throw new NotFoundException('Station not found'); + + const [routeStopCount, originCount, destCount, stopTimeCount] = await Promise.all([ + this.prisma.routeStop.count({ where: { stationId: id } }), + this.prisma.trainSchedule.count({ where: { originStationId: id } }), + this.prisma.trainSchedule.count({ where: { destinationStationId: id } }), + (station as any)._count.stopTimes as number, + ]); + + const constraints = []; + if (routeStopCount > 0) + constraints.push({ entityName: 'route', count: routeStopCount, action: 'delete' as const }); + const scheduleCount = originCount + destCount; + if (scheduleCount > 0) + constraints.push({ entityName: 'schedule', count: scheduleCount, action: 'delete' as const }); + if (stopTimeCount > 0) + constraints.push({ entityName: 'stop time', count: stopTimeCount, action: 'delete' as const }); + + if (constraints.length > 0) + throw new DeleteOperationException('Station', `${station.name} (${station.code})`, constraints); + const deleted = await this.prisma.station.delete({ where: { id } }); await this.auditService.log({ diff --git a/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts b/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts index 1ecb2edea..8b0c5ed2e 100644 --- a/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts +++ b/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Delete, UseGuards, SetMetadata, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { WalletService } from './wallet.service'; @@ -11,6 +11,8 @@ import { JwtGuard } from '../../common/jwt.guard'; @Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class WalletController { constructor(private service: WalletService) {} - @Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); } - @Post(':passengerId/topup') @ApiOperation({ summary: 'Top up wallet' }) topUp(@Param('passengerId') id: string, @Body('amountMinor') amount: number) { return this.service.topUp(id, amount); } + @Get('accounts') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all wallet accounts' }) getAccounts(@Query() q: any) { return this.service.getAccounts(q); } + @Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); } + @Post(':passengerId/topup') @ApiOperation({ summary: 'Top up wallet' }) topUp(@Param('passengerId') id: string, @Body('amountMinor') amount: number) { return this.service.topUp(id, amount); } + @Delete('accounts/:id') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete wallet account' }) deleteAccount(@Param('id') id: string) { return this.service.deleteAccount(id); } } diff --git a/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts b/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts index a83d97e0b..ac092ee41 100644 --- a/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts +++ b/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts @@ -5,6 +5,42 @@ import { PrismaService } from '../../common/prisma.service'; export class WalletService { constructor(private prisma: PrismaService) {} + async getAccounts(params: { search?: string; page?: string; pageSize?: string } = {}) { + const { search, page = '1', pageSize = '20' } = params; + const skip = (parseInt(page) - 1) * parseInt(pageSize); + const where: any = {}; + if (search) { + where.passenger = { + OR: [ + { user: { fullName: { contains: search, mode: 'insensitive' } } }, + { user: { email: { contains: search, mode: 'insensitive' } } }, + ], + }; + } + const [items, total] = await Promise.all([ + this.prisma.walletAccount.findMany({ + where, + skip, + take: parseInt(pageSize), + orderBy: { balanceMinor: 'desc' }, + include: { passenger: { include: { user: true } } }, + }), + this.prisma.walletAccount.count({ where }), + ]); + return { + items: items.map(w => ({ + ...w, + passenger: w.passenger ? { + id: w.passenger.id, + fullName: (w.passenger as any).user?.fullName ?? null, + email: (w.passenger as any).user?.email ?? null, + phone: (w.passenger as any).user?.phone ?? null, + } : null, + })), + meta: { page: parseInt(page), pageSize: parseInt(pageSize), total, totalPages: Math.ceil(total / parseInt(pageSize)) }, + }; + } + async getWallet(passengerId: string) { const wallet = await this.prisma.walletAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } }); if (!wallet) throw new NotFoundException('Wallet not found'); @@ -18,4 +54,14 @@ export class WalletService { await this.prisma.walletAccount.update({ where: { passengerId }, data: { balanceMinor: newBalance } }); return this.prisma.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'CREDIT', amountMinor, balanceAfterMinor: newBalance, description } }); } + + async deleteAccount(id: string) { + const wallet = await this.prisma.walletAccount.findUnique({ where: { id } }); + if (!wallet) throw new NotFoundException('Wallet account not found'); + await this.prisma.$transaction([ + this.prisma.walletLedgerEntry.deleteMany({ where: { walletId: id } }), + this.prisma.walletAccount.delete({ where: { id } }), + ]); + return { deleted: true, accountId: id }; + } } diff --git a/apps/edr-passenger-web/backoffice/src/app/app-releases/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/app-releases/layout.tsx new file mode 100644 index 000000000..86d53715f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/app-releases/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function Layout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx b/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx new file mode 100644 index 000000000..2e036f31e --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx @@ -0,0 +1,186 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Pencil, Trash2 } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { appReleasesApi } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; + +const EMPTY_FORM = { os: 'android', version: '', forceUpdate: false, storeLink: '', notes: '' }; + +export default function AppReleasesPage() { + const queryClient = useQueryClient(); + const [formOpen, setFormOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState({ ...EMPTY_FORM }); + const [formError, setFormError] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteError, setDeleteError] = useState(null); + const [successMessage, setSuccessMessage] = useState(''); + + const { data, isLoading } = useQuery({ + queryKey: ['app-releases'], + queryFn: () => appReleasesApi.getAll(), + }); + + const flash = (msg: string) => { setSuccessMessage(msg); setTimeout(() => setSuccessMessage(''), 3000); }; + + const saveMutation = useMutation({ + mutationFn: (payload: any) => + editing ? appReleasesApi.update(editing.id, payload) : appReleasesApi.create(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['app-releases'] }); + setFormOpen(false); + setEditing(null); + setForm({ ...EMPTY_FORM }); + setFormError(''); + flash(editing ? 'Release updated.' : 'Release created.'); + }, + onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to save.'), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => appReleasesApi.remove(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['app-releases'] }); + setDeleteTarget(null); + setDeleteError(null); + flash('Release deleted.'); + }, + onError: (e: any) => setDeleteError(e?.response?.data?.message || e?.message || 'Failed to delete.'), + }); + + const openCreate = () => { setEditing(null); setForm({ ...EMPTY_FORM }); setFormError(''); setFormOpen(true); }; + const openEdit = (r: any) => { + setEditing(r); + setForm({ os: r.os, version: r.version, forceUpdate: r.forceUpdate, storeLink: r.storeLink || '', notes: r.notes || '' }); + setFormError(''); + setFormOpen(true); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!form.version.trim()) { setFormError('Version is required.'); return; } + saveMutation.mutate({ ...form, version: form.version.trim(), storeLink: form.storeLink || undefined, notes: form.notes || undefined }); + }; + + const releases: any[] = Array.isArray(data) ? data : []; + + const columns = [ + { + key: 'os', label: 'OS', + render: (r: any) => ( + + {r.os === 'ios' ? '🍎 iOS' : '🤖 Android'} + + ), + }, + { key: 'version', label: 'Version', render: (r: any) => {r.version} }, + { + key: 'forceUpdate', label: 'Force Update', + render: (r: any) => {r.forceUpdate ? 'Yes' : 'No'}, + }, + { + key: 'storeLink', label: 'Store Link', + render: (r: any) => r.storeLink + ? {r.storeLink} + : , + }, + { key: 'notes', label: 'Notes', render: (r: any) => {r.notes || '—'} }, + { key: 'createdAt', label: 'Created', render: (r: any) => {formatDateTime(r.createdAt)} }, + ]; + + const actions = [ + { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Pencil }, + { label: 'Delete', onClick: (r: any) => { setDeleteError(null); setDeleteTarget(r); }, variant: 'danger' as const, icon: Trash2 }, + ]; + + return ( +
+
+
+

App Releases

+

Manage mobile app version release control

+
+ New Release +
+ + {successMessage && ( +
✓ {successMessage}
+ )} + +
+ +
+ + {/* Create / Edit Modal */} + setFormOpen(false)} title={editing ? 'Edit Release' : 'New Release'} size="md"> +
+
+
+ + +
+
+ + setForm({ ...form, version: e.target.value })} /> +
+
+ +
+ +
+ {(['true', 'false'] as const).map((val) => ( + + ))} +
+
+ +
+ + setForm({ ...form, storeLink: e.target.value })} /> +
+ +
+ +