Extra luggage, tourism package, manage my trip and other updates

This commit is contained in:
Stephanos A
2026-06-23 19:45:15 +03:00
parent 2bd76756a4
commit e25066d6d5
26 changed files with 2374 additions and 411 deletions

View File

@@ -0,0 +1,70 @@
import { Body, Controller, Get, Param, Post, Patch, UseGuards, Request, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PackagesService } from './packages.service';
import { CreatePackageDto, BookPackageDto } from './packages.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
@ApiTags('Packages')
@Controller('packages')
export class PackagesController {
constructor(private readonly service: PackagesService) {}
@Get()
@ApiOperation({ summary: 'List active packages' })
listActive() {
return this.service.listActive();
}
@Get('all')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all packages (admin)' })
listAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20);
}
@Get('my-bookings')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get my package bookings' })
myBookings(@Request() req: any) {
return this.service.getMyBookings(req.user.passengerId);
}
@Get('booking/:ref')
@ApiOperation({ summary: 'Get package booking by reference' })
getBookingByRef(@Param('ref') ref: string) {
return this.service.getBookingByRef(ref);
}
@Get(':id')
@ApiOperation({ summary: 'Get package details' })
getById(@Param('id') id: string) {
return this.service.getById(id);
}
@Post()
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create package (admin)' })
create(@Body() dto: CreatePackageDto) {
return this.service.create(dto);
}
@Patch(':id/activate')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Activate package (admin)' })
activate(@Param('id') id: string) {
return this.service.activate(id);
}
@Post('book')
@UseGuards(OptionalJwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Book a package (public or authenticated)' })
book(@Body() dto: BookPackageDto, @Request() req: any) {
return this.service.book(dto, req.user?.passengerId);
}
}

View File

