diff --git a/src/app.module.ts b/src/app.module.ts index 5b5cdde..dc7151f 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 { SmsTemplatesModule } from './sms-templates/sms-templates.module'; import { SuggestionsModule } from './suggestions/suggestions.module'; import { StylistsModule } from './stylists/stylists.module'; import { UsersModule } from './users/users.module'; @@ -27,6 +28,7 @@ import { UsersModule } from './users/users.module'; CustomersModule, ReservesModule, SuggestionsModule, + SmsTemplatesModule, ], controllers: [], providers: [], diff --git a/src/database/migrations/1720100000000-AddSmsTemplates.ts b/src/database/migrations/1720100000000-AddSmsTemplates.ts new file mode 100644 index 0000000..f9324aa --- /dev/null +++ b/src/database/migrations/1720100000000-AddSmsTemplates.ts @@ -0,0 +1,45 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableIndex, +} from 'typeorm'; + +export class AddSmsTemplates1720100000000 implements MigrationInterface { + name = 'AddSmsTemplates1720100000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'sms_templates', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'title', type: 'varchar', length: '200' }, + { name: 'body', type: 'text' }, + { name: 'isActive', type: 'boolean', default: true }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'sms_templates', + new TableIndex({ + name: 'IDX_sms_templates_isActive', + columnNames: ['isActive'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('sms_templates', true); + } +} diff --git a/src/sms-templates/dto/create-sms-template.dto.ts b/src/sms-templates/dto/create-sms-template.dto.ts new file mode 100644 index 0000000..9208815 --- /dev/null +++ b/src/sms-templates/dto/create-sms-template.dto.ts @@ -0,0 +1,23 @@ +import { + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; + +export class CreateSmsTemplateDto { + @IsString() + @IsNotEmpty() + @MaxLength(200) + title: string; + + @IsString() + @IsNotEmpty() + @MaxLength(1000) + body: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/sms-templates/dto/find-sms-templates-query.dto.ts b/src/sms-templates/dto/find-sms-templates-query.dto.ts new file mode 100644 index 0000000..c8844b7 --- /dev/null +++ b/src/sms-templates/dto/find-sms-templates-query.dto.ts @@ -0,0 +1,9 @@ +import { Transform } from 'class-transformer'; +import { IsBoolean, IsOptional } from 'class-validator'; + +export class FindSmsTemplatesQueryDto { + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + isActive?: boolean; +} diff --git a/src/sms-templates/dto/update-sms-template.dto.ts b/src/sms-templates/dto/update-sms-template.dto.ts new file mode 100644 index 0000000..dc8d87d --- /dev/null +++ b/src/sms-templates/dto/update-sms-template.dto.ts @@ -0,0 +1,17 @@ +import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class UpdateSmsTemplateDto { + @IsOptional() + @IsString() + @MaxLength(200) + title?: string; + + @IsOptional() + @IsString() + @MaxLength(1000) + body?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/sms-templates/entities/sms-template.entity.ts b/src/sms-templates/entities/sms-template.entity.ts new file mode 100644 index 0000000..32d8c3d --- /dev/null +++ b/src/sms-templates/entities/sms-template.entity.ts @@ -0,0 +1,30 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('sms_templates') +@Index(['isActive']) +export class SmsTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 200 }) + title: string; + + @Column({ type: 'text' }) + body: string; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/sms-templates/sms-templates.controller.ts b/src/sms-templates/sms-templates.controller.ts new file mode 100644 index 0000000..072d216 --- /dev/null +++ b/src/sms-templates/sms-templates.controller.ts @@ -0,0 +1,67 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { CurrentUser } from '../auth/decorators/current-user.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 { CreateSmsTemplateDto } from './dto/create-sms-template.dto'; +import { FindSmsTemplatesQueryDto } from './dto/find-sms-templates-query.dto'; +import { UpdateSmsTemplateDto } from './dto/update-sms-template.dto'; +import { SmsTemplatesService } from './sms-templates.service'; + +@Controller('sms-templates') +@UseGuards(RolesGuard) +export class SmsTemplatesController { + constructor(private readonly smsTemplatesService: SmsTemplatesService) {} + + @Roles(UserRole.ADMIN) + @Post() + create( + @Body() createDto: CreateSmsTemplateDto, + @CurrentUser() user: AuthUser, + ) { + return this.smsTemplatesService.create(createDto, user); + } + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Get() + findAll(@Query() query: FindSmsTemplatesQueryDto) { + return this.smsTemplatesService.findAll(query); + } + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Get(':id') + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.smsTemplatesService.findOne(id); + } + + @Roles(UserRole.ADMIN) + @Patch(':id') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateDto: UpdateSmsTemplateDto, + @CurrentUser() user: AuthUser, + ) { + return this.smsTemplatesService.update(id, updateDto, user); + } + + @Roles(UserRole.ADMIN) + @Delete(':id') + remove( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ) { + return this.smsTemplatesService.remove(id, user); + } +} diff --git a/src/sms-templates/sms-templates.module.ts b/src/sms-templates/sms-templates.module.ts new file mode 100644 index 0000000..8815e3f --- /dev/null +++ b/src/sms-templates/sms-templates.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthGuardsModule } from '../auth/auth-guards.module'; +import { AuthorizationModule } from '../authorization/authorization.module'; +import { SmsTemplate } from './entities/sms-template.entity'; +import { SmsTemplatesController } from './sms-templates.controller'; +import { SmsTemplatesService } from './sms-templates.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([SmsTemplate]), + AuthorizationModule, + AuthGuardsModule, + ], + controllers: [SmsTemplatesController], + providers: [SmsTemplatesService], + exports: [SmsTemplatesService], +}) +export class SmsTemplatesModule {} diff --git a/src/sms-templates/sms-templates.service.ts b/src/sms-templates/sms-templates.service.ts new file mode 100644 index 0000000..d64ef67 --- /dev/null +++ b/src/sms-templates/sms-templates.service.ts @@ -0,0 +1,96 @@ +import { + 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 { CreateSmsTemplateDto } from './dto/create-sms-template.dto'; +import { FindSmsTemplatesQueryDto } from './dto/find-sms-templates-query.dto'; +import { UpdateSmsTemplateDto } from './dto/update-sms-template.dto'; +import { SmsTemplate } from './entities/sms-template.entity'; +import { + extractPlaceholders, + renderSmsTemplate, +} from './utils/render-sms-template.util'; + +@Injectable() +export class SmsTemplatesService { + constructor( + @InjectRepository(SmsTemplate) + private readonly templatesRepository: Repository, + private readonly servicePolicy: ServicePolicy, + ) {} + + async create( + createDto: CreateSmsTemplateDto, + user: AuthUser, + ): Promise { + this.ensureCanManage(user); + const template = this.templatesRepository.create(createDto); + return this.templatesRepository.save(template); + } + + findAll(query: FindSmsTemplatesQueryDto): Promise { + return this.templatesRepository.find({ + where: this.buildWhere(query), + order: { title: 'ASC' }, + }); + } + + async findOne(id: string): Promise { + const template = await this.templatesRepository.findOne({ where: { id } }); + if (!template) { + throw new NotFoundException(`SMS template #${id} not found`); + } + return template; + } + + async update( + id: string, + updateDto: UpdateSmsTemplateDto, + user: AuthUser, + ): Promise { + this.ensureCanManage(user); + const template = await this.findOne(id); + Object.assign(template, updateDto); + return this.templatesRepository.save(template); + } + + async remove(id: string, user: AuthUser): Promise { + this.ensureCanManage(user); + const template = await this.findOne(id); + await this.templatesRepository.remove(template); + } + + getPlaceholders(template: SmsTemplate): string[] { + return extractPlaceholders(template.body); + } + + render( + template: SmsTemplate, + variables: Record, + ): string { + return renderSmsTemplate(template.body, variables); + } + + private buildWhere( + query: FindSmsTemplatesQueryDto, + ): FindOptionsWhere { + const where: FindOptionsWhere = {}; + + if (query.isActive !== undefined) { + where.isActive = query.isActive; + } + + return where; + } + + private ensureCanManage(user: AuthUser): void { + if (!this.servicePolicy.canManage(user)) { + throw new ForbiddenException(); + } + } +} diff --git a/src/sms-templates/utils/render-sms-template.util.ts b/src/sms-templates/utils/render-sms-template.util.ts new file mode 100644 index 0000000..8eb7300 --- /dev/null +++ b/src/sms-templates/utils/render-sms-template.util.ts @@ -0,0 +1,21 @@ +const PLACEHOLDER_PATTERN = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g; + +export function extractPlaceholders(body: string): string[] { + const placeholders = new Set(); + let match: RegExpExecArray | null; + + while ((match = PLACEHOLDER_PATTERN.exec(body)) !== null) { + placeholders.add(match[1]); + } + + return [...placeholders]; +} + +export function renderSmsTemplate( + body: string, + variables: Record, +): string { + return body.replace(PLACEHOLDER_PATTERN, (_, key: string) => + variables[key] ?? `{${key}}`, + ); +}