Seatmap rendering and other updates

This commit is contained in:
Stephanos A
2026-06-09 15:22:27 +03:00
parent bf8cc7e6cf
commit c2bab6cae8
30 changed files with 2020 additions and 329 deletions

View File

@@ -1,8 +1,11 @@
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException } from '@nestjs/common';
import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException, Param, Patch, Delete, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { RolesGuard } from '../../common/roles.guard';
import { Roles } from '../../common/roles.decorator';
import { UserRole } from '@prisma/client';
@ApiTags('Auth')
@Controller('auth')
@@ -240,4 +243,61 @@ export class AuthController {
}
return this.service.getProfile(req.user.userId);
}
@Get('users')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get all backoffice users (admin/supervisor only)' })
getUsers(
@Query('search') search?: string,
@Query('role') role?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.getUsers({
search,
role,
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 10,
});
}
@Post('users')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create new backoffice user (admin/supervisor only)' })
createUser(@Body() dto: any) {
return this.service.createUser(dto);
}
@Patch('users/:id')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update backoffice user (admin/supervisor only)' })
updateUser(@Param('id') id: string, @Body() dto: any) {
return this.service.updateUser(id, dto);
}
@Delete('users/:id')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete backoffice user (admin only)' })
deleteUser(@Param('id') id: string) {
return this.service.deleteUser(id);
}
@Post('users/:id/reset-password')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Reset user password with temporary password (admin/supervisor only)' })
resetUserPassword(@Param('id') id: string, @Body() dto: { tempPassword: string }) {
return this.service.resetUserPassword(id, dto.tempPassword);
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, UnauthorizedException, ConflictException, BadRequestException } from '@nestjs/common';
import { Injectable, UnauthorizedException, ConflictException, BadRequestException, NotFoundException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../../common/prisma.service';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
@@ -58,7 +58,7 @@ export class AuthService {
await this.prisma.user.update({
where: { id: user.id },
data: { failedLoginAttempts: 0, lockedUntil: null }
data: { failedLoginAttempts: 0, lockedUntil: null, lastLoginAt: new Date() }
});
await this.createAuditLog(user.id, 'USER_LOGIN', 'User', user.id, null, null);
@@ -131,6 +131,174 @@ export class AuthService {
return { reset: true };
}
async getUsers(filters: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) {
const { search, role, status, page = 1, pageSize = 10 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {
role: { not: 'PASSENGER' }, // Exclude passenger accounts
};
if (search) {
where.OR = [
{ email: { contains: search, mode: 'insensitive' } },
{ fullName: { contains: search, mode: 'insensitive' } },
];
}
if (role) {
where.role = role;
}
// For status filtering, we check if user is active (no lock/block) or inactive
if (status === 'ACTIVE') {
where.AND = [
{ blockedUntil: { lte: new Date() } },
{ lockedUntil: { lte: new Date() } }
];
} else if (status === 'INACTIVE') {
where.OR = [
{ blockedUntil: { gt: new Date() } },
{ lockedUntil: { gt: new Date() } }
];
}
const [items, total] = await Promise.all([
this.prisma.user.findMany({
where,
select: {
id: true,
email: true,
fullName: true,
role: true,
lastLoginAt: true,
createdAt: true,
blockedUntil: true,
lockedUntil: true,
},
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
this.prisma.user.count({ where }),
]);
return {
items: items.map(user => ({
id: user.id,
email: user.email,
fullName: user.fullName,
role: user.role,
lastLogin: user.lastLoginAt,
status: (!user.blockedUntil || user.blockedUntil <= new Date()) &&
(!user.lockedUntil || user.lockedUntil <= new Date())
? 'ACTIVE'
: 'INACTIVE',
})),
total,
page,
pageSize,
};
}
async createUser(dto: { email: string; fullName: string; role: string; status?: string; password?: string }) {
const exists = await this.prisma.user.findFirst({
where: { OR: [{ email: dto.email }] },
});
if (exists) throw new ConflictException('Email already registered');
const passwordHash = await bcrypt.hash(dto.password || 'TempPassword123!', 10);
const user = await this.prisma.user.create({
data: {
email: dto.email,
fullName: dto.fullName,
role: dto.role as any,
phone: dto.email, // Use email as phone temporarily for unique constraint
passwordHash,
blockedUntil: dto.status === 'INACTIVE' ? new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) : undefined,
},
select: {
id: true,
email: true,
fullName: true,
role: true,
lastLoginAt: true,
createdAt: true,
},
});
await this.createAuditLog(user.id, 'USER_CREATED', 'User', user.id, null, { email: user.email, role: dto.role });
return user;
}
async updateUser(id: string, dto: Partial<{ email: string; fullName: string; role: string; status: string }>) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('User not found');
const updateData: any = {};
if (dto.fullName) updateData.fullName = dto.fullName;
if (dto.role) updateData.role = dto.role;
if (dto.status === 'ACTIVE') {
updateData.blockedUntil = null;
updateData.lockedUntil = null;
} else if (dto.status === 'INACTIVE') {
updateData.blockedUntil = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
}
const updated = await this.prisma.user.update({
where: { id },
data: updateData,
select: {
id: true,
email: true,
fullName: true,
role: true,
lastLoginAt: true,
createdAt: true,
},
});
await this.createAuditLog(id, 'USER_UPDATED', 'User', id, { oldData: user }, { newData: updateData });
return updated;
}
async deleteUser(id: string) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('User not found');
// Don't actually delete, just deactivate
await this.prisma.user.update({
where: { id },
data: { blockedUntil: new Date(), lockedUntil: new Date() },
});
await this.createAuditLog(id, 'USER_DELETED', 'User', id, { email: user.email }, null);
return { deleted: true };
}
async resetUserPassword(id: string, tempPassword: string) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('User not found');
const passwordHash = await bcrypt.hash(tempPassword, 10);
await this.prisma.user.update({
where: { id },
data: {
passwordHash,
failedLoginAttempts: 0,
lockedUntil: null,
},
});
await this.createAuditLog(id, 'PASSWORD_RESET_ADMIN', 'User', id, null, { resetBy: 'admin' });
return { reset: true, tempPassword };
}
private async signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
// Get the full user data to include fullName
const user = await this.prisma.user.findUnique({

View File

@@ -54,4 +54,13 @@ export class CreateClassDto {
@ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number;
}
export class UpdateClassDto extends PartialType(OmitType(CreateClassDto, ['coachTypeId'] as const)) {}
export class UpdateClassDto {
@ApiPropertyOptional({ example: 'coach-type-uuid' }) @IsOptional() @IsString() coachTypeId?: string;
@ApiPropertyOptional({ example: 'Economy' }) @IsOptional() @IsString() name?: string;
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
@ApiPropertyOptional({ example: 5000 }) @IsOptional() @IsInt() baseFareMinor?: number;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -167,13 +167,21 @@ export class FleetService {
const seatClass = await this.prisma.seatClass.findUnique({ where: { id } });
if (!seatClass) throw new NotFoundException('Seat class not found');
const updateData: any = {
coachTypeId: dto.coachTypeId,
name: dto.name,
description: dto.description,
baseFareMinor: dto.baseFareMinor,
};
if (dto.isActive !== undefined) {
updateData.isActive = dto.isActive;
}
return this.prisma.seatClass.update({
where: { id },
data: {
name: dto.name,
description: dto.description,
baseFareMinor: dto.baseFareMinor,
},
data: updateData,
include: { coachType: true },
});
}

View File

@@ -12,28 +12,37 @@ import { UserRole } from '@prisma/client';
@Controller('payments')
export class PaymentsController {
constructor(private service: PaymentsService) {}
@Get('all')
@UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.STAFF)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get all payments with filters (staff/admin only)' })
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'status', required: false })
@ApiQuery({ name: 'method', required: false })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
async getAll(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('method') method?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.getAll({
search,
status,
method,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 10,
});
}
@Post('initiate')
@ApiOperation({
summary: 'Initiate payment with nationality-based payment methods',
description: `Initiates payment for a booking with support for multiple payment providers:
**Ethiopian Payment Methods:**
- TELEBIRR - Ethiopia's leading mobile money
- CBE_BIRR - Commercial Bank of Ethiopia
- EBIRR - Electronic payment gateway
**Djiboutian Payment Methods:**
- WAAFI - Djibouti's mobile money service
**International Payment Methods:**
- CARD - Visa, Mastercard
- WALLET - Internal wallet balance
**Multi-Currency:**
- All transactions processed in ETB
- Display amounts in ETB, DJF, or USD
- Real-time exchange rate conversion`
description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion`
})
initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); }
@@ -102,7 +111,7 @@ export class PaymentsController {
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/"/g, '&quot;');
const escaped = url.replace(/\"/g, '&quot;');
return `<!DOCTYPE html>
<html lang="en">
<head>

View File

@@ -44,6 +44,54 @@ export class PaymentsService {
]);
}
async getAll(filters: { search?: string; status?: string; method?: string; page?: number; pageSize?: number }) {
const { search, status, method, page = 1, pageSize = 10 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
if (search) {
where.OR = [
{ id: { contains: search, mode: 'insensitive' } },
{ booking: { bookingRef: { contains: search, mode: 'insensitive' } } },
];
}
if (status) {
where.status = status;
}
if (method) {
where.method = method;
}
const [items, total] = await Promise.all([
this.prisma.paymentIntent.findMany({
where,
include: { booking: true },
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
this.prisma.paymentIntent.count({ where }),
]);
return {
items: items.map(item => ({
id: item.id,
reference: item.id.substring(0, 8),
bookingId: item.bookingId,
booking: { bookingRef: item.booking?.bookingRef },
amountMinor: item.amountMinor,
currency: item.currency,
method: item.method,
status: item.status,
createdAt: item.createdAt,
paidAt: item.paidAt,
})),
total,
page,
pageSize,
};
}
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
const booking = await this.prisma.booking.findUnique({
where: { id: dto.bookingId },

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, UseGuards, Query, Patch, Delete } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PromosService } from './promos.service';
import { CreatePromotionDto } from './promos.dto';
@@ -8,7 +8,66 @@ import { JwtGuard } from '../../common/jwt.guard';
@Controller('promos')
export class PromosController {
constructor(private service: PromosService) {}
@Get() @ApiOperation({ summary: 'Get active promotions' }) getActive() { return this.service.getActive(); }
@Get('validate/:code') @ApiOperation({ summary: 'Validate a promo code' }) validate(@Param('code') code: string) { return this.service.validate(code); }
@Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create promotion (admin)' }) create(@Body() dto: CreatePromotionDto) { return this.service.create(dto); }
@Get()
@ApiOperation({ summary: 'Get active promotions' })
getActive() {
return this.service.getActive();
}
@Get('all')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get all promos with filters (admin)' })
getAll(
@Query('search') search?: string,
@Query('active') active?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.getAll({
search,
active: active === 'true' ? true : active === 'false' ? false : undefined,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 10,
});
}
@Get(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get promo by ID' })
getById(@Param('id') id: string) {
return this.service.getById(id);
}
@Get('validate/:code')
@ApiOperation({ summary: 'Validate a promo code' })
validate(@Param('code') code: string) {
return this.service.validate(code);
}
@Post()
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create promotion (admin)' })
create(@Body() dto: CreatePromotionDto) {
return this.service.create(dto);
}
@Patch(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update promo (admin)' })
update(@Param('id') id: string, @Body() dto: Partial<CreatePromotionDto>) {
return this.service.update(id, dto);
}
@Delete(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete promo (admin)' })
delete(@Param('id') id: string) {
return this.service.delete(id);
}
}

View File

@@ -1,13 +1,46 @@
import { IsString, IsOptional, IsInt } from 'class-validator';
import { IsString, IsOptional, IsInt, IsBoolean } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePromotionDto {
@ApiProperty({ example: 'Weekend Special' }) @IsString() title: string;
@ApiPropertyOptional({ example: '15% off all routes' }) @IsOptional() @IsString() subtitle?: string;
@ApiProperty({ example: 'WEEKEND15' }) @IsString() code: string;
@ApiPropertyOptional({ example: 15 }) @IsOptional() @IsInt() percentOff?: number;
@ApiPropertyOptional({ example: 5000 }) @IsOptional() @IsInt() amountOffMinor?: number;
@ApiProperty({ example: '2026-12-31T23:59:59Z' }) @IsString() validUntil: string;
@ApiPropertyOptional({ example: 'Book Now' }) @IsOptional() @IsString() ctaLabel?: string;
@ApiPropertyOptional({ example: 'edr://search' }) @IsOptional() @IsString() deepLink?: string;
@ApiProperty({ example: 'SUMMER2024' })
@IsString()
code: string;
@ApiProperty({ example: 'Summer Discount' })
@IsString()
title: string;
@ApiPropertyOptional({ example: 'Get 15% off' })
@IsOptional()
@IsString()
subtitle?: string;
@ApiPropertyOptional({ example: 15 })
@IsOptional()
@IsInt()
percentOff?: number;
@ApiPropertyOptional({ example: 5000 })
@IsOptional()
@IsInt()
amountOffMinor?: number;
@ApiProperty({ example: '2026-12-31T23:59:59Z' })
@IsString()
validUntil: string;
@ApiPropertyOptional({ example: 'Book Now' })
@IsOptional()
@IsString()
ctaLabel?: string;
@ApiPropertyOptional({ example: 'edr://search' })
@IsOptional()
@IsString()
deepLink?: string;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
active?: boolean;
}

View File

@@ -1,20 +1,190 @@
import { Injectable } from '@nestjs/common';
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreatePromotionDto } from './promos.dto';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
@Injectable()
export class PromosService {
constructor(private prisma: PrismaService) {}
getActive() { return this.prisma.promotion.findMany({ where: { active: true, validUntil: { gte: new Date() } }, orderBy: { createdAt: 'desc' } }); }
getActive() {
return this.prisma.promotion.findMany({
where: { active: true, validUntil: { gte: new Date() } },
orderBy: { createdAt: 'desc' },
});
}
async getAll(filters: { search?: string; active?: boolean; page?: number; pageSize?: number }) {
const { search, active, page = 1, pageSize = 10 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
if (search) {
where.OR = [
{ code: { contains: search, mode: 'insensitive' } },
{ title: { contains: search, mode: 'insensitive' } },
];
}
if (active !== undefined) {
where.active = active;
}
const [items, total] = await Promise.all([
this.prisma.promotion.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
this.prisma.promotion.count({ where }),
]);
return { items: this.formatItems(items), total, page, pageSize };
}
async getById(id: string) {
const promo = await this.prisma.promotion.findUnique({ where: { id } });
if (!promo) throw new NotFoundException('Promo not found');
return this.formatItem(promo);
}
async validate(code: string) {
const promo = await this.prisma.promotion.findUnique({ where: { code } });
if (!promo || !promo.active || promo.validUntil < new Date()) return { applicable: false, message: 'Promo code invalid or expired' };
return { code: promo.code, percentOff: promo.percentOff, amountOffMinor: promo.amountOffMinor, validUntil: promo.validUntil, applicable: true, message: promo.percentOff ? `${promo.percentOff}% off` : `ETB ${((promo.amountOffMinor ?? 0) / 100).toFixed(2)} off` };
if (!promo || !promo.active || promo.validUntil < new Date())
return { applicable: false, message: 'Promo code invalid or expired' };
return {
code: promo.code,
percentOff: promo.percentOff,
amountOffMinor: promo.amountOffMinor,
validUntil: promo.validUntil,
applicable: true,
message: promo.percentOff
? `${promo.percentOff}% off`
: `ETB ${((promo.amountOffMinor ?? 0) / 100).toFixed(2)} off`,
};
}
create(dto: CreatePromotionDto) {
return this.prisma.promotion.create({ data: { ...dto, validUntil: new Date(dto.validUntil) } });
async create(dto: CreatePromotionDto & { discountType?: string; discountValue?: number }) {
try {
// Map frontend fields to database fields
let percentOff: number | undefined;
let amountOffMinor: number | undefined;
if (dto.discountType && dto.discountValue !== undefined) {
if (dto.discountType === 'PERCENTAGE') {
percentOff = dto.discountValue;
} else if (dto.discountType === 'FIXED') {
amountOffMinor = dto.discountValue;
}
} else {
// Fallback to direct fields
percentOff = dto.percentOff;
amountOffMinor = dto.amountOffMinor;
}
const promo = await this.prisma.promotion.create({
data: {
code: dto.code,
title: dto.title,
subtitle: dto.subtitle,
percentOff,
amountOffMinor,
validUntil: new Date(dto.validUntil),
ctaLabel: dto.ctaLabel,
deepLink: dto.deepLink,
active: dto.active ?? true,
},
});
return this.formatItem(promo);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
const field = (error.meta?.target as string[])?.[0];
throw new BadRequestException(
`A promo code with this ${field} already exists. Please use a different ${field}.`,
);
}
}
throw error;
}
}
async update(id: string, dto: Partial<CreatePromotionDto> & { discountType?: string; discountValue?: number }) {
const promo = await this.prisma.promotion.findUnique({ where: { id } });
if (!promo) throw new NotFoundException('Promo not found');
const updateData: any = {};
// Map frontend fields to database fields
if (dto.discountType && dto.discountValue !== undefined) {
// Clear existing discount fields
updateData.percentOff = null;
updateData.amountOffMinor = null;
if (dto.discountType === 'PERCENTAGE') {
updateData.percentOff = dto.discountValue;
} else if (dto.discountType === 'FIXED') {
updateData.amountOffMinor = dto.discountValue;
}
} else {
// Only include fields that are explicitly provided
if (dto.percentOff !== undefined) updateData.percentOff = dto.percentOff;
if (dto.amountOffMinor !== undefined) updateData.amountOffMinor = dto.amountOffMinor;
}
if (dto.title !== undefined) updateData.title = dto.title;
if (dto.subtitle !== undefined) updateData.subtitle = dto.subtitle;
if (dto.ctaLabel !== undefined) updateData.ctaLabel = dto.ctaLabel;
if (dto.deepLink !== undefined) updateData.deepLink = dto.deepLink;
if (dto.active !== undefined) updateData.active = dto.active;
if (dto.validUntil !== undefined) updateData.validUntil = new Date(dto.validUntil);
// Don't allow updating code - it's immutable after creation
try {
const updated = await this.prisma.promotion.update({
where: { id },
data: updateData,
});
return this.formatItem(updated);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2002') {
const field = (error.meta?.target as string[])?.[0];
throw new BadRequestException(
`A promo code with this ${field} already exists. Please use a different ${field}.`,
);
}
throw error;
}
}
async delete(id: string) {
const promo = await this.prisma.promotion.findUnique({ where: { id } });
if (!promo) throw new NotFoundException('Promo not found');
return this.prisma.promotion.delete({ where: { id } });
}
private formatItem(promo: any) {
return {
id: promo.id,
code: promo.code,
title: promo.title,
discountType: promo.percentOff ? 'PERCENTAGE' : 'FIXED',
discountValue: promo.percentOff || promo.amountOffMinor || 0,
maxDiscount: undefined,
minBookingAmount: undefined,
maxUsagePerUser: undefined,
totalUsageLimit: undefined,
usageCount: 0,
validFrom: promo.createdAt,
validUntil: promo.validUntil,
isActive: promo.active,
createdAt: promo.createdAt,
updatedAt: promo.createdAt,
};
}
private formatItems(promos: any[]) {
return promos.map((promo) => this.formatItem(promo));
}
}

View File

@@ -50,31 +50,56 @@ export class SearchService {
const availabilityByClass: Record<string, number> = {};
for (const assignment of schedule.coachAssignments) {
// Get seat class names from coach type
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
for (const seatClassName of seatClassNames) {
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
}
const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
// Count available seats (skip blocked and removed seats)
for (const seat of assignment.coach.seats) {
// Skip blocked seats
if (seat.status === 'BLOCKED') continue;
if (isBedCoach) {
const bedPositions = ['upper', 'middle', 'lower'];
for (const bedPosition of bedPositions) {
let count = 0;
for (const seat of assignment.coach.seats) {
if (seat.bedPosition !== bedPosition) continue;
if (seat.status === 'BLOCKED') continue;
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) count++;
}
if (count > 0) {
const matchingClass = seatClassNames.find((className: string) => {
const classNameLower = className.toLowerCase();
return (
(bedPosition === 'upper' && classNameLower.includes('upper')) ||
(bedPosition === 'middle' && classNameLower.includes('middle')) ||
(bedPosition === 'lower' && classNameLower.includes('lower'))
);
});
if (matchingClass) {
if (!availabilityByClass[matchingClass]) availabilityByClass[matchingClass] = 0;
availabilityByClass[matchingClass] += count;
}
}
}
} else {
let availableSeatsInCoach = 0;
for (const seat of assignment.coach.seats) {
if (seat.status === 'BLOCKED') continue;
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) availableSeatsInCoach++;
}
// Skip removed seats (empty seatNumber)
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) {
// Group by seat class - use the first seat class for now
// In a full implementation, seats would have a seatClassId
const className = seatClassNames[0] || 'Standard';
availabilityByClass[className]++;
for (const seatClassName of seatClassNames) {
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
availabilityByClass[seatClassName] += availableSeatsInCoach;
}
}
}
@@ -225,7 +250,6 @@ export class SearchService {
destinationStationId: string,
nationality?: string,
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
// Get unique seat classes from all coaches assigned to this schedule via their coach types
const seatClassIds: string[] = Array.from(
new Set(
schedule.coachAssignments

View File

@@ -32,10 +32,8 @@ export class SeatsService {
const response = {
coaches: assignments.map((a) => {
// Include all seats (both valid and removed with negative seatNumbers)
const allSeats = a.coach.seats;
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
const seatClass = seatClassNames.length > 0 ? seatClassNames[0] : 'Standard';
return {
id: a.coach.id,
@@ -44,7 +42,8 @@ export class SeatsService {
label: a.coach.number,
mode: a.coach.status,
name: `Coach ${a.coach.number}`,
seatClass,
seatClasses: seatClassNames,
seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard',
positionNumber: a.positionNumber,
seatArrangement: a.coach.arrangement,
totalSeats: a.coach.capacity,