This commit is contained in:
2026-07-07 22:52:01 +03:30
parent c009140f11
commit 23cca5a201
4 changed files with 308 additions and 1 deletions
+81
View File
@@ -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;
}
+61
View File
@@ -21,6 +21,11 @@ import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { PermissionCode } from '../authorization/enums/permission-code.enum'; import { PermissionCode } from '../authorization/enums/permission-code.enum';
import { UserRole } from '../users/enums/user-role.enum'; import { UserRole } from '../users/enums/user-role.enum';
import { CreateStylistDto } from './dto/create-stylist.dto'; import { CreateStylistDto } from './dto/create-stylist.dto';
import {
CreateSalonStylistDto,
FindSalonStylistsQueryDto,
UpdateSalonStylistDto,
} from './dto/salon-stylist.dto';
import { UpdateStylistDto } from './dto/update-stylist.dto'; import { UpdateStylistDto } from './dto/update-stylist.dto';
import { StylistsService } from './stylists.service'; import { StylistsService } from './stylists.service';
import { ApiPublicEndpoint } from '../swagger/decorators/swagger-auth.decorator'; import { ApiPublicEndpoint } from '../swagger/decorators/swagger-auth.decorator';
@@ -51,6 +56,62 @@ export class StylistsController {
return this.stylistsService.create(createStylistDto, user); 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() @Public()
@ApiPublicEndpoint('List stylists, optionally filtered by salon') @ApiPublicEndpoint('List stylists, optionally filtered by salon')
@ApiQuery({ name: 'salonId', required: false, type: String, format: 'uuid' }) @ApiQuery({ name: 'salonId', required: false, type: String, format: 'uuid' })
+2 -1
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthGuardsModule } from '../auth/auth-guards.module'; import { AuthGuardsModule } from '../auth/auth-guards.module';
import { AuthorizationModule } from '../authorization/authorization.module'; import { AuthorizationModule } from '../authorization/authorization.module';
import { Salon } from '../salons/entities/salon.entity';
import { SalonsModule } from '../salons/salons.module'; import { SalonsModule } from '../salons/salons.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { Stylist } from './entities/stylist.entity'; import { Stylist } from './entities/stylist.entity';
@@ -10,7 +11,7 @@ import { StylistsService } from './stylists.service';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Stylist]), TypeOrmModule.forFeature([Stylist, Salon]),
SalonsModule, SalonsModule,
UsersModule, UsersModule,
AuthorizationModule, AuthorizationModule,
+164
View File
@@ -1,4 +1,5 @@
import { import {
ConflictException,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -7,9 +8,16 @@ import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm'; import { FindOptionsWhere, Repository } from 'typeorm';
import { StylistPolicy } from '../authorization/policies/stylist.policy'; import { StylistPolicy } from '../authorization/policies/stylist.policy';
import { AuthUser } from '../auth/interfaces/auth-user.interface'; import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { Salon } from '../salons/entities/salon.entity';
import { SalonsService } from '../salons/salons.service'; import { SalonsService } from '../salons/salons.service';
import { UserRole } from '../users/enums/user-role.enum';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { CreateStylistDto } from './dto/create-stylist.dto'; import { CreateStylistDto } from './dto/create-stylist.dto';
import {
CreateSalonStylistDto,
FindSalonStylistsQueryDto,
UpdateSalonStylistDto,
} from './dto/salon-stylist.dto';
import { UpdateStylistDto } from './dto/update-stylist.dto'; import { UpdateStylistDto } from './dto/update-stylist.dto';
import { Stylist } from './entities/stylist.entity'; import { Stylist } from './entities/stylist.entity';
@@ -18,6 +26,8 @@ export class StylistsService {
constructor( constructor(
@InjectRepository(Stylist) @InjectRepository(Stylist)
private readonly stylistsRepository: Repository<Stylist>, private readonly stylistsRepository: Repository<Stylist>,
@InjectRepository(Salon)
private readonly salonsRepository: Repository<Salon>,
private readonly salonsService: SalonsService, private readonly salonsService: SalonsService,
private readonly usersService: UsersService, private readonly usersService: UsersService,
private readonly stylistPolicy: StylistPolicy, private readonly stylistPolicy: StylistPolicy,
@@ -84,4 +94,158 @@ export class StylistsService {
} }
await this.stylistsRepository.remove(stylist); await this.stylistsRepository.remove(stylist);
} }
async findAllForSalon(
user: AuthUser,
query: FindSalonStylistsQueryDto,
): Promise<Stylist[]> {
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<Stylist> {
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<Stylist> {
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<Stylist> {
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<void> {
const stylist = await this.findOneForSalon(id, user);
await this.stylistsRepository.remove(stylist);
}
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 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,
});
}
} }