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 { 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,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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user