update suggestion

This commit is contained in:
2026-07-06 16:04:06 +03:30
parent 16f5810cae
commit e2d9b5ef99
10 changed files with 403 additions and 58 deletions
@@ -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<void> {
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<void> {
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'],
}),
);
}
}
@@ -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;
}
@@ -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[];
}
@@ -13,10 +13,10 @@ import { SuggestionType } from '../enums/suggestion-type.enum';
export class CreateServiceSuggestionDto { export class CreateServiceSuggestionDto {
@IsUUID() @IsUUID()
serviceId: string; serviceSkillId: string;
@IsUUID() @IsUUID()
suggestedServiceId: string; suggestedServiceSkillId: string;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
@@ -4,7 +4,7 @@ import { IsBoolean, IsOptional, IsUUID } from 'class-validator';
export class FindSuggestionsQueryDto { export class FindSuggestionsQueryDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
serviceId?: string; serviceSkillId?: string;
@IsOptional() @IsOptional()
@Transform(({ value }) => value === 'true' || value === true) @Transform(({ value }) => value === 'true' || value === true)
@@ -13,11 +13,11 @@ import { SuggestionType } from '../enums/suggestion-type.enum';
export class UpdateServiceSuggestionDto { export class UpdateServiceSuggestionDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
serviceId?: string; serviceSkillId?: string;
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
suggestedServiceId?: string; suggestedServiceSkillId?: string;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
@@ -9,29 +9,29 @@ import {
Unique, Unique,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { Service } from '../../services/entities/service.entity'; import { ServiceSkill } from '../../services/entities/service-skill.entity';
import { SuggestionType } from '../enums/suggestion-type.enum'; import { SuggestionType } from '../enums/suggestion-type.enum';
@Entity('service_suggestions') @Entity('service_suggestions')
@Unique(['serviceId', 'suggestedServiceId']) @Unique(['serviceSkillId', 'suggestedServiceSkillId'])
@Index(['serviceId', 'isActive', 'displayPriority']) @Index(['serviceSkillId', 'isActive', 'displayPriority'])
export class ServiceSuggestion { export class ServiceSuggestion {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Column() @Column()
serviceId: string; serviceSkillId: string;
@ManyToOne(() => Service, { onDelete: 'CASCADE' }) @ManyToOne(() => ServiceSkill, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'serviceId' }) @JoinColumn({ name: 'serviceSkillId' })
service: Service; serviceSkill: ServiceSkill;
@Column() @Column()
suggestedServiceId: string; suggestedServiceSkillId: string;
@ManyToOne(() => Service, { onDelete: 'CASCADE' }) @ManyToOne(() => ServiceSkill, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'suggestedServiceId' }) @JoinColumn({ name: 'suggestedServiceSkillId' })
suggestedService: Service; suggestedServiceSkill: ServiceSkill;
@Column({ default: true }) @Column({ default: true })
isActive: boolean; isActive: boolean;
+18 -4
View File
@@ -20,6 +20,7 @@ import { RolesGuard } from '../auth/guards/roles.guard';
import { AuthUser } from '../auth/interfaces/auth-user.interface'; 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 { CreateBulkServiceSuggestionsDto } from './dto/create-bulk-service-suggestions.dto';
import { CreateServiceSuggestionDto } from './dto/create-service-suggestion.dto'; import { CreateServiceSuggestionDto } from './dto/create-service-suggestion.dto';
import { FindSuggestionsQueryDto } from './dto/find-suggestions-query.dto'; import { FindSuggestionsQueryDto } from './dto/find-suggestions-query.dto';
import { UpdateServiceSuggestionDto } from './dto/update-service-suggestion.dto'; import { UpdateServiceSuggestionDto } from './dto/update-service-suggestion.dto';
@@ -52,6 +53,17 @@ export class SuggestionsController {
return this.suggestionsService.create(createDto, user); 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) @Roles(UserRole.ADMIN)
@Permissions(PermissionCode.SERVICES_READ) @Permissions(PermissionCode.SERVICES_READ)
@ApiStandardOkResponse(ServiceSuggestion, { isArray: true }) @ApiStandardOkResponse(ServiceSuggestion, { isArray: true })
@@ -61,11 +73,13 @@ export class SuggestionsController {
} }
@Public() @Public()
@ApiPublicEndpoint('Get suggestions for a service') @ApiPublicEndpoint('Get suggestions for a service skill')
@ApiStandardOkResponse(ServiceSuggestion, { isArray: true }) @ApiStandardOkResponse(ServiceSuggestion, { isArray: true })
@Get('for-service/:serviceId') @Get('for-service-skill/:serviceSkillId')
findForService(@Param('serviceId', ParseUUIDPipe) serviceId: string) { findForServiceSkill(
return this.suggestionsService.findForService(serviceId); @Param('serviceSkillId', ParseUUIDPipe) serviceSkillId: string,
) {
return this.suggestionsService.findForServiceSkill(serviceSkillId);
} }
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN)
+2 -2
View File
@@ -2,14 +2,14 @@ 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 { Service } from '../services/entities/service.entity'; import { ServiceSkill } from '../services/entities/service-skill.entity';
import { ServiceSuggestion } from './entities/service-suggestion.entity'; import { ServiceSuggestion } from './entities/service-suggestion.entity';
import { SuggestionsController } from './suggestions.controller'; import { SuggestionsController } from './suggestions.controller';
import { SuggestionsService } from './suggestions.service'; import { SuggestionsService } from './suggestions.service';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([ServiceSuggestion, Service]), TypeOrmModule.forFeature([ServiceSuggestion, ServiceSkill]),
AuthorizationModule, AuthorizationModule,
AuthGuardsModule, AuthGuardsModule,
], ],
+131 -35
View File
@@ -5,22 +5,25 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm'; import { FindOptionsWhere, In, Repository } from 'typeorm';
import { ServicePolicy } from '../authorization/policies/service.policy'; import { ServicePolicy } from '../authorization/policies/service.policy';
import { AuthUser } from '../auth/interfaces/auth-user.interface'; 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 { CreateServiceSuggestionDto } from './dto/create-service-suggestion.dto';
import { FindSuggestionsQueryDto } from './dto/find-suggestions-query.dto'; import { FindSuggestionsQueryDto } from './dto/find-suggestions-query.dto';
import { UpdateServiceSuggestionDto } from './dto/update-service-suggestion.dto'; import { UpdateServiceSuggestionDto } from './dto/update-service-suggestion.dto';
import { ServiceSuggestion } from './entities/service-suggestion.entity'; import { ServiceSuggestion } from './entities/service-suggestion.entity';
const skillRelations = { service: true } as const;
@Injectable() @Injectable()
export class SuggestionsService { export class SuggestionsService {
constructor( constructor(
@InjectRepository(ServiceSuggestion) @InjectRepository(ServiceSuggestion)
private readonly suggestionsRepository: Repository<ServiceSuggestion>, private readonly suggestionsRepository: Repository<ServiceSuggestion>,
@InjectRepository(Service) @InjectRepository(ServiceSkill)
private readonly servicesRepository: Repository<Service>, private readonly serviceSkillsRepository: Repository<ServiceSkill>,
private readonly servicePolicy: ServicePolicy, private readonly servicePolicy: ServicePolicy,
) {} ) {}
@@ -29,27 +32,73 @@ export class SuggestionsService {
user: AuthUser, user: AuthUser,
): Promise<ServiceSuggestion> { ): Promise<ServiceSuggestion> {
this.ensureCanManage(user); this.ensureCanManage(user);
await this.validateServices( await this.validateSuggestionTargets(
createDto.serviceId, createDto.serviceSkillId,
createDto.suggestedServiceId, createDto.suggestedServiceSkillId,
); );
const suggestion = this.suggestionsRepository.create(createDto); const suggestion = this.suggestionsRepository.create(createDto);
const saved = await this.suggestionsRepository.save(suggestion); const saved = await this.suggestionsRepository.save(suggestion);
return this.findOne(saved.id); return this.findOne(saved.id);
} }
findAll(query: FindSuggestionsQueryDto): Promise<ServiceSuggestion[]> { async createBulk(
createDto: CreateBulkServiceSuggestionsDto,
user: AuthUser,
): Promise<ServiceSuggestion[]> {
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({ return this.suggestionsRepository.find({
where: this.buildWhere(query), where: { id: In(savedIds) },
relations: { service: true, suggestedService: true }, relations: {
serviceSkill: skillRelations,
suggestedServiceSkill: skillRelations,
},
order: { displayPriority: 'ASC', createdAt: 'ASC' }, order: { displayPriority: 'ASC', createdAt: 'ASC' },
}); });
} }
findForService(serviceId: string): Promise<ServiceSuggestion[]> { findAll(query: FindSuggestionsQueryDto): Promise<ServiceSuggestion[]> {
return this.suggestionsRepository.find({ return this.suggestionsRepository.find({
where: { serviceId, isActive: true }, where: this.buildWhere(query),
relations: { suggestedService: true }, relations: {
serviceSkill: skillRelations,
suggestedServiceSkill: skillRelations,
},
order: { displayPriority: 'ASC', createdAt: 'ASC' },
});
}
findForServiceSkill(serviceSkillId: string): Promise<ServiceSuggestion[]> {
return this.suggestionsRepository.find({
where: { serviceSkillId, isActive: true },
relations: { suggestedServiceSkill: skillRelations },
order: { displayPriority: 'ASC', createdAt: 'ASC' }, order: { displayPriority: 'ASC', createdAt: 'ASC' },
}); });
} }
@@ -57,7 +106,10 @@ export class SuggestionsService {
async findOne(id: string): Promise<ServiceSuggestion> { async findOne(id: string): Promise<ServiceSuggestion> {
const suggestion = await this.suggestionsRepository.findOne({ const suggestion = await this.suggestionsRepository.findOne({
where: { id }, where: { id },
relations: { service: true, suggestedService: true }, relations: {
serviceSkill: skillRelations,
suggestedServiceSkill: skillRelations,
},
}); });
if (!suggestion) { if (!suggestion) {
throw new NotFoundException(`Suggestion #${id} not found`); throw new NotFoundException(`Suggestion #${id} not found`);
@@ -72,11 +124,11 @@ export class SuggestionsService {
): Promise<ServiceSuggestion> { ): Promise<ServiceSuggestion> {
this.ensureCanManage(user); this.ensureCanManage(user);
const suggestion = await this.findOne(id); const suggestion = await this.findOne(id);
const serviceId = updateDto.serviceId ?? suggestion.serviceId; const serviceSkillId = updateDto.serviceSkillId ?? suggestion.serviceSkillId;
const suggestedServiceId = const suggestedServiceSkillId =
updateDto.suggestedServiceId ?? suggestion.suggestedServiceId; updateDto.suggestedServiceSkillId ?? suggestion.suggestedServiceSkillId;
await this.validateServices(serviceId, suggestedServiceId); await this.validateSuggestionTargets(serviceSkillId, suggestedServiceSkillId);
Object.assign(suggestion, updateDto); Object.assign(suggestion, updateDto);
await this.suggestionsRepository.save(suggestion); await this.suggestionsRepository.save(suggestion);
return this.findOne(id); return this.findOne(id);
@@ -93,8 +145,8 @@ export class SuggestionsService {
): FindOptionsWhere<ServiceSuggestion> { ): FindOptionsWhere<ServiceSuggestion> {
const where: FindOptionsWhere<ServiceSuggestion> = {}; const where: FindOptionsWhere<ServiceSuggestion> = {};
if (query.serviceId) { if (query.serviceSkillId) {
where.serviceId = query.serviceId; where.serviceSkillId = query.serviceSkillId;
} }
if (query.isActive !== undefined) { if (query.isActive !== undefined) {
where.isActive = query.isActive; where.isActive = query.isActive;
@@ -109,27 +161,71 @@ export class SuggestionsService {
} }
} }
private async validateServices( private async validateSuggestionTargets(
serviceId: string, serviceSkillId: string,
suggestedServiceId: string, suggestedServiceSkillId: string,
): Promise<void> { ): Promise<void> {
if (serviceId === suggestedServiceId) { if (serviceSkillId === suggestedServiceSkillId) {
throw new BadRequestException( throw new BadRequestException('A skill cannot be suggested for itself');
'A service cannot be suggested for itself',
);
} }
const [serviceExists, suggestedExists] = await Promise.all([ await this.validateBaseServiceSkill(serviceSkillId);
this.servicesRepository.existsBy({ id: serviceId }), await this.validateSuggestedServiceSkills(serviceSkillId, [
this.servicesRepository.existsBy({ id: suggestedServiceId }), suggestedServiceSkillId,
]); ]);
if (!serviceExists) {
throw new NotFoundException(`Service #${serviceId} not found`);
} }
if (!suggestedExists) {
private async validateBaseServiceSkill(serviceSkillId: string): Promise<void> {
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<void> {
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( throw new NotFoundException(
`Suggested service #${suggestedServiceId} not found`, `Suggested service skill #${missingId} not found`,
);
}
}
private async ensureNoExistingSuggestions(
serviceSkillId: string,
suggestedServiceSkillIds: string[],
): Promise<void> {
const existing = await this.suggestionsRepository.find({
where: {
serviceSkillId,
suggestedServiceSkillId: In(suggestedServiceSkillIds),
},
select: { suggestedServiceSkillId: true },
});
if (existing.length > 0) {
throw new BadRequestException(
'Some suggestions already exist for this service skill',
); );
} }
} }