From d5448ba07a8ae95986453a22cf0615808f0dad3f Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Tue, 30 Jun 2026 10:55:51 +0330 Subject: [PATCH] suggestion module --- src/app.module.ts | 2 + .../1720000000000-AddServiceSuggestions.ts | 92 ++++++++++++ .../dto/create-service-suggestion.dto.ts | 41 ++++++ .../dto/find-suggestions-query.dto.ts | 13 ++ .../dto/update-service-suggestion.dto.ts | 44 ++++++ .../entities/service-suggestion.entity.ts | 60 ++++++++ src/suggestions/enums/suggestion-type.enum.ts | 4 + src/suggestions/suggestions.controller.ts | 74 ++++++++++ src/suggestions/suggestions.module.ts | 20 +++ src/suggestions/suggestions.service.ts | 136 ++++++++++++++++++ 10 files changed, 486 insertions(+) create mode 100644 src/database/migrations/1720000000000-AddServiceSuggestions.ts create mode 100644 src/suggestions/dto/create-service-suggestion.dto.ts create mode 100644 src/suggestions/dto/find-suggestions-query.dto.ts create mode 100644 src/suggestions/dto/update-service-suggestion.dto.ts create mode 100644 src/suggestions/entities/service-suggestion.entity.ts create mode 100644 src/suggestions/enums/suggestion-type.enum.ts create mode 100644 src/suggestions/suggestions.controller.ts create mode 100644 src/suggestions/suggestions.module.ts create mode 100644 src/suggestions/suggestions.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 4e08f98..5b5cdde 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,7 @@ import { getTypeOrmOptions } from './database/typeorm-options'; import { ReservesModule } from './reserves/reserves.module'; import { SalonsModule } from './salons/salons.module'; import { ServicesModule } from './services/services.module'; +import { SuggestionsModule } from './suggestions/suggestions.module'; import { StylistsModule } from './stylists/stylists.module'; import { UsersModule } from './users/users.module'; @@ -25,6 +26,7 @@ import { UsersModule } from './users/users.module'; ServicesModule, CustomersModule, ReservesModule, + SuggestionsModule, ], controllers: [], providers: [], diff --git a/src/database/migrations/1720000000000-AddServiceSuggestions.ts b/src/database/migrations/1720000000000-AddServiceSuggestions.ts new file mode 100644 index 0000000..90d5c46 --- /dev/null +++ b/src/database/migrations/1720000000000-AddServiceSuggestions.ts @@ -0,0 +1,92 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, + TableUnique, +} from 'typeorm'; + +export class AddServiceSuggestions1720000000000 implements MigrationInterface { + name = 'AddServiceSuggestions1720000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "service_suggestions_type_enum" AS ENUM( + 'complementary', + 'renewal' + ) + `); + + await queryRunner.createTable( + new Table({ + name: 'service_suggestions', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'serviceId', type: 'uuid' }, + { name: 'suggestedServiceId', type: 'uuid' }, + { name: 'isActive', type: 'boolean', default: true }, + { name: 'badgeTitle', type: 'varchar', length: '100' }, + { name: 'displayPriority', type: 'int', default: 0 }, + { + name: 'shortDescription', + type: 'varchar', + length: '500', + isNullable: true, + }, + { name: 'type', type: 'service_suggestions_type_enum' }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'service_suggestions', + new TableForeignKey({ + columnNames: ['serviceId'], + referencedTableName: 'services', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'service_suggestions', + new TableForeignKey({ + columnNames: ['suggestedServiceId'], + referencedTableName: 'services', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createUniqueConstraint( + 'service_suggestions', + new TableUnique({ + name: 'UQ_service_suggestions_serviceId_suggestedServiceId', + columnNames: ['serviceId', 'suggestedServiceId'], + }), + ); + + await queryRunner.createIndex( + 'service_suggestions', + new TableIndex({ + name: 'IDX_service_suggestions_serviceId_isActive_displayPriority', + columnNames: ['serviceId', 'isActive', 'displayPriority'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('service_suggestions', true); + await queryRunner.query(`DROP TYPE "service_suggestions_type_enum"`); + } +} diff --git a/src/suggestions/dto/create-service-suggestion.dto.ts b/src/suggestions/dto/create-service-suggestion.dto.ts new file mode 100644 index 0000000..e0f37b4 --- /dev/null +++ b/src/suggestions/dto/create-service-suggestion.dto.ts @@ -0,0 +1,41 @@ +import { + IsBoolean, + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, +} from 'class-validator'; +import { SuggestionType } from '../enums/suggestion-type.enum'; + +export class CreateServiceSuggestionDto { + @IsUUID() + serviceId: string; + + @IsUUID() + suggestedServiceId: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + badgeTitle: string; + + @IsInt() + @Min(0) + displayPriority: number; + + @IsOptional() + @IsString() + @MaxLength(500) + shortDescription?: string; + + @IsEnum(SuggestionType) + type: SuggestionType; +} diff --git a/src/suggestions/dto/find-suggestions-query.dto.ts b/src/suggestions/dto/find-suggestions-query.dto.ts new file mode 100644 index 0000000..2b4e3b4 --- /dev/null +++ b/src/suggestions/dto/find-suggestions-query.dto.ts @@ -0,0 +1,13 @@ +import { Transform } from 'class-transformer'; +import { IsBoolean, IsOptional, IsUUID } from 'class-validator'; + +export class FindSuggestionsQueryDto { + @IsOptional() + @IsUUID() + serviceId?: string; + + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + isActive?: boolean; +} diff --git a/src/suggestions/dto/update-service-suggestion.dto.ts b/src/suggestions/dto/update-service-suggestion.dto.ts new file mode 100644 index 0000000..1a397be --- /dev/null +++ b/src/suggestions/dto/update-service-suggestion.dto.ts @@ -0,0 +1,44 @@ +import { + IsBoolean, + IsEnum, + IsInt, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, +} from 'class-validator'; +import { SuggestionType } from '../enums/suggestion-type.enum'; + +export class UpdateServiceSuggestionDto { + @IsOptional() + @IsUUID() + serviceId?: string; + + @IsOptional() + @IsUUID() + suggestedServiceId?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsString() + @MaxLength(100) + badgeTitle?: string; + + @IsOptional() + @IsInt() + @Min(0) + displayPriority?: number; + + @IsOptional() + @IsString() + @MaxLength(500) + shortDescription?: string; + + @IsOptional() + @IsEnum(SuggestionType) + type?: SuggestionType; +} diff --git a/src/suggestions/entities/service-suggestion.entity.ts b/src/suggestions/entities/service-suggestion.entity.ts new file mode 100644 index 0000000..4aaf886 --- /dev/null +++ b/src/suggestions/entities/service-suggestion.entity.ts @@ -0,0 +1,60 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; +import { Service } from '../../services/entities/service.entity'; +import { SuggestionType } from '../enums/suggestion-type.enum'; + +@Entity('service_suggestions') +@Unique(['serviceId', 'suggestedServiceId']) +@Index(['serviceId', 'isActive', 'displayPriority']) +export class ServiceSuggestion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + serviceId: string; + + @ManyToOne(() => Service, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'serviceId' }) + service: Service; + + @Column() + suggestedServiceId: string; + + @ManyToOne(() => Service, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'suggestedServiceId' }) + suggestedService: Service; + + @Column({ default: true }) + isActive: boolean; + + @Column({ length: 100 }) + badgeTitle: string; + + @Column({ type: 'int', default: 0 }) + displayPriority: number; + + @Column({ type: 'varchar', length: 500, nullable: true }) + shortDescription: string | null; + + @Column({ + type: 'enum', + enum: SuggestionType, + enumName: 'service_suggestions_type_enum', + }) + type: SuggestionType; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/suggestions/enums/suggestion-type.enum.ts b/src/suggestions/enums/suggestion-type.enum.ts new file mode 100644 index 0000000..1b546f9 --- /dev/null +++ b/src/suggestions/enums/suggestion-type.enum.ts @@ -0,0 +1,4 @@ +export enum SuggestionType { + COMPLEMENTARY = 'complementary', + RENEWAL = 'renewal', +} diff --git a/src/suggestions/suggestions.controller.ts b/src/suggestions/suggestions.controller.ts new file mode 100644 index 0000000..5fce57d --- /dev/null +++ b/src/suggestions/suggestions.controller.ts @@ -0,0 +1,74 @@ +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 { CreateServiceSuggestionDto } from './dto/create-service-suggestion.dto'; +import { FindSuggestionsQueryDto } from './dto/find-suggestions-query.dto'; +import { UpdateServiceSuggestionDto } from './dto/update-service-suggestion.dto'; +import { SuggestionsService } from './suggestions.service'; + +@Controller('suggestions') +@UseGuards(RolesGuard) +export class SuggestionsController { + constructor(private readonly suggestionsService: SuggestionsService) {} + + @Roles(UserRole.ADMIN) + @Post() + create( + @Body() createDto: CreateServiceSuggestionDto, + @CurrentUser() user: AuthUser, + ) { + return this.suggestionsService.create(createDto, user); + } + + @Roles(UserRole.ADMIN) + @Get() + findAll(@Query() query: FindSuggestionsQueryDto) { + return this.suggestionsService.findAll(query); + } + + @Public() + @Get('for-service/:serviceId') + findForService(@Param('serviceId', ParseUUIDPipe) serviceId: string) { + return this.suggestionsService.findForService(serviceId); + } + + @Roles(UserRole.ADMIN) + @Get(':id') + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.suggestionsService.findOne(id); + } + + @Roles(UserRole.ADMIN) + @Patch(':id') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateDto: UpdateServiceSuggestionDto, + @CurrentUser() user: AuthUser, + ) { + return this.suggestionsService.update(id, updateDto, user); + } + + @Roles(UserRole.ADMIN) + @Delete(':id') + remove( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ) { + return this.suggestionsService.remove(id, user); + } +} diff --git a/src/suggestions/suggestions.module.ts b/src/suggestions/suggestions.module.ts new file mode 100644 index 0000000..00b68d5 --- /dev/null +++ b/src/suggestions/suggestions.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 { Service } from '../services/entities/service.entity'; +import { ServiceSuggestion } from './entities/service-suggestion.entity'; +import { SuggestionsController } from './suggestions.controller'; +import { SuggestionsService } from './suggestions.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ServiceSuggestion, Service]), + AuthorizationModule, + AuthGuardsModule, + ], + controllers: [SuggestionsController], + providers: [SuggestionsService], + exports: [SuggestionsService], +}) +export class SuggestionsModule {} diff --git a/src/suggestions/suggestions.service.ts b/src/suggestions/suggestions.service.ts new file mode 100644 index 0000000..ad4670f --- /dev/null +++ b/src/suggestions/suggestions.service.ts @@ -0,0 +1,136 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { FindOptionsWhere, Repository } from 'typeorm'; +import { ServicePolicy } from '../authorization/policies/service.policy'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { Service } from '../services/entities/service.entity'; +import { CreateServiceSuggestionDto } from './dto/create-service-suggestion.dto'; +import { FindSuggestionsQueryDto } from './dto/find-suggestions-query.dto'; +import { UpdateServiceSuggestionDto } from './dto/update-service-suggestion.dto'; +import { ServiceSuggestion } from './entities/service-suggestion.entity'; + +@Injectable() +export class SuggestionsService { + constructor( + @InjectRepository(ServiceSuggestion) + private readonly suggestionsRepository: Repository, + @InjectRepository(Service) + private readonly servicesRepository: Repository, + private readonly servicePolicy: ServicePolicy, + ) {} + + async create( + createDto: CreateServiceSuggestionDto, + user: AuthUser, + ): Promise { + this.ensureCanManage(user); + await this.validateServices( + createDto.serviceId, + createDto.suggestedServiceId, + ); + const suggestion = this.suggestionsRepository.create(createDto); + const saved = await this.suggestionsRepository.save(suggestion); + return this.findOne(saved.id); + } + + findAll(query: FindSuggestionsQueryDto): Promise { + return this.suggestionsRepository.find({ + where: this.buildWhere(query), + relations: { service: true, suggestedService: true }, + order: { displayPriority: 'ASC', createdAt: 'ASC' }, + }); + } + + findForService(serviceId: string): Promise { + return this.suggestionsRepository.find({ + where: { serviceId, isActive: true }, + relations: { suggestedService: true }, + order: { displayPriority: 'ASC', createdAt: 'ASC' }, + }); + } + + async findOne(id: string): Promise { + const suggestion = await this.suggestionsRepository.findOne({ + where: { id }, + relations: { service: true, suggestedService: true }, + }); + if (!suggestion) { + throw new NotFoundException(`Suggestion #${id} not found`); + } + return suggestion; + } + + async update( + id: string, + updateDto: UpdateServiceSuggestionDto, + user: AuthUser, + ): Promise { + this.ensureCanManage(user); + const suggestion = await this.findOne(id); + const serviceId = updateDto.serviceId ?? suggestion.serviceId; + const suggestedServiceId = + updateDto.suggestedServiceId ?? suggestion.suggestedServiceId; + + await this.validateServices(serviceId, suggestedServiceId); + Object.assign(suggestion, updateDto); + await this.suggestionsRepository.save(suggestion); + return this.findOne(id); + } + + async remove(id: string, user: AuthUser): Promise { + this.ensureCanManage(user); + const suggestion = await this.findOne(id); + await this.suggestionsRepository.remove(suggestion); + } + + private buildWhere( + query: FindSuggestionsQueryDto, + ): FindOptionsWhere { + const where: FindOptionsWhere = {}; + + if (query.serviceId) { + where.serviceId = query.serviceId; + } + if (query.isActive !== undefined) { + where.isActive = query.isActive; + } + + return where; + } + + private ensureCanManage(user: AuthUser): void { + if (!this.servicePolicy.canManage(user)) { + throw new ForbiddenException(); + } + } + + private async validateServices( + serviceId: string, + suggestedServiceId: string, + ): Promise { + if (serviceId === suggestedServiceId) { + throw new BadRequestException( + 'A service cannot be suggested for itself', + ); + } + + const [serviceExists, suggestedExists] = await Promise.all([ + this.servicesRepository.existsBy({ id: serviceId }), + this.servicesRepository.existsBy({ id: suggestedServiceId }), + ]); + + if (!serviceExists) { + throw new NotFoundException(`Service #${serviceId} not found`); + } + if (!suggestedExists) { + throw new NotFoundException( + `Suggested service #${suggestedServiceId} not found`, + ); + } + } +}