From a73277ae3814f5cea3af4e843377bf2e01b9803c Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 19 May 2026 16:49:22 +0300 Subject: [PATCH] Updated seat class and coach mangement --- .../migration.sql | 52 ++++++++++++++++++ .../migration.sql | 1 + apps/edr-passenger-api/prisma/schema.prisma | 28 ++++++---- apps/edr-passenger-api/src/app.module.ts | 2 + apps/edr-passenger-api/src/main.ts | 3 +- .../src/modules/bookings/bookings.dto.ts | 2 +- .../src/modules/bookings/bookings.service.ts | 4 +- .../src/modules/fleet/fleet.controller.ts | 54 ++++++++++++++++--- .../src/modules/fleet/fleet.dto.ts | 13 ++--- .../src/modules/fleet/fleet.service.ts | 22 +++++++- .../modules/passengers/passengers.service.ts | 2 +- .../src/modules/schedules/schedules.dto.ts | 5 +- .../modules/schedules/schedules.service.ts | 6 +-- .../src/modules/search/search.dto.ts | 2 +- .../src/modules/search/search.service.ts | 19 ++++--- .../seat-classes/seat-classes.controller.ts | 40 ++++++++++++++ .../modules/seat-classes/seat-classes.dto.ts | 24 +++++++++ .../seat-classes/seat-classes.module.ts | 6 +++ .../seat-classes/seat-classes.service.ts | 40 ++++++++++++++ .../src/modules/seats/seats.controller.ts | 26 +++++++-- .../src/modules/seats/seats.dto.ts | 12 ++--- .../src/modules/seats/seats.service.ts | 6 ++- .../segments/enhanced-seats.service.ts | 2 +- 23 files changed, 310 insertions(+), 61 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260516000000_seat_class_model/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260516000001_seat_class_updated_at_default/migration.sql create mode 100644 apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/seat-classes/seat-classes.module.ts create mode 100644 apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts diff --git a/apps/edr-passenger-api/prisma/migrations/20260516000000_seat_class_model/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260516000000_seat_class_model/migration.sql new file mode 100644 index 000000000..09761e476 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260516000000_seat_class_model/migration.sql @@ -0,0 +1,52 @@ +-- CreateTable +CREATE TABLE "SeatClass" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "basePrice" INTEGER NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SeatClass_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "SeatClass_name_key" ON "SeatClass"("name"); + +-- Seed default seat classes so existing coaches can be migrated +INSERT INTO "SeatClass" ("id", "name", "description", "basePrice", "isActive", "createdAt", "updatedAt") +VALUES + ('sc_economy', 'Economy Seat', 'Standard economy seating', 45000, true, NOW(), NOW()), + ('sc_business', 'Business Seat','Comfortable business class', 90000, true, NOW(), NOW()), + ('sc_first', 'VIP Bed', 'First class VIP bed', 135000, true, NOW(), NOW()); + +-- Add seatClassId column to Coach (nullable first for migration safety) +ALTER TABLE "Coach" ADD COLUMN "seatClassId" TEXT; + +-- Map existing serviceClass enum values to new SeatClass ids +UPDATE "Coach" SET "seatClassId" = 'sc_economy' WHERE "serviceClass" = 'ECONOMY'; +UPDATE "Coach" SET "seatClassId" = 'sc_business' WHERE "serviceClass" = 'BUSINESS'; +UPDATE "Coach" SET "seatClassId" = 'sc_first' WHERE "serviceClass" = 'FIRST'; + +-- Make seatClassId NOT NULL now that all rows are populated +ALTER TABLE "Coach" ALTER COLUMN "seatClassId" SET NOT NULL; + +-- Drop old serviceClass column +ALTER TABLE "Coach" DROP COLUMN "serviceClass"; + +-- Add seatClassId to FareRule +ALTER TABLE "FareRule" ADD COLUMN "seatClassId" TEXT; + +UPDATE "FareRule" SET "seatClassId" = 'sc_economy' WHERE "serviceClass" = 'ECONOMY'; +UPDATE "FareRule" SET "seatClassId" = 'sc_business' WHERE "serviceClass" = 'BUSINESS'; +UPDATE "FareRule" SET "seatClassId" = 'sc_first' WHERE "serviceClass" = 'FIRST'; + +ALTER TABLE "FareRule" ALTER COLUMN "seatClassId" SET NOT NULL; +ALTER TABLE "FareRule" DROP COLUMN "serviceClass"; + +-- AddForeignKey +ALTER TABLE "Coach" ADD CONSTRAINT "Coach_seatClassId_fkey" + FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" + FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260516000001_seat_class_updated_at_default/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260516000001_seat_class_updated_at_default/migration.sql new file mode 100644 index 000000000..e1dc713c8 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260516000001_seat_class_updated_at_default/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "SeatClass" ALTER COLUMN "updatedAt" SET DEFAULT NOW(); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index afdae475f..3f6bef397 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -35,10 +35,16 @@ enum SeatStatus { BLOCKED } -enum ServiceClass { - ECONOMY - BUSINESS - FIRST +model SeatClass { + id String @id @default(uuid()) + name String @unique + description String? + basePrice Int + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + coaches Coach[] + fareRules FareRule[] } enum BookingStatus { @@ -249,12 +255,13 @@ model TripLiveStatus { } model Coach { - id String @id @default(uuid()) + id String @id @default(uuid()) tripId String label String - serviceClass ServiceClass - trip Trip @relation(fields: [tripId], references: [id]) - seats Seat[] + seatClassId String + trip Trip @relation(fields: [tripId], references: [id]) + seatClass SeatClass @relation(fields: [seatClassId], references: [id]) + seats Seat[] @@unique([tripId, label]) } @@ -283,11 +290,12 @@ model SeatHold { } model FareRule { - id String @id @default(uuid()) + id String @id @default(uuid()) tripId String? route String? - serviceClass ServiceClass + seatClassId String baseFareMinor Int + seatClass SeatClass @relation(fields: [seatClassId], references: [id]) currency String @default("ETB") refundable Boolean @default(true) validFrom DateTime diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index d7c43f85b..9fdfd90a6 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -23,6 +23,7 @@ import { LiveModule } from './modules/live/live.module'; import { SupportModule } from './modules/support/support.module'; import { DashboardModule } from './modules/dashboard/dashboard.module'; import { SegmentsModule } from './modules/segments/segments.module'; +import { SeatClassesModule } from './modules/seat-classes/seat-classes.module'; @Module({ imports: [ @@ -48,6 +49,7 @@ import { SegmentsModule } from './modules/segments/segments.module'; SupportModule, DashboardModule, SegmentsModule, + SeatClassesModule, ], }) export class AppModule {} diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 71de72738..5e0a7629e 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -29,7 +29,8 @@ async function bootstrap() { .addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT', in: 'header' }, 'JWT-auth') .addTag('Auth', 'Registration and login') .addTag('Stations', 'Station directory') - .addTag('Fleet', 'Train services and coaches') + .addTag('Fleet', 'Train services, coaches and seat batches') + .addTag('Seat Classes', 'Seat class management') .addTag('Schedule', 'Trips and fare rules') .addTag('Search', 'Trip search and fare quotes') .addTag('Seats', 'Seat maps and holds') 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 a2e208d2c..72f8994e6 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -16,7 +16,7 @@ export class CreateBookingDto { @ApiProperty() @IsString() tripId: string; @ApiProperty() @IsString() holdId: string; @ApiProperty({ type: [PassengerInputDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[]; - @ApiPropertyOptional({ example: 'ECONOMY', enum: ['ECONOMY', 'BUSINESS', 'FIRST'] }) @IsOptional() @IsString() serviceClass?: string; + @ApiPropertyOptional({ example: 'seat-class-uuid' }) @IsOptional() @IsString() seatClassId?: string; @ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string; @ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number; } 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 cb6158639..e1d4acd01 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -20,7 +20,7 @@ export class BookingsService { if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired'); const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId }, include: { originStation: true, destinationStation: true } }); if (!trip) throw new NotFoundException('Trip not found'); - const fareQuote = await this.searchService.getFareQuote({ tripId: dto.tripId, serviceClass: dto.serviceClass ?? 'ECONOMY', passengerCount: dto.passengers.length, promoCode: dto.promoCode, loyaltyRedemptionPoints: dto.loyaltyRedemptionPoints }); + const fareQuote = await this.searchService.getFareQuote({ tripId: dto.tripId, seatClassId: dto.seatClassId ?? '', passengerCount: dto.passengers.length, promoCode: dto.promoCode, loyaltyRedemptionPoints: dto.loyaltyRedemptionPoints }); const booking = await this.prisma.booking.create({ data: { bookingRef: generateRef(), passengerId: dto.passengerId, tripId: dto.tripId, status: 'PENDING_PAYMENT', totalMinor: fareQuote.totalMinor, seats: { create: dto.passengers.map((p) => ({ seatId: p.seatId, passengerName: p.fullName, idDocumentType: p.idDocumentType, idDocumentNumber: p.idDocumentNumber })) } }, include: { seats: { include: { seat: true } }, trip: { include: { originStation: true, destinationStation: true, service: true } } }, @@ -47,7 +47,7 @@ export class BookingsService { }, passengers: booking.seats.map((bs) => ({ fullName: bs.passengerName, - seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.serviceClass }, + seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClassId }, })), payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined, }; diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index 1557c2ece..c8b2c23ff 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -1,7 +1,7 @@ -import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse, ApiBody } from '@nestjs/swagger'; import { FleetService } from './fleet.service'; -import { CreateTrainServiceDto, CreateCoachDto, CreateSeatBatchDto } from './fleet.dto'; +import { CreateTrainServiceDto, CreateCoachDto, CreateSeatBatchDto, UpdateCoachDto } from './fleet.dto'; import { JwtGuard } from '../../common/jwt.guard'; @ApiTags('Fleet') @@ -10,9 +10,47 @@ import { JwtGuard } from '../../common/jwt.guard'; @ApiBearerAuth('JWT-auth') export class FleetController { constructor(private service: FleetService) {} - @Get('services') @ApiOperation({ summary: 'List train services' }) getServices() { return this.service.getServices(); } - @Post('services') @ApiOperation({ summary: 'Create train service' }) createService(@Body() dto: CreateTrainServiceDto) { return this.service.createService(dto); } - @Post('coaches') @ApiOperation({ summary: 'Add coach to trip' }) createCoach(@Body() dto: CreateCoachDto) { return this.service.createCoach(dto); } - @Post('seats/batch')@ApiOperation({ summary: 'Batch-create seats for coach' }) createSeatBatch(@Body() dto: CreateSeatBatchDto) { return this.service.createSeatBatch(dto); } - @Get('analytics') @ApiOperation({ summary: 'Fleet analytics' }) getAnalytics() { return this.service.getAnalytics(); } + + @Get('services') + @ApiOperation({ summary: 'List train services' }) + @ApiResponse({ status: 200, description: 'Returns all train services with recent trips' }) + getServices() { return this.service.getServices(); } + + @Post('services') + @ApiOperation({ summary: 'Create a train service' }) + @ApiBody({ type: CreateTrainServiceDto }) + @ApiResponse({ status: 201, description: 'Train service created' }) + createService(@Body() dto: CreateTrainServiceDto) { return this.service.createService(dto); } + + @Get('coaches') + @ApiOperation({ summary: 'List coaches' }) + @ApiQuery({ name: 'tripId', required: false, description: 'Filter by trip UUID' }) + @ApiResponse({ status: 200, description: 'Returns coaches with seat class and seat count' }) + listCoaches(@Query('tripId') tripId?: string) { return this.service.listCoaches(tripId); } + + @Post('coaches') + @ApiOperation({ summary: 'Add a coach to a trip' }) + @ApiBody({ type: CreateCoachDto }) + @ApiResponse({ status: 201, description: 'Coach created' }) + createCoach(@Body() dto: CreateCoachDto) { return this.service.createCoach(dto); } + + @Patch('coaches/:id') + @ApiOperation({ summary: 'Update a coach' }) + @ApiParam({ name: 'id', description: 'Coach UUID' }) + @ApiBody({ type: UpdateCoachDto }) + @ApiResponse({ status: 200, description: 'Coach updated' }) + @ApiResponse({ status: 404, description: 'Coach not found' }) + updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) { return this.service.updateCoach(id, dto); } + + @Post('seats/batch') + @ApiOperation({ summary: 'Batch-create seats for a coach' }) + @ApiBody({ type: CreateSeatBatchDto }) + @ApiResponse({ status: 201, description: 'Seats created' }) + @ApiResponse({ status: 404, description: 'Coach not found' }) + createSeatBatch(@Body() dto: CreateSeatBatchDto) { return this.service.createSeatBatch(dto); } + + @Get('analytics') + @ApiOperation({ summary: 'Fleet analytics' }) + @ApiResponse({ status: 200, description: 'Returns fleet occupancy analytics' }) + getAnalytics() { return this.service.getAnalytics(); } } diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts index bef3b5a9a..5aeadeae7 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts @@ -1,6 +1,5 @@ -import { IsString, IsEnum, IsInt } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; -import { ServiceClass } from '@prisma/client'; +import { IsString, IsInt, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional, PartialType, OmitType } from '@nestjs/swagger'; export class CreateTrainServiceDto { @ApiProperty({ example: '301' }) @IsString() number: string; @@ -8,13 +7,15 @@ export class CreateTrainServiceDto { } export class CreateCoachDto { - @ApiProperty() @IsString() tripId: string; + @ApiProperty({ example: 'trip-uuid' }) @IsString() tripId: string; @ApiProperty({ example: 'A' }) @IsString() label: string; - @ApiProperty({ enum: ServiceClass }) @IsEnum(ServiceClass) serviceClass: ServiceClass; + @ApiProperty({ example: 'seat-class-uuid' }) @IsString() seatClassId: string; } +export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['tripId'] as const)) {} + export class CreateSeatBatchDto { - @ApiProperty() @IsString() coachId: string; + @ApiProperty({ example: 'coach-uuid' }) @IsString() coachId: string; @ApiProperty({ example: 10 }) @IsInt() rows: number; @ApiProperty({ example: ['A', 'B', 'C', 'D'] }) cols: string[]; } diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index 6fa189666..ef6969aaa 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -1,13 +1,30 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; -import { CreateTrainServiceDto, CreateCoachDto, CreateSeatBatchDto } from './fleet.dto'; +import { CreateTrainServiceDto, CreateCoachDto, CreateSeatBatchDto, UpdateCoachDto } from './fleet.dto'; @Injectable() export class FleetService { constructor(private prisma: PrismaService) {} + getServices() { return this.prisma.trainService.findMany({ include: { trips: { take: 5, orderBy: { departureAt: 'desc' } } } }); } createService(dto: CreateTrainServiceDto) { return this.prisma.trainService.create({ data: dto }); } - createCoach(dto: CreateCoachDto) { return this.prisma.coach.create({ data: dto }); } + + listCoaches(tripId?: string) { + return this.prisma.coach.findMany({ + where: tripId ? { tripId } : undefined, + include: { seatClass: { select: { id: true, name: true, basePrice: true } }, _count: { select: { seats: true } } }, + orderBy: { label: 'asc' }, + }); + } + + createCoach(dto: CreateCoachDto) { return this.prisma.coach.create({ data: dto, include: { seatClass: { select: { id: true, name: true, basePrice: true } } } }); } + + async updateCoach(id: string, dto: UpdateCoachDto) { + const coach = await this.prisma.coach.findUnique({ where: { id } }); + if (!coach) throw new NotFoundException('Coach not found'); + return this.prisma.coach.update({ where: { id }, data: dto, include: { seatClass: { select: { id: true, name: true, basePrice: true } } } }); + } + async createSeatBatch(dto: CreateSeatBatchDto) { const coach = await this.prisma.coach.findUnique({ where: { id: dto.coachId } }); if (!coach) throw new NotFoundException('Coach not found'); @@ -16,6 +33,7 @@ export class FleetService { await this.prisma.seat.createMany({ data: seats, skipDuplicates: true }); return { created: seats.length }; } + async getAnalytics() { const [totalServices, totalTrips, totalSeats, bookedSeats] = await Promise.all([ this.prisma.trainService.count(), this.prisma.trip.count(), 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 54222751e..c1121fbed 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -30,7 +30,7 @@ export class PassengersService { destination: { id: b.trip.destinationStation.id, name: b.trip.destinationStation.name, code: b.trip.destinationStation.code, city: b.trip.destinationStation.city }, departureAt: b.trip.departureAt, }, - passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.serviceClass } })), + passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClassId } })), })), }; } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 80638d5ae..b00aaa906 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -1,6 +1,5 @@ -import { IsString, IsDateString, IsInt, IsOptional, IsEnum } from 'class-validator'; +import { IsString, IsDateString, IsInt, IsOptional } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { ServiceClass } from '@prisma/client'; export class CreateTripDto { @ApiProperty() @IsString() serviceId: string; @@ -14,7 +13,7 @@ export class CreateTripDto { export class CreateFareRuleDto { @ApiPropertyOptional() @IsOptional() @IsString() tripId?: string; @ApiPropertyOptional() @IsOptional() @IsString() route?: string; - @ApiProperty({ enum: ServiceClass }) @IsEnum(ServiceClass) serviceClass: ServiceClass; + @ApiProperty({ example: 'seat-class-uuid' }) @IsString() seatClassId: string; @ApiProperty({ example: 45000 }) @IsInt() baseFareMinor: number; @ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string; @ApiPropertyOptional() @IsOptional() @IsDateString() validUntil?: string; diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index a97b54fd6..062e7f0df 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -26,14 +26,14 @@ export class SchedulesService { return this.prisma.fareRule.create({ data: { ...dto, validFrom: new Date(dto.validFrom), validUntil: dto.validUntil ? new Date(dto.validUntil) : null } }); } - async getFare(tripId: string, serviceClass: string) { + async getFare(tripId: string, seatClassId: string) { const trip = await this.prisma.trip.findUnique({ where: { id: tripId }, include: { originStation: true, destinationStation: true } }); if (!trip) throw new NotFoundException('Trip not found'); const route = `${trip.originStation.code}-${trip.destinationStation.code}`; const rule = await this.prisma.fareRule.findFirst({ - where: { serviceClass: serviceClass as any, validFrom: { lte: new Date() }, OR: [{ tripId }, { route }, { tripId: null, route: null }], AND: [{ OR: [{ validUntil: null }, { validUntil: { gte: new Date() } }] }] }, + where: { seatClassId, validFrom: { lte: new Date() }, OR: [{ tripId }, { route }, { tripId: null, route: null }], AND: [{ OR: [{ validUntil: null }, { validUntil: { gte: new Date() } }] }] }, orderBy: { validFrom: 'desc' }, }); - return rule ?? { baseFareMinor: 45000, currency: 'ETB', serviceClass }; + return rule ?? { baseFareMinor: 45000, currency: 'ETB', seatClassId }; } } 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 e4e3d2cae..45818037f 100644 --- a/apps/edr-passenger-api/src/modules/search/search.dto.ts +++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts @@ -11,7 +11,7 @@ export class SearchTripsDto { export class FareQuoteDto { @ApiProperty() @IsString() tripId: string; - @ApiProperty({ example: 'ECONOMY' }) @IsString() serviceClass: string; + @ApiProperty({ example: 'uuid-of-seat-class' }) @IsString() seatClassId: string; @ApiPropertyOptional({ example: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) passengerCount?: number; @ApiPropertyOptional({ example: 'WEEKEND15' }) @IsOptional() @IsString() promoCode?: string; @ApiPropertyOptional({ example: 450 }) @IsOptional() @Type(() => Number) @IsInt() loyaltyRedemptionPoints?: number; 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 046bdc70d..9d9de275f 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -12,19 +12,19 @@ export class SearchService { const date = new Date(dto.date), nextDay = new Date(date.getTime() + 86400000); const trips = await this.prisma.trip.findMany({ where: { originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, departureAt: { gte: date, lt: nextDay }, status: { in: ['SCHEDULED', 'BOARDING'] } }, - include: { service: true, originStation: true, destinationStation: true, coaches: { include: { seats: true } } }, + include: { service: true, originStation: true, destinationStation: true, coaches: { include: { seats: true, seatClass: true } } }, }); return trips.map((trip) => { - const seatsByClass = (cls: string) => trip.coaches.filter((c) => c.serviceClass === cls).flatMap((c) => c.seats); - const avail = (cls: string) => seatsByClass(cls).filter((s) => s.status === 'AVAILABLE').length; + const seatsByClass = (id: string) => trip.coaches.filter((c) => c.seatClassId === id).flatMap((c) => c.seats); + const uniqueClasses = [...new Map(trip.coaches.map(c => [c.seatClassId, c.seatClass])).values()]; return { id: trip.id, number: trip.service.number, origin: { id: trip.originStation.id, code: trip.originStation.code, name: trip.originStation.name, city: trip.originStation.city }, destination: { id: trip.destinationStation.id, code: trip.destinationStation.code, name: trip.destinationStation.name, city: trip.destinationStation.city }, departureAt: trip.departureAt, arrivalAt: trip.arrivalAt, status: trip.status, - availability: { ECONOMY: avail('ECONOMY'), BUSINESS: avail('BUSINESS'), FIRST: avail('FIRST') }, - fares: { ECONOMY: this.defaultFare('ECONOMY') / 100, BUSINESS: this.defaultFare('BUSINESS') / 100, FIRST: this.defaultFare('FIRST') / 100 }, + availability: Object.fromEntries(uniqueClasses.map(sc => [sc.name, seatsByClass(sc.id).filter(s => s.status === 'AVAILABLE').length])), + fares: Object.fromEntries(uniqueClasses.map(sc => [sc.name, sc.basePrice / 100])), }; }); } @@ -32,8 +32,10 @@ export class SearchService { async getFareQuote(dto: FareQuoteDto) { const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId } }); if (!trip) throw new NotFoundException('Trip not found'); + const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } }); + if (!seatClass) throw new NotFoundException('SeatClass not found'); const count = dto.passengerCount ?? 1; - const baseFareMinor = this.defaultFare(dto.serviceClass) * count; + const baseFareMinor = seatClass.basePrice * count; let discountMinor = 0; if (dto.promoCode) { const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); @@ -41,10 +43,7 @@ export class SearchService { } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR; const taxesMinor = Math.round(baseFareMinor * 0.05); - return { tripId: dto.tripId, serviceClass: dto.serviceClass, passengerCount: count, baseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor: Math.max(0, baseFareMinor - discountMinor - loyaltyMinor + taxesMinor), currency: 'ETB' }; + return { tripId: dto.tripId, seatClassId: dto.seatClassId, seatClassName: seatClass.name, passengerCount: count, baseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor: Math.max(0, baseFareMinor - discountMinor - loyaltyMinor + taxesMinor), currency: 'ETB' }; } - private defaultFare(serviceClass: string): number { - return ({ ECONOMY: 45000, BUSINESS: 90000, FIRST: 135000 } as any)[serviceClass] ?? 45000; - } } diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts new file mode 100644 index 000000000..945834ac0 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts @@ -0,0 +1,40 @@ +import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger'; +import { SeatClassesService } from './seat-classes.service'; +import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto'; +import { JwtGuard } from '../../common/jwt.guard'; + +@ApiTags('Seat Classes') +@Controller('seat-classes') +export class SeatClassesController { + constructor(private service: SeatClassesService) {} + + @Get() + @ApiOperation({ summary: 'List all seat classes' }) + @ApiResponse({ status: 200, description: 'Returns all seat classes with their coaches' }) + listSeatClasses() { return this.service.listSeatClasses(); } + + @Get(':id') + @ApiOperation({ summary: 'Get a seat class by ID' }) + @ApiParam({ name: 'id', description: 'Seat class UUID' }) + @ApiResponse({ status: 200, description: 'Returns seat class with its coaches' }) + @ApiResponse({ status: 404, description: 'Seat class not found' }) + getSeatClass(@Param('id') id: string) { return this.service.getSeatClass(id); } + + @Post() + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Create a seat class' }) + @ApiBody({ type: CreateSeatClassDto }) + @ApiResponse({ status: 201, description: 'Seat class created' }) + @ApiResponse({ status: 409, description: 'Seat class name already exists' }) + createSeatClass(@Body() dto: CreateSeatClassDto) { return this.service.createSeatClass(dto); } + + @Patch(':id') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Update a seat class' }) + @ApiParam({ name: 'id', description: 'Seat class UUID' }) + @ApiBody({ type: UpdateSeatClassDto }) + @ApiResponse({ status: 200, description: 'Seat class updated' }) + @ApiResponse({ status: 404, description: 'Seat class not found' }) + updateSeatClass(@Param('id') id: string, @Body() dto: UpdateSeatClassDto) { return this.service.updateSeatClass(id, dto); } +} diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts new file mode 100644 index 000000000..d12fe0fb6 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts @@ -0,0 +1,24 @@ +import { IsString, IsInt, IsBoolean, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; + +export class CreateSeatClassDto { + @ApiProperty({ example: 'Economy Seat' }) + @IsString() + name: string; + + @ApiPropertyOptional({ example: 'Standard economy seating' }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ example: 45000, description: 'Base price in minor currency units' }) + @IsInt() + basePrice: number; + + @ApiPropertyOptional({ example: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateSeatClassDto extends PartialType(CreateSeatClassDto) {} diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.module.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.module.ts new file mode 100644 index 000000000..a7e8648e1 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.module.ts @@ -0,0 +1,6 @@ +import { Module } from '@nestjs/common'; +import { SeatClassesController } from './seat-classes.controller'; +import { SeatClassesService } from './seat-classes.service'; + +@Module({ controllers: [SeatClassesController], providers: [SeatClassesService], exports: [SeatClassesService] }) +export class SeatClassesModule {} 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 new file mode 100644 index 000000000..2e3998651 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts @@ -0,0 +1,40 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto'; + +@Injectable() +export class SeatClassesService { + constructor(private prisma: PrismaService) {} + + private readonly coachInclude = { + coaches: { + select: { id: true, label: true, tripId: true, _count: { select: { seats: true } } }, + orderBy: { label: 'asc' as const }, + }, + }; + + listSeatClasses() { + return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' }, include: this.coachInclude }); + } + + async getSeatClass(id: string) { + const sc = await this.prisma.seatClass.findUnique({ where: { id }, include: this.coachInclude }); + if (!sc) throw new NotFoundException('SeatClass not found'); + return sc; + } + + async createSeatClass(dto: CreateSeatClassDto) { + try { + return await this.prisma.seatClass.create({ data: dto }); + } catch (e: any) { + if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`); + throw e; + } + } + + async updateSeatClass(id: string, dto: UpdateSeatClassDto) { + const sc = await this.prisma.seatClass.findUnique({ where: { id } }); + if (!sc) throw new NotFoundException('SeatClass not found'); + return this.prisma.seatClass.update({ where: { id }, data: dto, include: this.coachInclude }); + } +} 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 a872f337d..16237387d 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -1,5 +1,5 @@ import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { SeatsService } from './seats.service'; import { HoldSeatsDto } from './seats.dto'; import { JwtGuard } from '../../common/jwt.guard'; @@ -8,10 +8,28 @@ import { JwtGuard } from '../../common/jwt.guard'; @Controller('seats') export class SeatsController { constructor(private service: SeatsService) {} - @Get('seatmap/:tripId') @ApiOperation({ summary: 'Get seat map for a trip' }) + + // ── Seat Map ────────────────────────────────────────────────────────────── + @Get('seatmap/:tripId') + @ApiOperation({ summary: 'Get seat map for a trip' }) + @ApiParam({ name: 'tripId', description: 'Trip UUID' }) + @ApiQuery({ name: 'coachId', required: false, description: 'Filter by coach UUID' }) + @ApiResponse({ status: 200, description: 'Returns coaches with seats and seat class info' }) getSeatMap(@Param('tripId') tripId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(tripId, coachId); } - @Post('hold') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Hold seats for 15 minutes' }) + + // ── Hold / Release ──────────────────────────────────────────────────────── + @Post('hold') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Hold seats for 15 minutes' }) + @ApiResponse({ status: 201, description: 'Seats held successfully' }) + @ApiResponse({ status: 409, description: 'One or more seats unavailable' }) holdSeats(@Body() dto: HoldSeatsDto) { return this.service.holdSeats(dto); } - @Delete('hold/:holdId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Release a seat hold' }) + + @Delete('hold/:holdId') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Release a seat hold' }) + @ApiParam({ name: 'holdId', description: 'Hold UUID' }) + @ApiResponse({ status: 200, description: 'Hold released' }) + @ApiResponse({ status: 404, description: 'Hold not found' }) releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); } } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.dto.ts b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts index a36d76f18..52136e9fa 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.dto.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts @@ -1,9 +1,9 @@ -import { IsString, IsArray } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsArray, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class HoldSeatsDto { - @ApiProperty() @IsString() tripId: string; - @ApiProperty() @IsString() passengerId: string; - @ApiProperty({ type: [String] }) @IsArray() seatIds: string[]; - @ApiProperty({ required: false }) fareQuoteId?: string; + @ApiProperty({ example: 'trip-uuid' }) @IsString() tripId: string; + @ApiProperty({ example: 'passenger-uuid' }) @IsString() passengerId: string; + @ApiProperty({ type: [String], example: ['seat-uuid-1', 'seat-uuid-2'] }) @IsArray() seatIds: string[]; + @ApiPropertyOptional({ example: 'fare-quote-uuid' }) @IsOptional() @IsString() fareQuoteId?: string; } 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 90234b6f2..2da732f3e 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -7,18 +7,20 @@ import { Cron, CronExpression } from '@nestjs/schedule'; export class SeatsService { constructor(private prisma: PrismaService) {} + // ── Seat Map ────────────────────────────────────────────────────────────── async getSeatMap(tripId: string, coachId?: string) { - const coaches = await this.prisma.coach.findMany({ where: { tripId, ...(coachId ? { id: coachId } : {}) }, include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } }); + const coaches = await this.prisma.coach.findMany({ where: { tripId, ...(coachId ? { id: coachId } : {}) }, include: { seatClass: true, seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } }); return { coaches: coaches.map((coach) => ({ id: coach.id, name: `Coach ${coach.label}`, - type: coach.serviceClass, + seatClass: { id: coach.seatClass.id, name: coach.seatClass.name, basePrice: coach.seatClass.basePrice }, seats: coach.seats.map((s) => ({ id: s.id, number: s.label, status: s.status, kind: s.kind })), })), }; } + // ── Hold / Release ──────────────────────────────────────────────────────── async holdSeats(dto: HoldSeatsDto) { const expiresAt = new Date(Date.now() + 15 * 60 * 1000); const hold = await this.prisma.$transaction(async (tx) => { diff --git a/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts index 57b6f2d2f..e7213cf6c 100644 --- a/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts +++ b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts @@ -353,7 +353,7 @@ export class EnhancedSeatsService { id: seat.id, label: seat.label, coach: coach.label, - serviceClass: coach.serviceClass, + serviceClass: coach.seatClassId, row: seat.row, col: seat.col });