diff --git a/src/database/migrations/1721400000000-AddSalonServices.ts b/src/database/migrations/1721400000000-AddSalonServices.ts new file mode 100644 index 0000000..1267f13 --- /dev/null +++ b/src/database/migrations/1721400000000-AddSalonServices.ts @@ -0,0 +1,65 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableUnique, +} from 'typeorm'; + +export class AddSalonServices1721400000000 implements MigrationInterface { + name = 'AddSalonServices1721400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'salon_services', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'salonId', type: 'uuid' }, + { name: 'serviceId', type: 'uuid' }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createUniqueConstraint( + 'salon_services', + new TableUnique({ + name: 'UQ_salon_services_salon_service', + columnNames: ['salonId', 'serviceId'], + }), + ); + + await queryRunner.createForeignKey( + 'salon_services', + new TableForeignKey({ + columnNames: ['salonId'], + referencedTableName: 'salons', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'salon_services', + new TableForeignKey({ + columnNames: ['serviceId'], + referencedTableName: 'services', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('salon_services', true); + } +} diff --git a/src/reserves/reserves.module.ts b/src/reserves/reserves.module.ts index c94399a..47dec22 100644 --- a/src/reserves/reserves.module.ts +++ b/src/reserves/reserves.module.ts @@ -6,6 +6,7 @@ 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 { ServicesModule } from '../services/services.module'; import { SmsSettingsModule } from '../sms-settings/sms-settings.module'; import { StylistsModule } from '../stylists/stylists.module'; import { ReserveItem } from './entities/reserve-item.entity'; @@ -26,6 +27,7 @@ import { ReservesService } from './reserves.service'; SalonsModule, CustomersModule, StylistsModule, + ServicesModule, AuthorizationModule, AuthGuardsModule, SmsSettingsModule, diff --git a/src/reserves/reserves.service.ts b/src/reserves/reserves.service.ts index af8030e..515aad1 100644 --- a/src/reserves/reserves.service.ts +++ b/src/reserves/reserves.service.ts @@ -13,6 +13,7 @@ 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 { ServicesService } from '../services/services.service'; import { StylistsService } from '../stylists/stylists.service'; import { UserRole } from '../users/enums/user-role.enum'; import { CreateReserveDto } from './dto/create-reserve.dto'; @@ -43,6 +44,7 @@ export class ReservesService { private readonly authorizationService: AuthorizationService, private readonly reservePolicy: ReservePolicy, private readonly reserveSmsService: ReserveSmsService, + private readonly servicesService: ServicesService, ) {} async create( @@ -76,6 +78,10 @@ export class ReservesService { createReserveDto.depositAmount, ); await this.validateItems(createReserveDto.items); + await this.servicesService.ensureSalonOffersServices( + createReserveDto.salonId, + createReserveDto.items.map((item) => item.serviceId), + ); const reserve = this.reservesRepository.create({ salonId: createReserveDto.salonId, diff --git a/src/services/dto/salon-service.dto.ts b/src/services/dto/salon-service.dto.ts new file mode 100644 index 0000000..9965a3a --- /dev/null +++ b/src/services/dto/salon-service.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +export class CreateSalonServiceDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + serviceId: string; +} + +export class FindSalonServicesQueryDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; +} diff --git a/src/services/entities/salon-service-offering.entity.ts b/src/services/entities/salon-service-offering.entity.ts new file mode 100644 index 0000000..e46226a --- /dev/null +++ b/src/services/entities/salon-service-offering.entity.ts @@ -0,0 +1,39 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; +import { Salon } from '../../salons/entities/salon.entity'; +import { Service } from './service.entity'; + +@Entity('salon_services') +@Unique(['salonId', 'serviceId']) +export class SalonServiceOffering { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + salonId: string; + + @Column({ type: 'uuid' }) + serviceId: string; + + @ManyToOne(() => Salon, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'salonId' }) + salon: Salon; + + @ManyToOne(() => Service, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'serviceId' }) + service: Service; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/services/services.controller.ts b/src/services/services.controller.ts index c920467..d7f0307 100644 --- a/src/services/services.controller.ts +++ b/src/services/services.controller.ts @@ -7,6 +7,7 @@ import { ParseUUIDPipe, Patch, Post, + Query, UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; @@ -21,6 +22,10 @@ import { PermissionCode } from '../authorization/enums/permission-code.enum'; import { UserRole } from '../users/enums/user-role.enum'; import { CreateServiceSkillDto } from './dto/create-service-skill.dto'; import { CreateServiceDto } from './dto/create-service.dto'; +import { + CreateSalonServiceDto, + FindSalonServicesQueryDto, +} from './dto/salon-service.dto'; import { UpdateServiceSkillDto } from './dto/update-service-skill.dto'; import { UpdateServiceDto } from './dto/update-service.dto'; import { ServicesService } from './services.service'; @@ -61,6 +66,39 @@ export class ServicesController { return this.servicesService.findAll(); } + @Roles(UserRole.SALON) + @Permissions(PermissionCode.SERVICES_READ) + @ApiStandardOkResponse(Service, { isArray: true }) + @Get('salon') + findAllForSalon( + @CurrentUser() user: AuthUser, + @Query() query: FindSalonServicesQueryDto, + ) { + return this.servicesService.findAllForSalon(user, query); + } + + @Roles(UserRole.SALON) + @Permissions(PermissionCode.SERVICES_WRITE) + @ApiStandardOkResponse(Service) + @Post('salon') + addServiceForSalon( + @Body() createSalonServiceDto: CreateSalonServiceDto, + @CurrentUser() user: AuthUser, + ) { + return this.servicesService.addServiceForSalon(createSalonServiceDto, user); + } + + @Roles(UserRole.SALON) + @Permissions(PermissionCode.SERVICES_DELETE) + @ApiStandardEmptyResponse() + @Delete('salon/:serviceId') + removeServiceForSalon( + @Param('serviceId', ParseUUIDPipe) serviceId: string, + @CurrentUser() user: AuthUser, + ) { + return this.servicesService.removeServiceForSalon(serviceId, user); + } + @Public() @ApiPublicEndpoint('Get service by ID') @ApiStandardOkResponse(Service) diff --git a/src/services/services.module.ts b/src/services/services.module.ts index 65b8595..d56ba4a 100644 --- a/src/services/services.module.ts +++ b/src/services/services.module.ts @@ -2,6 +2,8 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthGuardsModule } from '../auth/auth-guards.module'; import { AuthorizationModule } from '../authorization/authorization.module'; +import { Salon } from '../salons/entities/salon.entity'; +import { SalonServiceOffering } from './entities/salon-service-offering.entity'; import { ServiceSkill } from './entities/service-skill.entity'; import { Service } from './entities/service.entity'; import { ServicesController } from './services.controller'; @@ -9,7 +11,7 @@ import { ServicesService } from './services.service'; @Module({ imports: [ - TypeOrmModule.forFeature([Service, ServiceSkill]), + TypeOrmModule.forFeature([Service, ServiceSkill, SalonServiceOffering, Salon]), AuthorizationModule, AuthGuardsModule, ], diff --git a/src/services/services.service.ts b/src/services/services.service.ts index b347acf..8cf396f 100644 --- a/src/services/services.service.ts +++ b/src/services/services.service.ts @@ -1,16 +1,25 @@ import { + BadRequestException, + ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { ServicePolicy } from '../authorization/policies/service.policy'; import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { Salon } from '../salons/entities/salon.entity'; +import { UserRole } from '../users/enums/user-role.enum'; import { CreateServiceSkillDto } from './dto/create-service-skill.dto'; import { CreateServiceDto } from './dto/create-service.dto'; +import { + CreateSalonServiceDto, + FindSalonServicesQueryDto, +} from './dto/salon-service.dto'; import { UpdateServiceSkillDto } from './dto/update-service-skill.dto'; import { UpdateServiceDto } from './dto/update-service.dto'; +import { SalonServiceOffering } from './entities/salon-service-offering.entity'; import { ServiceSkill } from './entities/service-skill.entity'; import { Service } from './entities/service.entity'; @@ -21,6 +30,10 @@ export class ServicesService { private readonly servicesRepository: Repository, @InjectRepository(ServiceSkill) private readonly serviceSkillsRepository: Repository, + @InjectRepository(SalonServiceOffering) + private readonly salonServicesRepository: Repository, + @InjectRepository(Salon) + private readonly salonsRepository: Repository, private readonly servicePolicy: ServicePolicy, ) {} @@ -127,6 +140,117 @@ export class ServicesService { await this.serviceSkillsRepository.remove(skill); } + async findAllForSalon( + user: AuthUser, + query: FindSalonServicesQueryDto, + ): Promise { + const salon = await this.resolveSalonForUser(user); + const offerings = await this.salonServicesRepository.find({ + where: { salonId: salon.id }, + relations: { service: { skills: true } }, + order: { createdAt: 'ASC', service: { skills: { createdAt: 'ASC' } } }, + }); + + let services = offerings.map((offering) => offering.service); + + if (query.search?.trim()) { + const search = query.search.trim().toLowerCase(); + services = services.filter((service) => + service.title.toLowerCase().includes(search), + ); + } + + return services; + } + + async addServiceForSalon( + dto: CreateSalonServiceDto, + user: AuthUser, + ): Promise { + const salon = await this.resolveSalonForUser(user); + await this.findOne(dto.serviceId); + + const existing = await this.salonServicesRepository.findOne({ + where: { salonId: salon.id, serviceId: dto.serviceId }, + }); + if (existing) { + throw new ConflictException('This service is already added to the salon'); + } + + const offering = this.salonServicesRepository.create({ + salonId: salon.id, + serviceId: dto.serviceId, + }); + await this.salonServicesRepository.save(offering); + return this.findOne(dto.serviceId); + } + + async removeServiceForSalon( + serviceId: string, + user: AuthUser, + ): Promise { + const salon = await this.resolveSalonForUser(user); + const offering = await this.salonServicesRepository.findOne({ + where: { salonId: salon.id, serviceId }, + }); + + if (!offering) { + throw new NotFoundException( + `Service #${serviceId} is not offered by this salon`, + ); + } + + await this.salonServicesRepository.remove(offering); + } + + async ensureSalonOffersServices( + salonId: string, + serviceIds: string[], + ): Promise { + const uniqueServiceIds = [...new Set(serviceIds)]; + if (uniqueServiceIds.length === 0) { + return; + } + + const salonOfferingCount = await this.salonServicesRepository.count({ + where: { salonId }, + }); + if (salonOfferingCount === 0) { + return; + } + + const offerings = await this.salonServicesRepository.find({ + where: { salonId, serviceId: In(uniqueServiceIds) }, + select: { serviceId: true }, + }); + + const offeredIds = new Set(offerings.map((offering) => offering.serviceId)); + const missing = uniqueServiceIds.filter((id) => !offeredIds.has(id)); + + if (missing.length > 0) { + throw new BadRequestException( + `Salon does not offer service(s): ${missing.join(', ')}`, + ); + } + } + + private async resolveSalonForUser(user: AuthUser): Promise { + if (user.role !== UserRole.SALON) { + throw new ForbiddenException(); + } + + const salon = await this.salonsRepository.findOne({ + where: { ownerId: user.id }, + order: { createdAt: 'ASC' }, + }); + + if (!salon) { + throw new NotFoundException('Salon not found for this user'); + } + + return salon; + } + private ensureCanManage(user: AuthUser): void { if (!this.servicePolicy.canManage(user)) { throw new ForbiddenException();