update rezerv

This commit is contained in:
2026-05-31 21:36:19 +03:30
parent 7affab06f1
commit 150b189be5
25 changed files with 1134 additions and 49 deletions
+23 -2
View File
@@ -19,6 +19,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { BookingsService } from './bookings.service';
import { CreateBookingDto } from './dto/create-booking.dto';
import { CreateBookingBatchDto } from './dto/create-booking-batch.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
@Controller('bookings')
@@ -28,10 +29,20 @@ export class BookingsController {
@Get('available-slots')
getAvailableSlots(
@Query('businessId') businessId: string,
@Query('businessServiceId') businessServiceId: string,
@Query('date') date: string,
@Query('businessServiceId') businessServiceId?: string,
@Query('businessServiceIds') businessServiceIds?: string,
@Query('serviceClassId') serviceClassId?: string,
) {
return this.service.getAvailableSlots(businessId, businessServiceId, date);
const ids = businessServiceIds
? businessServiceIds.split(',').map((id) => id.trim()).filter(Boolean)
: undefined;
return this.service.getAvailableSlots(businessId, date, {
businessServiceId,
businessServiceIds: ids,
serviceClassId,
});
}
@Get()
@@ -71,6 +82,16 @@ export class BookingsController {
return this.service.create(payload, user);
}
@Post('batch')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER)
createBatch(
@Body() payload: CreateBookingBatchDto,
@CurrentUser() user: AuthUser,
): Promise<BookingEntity[]> {
return this.service.createBatch(payload, user);
}
@Patch(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
+4
View File
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingEntity } from '../../entities/booking.entity';
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity';
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
import { BusinessServiceEntity } from '../../entities/business-service.entity';
import { BusinessEntity } from '../../entities/business.entity';
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
@@ -18,6 +20,8 @@ import { BookingsService } from './bookings.service';
BusinessEntity,
BusinessServiceEntity,
BusinessAvailabilitySlotEntity,
BusinessServiceClassEntity,
BusinessServiceClassPriceEntity,
ServiceTemplateEntity,
]),
AuthModule,
+342 -45
View File
@@ -6,7 +6,8 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { randomUUID } from 'crypto';
import { In, Repository } from 'typeorm';
import {
generateAvailableSlotStarts,
parseGregorianYmd,
@@ -17,11 +18,14 @@ import {
} from '../../common/utils/booking-schedule.util';
import { BookingEntity, BookingStatus } from '../../entities/booking.entity';
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity';
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
import { BusinessServiceEntity } from '../../entities/business-service.entity';
import { BusinessEntity } from '../../entities/business.entity';
import { UserEntity, UserRole } from '../../entities/user.entity';
import { AuthUser } from '../auth/auth.types';
import { CreateBookingDto } from './dto/create-booking.dto';
import { CreateBookingBatchDto } from './dto/create-booking-batch.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
type FindAllFilters = {
@@ -47,6 +51,10 @@ export class BookingsService {
private readonly businessServiceRepo: Repository<BusinessServiceEntity>,
@InjectRepository(BusinessAvailabilitySlotEntity)
private readonly availabilityRepo: Repository<BusinessAvailabilitySlotEntity>,
@InjectRepository(BusinessServiceClassEntity)
private readonly serviceClassRepo: Repository<BusinessServiceClassEntity>,
@InjectRepository(BusinessServiceClassPriceEntity)
private readonly classPriceRepo: Repository<BusinessServiceClassPriceEntity>,
) {
const raw = this.config.get<string | undefined>('BOOKING_MIN_LEAD_MINUTES');
const parsed = raw !== undefined ? Number.parseInt(String(raw), 10) : NaN;
@@ -63,6 +71,7 @@ export class BookingsService {
.leftJoinAndSelect('b.business', 'business')
.leftJoinAndSelect('b.businessService', 'businessService')
.leftJoinAndSelect('businessService.serviceTemplate', 'template')
.leftJoinAndSelect('b.serviceClass', 'serviceClass')
.orderBy('b.scheduledAt', 'ASC');
if (actor?.role === UserRole.CUSTOMER) {
@@ -108,6 +117,7 @@ export class BookingsService {
customer: true,
business: true,
businessService: { serviceTemplate: true },
serviceClass: true,
},
});
if (!row) {
@@ -151,21 +161,21 @@ export class BookingsService {
}
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
const durationMinutes = resolveServiceDurationMinutes(
businessService.durationMinutes,
businessService.serviceTemplate?.defaultDurationMinutes,
const pricing = await this.resolveBookingPricing(
businessService,
dto.serviceClassId ?? null,
);
await this.assertSchedulableSlot({
businessId: dto.businessId,
businessServiceId: dto.businessServiceId,
scheduledAt,
durationMinutes,
durationMinutes: pricing.durationMinutes,
});
const entity = this.repo.create({
customerId: dto.customerId,
businessId: dto.businessId,
businessServiceId: dto.businessServiceId,
serviceClassId: pricing.serviceClassId,
scheduledAt,
note: dto.note ?? null,
status: BookingStatus.PENDING,
@@ -174,6 +184,93 @@ export class BookingsService {
return this.repo.save(entity);
}
async createBatch(
dto: CreateBookingBatchDto,
actor?: AuthUser,
): Promise<BookingEntity[]> {
if (actor && dto.customerId !== actor.id) {
throw new ForbiddenException('You can only book for yourself');
}
const customer = await this.userRepo.findOne({
where: { id: dto.customerId },
});
if (!customer) {
throw new NotFoundException('User not found');
}
const business = await this.businessRepo.findOne({
where: { id: dto.businessId },
});
if (!business) {
throw new NotFoundException('Business not found');
}
const serviceIds = [
...new Set(dto.items.map((item) => item.businessServiceId)),
];
if (serviceIds.length !== dto.items.length) {
throw new BadRequestException('Duplicate services in booking batch');
}
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
const serviceClassId = dto.serviceClassId ?? null;
const resolvedItems: Array<{
businessService: BusinessServiceEntity;
serviceClassId: string | null;
durationMinutes: number;
}> = [];
let totalDurationMinutes = 0;
for (const serviceId of serviceIds) {
const businessService = await this.businessServiceRepo.findOne({
where: { id: serviceId, businessId: dto.businessId, isActive: true },
relations: { serviceTemplate: true },
});
if (!businessService) {
throw new NotFoundException(
`Business service ${serviceId} not found or inactive`,
);
}
const pricing = await this.resolveBookingPricing(
businessService,
serviceClassId,
);
resolvedItems.push({
businessService,
serviceClassId: pricing.serviceClassId,
durationMinutes: pricing.durationMinutes,
});
totalDurationMinutes += pricing.durationMinutes;
}
await this.assertSchedulableSlot({
businessId: dto.businessId,
scheduledAt,
durationMinutes: totalDurationMinutes,
});
const bookingGroupId = serviceIds.length > 1 ? randomUUID() : null;
const note = dto.note ?? null;
const entities = resolvedItems.map(({ businessService, serviceClassId: classId }) =>
this.repo.create({
customerId: dto.customerId,
businessId: dto.businessId,
businessServiceId: businessService.id,
serviceClassId: classId,
bookingGroupId,
scheduledAt,
note,
status: BookingStatus.PENDING,
}),
);
return this.repo.save(entities);
}
async update(
id: string,
dto: UpdateBookingDto,
@@ -185,18 +282,21 @@ export class BookingsService {
if (dto.scheduledAt !== undefined) {
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
const durationMinutes = resolveServiceDurationMinutes(
row.businessService?.durationMinutes,
row.businessService?.serviceTemplate?.defaultDurationMinutes,
);
const durationMinutes = await this.resolveGroupDuration(row);
await this.assertSchedulableSlot({
businessId: row.businessId,
businessServiceId: row.businessServiceId,
scheduledAt,
durationMinutes,
excludeBookingId: row.id,
excludeBookingGroupId: row.bookingGroupId ?? undefined,
});
row.scheduledAt = scheduledAt;
if (row.bookingGroupId) {
await this.repo.update(
{ bookingGroupId: row.bookingGroupId },
{ scheduledAt },
);
}
}
if (dto.status !== undefined) {
@@ -240,9 +340,13 @@ export class BookingsService {
async getAvailableSlots(
businessId: string,
businessServiceId: string,
dateYmd: string,
): Promise<{ date: string; slots: string[] }> {
options: {
businessServiceId?: string;
businessServiceIds?: string[];
serviceClassId?: string;
},
): Promise<{ date: string; slots: string[]; totalDurationMinutes: number }> {
parseGregorianYmd(dateYmd);
const business = await this.businessRepo.findOne({
@@ -252,32 +356,49 @@ export class BookingsService {
throw new NotFoundException('Business not found or inactive');
}
const businessService = await this.businessServiceRepo.findOne({
where: { id: businessServiceId, businessId, isActive: true },
relations: { serviceTemplate: true },
});
if (!businessService) {
throw new NotFoundException('Business service not found or inactive');
const serviceIds =
options.businessServiceIds?.length
? options.businessServiceIds
: options.businessServiceId
? [options.businessServiceId]
: [];
if (!serviceIds.length) {
throw new BadRequestException('At least one business service is required');
}
const durationMinutes = resolveServiceDurationMinutes(
businessService.durationMinutes,
businessService.serviceTemplate?.defaultDurationMinutes,
);
const serviceClassId = options.serviceClassId ?? null;
let totalDurationMinutes = 0;
for (const serviceId of serviceIds) {
const businessService = await this.businessServiceRepo.findOne({
where: { id: serviceId, businessId, isActive: true },
relations: { serviceTemplate: true },
});
if (!businessService) {
throw new NotFoundException('Business service not found or inactive');
}
const pricing = await this.resolveBookingPricing(
businessService,
serviceClassId,
);
totalDurationMinutes += pricing.durationMinutes;
}
const windows = await this.loadAvailabilityWindows(businessId);
const busy = await this.loadBusyIntervals(businessId, dateYmd);
const slots = generateAvailableSlotStarts({
dateYmd,
durationMinutes,
durationMinutes: totalDurationMinutes,
leadMinutes: this.minLeadMinutes,
slotStepMinutes: 15,
windows,
busy,
});
return { date: dateYmd, slots };
return { date: dateYmd, slots, totalDurationMinutes };
}
private async loadAvailabilityWindows(
@@ -294,11 +415,20 @@ export class BookingsService {
private async loadBusyIntervals(
businessId: string,
dateYmd: string,
excludeBookingId?: string,
options?: { excludeBookingId?: string; excludeBookingGroupId?: string },
): Promise<BusyInterval[]> {
const dayStart = new Date(`${dateYmd}T00:00:00+03:30`);
const dayEnd = new Date(`${dateYmd}T23:59:59+03:30`);
let excludeGroupId = options?.excludeBookingGroupId ?? null;
if (options?.excludeBookingId && !excludeGroupId) {
const excluded = await this.repo.findOne({
where: { id: options.excludeBookingId },
select: ['id', 'bookingGroupId'],
});
excludeGroupId = excluded?.bookingGroupId ?? null;
}
const qb = this.repo
.createQueryBuilder('b')
.leftJoinAndSelect('b.businessService', 'bs')
@@ -310,34 +440,202 @@ export class BookingsService {
.andWhere('b.scheduledAt >= :dayStart', { dayStart })
.andWhere('b.scheduledAt <= :dayEnd', { dayEnd });
if (excludeBookingId) {
qb.andWhere('b.id != :excludeBookingId', { excludeBookingId });
if (options?.excludeBookingId && !excludeGroupId) {
qb.andWhere('b.id != :excludeBookingId', {
excludeBookingId: options.excludeBookingId,
});
}
if (excludeGroupId) {
qb.andWhere('b.bookingGroupId IS NULL OR b.bookingGroupId != :excludeGroupId', {
excludeGroupId,
});
}
const bookings = await qb.getMany();
const durations = await Promise.all(
bookings.map((b) => this.resolveBookingDuration(b)),
);
return bookings.map((b) => {
const duration = resolveServiceDurationMinutes(
b.businessService?.durationMinutes,
b.businessService?.serviceTemplate?.defaultDurationMinutes,
);
const startMs = b.scheduledAt.getTime();
return {
startMs,
endMs: startMs + duration * 60 * 1000,
};
const groupTotals = new Map<
string,
{ startMs: number; totalDuration: number }
>();
const singles: BusyInterval[] = [];
for (const [index, booking] of bookings.entries()) {
const duration = durations[index] ?? 30;
const startMs = booking.scheduledAt.getTime();
if (booking.bookingGroupId) {
const key = `${booking.bookingGroupId}:${startMs}`;
const existing = groupTotals.get(key);
if (existing) {
existing.totalDuration += duration;
} else {
groupTotals.set(key, { startMs, totalDuration: duration });
}
} else {
singles.push({
startMs,
endMs: startMs + duration * 60 * 1000,
});
}
}
return [
...singles,
...Array.from(groupTotals.values()).map((group) => ({
startMs: group.startMs,
endMs: group.startMs + group.totalDuration * 60 * 1000,
})),
];
}
private async resolveBookingPricing(
businessService: BusinessServiceEntity,
serviceClassId: string | null,
): Promise<{ serviceClassId: string | null; durationMinutes: number }> {
const business = await this.businessRepo.findOne({
where: { id: businessService.businessId },
});
const activeClassCount = await this.serviceClassRepo.count({
where: { businessId: businessService.businessId, isActive: true },
});
const usesClasses =
business?.serviceClassesEnabled === true && activeClassCount > 0;
if (!usesClasses) {
if (serviceClassId) {
throw new BadRequestException(
'This business does not use service classes',
);
}
return {
serviceClassId: null,
durationMinutes: resolveServiceDurationMinutes(
businessService.durationMinutes,
businessService.serviceTemplate?.defaultDurationMinutes,
),
};
}
if (!serviceClassId) {
throw new BadRequestException('serviceClassId is required for this business');
}
const serviceClass = await this.serviceClassRepo.findOne({
where: {
id: serviceClassId,
businessId: businessService.businessId,
isActive: true,
},
});
if (!serviceClass) {
throw new BadRequestException('Service class not found or inactive');
}
const classPrice = await this.classPriceRepo.findOne({
where: {
businessServiceId: businessService.id,
classId: serviceClassId,
isActive: true,
},
});
if (!classPrice) {
throw new BadRequestException(
'This service is not available for the selected class',
);
}
return {
serviceClassId,
durationMinutes: resolveServiceDurationMinutes(
classPrice.durationMinutes ?? businessService.durationMinutes,
businessService.serviceTemplate?.defaultDurationMinutes,
),
};
}
private async resolveGroupDuration(booking: BookingEntity): Promise<number> {
if (!booking.bookingGroupId) {
return this.resolveBookingDuration(booking);
}
const siblings = await this.repo.find({
where: {
bookingGroupId: booking.bookingGroupId,
status: In([BookingStatus.PENDING, BookingStatus.CONFIRMED]),
},
relations: { businessService: { serviceTemplate: true } },
});
const rows = siblings.length
? siblings
: [
{
...booking,
businessService:
booking.businessService ??
(await this.businessServiceRepo.findOne({
where: { id: booking.businessServiceId },
relations: { serviceTemplate: true },
})),
} as BookingEntity,
];
let total = 0;
for (const row of rows) {
total += await this.resolveBookingDuration(row);
}
return total;
}
private async resolveBookingDuration(booking: BookingEntity): Promise<number> {
if (!booking.businessService) {
const businessService = await this.businessServiceRepo.findOne({
where: { id: booking.businessServiceId },
relations: { serviceTemplate: true },
});
if (!businessService) {
return 30;
}
booking.businessService = businessService;
}
if (booking.serviceClassId) {
const classPrice = await this.classPriceRepo.findOne({
where: {
businessServiceId: booking.businessServiceId,
classId: booking.serviceClassId,
},
});
return resolveServiceDurationMinutes(
classPrice?.durationMinutes ?? booking.businessService.durationMinutes,
booking.businessService.serviceTemplate?.defaultDurationMinutes,
);
}
return resolveServiceDurationMinutes(
booking.businessService.durationMinutes,
booking.businessService.serviceTemplate?.defaultDurationMinutes,
);
}
private async assertSchedulableSlot(params: {
businessId: string;
businessServiceId: string;
scheduledAt: Date;
durationMinutes: number;
excludeBookingId?: string;
excludeBookingGroupId?: string;
}): Promise<void> {
const { businessId, scheduledAt, durationMinutes, excludeBookingId } =
params;
const {
businessId,
scheduledAt,
durationMinutes,
excludeBookingId,
excludeBookingGroupId,
} = params;
this.assertLeadTime(scheduledAt);
@@ -355,11 +653,10 @@ export class BookingsService {
leadMinutes: this.minLeadMinutes,
slotStepMinutes: 15,
windows,
busy: await this.loadBusyIntervals(
businessId,
dateYmd,
busy: await this.loadBusyIntervals(businessId, dateYmd, {
excludeBookingId,
),
excludeBookingGroupId,
}),
});
const scheduledMs = scheduledAt.getTime();
@@ -0,0 +1,50 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsISO8601,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
export class CreateBookingBatchItemDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
businessServiceId!: string;
}
export class CreateBookingBatchDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
customerId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
businessId!: string;
@ApiProperty({ type: [CreateBookingBatchItemDto], minItems: 1 })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateBookingBatchItemDto)
items!: CreateBookingBatchItemDto[];
@ApiProperty({ example: '2026-05-13T10:00:00.000Z' })
@IsISO8601()
scheduledAt!: string;
@ApiPropertyOptional({ format: 'uuid', nullable: true })
@IsOptional()
@IsUUID()
serviceClassId?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(500)
note?: string | null;
}
@@ -14,6 +14,11 @@ export class CreateBookingDto {
@IsUUID()
businessServiceId!: string;
@ApiPropertyOptional({ format: 'uuid', nullable: true })
@IsOptional()
@IsUUID()
serviceClassId?: string | null;
/** ISO 8601 datetime string */
@ApiProperty({ example: '2026-05-13T10:00:00.000Z' })
@IsISO8601()
@@ -0,0 +1,74 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
import { UserRole } from '../../entities/user.entity';
import { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { BusinessServiceClassesService } from './business-service-classes.service';
import { CreateBusinessServiceClassDto } from './dto/create-business-service-class.dto';
import { UpdateBusinessServiceClassDto } from './dto/update-business-service-class.dto';
@Controller('business-service-classes')
export class BusinessServiceClassesController {
constructor(private readonly service: BusinessServiceClassesService) {}
@Get()
findAll(
@Query('businessId') businessId?: string,
@Query('isActive') isActive?: string,
): Promise<BusinessServiceClassEntity[]> {
const parsedIsActive =
typeof isActive === 'string' ? isActive === 'true' : undefined;
return this.service.findAll({ businessId, isActive: parsedIsActive });
}
@Get(':id')
findOne(@Param('id') id: string): Promise<BusinessServiceClassEntity> {
return this.service.findOne(id);
}
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
create(
@Body() payload: CreateBusinessServiceClassDto,
@CurrentUser() user: AuthUser,
): Promise<BusinessServiceClassEntity> {
return this.service.create(payload, user);
}
@Patch(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
update(
@Param('id') id: string,
@Body() payload: UpdateBusinessServiceClassDto,
@CurrentUser() user: AuthUser,
): Promise<BusinessServiceClassEntity> {
return this.service.update(id, payload, user);
}
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
async remove(
@Param('id') id: string,
@CurrentUser() user: AuthUser,
): Promise<{ success: true }> {
await this.service.remove(id, user);
return { success: true };
}
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BusinessEntity } from '../../entities/business.entity';
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
import { AuthModule } from '../auth/auth.module';
import { BusinessServiceClassesController } from './business-service-classes.controller';
import { BusinessServiceClassesService } from './business-service-classes.service';
@Module({
imports: [
TypeOrmModule.forFeature([BusinessServiceClassEntity, BusinessEntity]),
AuthModule,
],
controllers: [BusinessServiceClassesController],
providers: [BusinessServiceClassesService],
exports: [BusinessServiceClassesService],
})
export class BusinessServiceClassesModule {}
@@ -0,0 +1,168 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BusinessEntity } from '../../entities/business.entity';
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
import { UserRole } from '../../entities/user.entity';
import { AuthUser } from '../auth/auth.types';
import { CreateBusinessServiceClassDto } from './dto/create-business-service-class.dto';
import { UpdateBusinessServiceClassDto } from './dto/update-business-service-class.dto';
type FindAllFilters = {
businessId?: string;
isActive?: boolean;
};
@Injectable()
export class BusinessServiceClassesService {
constructor(
@InjectRepository(BusinessServiceClassEntity)
private readonly repo: Repository<BusinessServiceClassEntity>,
@InjectRepository(BusinessEntity)
private readonly businessRepo: Repository<BusinessEntity>,
) {}
async findAll(filters: FindAllFilters): Promise<BusinessServiceClassEntity[]> {
const query = this.repo
.createQueryBuilder('c')
.orderBy('c.sortOrder', 'ASC')
.addOrderBy('c.createdAt', 'ASC');
if (filters.businessId) {
query.andWhere('c.businessId = :businessId', {
businessId: filters.businessId,
});
}
if (typeof filters.isActive === 'boolean') {
query.andWhere('c.isActive = :isActive', { isActive: filters.isActive });
}
return query.getMany();
}
async findOne(id: string): Promise<BusinessServiceClassEntity> {
const row = await this.repo.findOne({ where: { id } });
if (!row) {
throw new NotFoundException('Service class not found');
}
return row;
}
async create(
dto: CreateBusinessServiceClassDto,
actor?: AuthUser,
): Promise<BusinessServiceClassEntity> {
const business = await this.businessRepo.findOne({
where: { id: dto.businessId },
});
if (!business) {
throw new NotFoundException('Business not found');
}
this.assertCanManageBusiness(business, actor);
const dup = await this.repo.findOne({
where: { businessId: dto.businessId, title: dto.title.trim() },
});
if (dup) {
throw new ConflictException('A class with this title already exists');
}
let sortOrder = dto.sortOrder;
if (sortOrder === undefined) {
const max = await this.repo
.createQueryBuilder('c')
.select('MAX(c.sortOrder)', 'max')
.where('c.businessId = :businessId', { businessId: dto.businessId })
.getRawOne<{ max: string | null }>();
sortOrder = (max?.max ? Number.parseInt(max.max, 10) : -1) + 1;
}
const entity = this.repo.create({
businessId: dto.businessId,
title: dto.title.trim(),
description: dto.description?.trim() || null,
sortOrder,
isActive: dto.isActive ?? true,
});
return this.repo.save(entity);
}
async update(
id: string,
dto: UpdateBusinessServiceClassDto,
actor?: AuthUser,
): Promise<BusinessServiceClassEntity> {
const row = await this.findOne(id);
const business = await this.businessRepo.findOne({
where: { id: row.businessId },
});
if (!business) {
throw new NotFoundException('Business not found');
}
this.assertCanManageBusiness(business, actor);
if (dto.title !== undefined) {
const title = dto.title.trim();
if (!title) {
throw new BadRequestException('title cannot be empty');
}
const dup = await this.repo.findOne({
where: { businessId: row.businessId, title },
});
if (dup && dup.id !== row.id) {
throw new ConflictException('A class with this title already exists');
}
row.title = title;
}
if (dto.description !== undefined) {
row.description = dto.description?.trim() || null;
}
if (dto.sortOrder !== undefined) {
row.sortOrder = dto.sortOrder;
}
if (dto.isActive !== undefined) {
row.isActive = dto.isActive;
}
return this.repo.save(row);
}
async remove(id: string, actor?: AuthUser): Promise<void> {
const row = await this.findOne(id);
const business = await this.businessRepo.findOne({
where: { id: row.businessId },
});
if (!business) {
throw new NotFoundException('Business not found');
}
this.assertCanManageBusiness(business, actor);
const result = await this.repo.delete(id);
if (!result.affected) {
throw new NotFoundException('Service class not found');
}
}
private assertCanManageBusiness(
business: BusinessEntity,
actor?: AuthUser,
): void {
if (!actor || actor.role === UserRole.ADMIN) {
return;
}
if (
actor.role !== UserRole.BUSINESS_OWNER ||
business.ownerId !== actor.id
) {
throw new ForbiddenException('You can only manage your own business');
}
}
}
@@ -0,0 +1,30 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateBusinessServiceClassDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
businessId!: string;
@ApiProperty({ example: 'کلاس ۱ — مینی و کوچک' })
@IsString()
@MaxLength(120)
title!: string;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(500)
description?: string | null;
@ApiPropertyOptional({ default: 0 })
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
@@ -0,0 +1,27 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class UpdateBusinessServiceClassDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(120)
title?: string;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(500)
description?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
@@ -6,6 +6,7 @@ import {
Param,
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
@@ -19,6 +20,7 @@ import { RolesGuard } from '../auth/guards/roles.guard';
import { BusinessServicesService } from './business-services.service';
import { CreateBusinessServiceDto } from './dto/create-business-service.dto';
import { UpdateBusinessServiceDto } from './dto/update-business-service.dto';
import { SetBusinessServiceClassPricesDto } from './dto/set-business-service-class-prices.dto';
@Controller('business-services')
export class BusinessServicesController {
@@ -66,6 +68,17 @@ export class BusinessServicesController {
return this.service.update(id, payload, user);
}
@Put(':id/class-prices')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
setClassPrices(
@Param('id') id: string,
@Body() payload: SetBusinessServiceClassPricesDto,
@CurrentUser() user: AuthUser,
): Promise<BusinessServiceEntity> {
return this.service.setClassPrices(id, payload, user);
}
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BusinessEntity } from '../../entities/business.entity';
import { BusinessServiceEntity } from '../../entities/business-service.entity';
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity';
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
import { AuthModule } from '../auth/auth.module';
import { BusinessServicesController } from './business-services.controller';
@@ -13,6 +15,8 @@ import { BusinessServicesService } from './business-services.service';
BusinessServiceEntity,
BusinessEntity,
ServiceTemplateEntity,
BusinessServiceClassEntity,
BusinessServiceClassPriceEntity,
]),
AuthModule,
],
@@ -6,14 +6,17 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { BusinessEntity } from '../../entities/business.entity';
import { BusinessServiceEntity } from '../../entities/business-service.entity';
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity';
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
import { UserRole } from '../../entities/user.entity';
import { AuthUser } from '../auth/auth.types';
import { CreateBusinessServiceDto } from './dto/create-business-service.dto';
import { UpdateBusinessServiceDto } from './dto/update-business-service.dto';
import { SetBusinessServiceClassPricesDto } from './dto/set-business-service-class-prices.dto';
type FindAllFilters = {
businessId?: string;
@@ -37,6 +40,10 @@ export class BusinessServicesService {
private readonly businessRepo: Repository<BusinessEntity>,
@InjectRepository(ServiceTemplateEntity)
private readonly templateRepo: Repository<ServiceTemplateEntity>,
@InjectRepository(BusinessServiceClassEntity)
private readonly classRepo: Repository<BusinessServiceClassEntity>,
@InjectRepository(BusinessServiceClassPriceEntity)
private readonly classPriceRepo: Repository<BusinessServiceClassPriceEntity>,
) {}
async findAll(filters: FindAllFilters): Promise<BusinessServiceEntity[]> {
@@ -44,6 +51,8 @@ export class BusinessServicesService {
.createQueryBuilder('bs')
.leftJoinAndSelect('bs.business', 'business')
.leftJoinAndSelect('bs.serviceTemplate', 'template')
.leftJoinAndSelect('bs.classPrices', 'classPrices')
.leftJoinAndSelect('classPrices.serviceClass', 'serviceClass')
.orderBy('bs.createdAt', 'DESC');
if (filters.businessId) {
@@ -68,7 +77,11 @@ export class BusinessServicesService {
async findOne(id: string): Promise<BusinessServiceEntity> {
const row = await this.repo.findOne({
where: { id },
relations: { business: true, serviceTemplate: true },
relations: {
business: true,
serviceTemplate: true,
classPrices: { serviceClass: true },
},
});
if (!row) {
throw new NotFoundException('Business service not found');
@@ -193,6 +206,67 @@ export class BusinessServicesService {
return this.repo.save(row);
}
async setClassPrices(
id: string,
dto: SetBusinessServiceClassPricesDto,
actor?: AuthUser,
): Promise<BusinessServiceEntity> {
const row = await this.findOne(id);
const business = await this.businessRepo.findOne({
where: { id: row.businessId },
});
if (!business) {
throw new NotFoundException('Business not found');
}
this.assertCanManageBusiness(business, actor);
const classIds = [...new Set(dto.prices.map((p) => p.classId))];
const classes = await this.classRepo.find({
where: { businessId: row.businessId, id: In(classIds) },
});
if (classes.length !== classIds.length) {
throw new BadRequestException(
'One or more service classes do not belong to this business',
);
}
for (const item of dto.prices) {
const existing = await this.classPriceRepo.findOne({
where: { businessServiceId: id, classId: item.classId },
});
const payload = {
price: normalizePrice(item.price),
durationMinutes: item.durationMinutes ?? null,
isActive: item.isActive ?? true,
};
if (existing) {
Object.assign(existing, payload);
await this.classPriceRepo.save(existing);
} else {
await this.classPriceRepo.save(
this.classPriceRepo.create({
businessServiceId: id,
classId: item.classId,
...payload,
}),
);
}
}
const activePrices = await this.classPriceRepo.find({
where: { businessServiceId: id, isActive: true },
});
if (activePrices.length) {
const minPrice = activePrices.reduce((min, p) =>
Number.parseFloat(p.price) < Number.parseFloat(min.price) ? p : min,
);
row.price = minPrice.price;
await this.repo.save(row);
}
return this.findOne(id);
}
async remove(id: string, actor?: AuthUser): Promise<void> {
const row = await this.findOne(id);
const business = await this.businessRepo.findOne({
@@ -0,0 +1,44 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsInt,
IsNumber,
IsOptional,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
export class BusinessServiceClassPriceItemDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
classId!: string;
@ApiProperty({ example: 100000 })
@IsNumber()
@Min(0)
price!: number;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsInt()
@Min(1)
durationMinutes?: number | null;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class SetBusinessServiceClassPricesDto {
@ApiProperty({ type: [BusinessServiceClassPriceItemDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => BusinessServiceClassPriceItemDto)
prices!: BusinessServiceClassPriceItemDto[];
}
@@ -68,4 +68,12 @@ export class UpdateBusinessDto {
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({
example: false,
description: 'When true, services are priced per owner-defined class (e.g. car size)',
})
@IsOptional()
@IsBoolean()
serviceClassesEnabled?: boolean;
}