stylist
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Stylist>,
|
||||
@InjectRepository(Salon)
|
||||
private readonly salonsRepository: Repository<Salon>,
|
||||
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<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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user