mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
Seatmap rendering and other updates
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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, '"');
|
||||
const escaped = url.replace(/\"/g, '"');
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user