sms templated module

This commit is contained in:
2026-06-30 11:07:23 +03:30
parent d5448ba07a
commit 3420de0011
10 changed files with 329 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 { 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: [],
@@ -0,0 +1,45 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableIndex,
} from 'typeorm';
export class AddSmsTemplates1720100000000 implements MigrationInterface {
name = 'AddSmsTemplates1720100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.dropTable('sms_templates', true);
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
}
+19
View File
@@ -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 {}
@@ -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<SmsTemplate>,
private readonly servicePolicy: ServicePolicy,
) {}
async create(
createDto: CreateSmsTemplateDto,
user: AuthUser,
): Promise<SmsTemplate> {
this.ensureCanManage(user);
const template = this.templatesRepository.create(createDto);
return this.templatesRepository.save(template);
}
findAll(query: FindSmsTemplatesQueryDto): Promise<SmsTemplate[]> {
return this.templatesRepository.find({
where: this.buildWhere(query),
order: { title: 'ASC' },
});
}
async findOne(id: string): Promise<SmsTemplate> {
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<SmsTemplate> {
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<void> {
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, string>,
): string {
return renderSmsTemplate(template.body, variables);
}
private buildWhere(
query: FindSmsTemplatesQueryDto,
): FindOptionsWhere<SmsTemplate> {
const where: FindOptionsWhere<SmsTemplate> = {};
if (query.isActive !== undefined) {
where.isActive = query.isActive;
}
return where;
}
private ensureCanManage(user: AuthUser): void {
if (!this.servicePolicy.canManage(user)) {
throw new ForbiddenException();
}
}
}
@@ -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<string>();
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, string>,
): string {
return body.replace(PLACEHOLDER_PATTERN, (_, key: string) =>
variables[key] ?? `{${key}}`,
);
}