salon's services

This commit is contained in:
2026-07-09 20:32:41 +03:30
parent ad9bca8e6c
commit 0732964753
8 changed files with 293 additions and 2 deletions
@@ -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<void> {
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<void> {
await queryRunner.dropTable('salon_services', true);
}
}
+2
View File
@@ -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,
+6
View File
@@ -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,
+15
View File
@@ -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;
}
@@ -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;
}
+38
View File
@@ -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)
+3 -1
View File
@@ -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,
],
+125 -1
View File
@@ -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<Service>,
@InjectRepository(ServiceSkill)
private readonly serviceSkillsRepository: Repository<ServiceSkill>,
@InjectRepository(SalonServiceOffering)
private readonly salonServicesRepository: Repository<SalonServiceOffering>,
@InjectRepository(Salon)
private readonly salonsRepository: Repository<Salon>,
private readonly servicePolicy: ServicePolicy,
) {}
@@ -127,6 +140,117 @@ export class ServicesService {
await this.serviceSkillsRepository.remove(skill);
}
async findAllForSalon(
user: AuthUser,
query: FindSalonServicesQueryDto,
): Promise<Service[]> {
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<Service> {
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<void> {
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<void> {
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<Salon> {
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();