From 127c1eaf781f805c29102d715cf3e2e4a2a4585b Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Sun, 28 Jun 2026 22:43:08 +0330 Subject: [PATCH] Services --- src/app.module.ts | 2 + src/authorization/authorization.module.ts | 3 + .../enums/permission-code.enum.ts | 3 + src/authorization/policies/service.policy.ts | 12 ++ .../migrations/1719800000000-AddServices.ts | 64 ++++++++ src/services/dto/create-service-skill.dto.ts | 12 ++ src/services/dto/create-service.dto.ts | 20 +++ src/services/dto/update-service-skill.dto.ts | 13 ++ src/services/dto/update-service.dto.ts | 20 +++ src/services/entities/service-skill.entity.ts | 35 +++++ src/services/entities/service.entity.ts | 33 +++++ src/services/services.controller.ts | 123 +++++++++++++++ src/services/services.module.ts | 20 +++ src/services/services.service.ts | 140 ++++++++++++++++++ 14 files changed, 500 insertions(+) create mode 100644 src/authorization/policies/service.policy.ts create mode 100644 src/database/migrations/1719800000000-AddServices.ts create mode 100644 src/services/dto/create-service-skill.dto.ts create mode 100644 src/services/dto/create-service.dto.ts create mode 100644 src/services/dto/update-service-skill.dto.ts create mode 100644 src/services/dto/update-service.dto.ts create mode 100644 src/services/entities/service-skill.entity.ts create mode 100644 src/services/entities/service.entity.ts create mode 100644 src/services/services.controller.ts create mode 100644 src/services/services.module.ts create mode 100644 src/services/services.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index e1a3eb8..42a7c19 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 { ServicesModule } from './services/services.module'; import { StylistsModule } from './stylists/stylists.module'; import { UsersModule } from './users/users.module'; @@ -20,6 +21,7 @@ import { UsersModule } from './users/users.module'; UsersModule, SalonsModule, StylistsModule, + ServicesModule, CustomersModule, ], controllers: [], diff --git a/src/authorization/authorization.module.ts b/src/authorization/authorization.module.ts index ca21ccd..c0a3f9c 100644 --- a/src/authorization/authorization.module.ts +++ b/src/authorization/authorization.module.ts @@ -5,6 +5,7 @@ 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 { ServicePolicy } from './policies/service.policy'; import { StylistPolicy } from './policies/stylist.policy'; @Module({ @@ -12,12 +13,14 @@ import { StylistPolicy } from './policies/stylist.policy'; providers: [ AuthorizationService, SalonPolicy, + ServicePolicy, CustomerPolicy, StylistPolicy, ], exports: [ AuthorizationService, SalonPolicy, + ServicePolicy, CustomerPolicy, StylistPolicy, ], diff --git a/src/authorization/enums/permission-code.enum.ts b/src/authorization/enums/permission-code.enum.ts index 28e4304..648d05b 100644 --- a/src/authorization/enums/permission-code.enum.ts +++ b/src/authorization/enums/permission-code.enum.ts @@ -10,4 +10,7 @@ export enum PermissionCode { STYLISTS_READ = 'stylists:read', STYLISTS_WRITE = 'stylists:write', STYLISTS_DELETE = 'stylists:delete', + SERVICES_READ = 'services:read', + SERVICES_WRITE = 'services:write', + SERVICES_DELETE = 'services:delete', } diff --git a/src/authorization/policies/service.policy.ts b/src/authorization/policies/service.policy.ts new file mode 100644 index 0000000..5a0930c --- /dev/null +++ b/src/authorization/policies/service.policy.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { AuthUser } from '../../auth/interfaces/auth-user.interface'; +import { AuthorizationService } from '../authorization.service'; + +@Injectable() +export class ServicePolicy { + constructor(private readonly authorizationService: AuthorizationService) {} + + canManage(user: AuthUser): boolean { + return this.authorizationService.isAdmin(user); + } +} diff --git a/src/database/migrations/1719800000000-AddServices.ts b/src/database/migrations/1719800000000-AddServices.ts new file mode 100644 index 0000000..4fcabe3 --- /dev/null +++ b/src/database/migrations/1719800000000-AddServices.ts @@ -0,0 +1,64 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +export class AddServices1719800000000 implements MigrationInterface { + name = 'AddServices1719800000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'services', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'title', type: 'varchar', length: '200' }, + { name: 'iconUrl', type: 'varchar', length: '500', isNullable: true }, + { name: 'coverUrl', type: 'varchar', length: '500', isNullable: true }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + name: 'service_skills', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'serviceId', type: 'uuid' }, + { name: 'title', type: 'varchar', length: '200' }, + { name: 'renewalDays', type: 'int' }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'service_skills', + new TableForeignKey({ + columnNames: ['serviceId'], + referencedTableName: 'services', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('service_skills', true); + await queryRunner.dropTable('services', true); + } +} diff --git a/src/services/dto/create-service-skill.dto.ts b/src/services/dto/create-service-skill.dto.ts new file mode 100644 index 0000000..e53e31e --- /dev/null +++ b/src/services/dto/create-service-skill.dto.ts @@ -0,0 +1,12 @@ +import { IsInt, IsNotEmpty, IsString, MaxLength, Min } from 'class-validator'; + +export class CreateServiceSkillDto { + @IsString() + @IsNotEmpty() + @MaxLength(200) + title: string; + + @IsInt() + @Min(1) + renewalDays: number; +} diff --git a/src/services/dto/create-service.dto.ts b/src/services/dto/create-service.dto.ts new file mode 100644 index 0000000..c87c4be --- /dev/null +++ b/src/services/dto/create-service.dto.ts @@ -0,0 +1,20 @@ +import { IsNotEmpty, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator'; + +export class CreateServiceDto { + @IsString() + @IsNotEmpty() + @MaxLength(200) + title: string; + + @IsOptional() + @IsString() + @IsUrl() + @MaxLength(500) + iconUrl?: string; + + @IsOptional() + @IsString() + @IsUrl() + @MaxLength(500) + coverUrl?: string; +} diff --git a/src/services/dto/update-service-skill.dto.ts b/src/services/dto/update-service-skill.dto.ts new file mode 100644 index 0000000..5aeed8d --- /dev/null +++ b/src/services/dto/update-service-skill.dto.ts @@ -0,0 +1,13 @@ +import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +export class UpdateServiceSkillDto { + @IsOptional() + @IsString() + @MaxLength(200) + title?: string; + + @IsOptional() + @IsInt() + @Min(1) + renewalDays?: number; +} diff --git a/src/services/dto/update-service.dto.ts b/src/services/dto/update-service.dto.ts new file mode 100644 index 0000000..b78353a --- /dev/null +++ b/src/services/dto/update-service.dto.ts @@ -0,0 +1,20 @@ +import { IsOptional, IsString, IsUrl, MaxLength } from 'class-validator'; + +export class UpdateServiceDto { + @IsOptional() + @IsString() + @MaxLength(200) + title?: string; + + @IsOptional() + @IsString() + @IsUrl() + @MaxLength(500) + iconUrl?: string; + + @IsOptional() + @IsString() + @IsUrl() + @MaxLength(500) + coverUrl?: string; +} diff --git a/src/services/entities/service-skill.entity.ts b/src/services/entities/service-skill.entity.ts new file mode 100644 index 0000000..1f6b5c2 --- /dev/null +++ b/src/services/entities/service-skill.entity.ts @@ -0,0 +1,35 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Service } from './service.entity'; + +@Entity('service_skills') +export class ServiceSkill { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + serviceId: string; + + @ManyToOne(() => Service, (service) => service.skills, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'serviceId' }) + service: Service; + + @Column({ length: 200 }) + title: string; + + @Column({ type: 'int' }) + renewalDays: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/services/entities/service.entity.ts b/src/services/entities/service.entity.ts new file mode 100644 index 0000000..ffdd1e0 --- /dev/null +++ b/src/services/entities/service.entity.ts @@ -0,0 +1,33 @@ +import { + Column, + CreateDateColumn, + Entity, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ServiceSkill } from './service-skill.entity'; + +@Entity('services') +export class Service { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 200 }) + title: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + iconUrl: string | null; + + @Column({ type: 'varchar', length: 500, nullable: true }) + coverUrl: string | null; + + @OneToMany(() => ServiceSkill, (skill) => skill.service) + skills: ServiceSkill[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/services/services.controller.ts b/src/services/services.controller.ts new file mode 100644 index 0000000..cb3cedc --- /dev/null +++ b/src/services/services.controller.ts @@ -0,0 +1,123 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + 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 { CreateServiceSkillDto } from './dto/create-service-skill.dto'; +import { CreateServiceDto } from './dto/create-service.dto'; +import { UpdateServiceSkillDto } from './dto/update-service-skill.dto'; +import { UpdateServiceDto } from './dto/update-service.dto'; +import { ServicesService } from './services.service'; + +@Controller('services') +@UseGuards(RolesGuard) +export class ServicesController { + constructor(private readonly servicesService: ServicesService) {} + + @Roles(UserRole.ADMIN) + @Post() + create( + @Body() createServiceDto: CreateServiceDto, + @CurrentUser() user: AuthUser, + ) { + return this.servicesService.create(createServiceDto, user); + } + + @Public() + @Get() + findAll() { + return this.servicesService.findAll(); + } + + @Public() + @Get(':id') + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.servicesService.findOne(id); + } + + @Roles(UserRole.ADMIN) + @Patch(':id') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateServiceDto: UpdateServiceDto, + @CurrentUser() user: AuthUser, + ) { + return this.servicesService.update(id, updateServiceDto, user); + } + + @Roles(UserRole.ADMIN) + @Delete(':id') + remove( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ) { + return this.servicesService.remove(id, user); + } + + @Roles(UserRole.ADMIN) + @Post(':serviceId/skills') + createSkill( + @Param('serviceId', ParseUUIDPipe) serviceId: string, + @Body() createServiceSkillDto: CreateServiceSkillDto, + @CurrentUser() user: AuthUser, + ) { + return this.servicesService.createSkill( + serviceId, + createServiceSkillDto, + user, + ); + } + + @Public() + @Get(':serviceId/skills') + findSkills(@Param('serviceId', ParseUUIDPipe) serviceId: string) { + return this.servicesService.findSkills(serviceId); + } + + @Public() + @Get(':serviceId/skills/:skillId') + findSkill( + @Param('serviceId', ParseUUIDPipe) serviceId: string, + @Param('skillId', ParseUUIDPipe) skillId: string, + ) { + return this.servicesService.findSkill(serviceId, skillId); + } + + @Roles(UserRole.ADMIN) + @Patch(':serviceId/skills/:skillId') + updateSkill( + @Param('serviceId', ParseUUIDPipe) serviceId: string, + @Param('skillId', ParseUUIDPipe) skillId: string, + @Body() updateServiceSkillDto: UpdateServiceSkillDto, + @CurrentUser() user: AuthUser, + ) { + return this.servicesService.updateSkill( + serviceId, + skillId, + updateServiceSkillDto, + user, + ); + } + + @Roles(UserRole.ADMIN) + @Delete(':serviceId/skills/:skillId') + removeSkill( + @Param('serviceId', ParseUUIDPipe) serviceId: string, + @Param('skillId', ParseUUIDPipe) skillId: string, + @CurrentUser() user: AuthUser, + ) { + return this.servicesService.removeSkill(serviceId, skillId, user); + } +} diff --git a/src/services/services.module.ts b/src/services/services.module.ts new file mode 100644 index 0000000..65b8595 --- /dev/null +++ b/src/services/services.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthGuardsModule } from '../auth/auth-guards.module'; +import { AuthorizationModule } from '../authorization/authorization.module'; +import { ServiceSkill } from './entities/service-skill.entity'; +import { Service } from './entities/service.entity'; +import { ServicesController } from './services.controller'; +import { ServicesService } from './services.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Service, ServiceSkill]), + AuthorizationModule, + AuthGuardsModule, + ], + controllers: [ServicesController], + providers: [ServicesService], + exports: [ServicesService], +}) +export class ServicesModule {} diff --git a/src/services/services.service.ts b/src/services/services.service.ts new file mode 100644 index 0000000..5ee26ff --- /dev/null +++ b/src/services/services.service.ts @@ -0,0 +1,140 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ServicePolicy } from '../authorization/policies/service.policy'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { CreateServiceSkillDto } from './dto/create-service-skill.dto'; +import { CreateServiceDto } from './dto/create-service.dto'; +import { UpdateServiceSkillDto } from './dto/update-service-skill.dto'; +import { UpdateServiceDto } from './dto/update-service.dto'; +import { ServiceSkill } from './entities/service-skill.entity'; +import { Service } from './entities/service.entity'; + +@Injectable() +export class ServicesService { + constructor( + @InjectRepository(Service) + private readonly servicesRepository: Repository, + @InjectRepository(ServiceSkill) + private readonly serviceSkillsRepository: Repository, + private readonly servicePolicy: ServicePolicy, + ) {} + + create(createServiceDto: CreateServiceDto, user: AuthUser): Promise { + this.ensureCanManage(user); + const service = this.servicesRepository.create(createServiceDto); + return this.servicesRepository.save(service).then((saved) => this.findOne(saved.id)); + } + + findAll(): Promise { + return this.servicesRepository.find({ + relations: { skills: true }, + order: { createdAt: 'ASC', skills: { createdAt: 'ASC' } }, + }); + } + + async findOne(id: string): Promise { + const service = await this.servicesRepository.findOne({ + where: { id }, + relations: { skills: true }, + order: { skills: { createdAt: 'ASC' } }, + }); + if (!service) { + throw new NotFoundException(`Service #${id} not found`); + } + return service; + } + + async update( + id: string, + updateServiceDto: UpdateServiceDto, + user: AuthUser, + ): Promise { + this.ensureCanManage(user); + const service = await this.findOne(id); + Object.assign(service, updateServiceDto); + await this.servicesRepository.save(service); + return this.findOne(id); + } + + async remove(id: string, user: AuthUser): Promise { + this.ensureCanManage(user); + const service = await this.findOne(id); + await this.servicesRepository.remove(service); + } + + async createSkill( + serviceId: string, + createServiceSkillDto: CreateServiceSkillDto, + user: AuthUser, + ): Promise { + this.ensureCanManage(user); + await this.findOne(serviceId); + const skill = this.serviceSkillsRepository.create({ + ...createServiceSkillDto, + serviceId, + }); + const saved = await this.serviceSkillsRepository.save(skill); + return this.findSkill(serviceId, saved.id); + } + + async findSkills(serviceId: string): Promise { + await this.ensureServiceExists(serviceId); + return this.serviceSkillsRepository.find({ + where: { serviceId }, + order: { createdAt: 'ASC' }, + }); + } + + async findSkill(serviceId: string, skillId: string): Promise { + const skill = await this.serviceSkillsRepository.findOne({ + where: { id: skillId, serviceId }, + }); + if (!skill) { + throw new NotFoundException( + `Service skill #${skillId} not found for service #${serviceId}`, + ); + } + return skill; + } + + async updateSkill( + serviceId: string, + skillId: string, + updateServiceSkillDto: UpdateServiceSkillDto, + user: AuthUser, + ): Promise { + this.ensureCanManage(user); + const skill = await this.findSkill(serviceId, skillId); + Object.assign(skill, updateServiceSkillDto); + await this.serviceSkillsRepository.save(skill); + return this.findSkill(serviceId, skillId); + } + + async removeSkill( + serviceId: string, + skillId: string, + user: AuthUser, + ): Promise { + this.ensureCanManage(user); + const skill = await this.findSkill(serviceId, skillId); + await this.serviceSkillsRepository.remove(skill); + } + + private ensureCanManage(user: AuthUser): void { + if (!this.servicePolicy.canManage(user)) { + throw new ForbiddenException(); + } + } + + private async ensureServiceExists(serviceId: string): Promise { + const exists = await this.servicesRepository.existsBy({ id: serviceId }); + if (!exists) { + throw new NotFoundException(`Service #${serviceId} not found`); + } + } +}