From 23cca5a20106d26f3f98e2fc4b68edcf401d0c40 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Tue, 7 Jul 2026 22:52:01 +0330 Subject: [PATCH] stylist --- src/stylists/dto/salon-stylist.dto.ts | 81 +++++++++++++ src/stylists/stylists.controller.ts | 61 ++++++++++ src/stylists/stylists.module.ts | 3 +- src/stylists/stylists.service.ts | 164 ++++++++++++++++++++++++++ 4 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 src/stylists/dto/salon-stylist.dto.ts diff --git a/src/stylists/dto/salon-stylist.dto.ts b/src/stylists/dto/salon-stylist.dto.ts new file mode 100644 index 0000000..cf96c5e --- /dev/null +++ b/src/stylists/dto/salon-stylist.dto.ts @@ -0,0 +1,81 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString, + IsUrl, + Length, + Matches, + Min, + ValidateIf, +} from 'class-validator'; + +export class CreateSalonStylistDto { + @ApiProperty({ example: 'زهرا' }) + @IsString() + @Length(1, 100) + @IsNotEmpty() + firstName: string; + + @ApiProperty({ example: 'رضایی' }) + @IsString() + @Length(1, 100) + @IsNotEmpty() + lastName: string; + + @ApiProperty({ example: '09123456789' }) + @Matches(/^09\d{9}$/) + mobile: string; + + @ApiPropertyOptional({ example: 5, default: 0 }) + @IsOptional() + @IsInt() + @Min(0) + experienceYears?: number; + + @ApiPropertyOptional({ example: 'https://example.com/avatar.jpg' }) + @IsOptional() + @IsString() + @IsUrl() + avatarUrl?: string; +} + +export class UpdateSalonStylistDto { + @ApiPropertyOptional({ example: 'زهرا' }) + @IsOptional() + @IsString() + @Length(1, 100) + firstName?: string; + + @ApiPropertyOptional({ example: 'رضایی' }) + @IsOptional() + @IsString() + @Length(1, 100) + lastName?: string; + + @ApiPropertyOptional({ example: '09123456789' }) + @IsOptional() + @Matches(/^09\d{9}$/) + mobile?: string; + + @ApiPropertyOptional({ example: 5 }) + @IsOptional() + @IsInt() + @Min(0) + experienceYears?: number; + + @ApiPropertyOptional({ example: 'https://example.com/avatar.jpg', nullable: true }) + @IsOptional() + @ValidateIf((_, value) => value !== null) + @IsString() + @IsUrl() + avatarUrl?: string | null; +} + +export class FindSalonStylistsQueryDto { + @ApiPropertyOptional({ description: 'Search by name or mobile' }) + @IsOptional() + @IsString() + search?: string; +} diff --git a/src/stylists/stylists.controller.ts b/src/stylists/stylists.controller.ts index edbdb62..77f05a2 100644 --- a/src/stylists/stylists.controller.ts +++ b/src/stylists/stylists.controller.ts @@ -21,6 +21,11 @@ import { AuthUser } from '../auth/interfaces/auth-user.interface'; import { PermissionCode } from '../authorization/enums/permission-code.enum'; import { UserRole } from '../users/enums/user-role.enum'; import { CreateStylistDto } from './dto/create-stylist.dto'; +import { + CreateSalonStylistDto, + FindSalonStylistsQueryDto, + UpdateSalonStylistDto, +} from './dto/salon-stylist.dto'; import { UpdateStylistDto } from './dto/update-stylist.dto'; import { StylistsService } from './stylists.service'; import { ApiPublicEndpoint } from '../swagger/decorators/swagger-auth.decorator'; @@ -51,6 +56,62 @@ export class StylistsController { return this.stylistsService.create(createStylistDto, user); } + @Roles(UserRole.SALON) + @Permissions(PermissionCode.STYLISTS_READ) + @ApiStandardOkResponse(Stylist, { isArray: true }) + @Get('salon') + findAllForSalon( + @CurrentUser() user: AuthUser, + @Query() query: FindSalonStylistsQueryDto, + ) { + return this.stylistsService.findAllForSalon(user, query); + } + + @Roles(UserRole.SALON) + @Permissions(PermissionCode.STYLISTS_WRITE) + @ApiStandardOkResponse(Stylist) + @Post('salon') + createForSalon( + @Body() createSalonStylistDto: CreateSalonStylistDto, + @CurrentUser() user: AuthUser, + ) { + return this.stylistsService.createForSalon(createSalonStylistDto, user); + } + + @Roles(UserRole.SALON) + @Permissions(PermissionCode.STYLISTS_READ) + @ApiStandardOkResponse(Stylist) + @Get('salon/:id') + findOneForSalon( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ) { + return this.stylistsService.findOneForSalon(id, user); + } + + @Roles(UserRole.SALON) + @Permissions(PermissionCode.STYLISTS_WRITE) + @ApiStandardOkResponse(Stylist) + @Patch('salon/:id') + updateForSalon( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateSalonStylistDto: UpdateSalonStylistDto, + @CurrentUser() user: AuthUser, + ) { + return this.stylistsService.updateForSalon(id, updateSalonStylistDto, user); + } + + @Roles(UserRole.SALON) + @Permissions(PermissionCode.STYLISTS_DELETE) + @ApiStandardEmptyResponse() + @Delete('salon/:id') + removeForSalon( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ) { + return this.stylistsService.removeForSalon(id, user); + } + @Public() @ApiPublicEndpoint('List stylists, optionally filtered by salon') @ApiQuery({ name: 'salonId', required: false, type: String, format: 'uuid' }) diff --git a/src/stylists/stylists.module.ts b/src/stylists/stylists.module.ts index e0834da..e7b21b6 100644 --- a/src/stylists/stylists.module.ts +++ b/src/stylists/stylists.module.ts @@ -2,6 +2,7 @@ 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 { SalonsModule } from '../salons/salons.module'; import { UsersModule } from '../users/users.module'; import { Stylist } from './entities/stylist.entity'; @@ -10,7 +11,7 @@ import { StylistsService } from './stylists.service'; @Module({ imports: [ - TypeOrmModule.forFeature([Stylist]), + TypeOrmModule.forFeature([Stylist, Salon]), SalonsModule, UsersModule, AuthorizationModule, diff --git a/src/stylists/stylists.service.ts b/src/stylists/stylists.service.ts index 6a513a7..ccbad1f 100644 --- a/src/stylists/stylists.service.ts +++ b/src/stylists/stylists.service.ts @@ -1,4 +1,5 @@ import { + ConflictException, ForbiddenException, Injectable, NotFoundException, @@ -7,9 +8,16 @@ import { InjectRepository } from '@nestjs/typeorm'; import { FindOptionsWhere, Repository } from 'typeorm'; import { StylistPolicy } from '../authorization/policies/stylist.policy'; import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { Salon } from '../salons/entities/salon.entity'; import { SalonsService } from '../salons/salons.service'; +import { UserRole } from '../users/enums/user-role.enum'; import { UsersService } from '../users/users.service'; import { CreateStylistDto } from './dto/create-stylist.dto'; +import { + CreateSalonStylistDto, + FindSalonStylistsQueryDto, + UpdateSalonStylistDto, +} from './dto/salon-stylist.dto'; import { UpdateStylistDto } from './dto/update-stylist.dto'; import { Stylist } from './entities/stylist.entity'; @@ -18,6 +26,8 @@ export class StylistsService { constructor( @InjectRepository(Stylist) private readonly stylistsRepository: Repository, + @InjectRepository(Salon) + private readonly salonsRepository: Repository, private readonly salonsService: SalonsService, private readonly usersService: UsersService, private readonly stylistPolicy: StylistPolicy, @@ -84,4 +94,158 @@ export class StylistsService { } await this.stylistsRepository.remove(stylist); } + + async findAllForSalon( + user: AuthUser, + query: FindSalonStylistsQueryDto, + ): Promise { + const salon = await this.resolveSalonForUser(user); + const stylists = await this.stylistsRepository.find({ + where: { salonId: salon.id }, + relations: { salon: true, user: true }, + order: { createdAt: 'ASC' }, + }); + + if (!query.search?.trim()) { + return stylists; + } + + const search = query.search.trim().toLowerCase(); + return stylists.filter((stylist) => { + const fullName = + `${stylist.user.firstName} ${stylist.user.lastName}`.toLowerCase(); + return ( + fullName.includes(search) || stylist.user.mobile.includes(search) + ); + }); + } + + async findOneForSalon(id: string, user: AuthUser): Promise { + const salon = await this.resolveSalonForUser(user); + const stylist = await this.findOne(id); + + if (stylist.salonId !== salon.id) { + throw new NotFoundException(`Stylist #${id} not found in this salon`); + } + + return stylist; + } + + async createForSalon( + dto: CreateSalonStylistDto, + user: AuthUser, + ): Promise { + const salon = await this.resolveSalonForUser(user); + const stylistUser = await this.findOrCreateUserForStylist(dto); + + const stylist = this.stylistsRepository.create({ + salonId: salon.id, + userId: stylistUser.id, + experienceYears: dto.experienceYears ?? 0, + avatarUrl: dto.avatarUrl ?? null, + }); + + const saved = await this.stylistsRepository.save(stylist); + return this.findOne(saved.id); + } + + async updateForSalon( + id: string, + dto: UpdateSalonStylistDto, + user: AuthUser, + ): Promise { + const stylist = await this.findOneForSalon(id, user); + + const { firstName, lastName, mobile, experienceYears, avatarUrl } = dto; + + if ( + firstName !== undefined || + lastName !== undefined || + mobile !== undefined + ) { + await this.usersService.update(stylist.userId, { + firstName, + lastName, + mobile, + }); + } + + if (experienceYears !== undefined) { + stylist.experienceYears = experienceYears; + } + if (avatarUrl !== undefined) { + stylist.avatarUrl = avatarUrl; + } + + await this.stylistsRepository.save(stylist); + return this.findOne(stylist.id); + } + + async removeForSalon(id: string, user: AuthUser): Promise { + const stylist = await this.findOneForSalon(id, user); + await this.stylistsRepository.remove(stylist); + } + + 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 async findOrCreateUserForStylist(dto: CreateSalonStylistDto) { + const existingUser = await this.usersService.findByMobile(dto.mobile); + + if (existingUser) { + if (existingUser.role === UserRole.SALON) { + throw new ConflictException( + 'Salon owners cannot be registered as stylists', + ); + } + + if (existingUser.role === UserRole.ADMIN) { + throw new ConflictException( + 'Admins cannot be registered as stylists', + ); + } + + const existingStylist = await this.stylistsRepository.findOne({ + where: { userId: existingUser.id }, + }); + if (existingStylist) { + throw new ConflictException( + 'This user is already registered as a stylist', + ); + } + + if ( + existingUser.firstName !== dto.firstName || + existingUser.lastName !== dto.lastName + ) { + return this.usersService.update(existingUser.id, { + firstName: dto.firstName, + lastName: dto.lastName, + }); + } + + return existingUser; + } + + return this.usersService.create({ + firstName: dto.firstName, + lastName: dto.lastName, + mobile: dto.mobile, + role: UserRole.CUSTOMER, + }); + } }