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
+2
View File
@@ -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: [],
@@ -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<void> {
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<void> {
await queryRunner.dropTable('service_suggestions', true);
await queryRunner.query(`DROP TYPE "service_suggestions_type_enum"`);
}
}
@@ -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`,
);
}
}
}