diff --git a/src/database/migrations/1720900000000-MoveSuggestionsToServiceSkill.ts b/src/database/migrations/1720900000000-MoveSuggestionsToServiceSkill.ts new file mode 100644 index 0000000..b81c289 --- /dev/null +++ b/src/database/migrations/1720900000000-MoveSuggestionsToServiceSkill.ts @@ -0,0 +1,178 @@ +import { + MigrationInterface, + QueryRunner, + TableColumn, + TableForeignKey, + TableIndex, + TableUnique, +} from 'typeorm'; + +export class MoveSuggestionsToServiceSkill1720900000000 + implements MigrationInterface +{ + name = 'MoveSuggestionsToServiceSkill1720900000000'; + + public async up(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('service_suggestions'); + + const serviceFk = table?.foreignKeys.find((fk) => + fk.columnNames.includes('serviceId'), + ); + if (serviceFk) { + await queryRunner.dropForeignKey('service_suggestions', serviceFk); + } + + const suggestedServiceFk = table?.foreignKeys.find((fk) => + fk.columnNames.includes('suggestedServiceId'), + ); + if (suggestedServiceFk) { + await queryRunner.dropForeignKey('service_suggestions', suggestedServiceFk); + } + + const uniqueConstraints = table?.uniques ?? []; + for (const unique of uniqueConstraints) { + await queryRunner.dropUniqueConstraint('service_suggestions', unique); + } + + const indexes = table?.indices ?? []; + for (const index of indexes) { + if (index.columnNames.includes('serviceId') || index.columnNames.includes('serviceSkillId')) { + await queryRunner.dropIndex('service_suggestions', index); + } + } + + if (table?.findColumnByName('serviceId')) { + await queryRunner.dropColumn('service_suggestions', 'serviceId'); + } + if (table?.findColumnByName('suggestedServiceId')) { + await queryRunner.dropColumn('service_suggestions', 'suggestedServiceId'); + } + + const refreshedTable = await queryRunner.getTable('service_suggestions'); + + if (!refreshedTable?.findColumnByName('serviceSkillId')) { + await queryRunner.addColumn( + 'service_suggestions', + new TableColumn({ + name: 'serviceSkillId', + type: 'uuid', + }), + ); + } + + if (!refreshedTable?.findColumnByName('suggestedServiceSkillId')) { + await queryRunner.addColumn( + 'service_suggestions', + new TableColumn({ + name: 'suggestedServiceSkillId', + type: 'uuid', + }), + ); + } + + await queryRunner.createForeignKey( + 'service_suggestions', + new TableForeignKey({ + columnNames: ['serviceSkillId'], + referencedTableName: 'service_skills', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'service_suggestions', + new TableForeignKey({ + columnNames: ['suggestedServiceSkillId'], + referencedTableName: 'service_skills', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createUniqueConstraint( + 'service_suggestions', + new TableUnique({ + name: 'UQ_service_suggestions_serviceSkillId_suggestedServiceSkillId', + columnNames: ['serviceSkillId', 'suggestedServiceSkillId'], + }), + ); + + await queryRunner.createIndex( + 'service_suggestions', + new TableIndex({ + name: 'IDX_service_suggestions_serviceSkillId_isActive_displayPriority', + columnNames: ['serviceSkillId', 'isActive', 'displayPriority'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('service_suggestions'); + + for (const fk of table?.foreignKeys ?? []) { + if ( + fk.columnNames.includes('serviceSkillId') || + fk.columnNames.includes('suggestedServiceSkillId') + ) { + await queryRunner.dropForeignKey('service_suggestions', fk); + } + } + + await queryRunner.dropUniqueConstraint( + 'service_suggestions', + 'UQ_service_suggestions_serviceSkillId_suggestedServiceSkillId', + ); + await queryRunner.dropIndex( + 'service_suggestions', + 'IDX_service_suggestions_serviceSkillId_isActive_displayPriority', + ); + await queryRunner.dropColumn('service_suggestions', 'serviceSkillId'); + await queryRunner.dropColumn('service_suggestions', 'suggestedServiceSkillId'); + + await queryRunner.addColumn( + 'service_suggestions', + new TableColumn({ name: 'serviceId', type: 'uuid' }), + ); + await queryRunner.addColumn( + 'service_suggestions', + new TableColumn({ name: 'suggestedServiceId', type: 'uuid' }), + ); + + 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'], + }), + ); + } +} diff --git a/src/suggestions/dto/create-bulk-service-suggestion-item.dto.ts b/src/suggestions/dto/create-bulk-service-suggestion-item.dto.ts new file mode 100644 index 0000000..bc8c4c7 --- /dev/null +++ b/src/suggestions/dto/create-bulk-service-suggestion-item.dto.ts @@ -0,0 +1,38 @@ +import { + IsBoolean, + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, +} from 'class-validator'; +import { SuggestionType } from '../enums/suggestion-type.enum'; + +export class CreateBulkServiceSuggestionItemDto { + @IsUUID() + suggestedServiceSkillId: 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/create-bulk-service-suggestions.dto.ts b/src/suggestions/dto/create-bulk-service-suggestions.dto.ts new file mode 100644 index 0000000..1ae7550 --- /dev/null +++ b/src/suggestions/dto/create-bulk-service-suggestions.dto.ts @@ -0,0 +1,19 @@ +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsUUID, + ValidateNested, +} from 'class-validator'; +import { CreateBulkServiceSuggestionItemDto } from './create-bulk-service-suggestion-item.dto'; + +export class CreateBulkServiceSuggestionsDto { + @IsUUID() + serviceSkillId: string; + + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateBulkServiceSuggestionItemDto) + suggestions: CreateBulkServiceSuggestionItemDto[]; +} diff --git a/src/suggestions/dto/create-service-suggestion.dto.ts b/src/suggestions/dto/create-service-suggestion.dto.ts index e0f37b4..9dccec5 100644 --- a/src/suggestions/dto/create-service-suggestion.dto.ts +++ b/src/suggestions/dto/create-service-suggestion.dto.ts @@ -13,10 +13,10 @@ import { SuggestionType } from '../enums/suggestion-type.enum'; export class CreateServiceSuggestionDto { @IsUUID() - serviceId: string; + serviceSkillId: string; @IsUUID() - suggestedServiceId: string; + suggestedServiceSkillId: string; @IsOptional() @IsBoolean() diff --git a/src/suggestions/dto/find-suggestions-query.dto.ts b/src/suggestions/dto/find-suggestions-query.dto.ts index 2b4e3b4..8cb8bf0 100644 --- a/src/suggestions/dto/find-suggestions-query.dto.ts +++ b/src/suggestions/dto/find-suggestions-query.dto.ts @@ -4,7 +4,7 @@ import { IsBoolean, IsOptional, IsUUID } from 'class-validator'; export class FindSuggestionsQueryDto { @IsOptional() @IsUUID() - serviceId?: string; + serviceSkillId?: string; @IsOptional() @Transform(({ value }) => value === 'true' || value === true) diff --git a/src/suggestions/dto/update-service-suggestion.dto.ts b/src/suggestions/dto/update-service-suggestion.dto.ts index 1a397be..8744338 100644 --- a/src/suggestions/dto/update-service-suggestion.dto.ts +++ b/src/suggestions/dto/update-service-suggestion.dto.ts @@ -13,11 +13,11 @@ import { SuggestionType } from '../enums/suggestion-type.enum'; export class UpdateServiceSuggestionDto { @IsOptional() @IsUUID() - serviceId?: string; + serviceSkillId?: string; @IsOptional() @IsUUID() - suggestedServiceId?: string; + suggestedServiceSkillId?: string; @IsOptional() @IsBoolean() diff --git a/src/suggestions/entities/service-suggestion.entity.ts b/src/suggestions/entities/service-suggestion.entity.ts index 4aaf886..482f6e4 100644 --- a/src/suggestions/entities/service-suggestion.entity.ts +++ b/src/suggestions/entities/service-suggestion.entity.ts @@ -9,29 +9,29 @@ import { Unique, UpdateDateColumn, } from 'typeorm'; -import { Service } from '../../services/entities/service.entity'; +import { ServiceSkill } from '../../services/entities/service-skill.entity'; import { SuggestionType } from '../enums/suggestion-type.enum'; @Entity('service_suggestions') -@Unique(['serviceId', 'suggestedServiceId']) -@Index(['serviceId', 'isActive', 'displayPriority']) +@Unique(['serviceSkillId', 'suggestedServiceSkillId']) +@Index(['serviceSkillId', 'isActive', 'displayPriority']) export class ServiceSuggestion { @PrimaryGeneratedColumn('uuid') id: string; @Column() - serviceId: string; + serviceSkillId: string; - @ManyToOne(() => Service, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'serviceId' }) - service: Service; + @ManyToOne(() => ServiceSkill, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'serviceSkillId' }) + serviceSkill: ServiceSkill; @Column() - suggestedServiceId: string; + suggestedServiceSkillId: string; - @ManyToOne(() => Service, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'suggestedServiceId' }) - suggestedService: Service; + @ManyToOne(() => ServiceSkill, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'suggestedServiceSkillId' }) + suggestedServiceSkill: ServiceSkill; @Column({ default: true }) isActive: boolean; diff --git a/src/suggestions/suggestions.controller.ts b/src/suggestions/suggestions.controller.ts index e426ba7..2338315 100644 --- a/src/suggestions/suggestions.controller.ts +++ b/src/suggestions/suggestions.controller.ts @@ -20,6 +20,7 @@ import { RolesGuard } from '../auth/guards/roles.guard'; 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 { CreateBulkServiceSuggestionsDto } from './dto/create-bulk-service-suggestions.dto'; import { CreateServiceSuggestionDto } from './dto/create-service-suggestion.dto'; import { FindSuggestionsQueryDto } from './dto/find-suggestions-query.dto'; import { UpdateServiceSuggestionDto } from './dto/update-service-suggestion.dto'; @@ -52,6 +53,17 @@ export class SuggestionsController { return this.suggestionsService.create(createDto, user); } + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.SERVICES_WRITE) + @ApiStandardOkResponse(ServiceSuggestion, { isArray: true }) + @Post('bulk') + createBulk( + @Body() createDto: CreateBulkServiceSuggestionsDto, + @CurrentUser() user: AuthUser, + ) { + return this.suggestionsService.createBulk(createDto, user); + } + @Roles(UserRole.ADMIN) @Permissions(PermissionCode.SERVICES_READ) @ApiStandardOkResponse(ServiceSuggestion, { isArray: true }) @@ -61,11 +73,13 @@ export class SuggestionsController { } @Public() - @ApiPublicEndpoint('Get suggestions for a service') + @ApiPublicEndpoint('Get suggestions for a service skill') @ApiStandardOkResponse(ServiceSuggestion, { isArray: true }) - @Get('for-service/:serviceId') - findForService(@Param('serviceId', ParseUUIDPipe) serviceId: string) { - return this.suggestionsService.findForService(serviceId); + @Get('for-service-skill/:serviceSkillId') + findForServiceSkill( + @Param('serviceSkillId', ParseUUIDPipe) serviceSkillId: string, + ) { + return this.suggestionsService.findForServiceSkill(serviceSkillId); } @Roles(UserRole.ADMIN) diff --git a/src/suggestions/suggestions.module.ts b/src/suggestions/suggestions.module.ts index 00b68d5..7fb9659 100644 --- a/src/suggestions/suggestions.module.ts +++ b/src/suggestions/suggestions.module.ts @@ -2,14 +2,14 @@ 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 { ServiceSkill } from '../services/entities/service-skill.entity'; import { ServiceSuggestion } from './entities/service-suggestion.entity'; import { SuggestionsController } from './suggestions.controller'; import { SuggestionsService } from './suggestions.service'; @Module({ imports: [ - TypeOrmModule.forFeature([ServiceSuggestion, Service]), + TypeOrmModule.forFeature([ServiceSuggestion, ServiceSkill]), AuthorizationModule, AuthGuardsModule, ], diff --git a/src/suggestions/suggestions.service.ts b/src/suggestions/suggestions.service.ts index ad4670f..6c34e32 100644 --- a/src/suggestions/suggestions.service.ts +++ b/src/suggestions/suggestions.service.ts @@ -5,22 +5,25 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { FindOptionsWhere, Repository } from 'typeorm'; +import { FindOptionsWhere, In, 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 { ServiceSkill } from '../services/entities/service-skill.entity'; +import { CreateBulkServiceSuggestionsDto } from './dto/create-bulk-service-suggestions.dto'; 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'; +const skillRelations = { service: true } as const; + @Injectable() export class SuggestionsService { constructor( @InjectRepository(ServiceSuggestion) private readonly suggestionsRepository: Repository, - @InjectRepository(Service) - private readonly servicesRepository: Repository, + @InjectRepository(ServiceSkill) + private readonly serviceSkillsRepository: Repository, private readonly servicePolicy: ServicePolicy, ) {} @@ -29,27 +32,73 @@ export class SuggestionsService { user: AuthUser, ): Promise { this.ensureCanManage(user); - await this.validateServices( - createDto.serviceId, - createDto.suggestedServiceId, + await this.validateSuggestionTargets( + createDto.serviceSkillId, + createDto.suggestedServiceSkillId, ); const suggestion = this.suggestionsRepository.create(createDto); const saved = await this.suggestionsRepository.save(suggestion); return this.findOne(saved.id); } - findAll(query: FindSuggestionsQueryDto): Promise { + async createBulk( + createDto: CreateBulkServiceSuggestionsDto, + user: AuthUser, + ): Promise { + this.ensureCanManage(user); + + const { serviceSkillId, suggestions } = createDto; + const suggestedServiceSkillIds = suggestions.map( + (item) => item.suggestedServiceSkillId, + ); + + await this.validateBaseServiceSkill(serviceSkillId); + this.ensureUniqueSuggestedSkills(suggestedServiceSkillIds); + await this.validateSuggestedServiceSkills( + serviceSkillId, + suggestedServiceSkillIds, + ); + await this.ensureNoExistingSuggestions( + serviceSkillId, + suggestedServiceSkillIds, + ); + + const savedIds = await this.suggestionsRepository.manager.transaction( + async (manager) => { + const repository = manager.getRepository(ServiceSuggestion); + const entities = suggestions.map((item) => + repository.create({ ...item, serviceSkillId }), + ); + const saved = await repository.save(entities); + return saved.map((suggestion) => suggestion.id); + }, + ); + return this.suggestionsRepository.find({ - where: this.buildWhere(query), - relations: { service: true, suggestedService: true }, + where: { id: In(savedIds) }, + relations: { + serviceSkill: skillRelations, + suggestedServiceSkill: skillRelations, + }, order: { displayPriority: 'ASC', createdAt: 'ASC' }, }); } - findForService(serviceId: string): Promise { + findAll(query: FindSuggestionsQueryDto): Promise { return this.suggestionsRepository.find({ - where: { serviceId, isActive: true }, - relations: { suggestedService: true }, + where: this.buildWhere(query), + relations: { + serviceSkill: skillRelations, + suggestedServiceSkill: skillRelations, + }, + order: { displayPriority: 'ASC', createdAt: 'ASC' }, + }); + } + + findForServiceSkill(serviceSkillId: string): Promise { + return this.suggestionsRepository.find({ + where: { serviceSkillId, isActive: true }, + relations: { suggestedServiceSkill: skillRelations }, order: { displayPriority: 'ASC', createdAt: 'ASC' }, }); } @@ -57,7 +106,10 @@ export class SuggestionsService { async findOne(id: string): Promise { const suggestion = await this.suggestionsRepository.findOne({ where: { id }, - relations: { service: true, suggestedService: true }, + relations: { + serviceSkill: skillRelations, + suggestedServiceSkill: skillRelations, + }, }); if (!suggestion) { throw new NotFoundException(`Suggestion #${id} not found`); @@ -72,11 +124,11 @@ export class SuggestionsService { ): Promise { this.ensureCanManage(user); const suggestion = await this.findOne(id); - const serviceId = updateDto.serviceId ?? suggestion.serviceId; - const suggestedServiceId = - updateDto.suggestedServiceId ?? suggestion.suggestedServiceId; + const serviceSkillId = updateDto.serviceSkillId ?? suggestion.serviceSkillId; + const suggestedServiceSkillId = + updateDto.suggestedServiceSkillId ?? suggestion.suggestedServiceSkillId; - await this.validateServices(serviceId, suggestedServiceId); + await this.validateSuggestionTargets(serviceSkillId, suggestedServiceSkillId); Object.assign(suggestion, updateDto); await this.suggestionsRepository.save(suggestion); return this.findOne(id); @@ -93,8 +145,8 @@ export class SuggestionsService { ): FindOptionsWhere { const where: FindOptionsWhere = {}; - if (query.serviceId) { - where.serviceId = query.serviceId; + if (query.serviceSkillId) { + where.serviceSkillId = query.serviceSkillId; } if (query.isActive !== undefined) { where.isActive = query.isActive; @@ -109,27 +161,71 @@ export class SuggestionsService { } } - private async validateServices( - serviceId: string, - suggestedServiceId: string, + private async validateSuggestionTargets( + serviceSkillId: string, + suggestedServiceSkillId: string, ): Promise { - if (serviceId === suggestedServiceId) { - throw new BadRequestException( - 'A service cannot be suggested for itself', + if (serviceSkillId === suggestedServiceSkillId) { + throw new BadRequestException('A skill cannot be suggested for itself'); + } + + await this.validateBaseServiceSkill(serviceSkillId); + await this.validateSuggestedServiceSkills(serviceSkillId, [ + suggestedServiceSkillId, + ]); + } + + private async validateBaseServiceSkill(serviceSkillId: string): Promise { + const exists = await this.serviceSkillsRepository.existsBy({ id: serviceSkillId }); + if (!exists) { + throw new NotFoundException(`Service skill #${serviceSkillId} not found`); + } + } + + private ensureUniqueSuggestedSkills(suggestedServiceSkillIds: string[]): void { + const uniqueIds = new Set(suggestedServiceSkillIds); + if (uniqueIds.size !== suggestedServiceSkillIds.length) { + throw new BadRequestException('Duplicate suggested skills in batch'); + } + } + + private async validateSuggestedServiceSkills( + serviceSkillId: string, + suggestedServiceSkillIds: string[], + ): Promise { + if (suggestedServiceSkillIds.includes(serviceSkillId)) { + throw new BadRequestException('A skill cannot be suggested for itself'); + } + + const suggestedSkills = await this.serviceSkillsRepository.find({ + where: { id: In(suggestedServiceSkillIds) }, + select: { id: true }, + }); + + if (suggestedSkills.length !== suggestedServiceSkillIds.length) { + const foundIds = new Set(suggestedSkills.map((skill) => skill.id)); + const missingId = suggestedServiceSkillIds.find((id) => !foundIds.has(id)); + throw new NotFoundException( + `Suggested service skill #${missingId} not found`, ); } + } - const [serviceExists, suggestedExists] = await Promise.all([ - this.servicesRepository.existsBy({ id: serviceId }), - this.servicesRepository.existsBy({ id: suggestedServiceId }), - ]); + private async ensureNoExistingSuggestions( + serviceSkillId: string, + suggestedServiceSkillIds: string[], + ): Promise { + const existing = await this.suggestionsRepository.find({ + where: { + serviceSkillId, + suggestedServiceSkillId: In(suggestedServiceSkillIds), + }, + select: { suggestedServiceSkillId: true }, + }); - if (!serviceExists) { - throw new NotFoundException(`Service #${serviceId} not found`); - } - if (!suggestedExists) { - throw new NotFoundException( - `Suggested service #${suggestedServiceId} not found`, + if (existing.length > 0) { + throw new BadRequestException( + 'Some suggestions already exist for this service skill', ); } }