@@ -0,0 +1,94 @@
import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePriceTierDto {
@ApiProperty({ example: 'HSC' })
@IsString() seatType: string;
@ApiProperty({ example: 'Regular Seat (HSC)' })
@IsString() label: string;
@ApiProperty({ example: 1023200 })
@IsInt() @Min(0) priceMinor: number;
@ApiProperty({ example: 100 })
@IsInt() @Min(0) availableSeats: number;
}
export class CreatePackageDto {
@ApiProperty({ example: 'KULUBBI-2025' })
@IsString() code: string;
@ApiProperty({ example: 'Kulubbi Gabriel Pilgrimage Package' })
@IsString() name: string;
@ApiPropertyOptional()
@IsOptional() @IsString() description?: string;
@ApiProperty() @IsUUID() outboundScheduleId: string;
@ApiProperty() @IsUUID() returnScheduleId: string;
@ApiProperty() @IsUUID() originStationId: string;
@ApiProperty() @IsUUID() destinationStationId: string;
@ApiProperty({ example: '2025-07-24T07:00:00Z' })
@IsDateString() boardingTime: string;
@ApiProperty({ example: '2025-07-24T09:00:00Z' })
@IsDateString() departureTime: string;
@ApiProperty({ example: '2025-07-25T06:00:00Z' })
@IsDateString() arrivalTime: string;
@ApiProperty({ example: 912 })
@IsInt() @Min(1) totalCapacity: number;
@ApiPropertyOptional({ example: '1 Locomotive + 2SBC + 2HBC + 6HSC' })
@IsOptional() @IsString() coachConfiguration?: string;
@ApiProperty({ type: [String] })
@IsArray() @IsString({ each: true }) includedServices: string[];
@ApiPropertyOptional() @IsOptional() @IsBoolean() busTransferIncluded?: boolean;
@ApiPropertyOptional() @IsOptional() @IsString() busTransferRoute?: string;
@ApiProperty({ example: '2025-07-01T00:00:00Z' })
@IsDateString() validFrom: string;
@ApiProperty({ example: '2025-07-24T09:00:00Z' })
@IsDateString() validUntil: string;
@ApiProperty({ type: [CreatePriceTierDto] })
@IsArray() @ValidateNested({ each: true }) @Type(() => CreatePriceTierDto)
priceTiers: CreatePriceTierDto[];
}
export class BookPackagePassengerDto {
@ApiProperty() @IsString() passengerName: string;
@ApiPropertyOptional() @IsOptional() @IsDateString() dateOfBirth?: string;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentType?: string;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
@ApiPropertyOptional() @IsOptional() @IsString() passportNumber?: string;
@ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string;
}
export class BookPackageDto {
@ApiProperty() @IsUUID() packageId: string;
@ApiProperty() @IsUUID() priceTierId: string;
@ApiPropertyOptional()
@IsOptional() @IsString() displayCurrency?: string;
@ApiPropertyOptional()
@IsOptional() @IsString() contactEmail?: string;
@ApiPropertyOptional()
@IsOptional() @IsString() contactPhone?: string;
@ApiPropertyOptional()
@IsOptional() @IsString() promoCode?: string;
@ApiProperty({ type: [BookPackagePassengerDto] })
@IsArray() @ValidateNested({ each: true }) @Type(() => BookPackagePassengerDto)
passengers: BookPackagePassengerDto[];
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma.module';
import { PackagesController } from './packages.controller';
import { PackagesService } from './packages.service';
import { CurrencyModule } from '../currency/currency.module';
@Module({
imports: [PrismaModule, CurrencyModule],
controllers: [PackagesController],
providers: [PackagesService],
exports: [PackagesService],
})
export class PackagesModule {}

View File

@@ -0,0 +1,191 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CurrencyService } from '../currency/currency.service';
import { CreatePackageDto, BookPackageDto } from './packages.dto';
import { Currency } from '@prisma/client';
function generateRef(): string {
return 'PKG-' + Array.from({ length: 6 }, () =>
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Math.floor(Math.random() * 26)],
).join('');
}
@Injectable()
export class PackagesService {
constructor(
private readonly prisma: PrismaService,
private readonly currencyService: CurrencyService,
) {}
listActive() {
const now = new Date();
return this.prisma.travelPackage.findMany({
where: { status: 'ACTIVE', validFrom: { lte: now }, validUntil: { gte: now } },
include: {
priceTiers: true,
outboundSchedule: { include: { originStation: true, destinationStation: true } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
},
orderBy: { validFrom: 'asc' },
});
}
async getById(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({
where: { id },
include: {
priceTiers: true,
outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } },
returnSchedule: { include: { originStation: true, destinationStation: true, train: true } },
},
});
if (!pkg) throw new NotFoundException('Package not found');
return pkg;
}
create(dto: CreatePackageDto) {
return this.prisma.travelPackage.create({
data: {
code: dto.code,
name: dto.name,
description: dto.description,
outboundScheduleId: dto.outboundScheduleId,
returnScheduleId: dto.returnScheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
boardingTime: new Date(dto.boardingTime),
departureTime: new Date(dto.departureTime),
arrivalTime: new Date(dto.arrivalTime),
totalCapacity: dto.totalCapacity,
coachConfiguration: dto.coachConfiguration,
includedServices: dto.includedServices,
busTransferIncluded: dto.busTransferIncluded ?? false,
busTransferRoute: dto.busTransferRoute,
validFrom: new Date(dto.validFrom),
validUntil: new Date(dto.validUntil),
status: 'DRAFT',
priceTiers: { create: dto.priceTiers },
},
include: { priceTiers: true },
});
}
async activate(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException('Package not found');
return this.prisma.travelPackage.update({ where: { id }, data: { status: 'ACTIVE' } });
}
async book(dto: BookPackageDto, passengerId?: string) {
const pkg = await this.prisma.travelPackage.findUnique({
where: { id: dto.packageId },
include: { priceTiers: true },
});
if (!pkg) throw new NotFoundException('Package not found');
if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking');
if (new Date() > pkg.validUntil) throw new BadRequestException('Package has expired');
const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId);
if (!tier) throw new NotFoundException('Price tier not found');
const passengerCount = dto.passengers.length;
const remaining = tier.availableSeats - tier.bookedSeats;
if (passengerCount > remaining) {
throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`);
}
const totalMinor = tier.priceMinor * passengerCount;
const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB;
const displayTotalMinor =
displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
const [booking] = await this.prisma.$transaction([
this.prisma.packageBooking.create({
data: {
bookingRef: generateRef(),
packageId: dto.packageId,
priceTierId: dto.priceTierId,
passengerId: passengerId ?? null,
contactEmail: dto.contactEmail,
contactPhone: dto.contactPhone,
promoCode: dto.promoCode,
passengerCount,
totalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
status: 'PENDING_PAYMENT',
passengers: {
create: dto.passengers.map((p) => ({
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth ? new Date(p.dateOfBirth) : undefined,
idDocumentType: p.idDocumentType as any,
idDocumentNumber: p.idDocumentNumber,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
})),
},
},
include: {
passengers: true,
priceTier: true,
package: {
include: {
outboundSchedule: { include: { originStation: true, destinationStation: true } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
},
},
},
}),
this.prisma.packagePriceTier.update({
where: { id: dto.priceTierId },
data: { bookedSeats: { increment: passengerCount } },
}),
]);
return booking;
}
getMyBookings(passengerId: string) {
return this.prisma.packageBooking.findMany({
where: { passengerId },
include: { package: true, priceTier: true, passengers: true, paymentIntent: true },
orderBy: { createdAt: 'desc' },
});
}
async getBookingByRef(bookingRef: string) {
const booking = await this.prisma.packageBooking.findUnique({
where: { bookingRef },
include: {
package: {
include: {
outboundSchedule: { include: { originStation: true, destinationStation: true } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
},
},
priceTier: true,
passengers: true,
paymentIntent: true,
},
});
if (!booking) throw new NotFoundException('Package booking not found');
return booking;
}
async listAll(page = 1, pageSize = 20) {
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([
this.prisma.travelPackage.findMany({
skip,
take: pageSize,
include: { priceTiers: true },
orderBy: { createdAt: 'desc' },
}),
this.prisma.travelPackage.count(),
]);
return { items, total, page, pageSize };
}
}

View File

@@ -4,9 +4,10 @@ import { SeatsController } from './seats.controller';
import { SeatsService } from './seats.service';
import { SegmentsModule } from '../segments/segments.module';
import { IamModule } from '../../common/iam.module';
import { SystemConfigModule } from '../system-config/system-config.module';
@Module({
imports: [SegmentsModule, HttpModule, IamModule],
imports: [SegmentsModule, HttpModule, IamModule, SystemConfigModule],
controllers: [SeatsController],
providers: [SeatsService],
exports: [SeatsService],

View File

@@ -3,12 +3,14 @@ import { PrismaService } from '../../common/prisma.service';
import { HoldSeatsDto } from './seats.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
@Injectable()
export class SeatsService {
constructor(
private prisma: PrismaService,
private segmentsService: SegmentsService,
private systemConfig: SystemConfigService,
) {}
async getSeatMap(scheduleId: string, coachId?: string, originStationId?: string, destinationStationId?: string) {
@@ -169,7 +171,8 @@ export class SeatsService {
if (new Set(seatIds).size !== seatIds.length)
throw new BadRequestException('Duplicate seatId in passengers list');
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
const holdMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES);
const expiresAt = new Date(Date.now() + holdMinutes * 60 * 1000);
const hold = await this.prisma.$transaction(async (tx) => {
const seats = await tx.seat.findMany({

View File

@@ -0,0 +1,24 @@
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { SystemConfigService } from './system-config.service';
import { IamGuard } from '../../common/iam-adapter';
import { Roles } from '../../common/roles.decorator';
@ApiTags('System Config')
@ApiBearerAuth('IAM-auth')
@UseGuards(IamGuard)
@Roles('ADMIN')
@Controller('system-config')
export class SystemConfigController {
constructor(private service: SystemConfigService) {}
@Get()
getAll() {
return this.service.getAll();
}
@Patch()
update(@Body() body: Record<string, string>) {
return this.service.updateMany(body);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { SystemConfigService } from './system-config.service';
import { SystemConfigController } from './system-config.controller';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [PrismaModule, HttpModule],
controllers: [SystemConfigController],
providers: [SystemConfigService],
exports: [SystemConfigService],
})
export class SystemConfigModule {}

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
export const CONFIG_KEYS = {
SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes',
} as const;
const DEFAULTS: Record<string, string> = {
[CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5',
};
@Injectable()
export class SystemConfigService {
constructor(private prisma: PrismaService) {}
async getAll(): Promise<Record<string, string>> {
const rows = await this.prisma.systemConfig.findMany();
const result: Record<string, string> = { ...DEFAULTS };
for (const row of rows) result[row.key] = row.value;
return result;
}
async getValue(key: string): Promise<string> {
const row = await this.prisma.systemConfig.findUnique({ where: { key } });
return row?.value ?? DEFAULTS[key] ?? '';
}
async getNumber(key: string): Promise<number> {
return parseInt(await this.getValue(key), 10) || parseInt(DEFAULTS[key] ?? '0', 10);
}
async set(key: string, value: string): Promise<void> {
await this.prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value },
});
}
async updateMany(entries: Record<string, string>): Promise<Record<string, string>> {
await Promise.all(Object.entries(entries).map(([k, v]) => this.set(k, v)));
return this.getAll();
}
}