mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
Extra luggage, tourism package, manage my trip and other updates
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
94
apps/edr-passenger-api/src/modules/packages/packages.dto.ts
Normal file
94
apps/edr-passenger-api/src/modules/packages/packages.dto.ts
Normal 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[];
|
||||
}
|
||||
@@ -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 {}
|
||||
191
apps/edr-passenger-api/src/modules/packages/packages.service.ts
Normal file
191
apps/edr-passenger-api/src/modules/packages/packages.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user