diff --git a/apps/api/prisma/migrations/20261114140000_org_sms_templates/migration.sql b/apps/api/prisma/migrations/20261114140000_org_sms_templates/migration.sql new file mode 100644 index 0000000..83f4cac --- /dev/null +++ b/apps/api/prisma/migrations/20261114140000_org_sms_templates/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Organization" ADD COLUMN "smsTemplates" JSONB NOT NULL DEFAULT '[]'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 1d275fb..0750357 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -164,6 +164,8 @@ model Organization { enabledModules String[] @default([]) /// برچسب/رنگ اولویت فعالیت و روتین (کلیدهای enum ثابت؛ فقط ظاهر) activityPriorities Json @default("{}") + /// قالب‌های آمادهٔ پیامک دستی ([{ id, name, body }, …]) + smsTemplates Json @default("[]") /// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخاب‌شده)؛ null = بدون فیلتر priorArchiveEnabledAt DateTime? isActive Boolean @default(true) diff --git a/apps/api/src/outbound-sms/outbound-sms.module.ts b/apps/api/src/outbound-sms/outbound-sms.module.ts index 760598b..4836425 100644 --- a/apps/api/src/outbound-sms/outbound-sms.module.ts +++ b/apps/api/src/outbound-sms/outbound-sms.module.ts @@ -1,9 +1,12 @@ import { Global, Module } from '@nestjs/common'; import { OutboundSmsLogService } from './outbound-sms-log.service'; +import { SmsTemplatesController } from './sms-templates.controller'; +import { SmsTemplatesService } from './sms-templates.service'; @Global() @Module({ - providers: [OutboundSmsLogService], - exports: [OutboundSmsLogService], + controllers: [SmsTemplatesController], + providers: [OutboundSmsLogService, SmsTemplatesService], + exports: [OutboundSmsLogService, SmsTemplatesService], }) export class OutboundSmsModule {} diff --git a/apps/api/src/outbound-sms/sms-templates.controller.ts b/apps/api/src/outbound-sms/sms-templates.controller.ts new file mode 100644 index 0000000..aaf8ac3 --- /dev/null +++ b/apps/api/src/outbound-sms/sms-templates.controller.ts @@ -0,0 +1,48 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { Actor } from '../common/record-scope'; +import { + CreateSmsTemplateDto, + UpdateSmsTemplateDto, +} from './sms-templates.dto'; +import { SmsTemplatesService } from './sms-templates.service'; + +@Controller('sms-templates') +@UseGuards(JwtAuthGuard) +export class SmsTemplatesController { + constructor(private readonly templates: SmsTemplatesService) {} + + @Get() + list(@CurrentUser() user: Actor) { + return this.templates.list(user.organizationId); + } + + @Post() + create(@CurrentUser() user: Actor, @Body() dto: CreateSmsTemplateDto) { + return this.templates.create(user.organizationId, dto); + } + + @Patch(':id') + update( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() dto: UpdateSmsTemplateDto, + ) { + return this.templates.update(user.organizationId, id, dto); + } + + @Delete(':id') + remove(@CurrentUser() user: Actor, @Param('id') id: string) { + return this.templates.remove(user.organizationId, id); + } +} diff --git a/apps/api/src/outbound-sms/sms-templates.dto.ts b/apps/api/src/outbound-sms/sms-templates.dto.ts new file mode 100644 index 0000000..860fd2f --- /dev/null +++ b/apps/api/src/outbound-sms/sms-templates.dto.ts @@ -0,0 +1,28 @@ +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import { SMS_TEMPLATE_BODY_MAX, SMS_TEMPLATE_NAME_MAX } from '@mento/shared'; + +export class CreateSmsTemplateDto { + @IsString() + @MinLength(1) + @MaxLength(SMS_TEMPLATE_NAME_MAX) + name!: string; + + @IsString() + @MinLength(2) + @MaxLength(SMS_TEMPLATE_BODY_MAX) + body!: string; +} + +export class UpdateSmsTemplateDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(SMS_TEMPLATE_NAME_MAX) + name?: string; + + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(SMS_TEMPLATE_BODY_MAX) + body?: string; +} diff --git a/apps/api/src/outbound-sms/sms-templates.service.ts b/apps/api/src/outbound-sms/sms-templates.service.ts new file mode 100644 index 0000000..cfe363d --- /dev/null +++ b/apps/api/src/outbound-sms/sms-templates.service.ts @@ -0,0 +1,92 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import type { Prisma } from '@prisma/client'; +import { + parseSmsTemplates, + SMS_TEMPLATE_MAX, + type SmsTemplateDto, +} from '@mento/shared'; +import { PrismaService } from '../prisma/prisma.service'; +import type { + CreateSmsTemplateDto, + UpdateSmsTemplateDto, +} from './sms-templates.dto'; + +@Injectable() +export class SmsTemplatesService { + constructor(private readonly prisma: PrismaService) {} + + async list(organizationId: string): Promise { + const row = await this.prisma.organization.findUnique({ + where: { id: organizationId }, + select: { smsTemplates: true }, + }); + return parseSmsTemplates(row?.smsTemplates); + } + + async create( + organizationId: string, + dto: CreateSmsTemplateDto, + ): Promise { + const current = await this.list(organizationId); + if (current.length >= SMS_TEMPLATE_MAX) { + throw new BadRequestException( + `حداکثر ${SMS_TEMPLATE_MAX} پیامک آماده می‌توانید داشته باشید.`, + ); + } + const next: SmsTemplateDto[] = [ + ...current, + { + id: randomUUID(), + name: dto.name.trim(), + body: dto.body.trim(), + }, + ]; + return this.persist(organizationId, next); + } + + async update( + organizationId: string, + id: string, + dto: UpdateSmsTemplateDto, + ): Promise { + const current = await this.list(organizationId); + const idx = current.findIndex((item) => item.id === id); + if (idx < 0) throw new NotFoundException('پیامک آماده پیدا نشد.'); + const prev = current[idx]!; + const next = [...current]; + next[idx] = { + id: prev.id, + name: dto.name !== undefined ? dto.name.trim() : prev.name, + body: dto.body !== undefined ? dto.body.trim() : prev.body, + }; + return this.persist(organizationId, next); + } + + async remove(organizationId: string, id: string): Promise { + const current = await this.list(organizationId); + if (!current.some((item) => item.id === id)) { + throw new NotFoundException('پیامک آماده پیدا نشد.'); + } + return this.persist( + organizationId, + current.filter((item) => item.id !== id), + ); + } + + private async persist( + organizationId: string, + items: SmsTemplateDto[], + ): Promise { + const stored = parseSmsTemplates(items); + await this.prisma.organization.update({ + where: { id: organizationId }, + data: { smsTemplates: stored as unknown as Prisma.InputJsonValue }, + }); + return stored; + } +} diff --git a/apps/web/src/components/SmsComposeModal.vue b/apps/web/src/components/SmsComposeModal.vue index 6bee146..5fe6658 100644 --- a/apps/web/src/components/SmsComposeModal.vue +++ b/apps/web/src/components/SmsComposeModal.vue @@ -1,8 +1,17 @@