diff --git a/apps/api/prisma/migrations/20260914100000_message_automation_description/migration.sql b/apps/api/prisma/migrations/20260914100000_message_automation_description/migration.sql new file mode 100644 index 0000000..8c39835 --- /dev/null +++ b/apps/api/prisma/migrations/20260914100000_message_automation_description/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "MessageAutomationRule" ADD COLUMN IF NOT EXISTS "description" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 49f18c4..4b6ca5c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -1142,6 +1142,7 @@ model MessageAutomationRule { notifyTarget MessageAutomationTarget @default(person) channel MessageAutomationChannel @default(sms) title String + description String? body String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/apps/api/src/message-automations/__tests__/message-automation.check.ts b/apps/api/src/message-automations/__tests__/message-automation.check.ts index abefba6..394feda 100644 --- a/apps/api/src/message-automations/__tests__/message-automation.check.ts +++ b/apps/api/src/message-automations/__tests__/message-automation.check.ts @@ -46,9 +46,16 @@ function testCancelMap() { function testTemplate() { assert( - applyMessageTemplate('سلام {title}', 'فاکتور ۱۲') === 'سلام فاکتور ۱۲', + applyMessageTemplate('سلام {title}', { title: 'فاکتور ۱۲' }) === 'سلام فاکتور ۱۲', 'title var', ); + assert( + applyMessageTemplate('سلام {نام} {شماره}', { + name: 'علی', + phone: '0912', + }) === 'سلام علی 0912', + 'name phone vars', + ); assert(clipSms('الف'.repeat(200)).length === 160, 'sms clip'); } diff --git a/apps/api/src/message-automations/dto.ts b/apps/api/src/message-automations/dto.ts index 4ab96a7..869501c 100644 --- a/apps/api/src/message-automations/dto.ts +++ b/apps/api/src/message-automations/dto.ts @@ -72,6 +72,11 @@ export class CreateMessageAutomationRuleDto { @IsIn(CHANNELS) channel?: MessageAutomationChannel; + @IsOptional() + @IsString() + @MaxLength(500) + description?: string | null; + @IsString() @MinLength(2) @MaxLength(120) @@ -135,6 +140,11 @@ export class UpdateMessageAutomationRuleDto { @IsIn(CHANNELS) channel?: MessageAutomationChannel; + @IsOptional() + @IsString() + @MaxLength(500) + description?: string | null; + @IsOptional() @IsString() @MinLength(2) diff --git a/apps/api/src/message-automations/message-automation.util.ts b/apps/api/src/message-automations/message-automation.util.ts index 094d324..17f53be 100644 --- a/apps/api/src/message-automations/message-automation.util.ts +++ b/apps/api/src/message-automations/message-automation.util.ts @@ -63,8 +63,32 @@ export function buildSendAts( return times; } -export function applyMessageTemplate(text: string, title: string): string { - return text.replace(/\{title\}/g, title).replace(/\{عنوان\}/g, title); +export function applyMessageTemplate( + text: string, + vars: { + title?: string; + name?: string; + phone?: string; + date?: string; + company?: string; + }, +): string { + const map: Record = { + title: vars.title ?? '', + عنوان: vars.title ?? '', + نام: vars.name ?? '', + name: vars.name ?? '', + شماره: vars.phone ?? '', + phone: vars.phone ?? '', + تاریخ: vars.date ?? '', + date: vars.date ?? '', + شرکت: vars.company ?? '', + company: vars.company ?? '', + }; + return text.replace(/\{([^}]+)\}/g, (full, key: string) => { + const next = map[key.trim()]; + return next === undefined ? full : next; + }); } export function clipSms(text: string): string { diff --git a/apps/api/src/message-automations/message-automations.service.ts b/apps/api/src/message-automations/message-automations.service.ts index e6eee33..d63f7a7 100644 --- a/apps/api/src/message-automations/message-automations.service.ts +++ b/apps/api/src/message-automations/message-automations.service.ts @@ -14,6 +14,7 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { SmsService } from '../common/sms.service'; +import { dateToJalali, jalaliMonthName } from '../common/jalali'; import { applyMessageTemplate, buildSendAts, @@ -97,6 +98,9 @@ export class MessageAutomationsService { : {}), ...(dto.channel !== undefined ? { channel: dto.channel } : {}), ...(dto.title !== undefined ? { title: dto.title.trim() } : {}), + ...(dto.description !== undefined + ? { description: dto.description?.trim() || null } + : {}), ...(dto.body !== undefined ? { body: dto.body.trim() } : {}), }, }); @@ -328,11 +332,33 @@ export class MessageAutomationsService { notifyTarget: string; }; }) { - const title = applyMessageTemplate(job.rule.title, job.entityTitle).slice( - 0, - 200, - ); - const body = applyMessageTemplate(job.rule.body, job.entityTitle); + const person = job.personId + ? await this.prisma.person.findFirst({ + where: { + id: job.personId, + organizationId: job.organizationId, + }, + select: { + firstName: true, + lastName: true, + mobile: true, + phone: true, + company: { select: { name: true } }, + }, + }) + : null; + const personName = person + ? [person.firstName, person.lastName].filter(Boolean).join(' ').trim() + : ''; + const vars = { + title: job.entityTitle, + name: personName || job.entityTitle, + phone: person?.mobile || person?.phone || '', + date: formatJalaliDayFa(new Date()), + company: person?.company?.name ?? '', + }; + const title = applyMessageTemplate(job.rule.title, vars).slice(0, 200); + const body = applyMessageTemplate(job.rule.body, vars); const smsText = clipSms(body || title); const wantApp = job.rule.channel === 'app' || job.rule.channel === 'both'; const wantSms = job.rule.channel === 'sms' || job.rule.channel === 'both'; @@ -366,15 +392,6 @@ export class MessageAutomationsService { } if (targetPerson && wantSms) { - const person = job.personId - ? await this.prisma.person.findFirst({ - where: { - id: job.personId, - organizationId: job.organizationId, - }, - select: { mobile: true, phone: true }, - }) - : null; await this.sendSms(person?.mobile || person?.phone, smsText); } } @@ -413,6 +430,7 @@ export class MessageAutomationsService { notifyTarget: dto.notifyTarget ?? 'person', channel: dto.channel ?? 'sms', title: dto.title.trim(), + description: dto.description?.trim() || null, body: dto.body.trim(), }; } @@ -430,6 +448,7 @@ export class MessageAutomationsService { notifyTarget: string; channel: string; title: string; + description?: string | null; body: string; createdAt: Date; updatedAt: Date; @@ -447,6 +466,7 @@ export class MessageAutomationsService { notifyTarget: row.notifyTarget as MessageAutomationRuleDto['notifyTarget'], channel: row.channel as MessageAutomationRuleDto['channel'], title: row.title, + description: row.description ?? null, body: row.body, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), @@ -454,6 +474,15 @@ export class MessageAutomationsService { } } +function toFaDigits(value: number | string) { + return String(value).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]!); +} + +function formatJalaliDayFa(d: Date) { + const { jy, jm, jd } = dateToJalali(d); + return `${toFaDigits(jd)} ${jalaliMonthName(jm)} ${toFaDigits(jy)}`; +} + function entityLink(entityType: string, entityId: string) { if (entityType === 'deal') return `/deals/${entityId}`; if (entityType === 'ticket') return `/tickets/${entityId}`; diff --git a/apps/web/public/brand/mento-logo.png b/apps/web/public/brand/mento-logo.png deleted file mode 100644 index b320f89..0000000 Binary files a/apps/web/public/brand/mento-logo.png and /dev/null differ diff --git a/apps/web/public/brand/mento-logo.svg b/apps/web/public/brand/mento-logo.svg deleted file mode 100644 index b37a921..0000000 --- a/apps/web/public/brand/mento-logo.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/apps/web/public/brand/message-automation-hero.png b/apps/web/public/brand/message-automation-hero.png new file mode 100644 index 0000000..7946a24 Binary files /dev/null and b/apps/web/public/brand/message-automation-hero.png differ diff --git a/apps/web/src/components/UiIcon.vue b/apps/web/src/components/UiIcon.vue index efc4b8e..9be9dfe 100644 --- a/apps/web/src/components/UiIcon.vue +++ b/apps/web/src/components/UiIcon.vue @@ -32,6 +32,7 @@ defineProps<{ | 'chevron' | 'star' | 'filter' + | 'funnel' | 'lightbulb' | 'building' | 'user' @@ -58,7 +59,9 @@ defineProps<{ | 'crown' | 'gift' | 'medal' - | 'logout'; + | 'logout' + | 'pulse' + | 'sliders'; size?: number; }>(); @@ -221,6 +224,9 @@ defineProps<{ + + + diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 1fd8d78..0d28bd1 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -48,9 +48,10 @@ const salesActive = computed(() => const activitiesActive = computed(() => pathIn(['/activities'])); const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/')); const supportActive = computed(() => pathIn(['/tickets'])); -const automationsActive = computed(() => route.path.startsWith('/settings/automations')); -const messageAutomationsActive = computed(() => - route.path.startsWith('/settings/message-automations'), +const automationsActive = computed( + () => + route.path.startsWith('/settings/automations') || + route.path.startsWith('/settings/message-automations'), ); const toolsActive = computed(() => pathIn(['/settings/lead-forms', '/projects', '/project-cards', '/inventory', '/cheques', '/attachments']), @@ -403,28 +404,24 @@ onUnmounted(() => { گزارش‌ها - - - اتوماسیون - - - - پیام خودکار - +