reserves module

This commit is contained in:
2026-06-29 08:39:34 +03:30
parent 127c1eaf78
commit d8086cab17
15 changed files with 871 additions and 0 deletions
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsUUID } from 'class-validator';
export class CreateReserveItemDto {
@IsUUID()
@IsNotEmpty()
serviceId: string;
@IsUUID()
@IsNotEmpty()
serviceSkillId: string;
}
+50
View File
@@ -0,0 +1,50 @@
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsDateString,
IsNotEmpty,
IsNumber,
IsUUID,
Matches,
Min,
ValidateNested,
} from 'class-validator';
import { CreateReserveItemDto } from './create-reserve-item.dto';
export class CreateReserveDto {
@IsUUID()
@IsNotEmpty()
salonId: string;
@IsUUID()
@IsNotEmpty()
customerId: string;
@IsUUID()
@IsNotEmpty()
stylistId: string;
@IsDateString()
appointmentDate: string;
@Matches(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/)
startTime: string;
@Matches(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/)
endTime: string;
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
totalAmount: number;
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
depositAmount: number;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateReserveItemDto)
items: CreateReserveItemDto[];
}
@@ -0,0 +1,24 @@
import { IsDateString, IsEnum, IsOptional, IsUUID } from 'class-validator';
import { ReserveStatus } from '../enums/reserve-status.enum';
export class FindReservesQueryDto {
@IsOptional()
@IsUUID()
salonId?: string;
@IsOptional()
@IsUUID()
customerId?: string;
@IsOptional()
@IsUUID()
stylistId?: string;
@IsOptional()
@IsEnum(ReserveStatus)
status?: ReserveStatus;
@IsOptional()
@IsDateString()
appointmentDate?: string;
}
+44
View File
@@ -0,0 +1,44 @@
import {
IsDateString,
IsEnum,
IsNumber,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
} from 'class-validator';
import { ReserveStatus } from '../enums/reserve-status.enum';
export class UpdateReserveDto {
@IsOptional()
@IsDateString()
appointmentDate?: string;
@IsOptional()
@Matches(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/)
startTime?: string;
@IsOptional()
@Matches(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/)
endTime?: string;
@IsOptional()
@IsEnum(ReserveStatus)
status?: ReserveStatus;
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
totalAmount?: number;
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
depositAmount?: number;
@IsOptional()
@IsString()
@MaxLength(1000)
cancellationReason?: string;
}
@@ -0,0 +1,47 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { ServiceSkill } from '../../services/entities/service-skill.entity';
import { Service } from '../../services/entities/service.entity';
import type { Reserve } from './reserve.entity.js';
@Entity('reserve_items')
@Index(['reserveId'])
export class ReserveItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
reserveId: string;
@ManyToOne('Reserve', 'items', { onDelete: 'CASCADE' })
@JoinColumn({ name: 'reserveId' })
reserve: Reserve;
@Column()
serviceId: string;
@ManyToOne(() => Service, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'serviceId' })
service: Service;
@Column()
serviceSkillId: string;
@ManyToOne(() => ServiceSkill, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'serviceSkillId' })
serviceSkill: ServiceSkill;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
+105
View File
@@ -0,0 +1,105 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Customer } from '../../customers/entities/customer.entity';
import { Salon } from '../../salons/entities/salon.entity';
import { Stylist } from '../../stylists/entities/stylist.entity';
import { ReserveStatus } from '../enums/reserve-status.enum';
import type { ReserveItem } from './reserve-item.entity.js';
const decimalTransformer = {
to: (value: number) => value,
from: (value: string) => parseFloat(value),
};
@Entity('reserves')
@Index(['salonId', 'appointmentDate'])
@Index(['stylistId', 'appointmentDate'])
@Index(['customerId'])
@Index(['status'])
export class Reserve {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
salonId: string;
@ManyToOne(() => Salon, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'salonId' })
salon: Salon;
@Column()
customerId: string;
@ManyToOne(() => Customer, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'customerId' })
customer: Customer;
@Column()
stylistId: string;
@ManyToOne(() => Stylist, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'stylistId' })
stylist: Stylist;
@Column({ type: 'date' })
appointmentDate: string;
@Column({ type: 'time' })
startTime: string;
@Column({ type: 'time' })
endTime: string;
@Column({
type: 'enum',
enum: ReserveStatus,
enumName: 'reserves_status_enum',
default: ReserveStatus.PENDING,
})
status: ReserveStatus;
@Column({
type: 'decimal',
precision: 12,
scale: 2,
transformer: decimalTransformer,
})
totalAmount: number;
@Column({
type: 'decimal',
precision: 12,
scale: 2,
transformer: decimalTransformer,
})
depositAmount: number;
@Column({
type: 'decimal',
precision: 12,
scale: 2,
transformer: decimalTransformer,
})
remainingAmount: number;
@Column({ type: 'text', nullable: true })
cancellationReason: string | null;
@OneToMany('ReserveItem', 'reserve', { cascade: true })
items: ReserveItem[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,7 @@
export enum ReserveStatus {
PENDING = 'pending',
CONFIRMED = 'confirmed',
COMPLETED = 'completed',
CANCELLED = 'cancelled',
NO_SHOW = 'no_show',
}
+63
View File
@@ -0,0 +1,63 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { UserRole } from '../users/enums/user-role.enum';
import { CreateReserveDto } from './dto/create-reserve.dto';
import { FindReservesQueryDto } from './dto/find-reserves-query.dto';
import { UpdateReserveDto } from './dto/update-reserve.dto';
import { ReservesService } from './reserves.service';
@Controller('reserves')
@UseGuards(RolesGuard)
export class ReservesController {
constructor(private readonly reservesService: ReservesService) {}
@Roles(UserRole.ADMIN, UserRole.SALON, UserRole.CUSTOMER)
@Post()
create(
@Body() createReserveDto: CreateReserveDto,
@CurrentUser() user: AuthUser,
) {
return this.reservesService.create(createReserveDto, user);
}
@Roles(UserRole.ADMIN, UserRole.SALON, UserRole.CUSTOMER)
@Get()
findAll(
@Query() query: FindReservesQueryDto,
@CurrentUser() user: AuthUser,
) {
return this.reservesService.findAll(user, query);
}
@Roles(UserRole.ADMIN, UserRole.SALON, UserRole.CUSTOMER)
@Get(':id')
findOne(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUser,
) {
return this.reservesService.findOne(id, user);
}
@Roles(UserRole.ADMIN, UserRole.SALON, UserRole.CUSTOMER)
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateReserveDto: UpdateReserveDto,
@CurrentUser() user: AuthUser,
) {
return this.reservesService.update(id, updateReserveDto, user);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthGuardsModule } from '../auth/auth-guards.module';
import { AuthorizationModule } from '../authorization/authorization.module';
import { CustomersModule } from '../customers/customers.module';
import { SalonsModule } from '../salons/salons.module';
import { ServiceSkill } from '../services/entities/service-skill.entity';
import { Service } from '../services/entities/service.entity';
import { StylistsModule } from '../stylists/stylists.module';
import { ReserveItem } from './entities/reserve-item.entity';
import { Reserve } from './entities/reserve.entity';
import { ReservesController } from './reserves.controller';
import { ReservesService } from './reserves.service';
@Module({
imports: [
TypeOrmModule.forFeature([Reserve, ReserveItem, Service, ServiceSkill]),
SalonsModule,
CustomersModule,
StylistsModule,
AuthorizationModule,
AuthGuardsModule,
],
controllers: [ReservesController],
providers: [ReservesService],
exports: [ReservesService],
})
export class ReservesModule {}
+262
View File
@@ -0,0 +1,262 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuthorizationService } from '../authorization/authorization.service';
import { ReservePolicy } from '../authorization/policies/reserve.policy';
import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { CustomersService } from '../customers/customers.service';
import { SalonsService } from '../salons/salons.service';
import { ServiceSkill } from '../services/entities/service-skill.entity';
import { Service } from '../services/entities/service.entity';
import { StylistsService } from '../stylists/stylists.service';
import { UserRole } from '../users/enums/user-role.enum';
import { CreateReserveDto } from './dto/create-reserve.dto';
import { FindReservesQueryDto } from './dto/find-reserves-query.dto';
import { UpdateReserveDto } from './dto/update-reserve.dto';
import { ReserveItem } from './entities/reserve-item.entity';
import { Reserve } from './entities/reserve.entity';
import { ReserveStatus } from './enums/reserve-status.enum';
@Injectable()
export class ReservesService {
constructor(
@InjectRepository(Reserve)
private readonly reservesRepository: Repository<Reserve>,
@InjectRepository(ReserveItem)
private readonly reserveItemsRepository: Repository<ReserveItem>,
@InjectRepository(Service)
private readonly servicesRepository: Repository<Service>,
@InjectRepository(ServiceSkill)
private readonly serviceSkillsRepository: Repository<ServiceSkill>,
private readonly salonsService: SalonsService,
private readonly customersService: CustomersService,
private readonly stylistsService: StylistsService,
private readonly authorizationService: AuthorizationService,
private readonly reservePolicy: ReservePolicy,
) {}
async create(
createReserveDto: CreateReserveDto,
user: AuthUser,
): Promise<Reserve> {
const salon = await this.salonsService.findOne(createReserveDto.salonId);
const customer = await this.customersService.findOne(
createReserveDto.customerId,
);
const stylist = await this.stylistsService.findOne(
createReserveDto.stylistId,
);
if (stylist.salonId !== salon.id) {
throw new BadRequestException(
'Stylist does not belong to the selected salon',
);
}
if (!this.reservePolicy.canCreate(user, { salon, customer })) {
throw new ForbiddenException();
}
this.validateTimeRange(createReserveDto.startTime, createReserveDto.endTime);
this.validateAmounts(
createReserveDto.totalAmount,
createReserveDto.depositAmount,
);
await this.validateItems(createReserveDto.items);
const reserve = this.reservesRepository.create({
salonId: createReserveDto.salonId,
customerId: createReserveDto.customerId,
stylistId: createReserveDto.stylistId,
appointmentDate: createReserveDto.appointmentDate,
startTime: this.normalizeTime(createReserveDto.startTime),
endTime: this.normalizeTime(createReserveDto.endTime),
totalAmount: createReserveDto.totalAmount,
depositAmount: createReserveDto.depositAmount,
remainingAmount:
createReserveDto.totalAmount - createReserveDto.depositAmount,
items: createReserveDto.items.map((item) =>
this.reserveItemsRepository.create(item),
),
});
const saved = await this.reservesRepository.save(reserve);
return this.findOne(saved.id, user);
}
async findAll(
user: AuthUser,
query: FindReservesQueryDto,
): Promise<Reserve[]> {
const qb = this.reservesRepository
.createQueryBuilder('reserve')
.leftJoinAndSelect('reserve.salon', 'salon')
.leftJoinAndSelect('reserve.customer', 'customer')
.leftJoinAndSelect('customer.user', 'customerUser')
.leftJoinAndSelect('reserve.stylist', 'stylist')
.leftJoinAndSelect('stylist.user', 'stylistUser')
.leftJoinAndSelect('reserve.items', 'items')
.leftJoinAndSelect('items.service', 'service')
.leftJoinAndSelect('items.serviceSkill', 'serviceSkill')
.orderBy('reserve.appointmentDate', 'ASC')
.addOrderBy('reserve.startTime', 'ASC');
if (query.salonId) {
qb.andWhere('reserve.salonId = :salonId', { salonId: query.salonId });
}
if (query.customerId) {
qb.andWhere('reserve.customerId = :customerId', {
customerId: query.customerId,
});
}
if (query.stylistId) {
qb.andWhere('reserve.stylistId = :stylistId', {
stylistId: query.stylistId,
});
}
if (query.status) {
qb.andWhere('reserve.status = :status', { status: query.status });
}
if (query.appointmentDate) {
qb.andWhere('reserve.appointmentDate = :appointmentDate', {
appointmentDate: query.appointmentDate,
});
}
if (!this.authorizationService.isAdmin(user)) {
if (user.role === UserRole.SALON) {
qb.andWhere('salon.ownerId = :ownerId', { ownerId: user.id });
} else if (user.role === UserRole.CUSTOMER) {
qb.andWhere('customer.userId = :userId', { userId: user.id });
} else {
throw new ForbiddenException();
}
}
return qb.getMany();
}
async findOne(id: string, user: AuthUser): Promise<Reserve> {
const reserve = await this.reservesRepository.findOne({
where: { id },
relations: this.defaultRelations,
});
if (!reserve) {
throw new NotFoundException(`Reserve #${id} not found`);
}
if (!this.reservePolicy.canRead(user, reserve)) {
throw new ForbiddenException();
}
return reserve;
}
async update(
id: string,
updateReserveDto: UpdateReserveDto,
user: AuthUser,
): Promise<Reserve> {
const reserve = await this.findOne(id, user);
if (!this.reservePolicy.canUpdate(user, reserve)) {
throw new ForbiddenException();
}
const {
appointmentDate,
startTime,
endTime,
status,
totalAmount,
depositAmount,
cancellationReason,
} = updateReserveDto;
if (appointmentDate !== undefined) {
reserve.appointmentDate = appointmentDate;
}
if (startTime !== undefined) {
reserve.startTime = this.normalizeTime(startTime);
}
if (endTime !== undefined) {
reserve.endTime = this.normalizeTime(endTime);
}
this.validateTimeRange(reserve.startTime, reserve.endTime);
if (totalAmount !== undefined) {
reserve.totalAmount = totalAmount;
}
if (depositAmount !== undefined) {
reserve.depositAmount = depositAmount;
}
if (totalAmount !== undefined || depositAmount !== undefined) {
this.validateAmounts(reserve.totalAmount, reserve.depositAmount);
reserve.remainingAmount = reserve.totalAmount - reserve.depositAmount;
}
if (status !== undefined) {
reserve.status = status;
if (status === ReserveStatus.CANCELLED) {
reserve.cancellationReason = cancellationReason ?? null;
} else if (cancellationReason !== undefined) {
reserve.cancellationReason = cancellationReason;
}
} else if (cancellationReason !== undefined) {
reserve.cancellationReason = cancellationReason;
}
await this.reservesRepository.save(reserve);
return this.findOne(id, user);
}
private readonly defaultRelations = {
salon: true,
customer: { user: true },
stylist: { user: true },
items: { service: true, serviceSkill: true },
} as const;
private validateTimeRange(startTime: string, endTime: string): void {
if (startTime >= endTime) {
throw new BadRequestException('endTime must be after startTime');
}
}
private validateAmounts(totalAmount: number, depositAmount: number): void {
if (depositAmount > totalAmount) {
throw new BadRequestException(
'depositAmount cannot exceed totalAmount',
);
}
}
private normalizeTime(time: string): string {
return time.length === 5 ? `${time}:00` : time;
}
private async validateItems(
items: CreateReserveDto['items'],
): Promise<void> {
for (const item of items) {
const serviceExists = await this.servicesRepository.existsBy({
id: item.serviceId,
});
if (!serviceExists) {
throw new BadRequestException(`Service #${item.serviceId} not found`);
}
const skill = await this.serviceSkillsRepository.findOne({
where: { id: item.serviceSkillId, serviceId: item.serviceId },
});
if (!skill) {
throw new BadRequestException(
`Service skill #${item.serviceSkillId} not found for service #${item.serviceId}`,
);
}
}
}
}