diff --git a/src/app.module.ts b/src/app.module.ts index 4e2d271..e1a3eb8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ import { CustomersModule } from './customers/customers.module'; import configuration from './config/configuration'; import { getTypeOrmOptions } from './database/typeorm-options'; import { SalonsModule } from './salons/salons.module'; +import { StylistsModule } from './stylists/stylists.module'; import { UsersModule } from './users/users.module'; @Module({ @@ -18,6 +19,7 @@ import { UsersModule } from './users/users.module'; AuthModule, UsersModule, SalonsModule, + StylistsModule, CustomersModule, ], controllers: [], diff --git a/src/authorization/authorization.module.ts b/src/authorization/authorization.module.ts index 40afd5e..ca21ccd 100644 --- a/src/authorization/authorization.module.ts +++ b/src/authorization/authorization.module.ts @@ -5,10 +5,21 @@ import { Permission } from './entities/permission.entity'; import { UserPermission } from './entities/user-permission.entity'; import { CustomerPolicy } from './policies/customer.policy'; import { SalonPolicy } from './policies/salon.policy'; +import { StylistPolicy } from './policies/stylist.policy'; @Module({ imports: [TypeOrmModule.forFeature([Permission, UserPermission])], - providers: [AuthorizationService, SalonPolicy, CustomerPolicy], - exports: [AuthorizationService, SalonPolicy, CustomerPolicy], + providers: [ + AuthorizationService, + SalonPolicy, + CustomerPolicy, + StylistPolicy, + ], + exports: [ + AuthorizationService, + SalonPolicy, + CustomerPolicy, + StylistPolicy, + ], }) export class AuthorizationModule {} diff --git a/src/authorization/enums/permission-code.enum.ts b/src/authorization/enums/permission-code.enum.ts index 23a226e..28e4304 100644 --- a/src/authorization/enums/permission-code.enum.ts +++ b/src/authorization/enums/permission-code.enum.ts @@ -7,4 +7,7 @@ export enum PermissionCode { SALONS_DELETE = 'salons:delete', CUSTOMERS_READ = 'customers:read', CUSTOMERS_WRITE = 'customers:write', + STYLISTS_READ = 'stylists:read', + STYLISTS_WRITE = 'stylists:write', + STYLISTS_DELETE = 'stylists:delete', } diff --git a/src/authorization/policies/stylist.policy.ts b/src/authorization/policies/stylist.policy.ts new file mode 100644 index 0000000..af63a11 --- /dev/null +++ b/src/authorization/policies/stylist.policy.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { AuthUser } from '../../auth/interfaces/auth-user.interface'; +import { Salon } from '../../salons/entities/salon.entity'; +import { Stylist } from '../../stylists/entities/stylist.entity'; +import { UserRole } from '../../users/enums/user-role.enum'; +import { AuthorizationService } from '../authorization.service'; + +@Injectable() +export class StylistPolicy { + constructor(private readonly authorizationService: AuthorizationService) {} + + canManageSalon(user: AuthUser, salon: Salon): boolean { + if (this.authorizationService.isAdmin(user)) { + return true; + } + return user.role === UserRole.SALON && salon.ownerId === user.id; + } + + canUpdate(user: AuthUser, stylist: Stylist): boolean { + if (this.authorizationService.isAdmin(user)) { + return true; + } + return ( + user.role === UserRole.SALON && stylist.salon.ownerId === user.id + ); + } + + canDelete(user: AuthUser, stylist: Stylist): boolean { + return this.canUpdate(user, stylist); + } +} diff --git a/src/database/migrations/1719700000000-AddStylists.ts b/src/database/migrations/1719700000000-AddStylists.ts new file mode 100644 index 0000000..8f691ca --- /dev/null +++ b/src/database/migrations/1719700000000-AddStylists.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +export class AddStylists1719700000000 implements MigrationInterface { + name = 'AddStylists1719700000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'stylists', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'salonId', type: 'uuid' }, + { name: 'userId', type: 'uuid', isUnique: true }, + { name: 'avatarUrl', type: 'varchar', length: '500', isNullable: true }, + { name: 'experienceYears', type: 'int', default: 0 }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'stylists', + new TableForeignKey({ + columnNames: ['salonId'], + referencedTableName: 'salons', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'stylists', + new TableForeignKey({ + columnNames: ['userId'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('stylists', true); + } +} diff --git a/src/stylists/dto/create-stylist.dto.ts b/src/stylists/dto/create-stylist.dto.ts new file mode 100644 index 0000000..964e423 --- /dev/null +++ b/src/stylists/dto/create-stylist.dto.ts @@ -0,0 +1,28 @@ +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString, + IsUrl, + IsUUID, + Min, +} from 'class-validator'; + +export class CreateStylistDto { + @IsUUID() + @IsNotEmpty() + salonId: string; + + @IsUUID() + @IsNotEmpty() + userId: string; + + @IsOptional() + @IsString() + @IsUrl() + avatarUrl?: string; + + @IsInt() + @Min(0) + experienceYears: number; +} diff --git a/src/stylists/dto/update-stylist.dto.ts b/src/stylists/dto/update-stylist.dto.ts new file mode 100644 index 0000000..c701fac --- /dev/null +++ b/src/stylists/dto/update-stylist.dto.ts @@ -0,0 +1,13 @@ +import { IsInt, IsOptional, IsString, IsUrl, Min } from 'class-validator'; + +export class UpdateStylistDto { + @IsOptional() + @IsString() + @IsUrl() + avatarUrl?: string; + + @IsOptional() + @IsInt() + @Min(0) + experienceYears?: number; +} diff --git a/src/stylists/entities/stylist.entity.ts b/src/stylists/entities/stylist.entity.ts new file mode 100644 index 0000000..f63010c --- /dev/null +++ b/src/stylists/entities/stylist.entity.ts @@ -0,0 +1,43 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Salon } from '../../salons/entities/salon.entity'; +import { User } from '../../users/entities/user.entity'; + +@Entity('stylists') +export class Stylist { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + salonId: string; + + @ManyToOne(() => Salon, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'salonId' }) + salon: Salon; + + @Column({ unique: true }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column({ type: 'varchar', length: 500, nullable: true }) + avatarUrl: string | null; + + @Column({ type: 'int', default: 0 }) + experienceYears: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/stylists/stylists.controller.ts b/src/stylists/stylists.controller.ts new file mode 100644 index 0000000..f77e201 --- /dev/null +++ b/src/stylists/stylists.controller.ts @@ -0,0 +1,67 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Public } from '../auth/decorators/public.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { UserRole } from '../users/enums/user-role.enum'; +import { CreateStylistDto } from './dto/create-stylist.dto'; +import { UpdateStylistDto } from './dto/update-stylist.dto'; +import { StylistsService } from './stylists.service'; + +@Controller('stylists') +@UseGuards(RolesGuard) +export class StylistsController { + constructor(private readonly stylistsService: StylistsService) {} + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Post() + create( + @Body() createStylistDto: CreateStylistDto, + @CurrentUser() user: AuthUser, + ) { + return this.stylistsService.create(createStylistDto, user); + } + + @Public() + @Get() + findAll(@Query('salonId', new ParseUUIDPipe({ optional: true })) salonId?: string) { + return this.stylistsService.findAll(salonId); + } + + @Public() + @Get(':id') + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.stylistsService.findOne(id); + } + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Patch(':id') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateStylistDto: UpdateStylistDto, + @CurrentUser() user: AuthUser, + ) { + return this.stylistsService.update(id, updateStylistDto, user); + } + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Delete(':id') + remove( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ) { + return this.stylistsService.remove(id, user); + } +} diff --git a/src/stylists/stylists.module.ts b/src/stylists/stylists.module.ts new file mode 100644 index 0000000..e0834da --- /dev/null +++ b/src/stylists/stylists.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthGuardsModule } from '../auth/auth-guards.module'; +import { AuthorizationModule } from '../authorization/authorization.module'; +import { SalonsModule } from '../salons/salons.module'; +import { UsersModule } from '../users/users.module'; +import { Stylist } from './entities/stylist.entity'; +import { StylistsController } from './stylists.controller'; +import { StylistsService } from './stylists.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Stylist]), + SalonsModule, + UsersModule, + AuthorizationModule, + AuthGuardsModule, + ], + controllers: [StylistsController], + providers: [StylistsService], + exports: [StylistsService], +}) +export class StylistsModule {} diff --git a/src/stylists/stylists.service.ts b/src/stylists/stylists.service.ts new file mode 100644 index 0000000..6a513a7 --- /dev/null +++ b/src/stylists/stylists.service.ts @@ -0,0 +1,87 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +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 { SalonsService } from '../salons/salons.service'; +import { UsersService } from '../users/users.service'; +import { CreateStylistDto } from './dto/create-stylist.dto'; +import { UpdateStylistDto } from './dto/update-stylist.dto'; +import { Stylist } from './entities/stylist.entity'; + +@Injectable() +export class StylistsService { + constructor( + @InjectRepository(Stylist) + private readonly stylistsRepository: Repository, + private readonly salonsService: SalonsService, + private readonly usersService: UsersService, + private readonly stylistPolicy: StylistPolicy, + ) {} + + async create( + createStylistDto: CreateStylistDto, + user: AuthUser, + ): Promise { + const salon = await this.salonsService.findOne(createStylistDto.salonId); + if (!this.stylistPolicy.canManageSalon(user, salon)) { + throw new ForbiddenException(); + } + + await this.usersService.findOne(createStylistDto.userId); + + const stylist = this.stylistsRepository.create(createStylistDto); + const saved = await this.stylistsRepository.save(stylist); + return this.findOne(saved.id); + } + + findAll(salonId?: string): Promise { + const where: FindOptionsWhere = {}; + if (salonId) { + where.salonId = salonId; + } + + return this.stylistsRepository.find({ + where, + relations: { salon: true, user: true }, + }); + } + + async findOne(id: string): Promise { + const stylist = await this.stylistsRepository.findOne({ + where: { id }, + relations: { salon: true, user: true }, + }); + if (!stylist) { + throw new NotFoundException(`Stylist #${id} not found`); + } + return stylist; + } + + async update( + id: string, + updateStylistDto: UpdateStylistDto, + user: AuthUser, + ): Promise { + const stylist = await this.findOne(id); + if (!this.stylistPolicy.canUpdate(user, stylist)) { + throw new ForbiddenException(); + } + + Object.assign(stylist, updateStylistDto); + await this.stylistsRepository.save(stylist); + return this.findOne(stylist.id); + } + + async remove(id: string, user: AuthUser): Promise { + const stylist = await this.findOne(id); + if (!this.stylistPolicy.canDelete(user, stylist)) { + throw new ForbiddenException(); + } + await this.stylistsRepository.remove(stylist); + } +}