Updated seat class and coach mangement

This commit is contained in:
Roba Boru
2026-05-19 16:49:22 +03:00
parent 071a57a668
commit a73277ae38
23 changed files with 310 additions and 61 deletions

View File

@@ -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;

View File

@@ -0,0 +1 @@
ALTER TABLE "SeatClass" ALTER COLUMN "updatedAt" SET DEFAULT NOW();

View File

@@ -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

View File

@@ -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 {}

View File

@@ -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')

View File

@@ -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;
}

View File

@@ -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,
};

View File

@@ -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(); }
}

View File

@@ -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[];
}

View File

@@ -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(),

View File

@@ -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 } })),
})),
};
}

View File

@@ -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;

View File

@@ -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 };
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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); }
}

View File

@@ -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) {}

View File

@@ -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 {}

View File

@@ -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 });
}
}

View File

@@ -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); }
}

View File

@@ -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;
}

View File

@@ -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) => {

View File

@@ -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
});