suggestion module

This commit is contained in:
2026-06-30 10:55:51 +03:30
parent d8086cab17
commit d5448ba07a
10 changed files with 486 additions and 0 deletions
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,4 @@
export enum SuggestionType {
COMPLEMENTARY = 'complementary',
RENEWAL = 'renewal',
}
+74
View File
@@ -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);
}
}
+20
View File
@@ -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 {}
+136
View File
@@ -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<ServiceSuggestion>,
@InjectRepository(Service)
private readonly servicesRepository: Repository<Service>,
private readonly servicePolicy: ServicePolicy,
) {}
async create(
createDto: CreateServiceSuggestionDto,
user: AuthUser,
): Promise<ServiceSuggestion> {
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<ServiceSuggestion[]> {
return this.suggestionsRepository.find({
where: this.buildWhere(query),
relations: { service: true, suggestedService: true },
order: { displayPriority: 'ASC', createdAt: 'ASC' },
});
}
findForService(serviceId: string): Promise<ServiceSuggestion[]> {
return this.suggestionsRepository.find({
where: { serviceId, isActive: true },
relations: { suggestedService: true },
order: { displayPriority: 'ASC', createdAt: 'ASC' },
});
}
async findOne(id: string): Promise<ServiceSuggestion> {
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<ServiceSuggestion> {
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<void> {
this.ensureCanManage(user);
const suggestion = await this.findOne(id);
await this.suggestionsRepository.remove(suggestion);
}
private buildWhere(
query: FindSuggestionsQueryDto,
): FindOptionsWhere<ServiceSuggestion> {
const where: FindOptionsWhere<ServiceSuggestion> = {};
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<void> {
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`,
);
}
}
}