Merge remote changes: Add SeatClass model and seat-classes module while preserving local IAM integration and enhancements

This commit is contained in:
Stephanos A
2026-05-21 14:06:05 +03:00
21 changed files with 307 additions and 43 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

@@ -64,6 +64,18 @@ enum Currency {
USD
}
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 {
DRAFT
PENDING_PAYMENT
@@ -303,12 +315,14 @@ model Coach {
tripId String
label String
serviceClass ServiceClass
seatClassId String?
capacity Int?
sequence Int?
coachType String?
amenities Json?
trip Trip @relation(fields: [tripId], references: [id])
seats Seat[]
trip Trip @relation(fields: [tripId], references: [id])
seatClass SeatClass? @relation(fields: [seatClassId], references: [id])
seats Seat[]
@@unique([tripId, label])
}
@@ -346,11 +360,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

@@ -33,6 +33,7 @@ import { SegmentsModule } from './modules/segments/segments.module';
import { AgentsModule } from './modules/agents/agents.module';
import { ReportsModule } from './modules/reports/reports.module';
import { FraudModule } from './modules/fraud/fraud.module';
import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
@Module({
imports: [
@@ -66,6 +67,7 @@ import { FraudModule } from './modules/fraud/fraud.module';
AgentsModule,
ReportsModule,
FraudModule,
SeatClassesModule,
],
})
export class AppModule implements NestModule {

View File

@@ -169,6 +169,7 @@ Payment providers send notifications to:
.addTag('Booking', '🎫 Booking lifecycle, modification, cancellation, refunds')
.addTag('Dashboard', '📊 Home dashboard aggregated data')
.addTag('Fleet', '🚂 Train services, coaches, seat configurations')
.addTag('Seat Classes', '🎨 Seat class management and configuration')
.addTag('Fraud Detection', '🔒 Fraud detection, risk scoring, user blocking')
.addTag('Live Tracking', '📍 Real-time trip status, location updates, crowd signals')
.addTag('Loyalty', '🏆 Points accumulation, tier management, rewards redemption')

View File

@@ -23,6 +23,7 @@ export class CreateBookingDto {
enum: ['ECONOMY_REGULAR', 'ECONOMY_BED_LOWER', 'ECONOMY_BED_MIDDLE', 'ECONOMY_BED_UPPER', 'VIP_BED_LOWER', 'VIP_BED_UPPER']
})
@IsString() serviceClass: string;
@ApiPropertyOptional({ example: 'seat-class-uuid' }) @IsOptional() @IsString() seatClassId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
@ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string;

View File

@@ -92,7 +92,7 @@ export class BookingsService {
}
// Calculate fare with age-based pricing
const baseFareMinor = await this.getBaseFare(dto.tripId, dto.serviceClass);
const baseFareMinor = await this.getBaseFare(dto.tripId, dto.serviceClass, dto.seatClassId);
const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1);
const childFareMinor = baseFareMinor * paidChildrenCount;
@@ -176,7 +176,13 @@ export class BookingsService {
};
}
private async getBaseFare(tripId: string, serviceClass: string): Promise<number> {
private async getBaseFare(tripId: string, serviceClass: string, seatClassId?: string): Promise<number> {
if (seatClassId) {
const fareRule = await this.prisma.fareRule.findFirst({
where: { tripId, seatClassId },
});
if (fareRule) return fareRule.baseFareMinor;
}
const fareRule = await this.prisma.fareRule.findFirst({
where: { tripId, serviceClass: serviceClass as any },
});
@@ -208,7 +214,7 @@ export class BookingsService {
fullName: bs.passengerName,
category: bs.passengerCategory,
verifaydaVerified: bs.verifaydaVerified,
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.serviceClass, seatClassId: 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

@@ -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,11 +8,29 @@ 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); }
@Get('export/csv/:tripId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' })

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