From 45b0d46cfe7550ad05d0b213d952dc1003aa7275 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sun, 13 Sep 2026 19:11:47 +0330 Subject: [PATCH] Adding SMS automation --- .../migration.sql | 84 +++ apps/api/prisma/schema.prisma | 89 +++ apps/api/src/activities/activities.module.ts | 3 +- apps/api/src/activities/activities.service.ts | 22 +- apps/api/src/app.module.ts | 2 + apps/api/src/contacts/contacts.module.ts | 3 +- apps/api/src/contacts/people.service.ts | 10 +- apps/api/src/invoices/invoices.module.ts | 3 +- apps/api/src/invoices/invoices.service.ts | 18 +- apps/api/src/lead-forms/lead-forms.module.ts | 3 +- apps/api/src/lead-forms/lead-forms.service.ts | 10 +- .../__tests__/message-automation.check.ts | 58 ++ apps/api/src/message-automations/dto.ts | 149 +++++ .../message-automation.util.ts | 66 +++ .../message-automations.controller.ts | 57 ++ .../message-automations.module.ts | 14 + .../message-automations.service.ts | 464 ++++++++++++++++ apps/api/src/pipelines/deals.service.ts | 13 +- apps/api/src/pipelines/pipelines.module.ts | 8 +- apps/api/src/tickets/tickets.module.ts | 7 +- apps/api/src/tickets/tickets.service.ts | 18 +- apps/web/src/layouts/AppLayout.vue | 14 + apps/web/src/lib/nav-hubs.ts | 11 +- apps/web/src/router/index.ts | 6 + apps/web/src/views/MessageAutomationsView.vue | 507 ++++++++++++++++++ packages/shared/src/index.ts | 116 ++++ 26 files changed, 1724 insertions(+), 31 deletions(-) create mode 100644 apps/api/prisma/migrations/20260913190000_message_automations/migration.sql create mode 100644 apps/api/src/message-automations/__tests__/message-automation.check.ts create mode 100644 apps/api/src/message-automations/dto.ts create mode 100644 apps/api/src/message-automations/message-automation.util.ts create mode 100644 apps/api/src/message-automations/message-automations.controller.ts create mode 100644 apps/api/src/message-automations/message-automations.module.ts create mode 100644 apps/api/src/message-automations/message-automations.service.ts create mode 100644 apps/web/src/views/MessageAutomationsView.vue diff --git a/apps/api/prisma/migrations/20260913190000_message_automations/migration.sql b/apps/api/prisma/migrations/20260913190000_message_automations/migration.sql new file mode 100644 index 0000000..93ce67c --- /dev/null +++ b/apps/api/prisma/migrations/20260913190000_message_automations/migration.sql @@ -0,0 +1,84 @@ +CREATE TYPE "MessageAutomationTrigger" AS ENUM ( + 'person_created', + 'lead_submitted', + 'deal_created', + 'deal_stage_changed', + 'deal_won', + 'deal_lost', + 'ticket_created', + 'ticket_resolved', + 'invoice_issued', + 'invoice_paid', + 'activity_created', + 'activity_done' +); + +CREATE TYPE "MessageAutomationDelayUnit" AS ENUM ('minutes', 'hours', 'days'); +CREATE TYPE "MessageAutomationChannel" AS ENUM ('app', 'sms', 'both'); +CREATE TYPE "MessageAutomationTarget" AS ENUM ('staff', 'person', 'both'); +CREATE TYPE "MessageAutomationJobStatus" AS ENUM ('pending', 'sent', 'cancelled', 'failed'); + +CREATE TABLE "MessageAutomationRule" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "trigger" "MessageAutomationTrigger" NOT NULL, + "startDelayValue" INTEGER NOT NULL DEFAULT 0, + "startDelayUnit" "MessageAutomationDelayUnit" NOT NULL DEFAULT 'minutes', + "repeatCount" INTEGER NOT NULL DEFAULT 1, + "intervalValue" INTEGER NOT NULL DEFAULT 1, + "intervalUnit" "MessageAutomationDelayUnit" NOT NULL DEFAULT 'days', + "notifyTarget" "MessageAutomationTarget" NOT NULL DEFAULT 'person', + "channel" "MessageAutomationChannel" NOT NULL DEFAULT 'sms', + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MessageAutomationRule_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "MessageAutomationJob" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "ruleId" TEXT NOT NULL, + "trigger" "MessageAutomationTrigger" NOT NULL, + "entityType" TEXT NOT NULL, + "entityId" TEXT NOT NULL, + "entityTitle" TEXT NOT NULL, + "personId" TEXT, + "ownerId" TEXT, + "assigneeId" TEXT, + "attemptIndex" INTEGER NOT NULL, + "sendAt" TIMESTAMP(3) NOT NULL, + "status" "MessageAutomationJobStatus" NOT NULL DEFAULT 'pending', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "sentAt" TIMESTAMP(3), + + CONSTRAINT "MessageAutomationJob_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "MessageAutomationRule_organizationId_isActive_trigger_idx" + ON "MessageAutomationRule"("organizationId", "isActive", "trigger"); + +CREATE UNIQUE INDEX "MessageAutomationJob_ruleId_entityId_attemptIndex_key" + ON "MessageAutomationJob"("ruleId", "entityId", "attemptIndex"); + +CREATE INDEX "MessageAutomationJob_status_sendAt_idx" + ON "MessageAutomationJob"("status", "sendAt"); + +CREATE INDEX "MessageAutomationJob_organizationId_entityType_entityId_status_idx" + ON "MessageAutomationJob"("organizationId", "entityType", "entityId", "status"); + +ALTER TABLE "MessageAutomationRule" + ADD CONSTRAINT "MessageAutomationRule_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "MessageAutomationJob" + ADD CONSTRAINT "MessageAutomationJob_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "MessageAutomationJob" + ADD CONSTRAINT "MessageAutomationJob_ruleId_fkey" + FOREIGN KEY ("ruleId") REFERENCES "MessageAutomationRule"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 574c274..f963c31 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -176,6 +176,8 @@ model Organization { loyaltyRewards LoyaltyReward[] loyaltyRedemptions LoyaltyRedemption[] automationRules AutomationRule[] + messageAutomationRules MessageAutomationRule[] + messageAutomationJobs MessageAutomationJob[] memoryItems MemoryItem[] noteAiSuggestions NoteAiSuggestion[] accountingConnections AccountingConnection[] @@ -1083,6 +1085,93 @@ model AutomationRule { @@index([organizationId, isActive, trigger]) } +enum MessageAutomationTrigger { + person_created + lead_submitted + deal_created + deal_stage_changed + deal_won + deal_lost + ticket_created + ticket_resolved + invoice_issued + invoice_paid + activity_created + activity_done +} + +enum MessageAutomationDelayUnit { + minutes + hours + days +} + +enum MessageAutomationChannel { + app + sms + both +} + +enum MessageAutomationTarget { + staff + person + both +} + +enum MessageAutomationJobStatus { + pending + sent + cancelled + failed +} + +model MessageAutomationRule { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String + isActive Boolean @default(true) + trigger MessageAutomationTrigger + startDelayValue Int @default(0) + startDelayUnit MessageAutomationDelayUnit @default(minutes) + repeatCount Int @default(1) + intervalValue Int @default(1) + intervalUnit MessageAutomationDelayUnit @default(days) + notifyTarget MessageAutomationTarget @default(person) + channel MessageAutomationChannel @default(sms) + title String + body String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + jobs MessageAutomationJob[] + + @@index([organizationId, isActive, trigger]) +} + +model MessageAutomationJob { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + ruleId String + rule MessageAutomationRule @relation(fields: [ruleId], references: [id], onDelete: Cascade) + trigger MessageAutomationTrigger + entityType String + entityId String + entityTitle String + personId String? + ownerId String? + assigneeId String? + attemptIndex Int + sendAt DateTime + status MessageAutomationJobStatus @default(pending) + createdAt DateTime @default(now()) + sentAt DateTime? + + @@unique([ruleId, entityId, attemptIndex]) + @@index([status, sendAt]) + @@index([organizationId, entityType, entityId, status]) +} + /// چالش OTP برای ثبت‌نام / ورود پنل model AuthChallenge { id String @id @default(cuid()) diff --git a/apps/api/src/activities/activities.module.ts b/apps/api/src/activities/activities.module.ts index ac8d99b..53de307 100644 --- a/apps/api/src/activities/activities.module.ts +++ b/apps/api/src/activities/activities.module.ts @@ -3,9 +3,10 @@ import { ActivitiesController } from './activities.controller'; import { ActivitiesService } from './activities.service'; import { NotificationsModule } from '../notifications/notifications.module'; import { AutomationsModule } from '../automations/automations.module'; +import { MessageAutomationsModule } from '../message-automations/message-automations.module'; @Module({ - imports: [NotificationsModule, AutomationsModule], + imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule], controllers: [ActivitiesController], providers: [ActivitiesService], exports: [ActivitiesService], diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index da527bf..c82f87e 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -15,6 +15,7 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { AutomationsService } from '../automations/automations.service'; +import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { type Actor, assigneeWhere, ownerWhere } from '../common/record-scope'; type ActivityActor = Pick; @@ -55,6 +56,7 @@ export class ActivitiesService { private readonly prisma: PrismaService, private readonly notifications: NotificationsService, private readonly automations: AutomationsService, + private readonly messageAutomations: MessageAutomationsService, ) {} async summary( @@ -211,6 +213,18 @@ export class ActivitiesService { }); } + await this.messageAutomations.onEvent('activity_created', { + organizationId, + entityType: 'activity', + entityId: row.id, + title: row.title, + personId: row.person?.id ?? null, + companyId: row.company?.id ?? null, + dealId: row.deal?.id ?? null, + assigneeId: row.assignee?.id ?? null, + ownerId: row.assignee?.id ?? null, + }); + return this.toDto(row); } @@ -377,9 +391,9 @@ export class ActivitiesService { assignee: { id: string } | null; }, ) { - await this.automations.runTrigger('activity_done', { + const ctx = { organizationId, - entityType: 'activity', + entityType: 'activity' as const, entityId: row.id, title: row.title, personId: row.person?.id ?? null, @@ -387,7 +401,9 @@ export class ActivitiesService { dealId: row.deal?.id ?? null, assigneeId: row.assignee?.id ?? null, ownerId: row.assignee?.id ?? null, - }); + }; + await this.automations.runTrigger('activity_done', ctx); + await this.messageAutomations.onEvent('activity_done', ctx); } private async ensureExists( diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index beb65f2..ddee91a 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -25,6 +25,7 @@ import { ReportsModule } from './reports/reports.module'; import { ProjectBoardsModule } from './project-boards/project-boards.module'; import { LoyaltyModule } from './loyalty/loyalty.module'; import { AutomationsModule } from './automations/automations.module'; +import { MessageAutomationsModule } from './message-automations/message-automations.module'; import { AiModule } from './ai/ai.module'; import { MemoryModule } from './memory/memory.module'; import { TahesabModule } from './integrations/tahesab/tahesab.module'; @@ -65,6 +66,7 @@ import { FinanceModule } from './finance/finance.module'; ProjectBoardsModule, LoyaltyModule, AutomationsModule, + MessageAutomationsModule, MemoryModule, TahesabModule, ], diff --git a/apps/api/src/contacts/contacts.module.ts b/apps/api/src/contacts/contacts.module.ts index 2d64183..e2dca54 100644 --- a/apps/api/src/contacts/contacts.module.ts +++ b/apps/api/src/contacts/contacts.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { LoyaltyModule } from '../loyalty/loyalty.module'; import { AutomationsModule } from '../automations/automations.module'; +import { MessageAutomationsModule } from '../message-automations/message-automations.module'; import { CompaniesController } from './companies.controller'; import { CompaniesService } from './companies.service'; import { PeopleController } from './people.controller'; @@ -8,7 +9,7 @@ import { PeopleService } from './people.service'; import { ContactsExcelService } from './contacts-excel.service'; @Module({ - imports: [LoyaltyModule, AutomationsModule], + imports: [LoyaltyModule, AutomationsModule, MessageAutomationsModule], controllers: [PeopleController, CompaniesController], providers: [PeopleService, CompaniesService, ContactsExcelService], exports: [PeopleService, CompaniesService], diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index be9c490..d0a3044 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -26,6 +26,7 @@ import { } from '../common/record-scope'; import { LoyaltyService } from '../loyalty/loyalty.service'; import { AutomationsService } from '../automations/automations.service'; +import { MessageAutomationsService } from '../message-automations/message-automations.service'; const personInclude = { company: { select: { id: true, name: true } }, @@ -66,6 +67,7 @@ export class PeopleService { private readonly companies: CompaniesService, private readonly loyalty: LoyaltyService, private readonly automations: AutomationsService, + private readonly messageAutomations: MessageAutomationsService, ) {} async list( @@ -151,15 +153,17 @@ export class PeopleService { include: personInclude, }); await this.loyalty.applyWelcomePoints(actor.organizationId, row.id); - await this.automations.runTrigger('person_created', { + const createdCtx = { organizationId: actor.organizationId, - entityType: 'person', + entityType: 'person' as const, entityId: row.id, title: `${row.firstName} ${row.lastName}`.trim(), personId: row.id, companyId: row.companyId, ownerId: row.ownerId, - }); + }; + await this.automations.runTrigger('person_created', createdCtx); + await this.messageAutomations.onEvent('person_created', createdCtx); const fresh = await this.prisma.person.findFirst({ where: { id: row.id }, include: personInclude, diff --git a/apps/api/src/invoices/invoices.module.ts b/apps/api/src/invoices/invoices.module.ts index 9059294..64695e8 100644 --- a/apps/api/src/invoices/invoices.module.ts +++ b/apps/api/src/invoices/invoices.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { AutomationsModule } from '../automations/automations.module'; +import { MessageAutomationsModule } from '../message-automations/message-automations.module'; import { FinanceModule } from '../finance/finance.module'; import { InventoryModule } from '../inventory/inventory.module'; import { InvoicesController } from './invoices.controller'; import { InvoicesService } from './invoices.service'; @Module({ - imports: [AutomationsModule, InventoryModule, FinanceModule], + imports: [AutomationsModule, MessageAutomationsModule, InventoryModule, FinanceModule], controllers: [InvoicesController], providers: [InvoicesService], exports: [InvoicesService], diff --git a/apps/api/src/invoices/invoices.service.ts b/apps/api/src/invoices/invoices.service.ts index 402a192..f2b7e09 100644 --- a/apps/api/src/invoices/invoices.service.ts +++ b/apps/api/src/invoices/invoices.service.ts @@ -17,6 +17,7 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { type Actor, ownerWhere } from '../common/record-scope'; import { AutomationsService } from '../automations/automations.service'; +import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { InventoryService } from '../inventory/inventory.service'; import { CommissionsService } from '../finance/commissions.service'; @@ -47,6 +48,7 @@ export class InvoicesService { constructor( private readonly prisma: PrismaService, private readonly automations: AutomationsService, + private readonly messageAutomations: MessageAutomationsService, private readonly inventory: InventoryService, private readonly commissions: CommissionsService, ) {} @@ -539,16 +541,18 @@ export class InvoicesService { if (invoice.status === 'draft') { await this.fireIssued(organizationId, row); } - await this.automations.runTrigger('invoice_paid', { + const paidCtx = { organizationId, - entityType: 'invoice', + entityType: 'invoice' as const, entityId: row.id, title: row.title || row.number, personId: row.person?.id ?? null, companyId: row.company?.id ?? null, dealId: row.deal?.id ?? null, ownerId: row.owner?.id ?? null, - }); + }; + await this.automations.runTrigger('invoice_paid', paidCtx); + await this.messageAutomations.onEvent('invoice_paid', paidCtx); return row; } @@ -657,16 +661,18 @@ export class InvoicesService { } private async fireIssued(organizationId: string, row: InvoiceDto) { - await this.automations.runTrigger('invoice_issued', { + const ctx = { organizationId, - entityType: 'invoice', + entityType: 'invoice' as const, entityId: row.id, title: row.title || row.number, personId: row.person?.id ?? null, companyId: row.company?.id ?? null, dealId: row.deal?.id ?? null, ownerId: row.owner?.id ?? null, - }); + }; + await this.automations.runTrigger('invoice_issued', ctx); + await this.messageAutomations.onEvent('invoice_issued', ctx); } private async ensureExists( diff --git a/apps/api/src/lead-forms/lead-forms.module.ts b/apps/api/src/lead-forms/lead-forms.module.ts index 1827115..41f8b3e 100644 --- a/apps/api/src/lead-forms/lead-forms.module.ts +++ b/apps/api/src/lead-forms/lead-forms.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { AutomationsModule } from '../automations/automations.module'; +import { MessageAutomationsModule } from '../message-automations/message-automations.module'; import { LeadFormsController } from './lead-forms.controller'; import { LeadFormsService } from './lead-forms.service'; import { PublicLeadFormsController } from './public-lead-forms.controller'; @Module({ - imports: [AuthModule, AutomationsModule], + imports: [AuthModule, AutomationsModule, MessageAutomationsModule], controllers: [LeadFormsController, PublicLeadFormsController], providers: [LeadFormsService], exports: [LeadFormsService], diff --git a/apps/api/src/lead-forms/lead-forms.service.ts b/apps/api/src/lead-forms/lead-forms.service.ts index 60f95a7..39ce02b 100644 --- a/apps/api/src/lead-forms/lead-forms.service.ts +++ b/apps/api/src/lead-forms/lead-forms.service.ts @@ -15,6 +15,7 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { LocalFileStorage } from '../storage/local-file.storage'; import { AutomationsService } from '../automations/automations.service'; +import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { emptyToNull, normalizeEmail, @@ -68,6 +69,7 @@ export class LeadFormsService { private readonly prisma: PrismaService, private readonly files: LocalFileStorage, private readonly automations: AutomationsService, + private readonly messageAutomations: MessageAutomationsService, ) {} async list(organizationId: string): Promise { @@ -281,14 +283,16 @@ export class LeadFormsService { .filter((p) => p?.trim()) .join(' ') .trim(); - await this.automations.runTrigger('lead_submitted', { + const leadCtx = { organizationId: form.organizationId, - entityType: 'person', + entityType: 'person' as const, entityId: personId ?? form.id, title: personName || form.name, personId, ownerId: ownerId ?? actorId, - }); + }; + await this.automations.runTrigger('lead_submitted', leadCtx); + await this.messageAutomations.onEvent('lead_submitted', leadCtx); return { ok: true, diff --git a/apps/api/src/message-automations/__tests__/message-automation.check.ts b/apps/api/src/message-automations/__tests__/message-automation.check.ts new file mode 100644 index 0000000..dff4156 --- /dev/null +++ b/apps/api/src/message-automations/__tests__/message-automation.check.ts @@ -0,0 +1,58 @@ +import { + applyMessageTemplate, + buildSendAts, + cancelTriggersFor, + clipSms, + delayMs, +} from '../message-automation.util'; + +function assert(cond: unknown, msg: string): asserts cond { + if (!cond) throw new Error(msg); +} + +function testDelay() { + assert(delayMs(0, 'minutes') === 0, 'zero delay'); + assert(delayMs(2, 'hours') === 2 * 3_600_000, '2 hours'); + assert(delayMs(2, 'days') === 2 * 86_400_000, '2 days'); +} + +function testSendAts() { + const now = new Date('2026-09-13T12:00:00.000Z'); + const once = buildSendAts(now, 0, 'minutes', 1, 2, 'days'); + assert(once.length === 1, 'one send'); + assert(once[0].getTime() === now.getTime(), 'immediate first'); + + const three = buildSendAts(now, 1, 'hours', 3, 2, 'days'); + assert(three.length === 3, 'three sends'); + assert(three[0].getTime() === now.getTime() + 3_600_000, 'start + 1h'); + assert( + three[1].getTime() === three[0].getTime() + 2 * 86_400_000, + 'second + 2 days', + ); + assert( + three[2].getTime() === three[0].getTime() + 4 * 86_400_000, + 'third + 4 days', + ); +} + +function testCancelMap() { + assert( + cancelTriggersFor('invoice_paid').includes('invoice_issued'), + 'paid cancels issued', + ); + assert(cancelTriggersFor('person_created').length === 0, 'no cancel on create'); +} + +function testTemplate() { + assert( + applyMessageTemplate('سلام {title}', 'فاکتور ۱۲') === 'سلام فاکتور ۱۲', + 'title var', + ); + assert(clipSms('الف'.repeat(200)).length === 160, 'sms clip'); +} + +testDelay(); +testSendAts(); +testCancelMap(); +testTemplate(); +console.log('message-automation checks passed'); diff --git a/apps/api/src/message-automations/dto.ts b/apps/api/src/message-automations/dto.ts new file mode 100644 index 0000000..3342581 --- /dev/null +++ b/apps/api/src/message-automations/dto.ts @@ -0,0 +1,149 @@ +import { Type } from 'class-transformer'; +import { + IsBoolean, + IsIn, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { + MESSAGE_AUTOMATION_TRIGGERS, + type MessageAutomationChannel, + type MessageAutomationDelayUnit, + type MessageAutomationTarget, + type MessageAutomationTrigger, +} from '@mento/shared'; + +const UNITS = ['minutes', 'hours', 'days'] as const; +const CHANNELS = ['app', 'sms', 'both'] as const; +const TARGETS = ['staff', 'person', 'both'] as const; + +export class CreateMessageAutomationRuleDto { + @IsString() + @MinLength(2) + @MaxLength(120) + name!: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsIn(MESSAGE_AUTOMATION_TRIGGERS) + trigger!: MessageAutomationTrigger; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(365) + startDelayValue?: number; + + @IsOptional() + @IsIn(UNITS) + startDelayUnit?: MessageAutomationDelayUnit; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(10) + repeatCount?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(365) + intervalValue?: number; + + @IsOptional() + @IsIn(UNITS) + intervalUnit?: MessageAutomationDelayUnit; + + @IsOptional() + @IsIn(TARGETS) + notifyTarget?: MessageAutomationTarget; + + @IsOptional() + @IsIn(CHANNELS) + channel?: MessageAutomationChannel; + + @IsString() + @MinLength(2) + @MaxLength(120) + title!: string; + + @IsString() + @MinLength(2) + @MaxLength(500) + body!: string; +} + +export class UpdateMessageAutomationRuleDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(120) + name?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsIn(MESSAGE_AUTOMATION_TRIGGERS) + trigger?: MessageAutomationTrigger; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(365) + startDelayValue?: number; + + @IsOptional() + @IsIn(UNITS) + startDelayUnit?: MessageAutomationDelayUnit; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(10) + repeatCount?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(365) + intervalValue?: number; + + @IsOptional() + @IsIn(UNITS) + intervalUnit?: MessageAutomationDelayUnit; + + @IsOptional() + @IsIn(TARGETS) + notifyTarget?: MessageAutomationTarget; + + @IsOptional() + @IsIn(CHANNELS) + channel?: MessageAutomationChannel; + + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(120) + title?: string; + + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(500) + body?: string; +} diff --git a/apps/api/src/message-automations/message-automation.util.ts b/apps/api/src/message-automations/message-automation.util.ts new file mode 100644 index 0000000..2362fa9 --- /dev/null +++ b/apps/api/src/message-automations/message-automation.util.ts @@ -0,0 +1,66 @@ +export type MessageDelayUnit = 'minutes' | 'hours' | 'days'; + +export const MESSAGE_AUTOMATION_TRIGGERS = [ + 'person_created', + 'lead_submitted', + 'deal_created', + 'deal_stage_changed', + 'deal_won', + 'deal_lost', + 'ticket_created', + 'ticket_resolved', + 'invoice_issued', + 'invoice_paid', + 'activity_created', + 'activity_done', +] as const; + +export const MAX_MESSAGE_REPEAT = 10; +export const MAX_MESSAGE_SMS = 160; + +export function delayMs(value: number, unit: MessageDelayUnit): number { + const n = Math.max(0, Math.trunc(Number(value) || 0)); + if (unit === 'minutes') return n * 60_000; + if (unit === 'hours') return n * 3_600_000; + return n * 86_400_000; +} + +export function buildSendAts( + now: Date, + startValue: number, + startUnit: MessageDelayUnit, + repeatCount: number, + intervalValue: number, + intervalUnit: MessageDelayUnit, +): Date[] { + const count = Math.min( + MAX_MESSAGE_REPEAT, + Math.max(1, Math.trunc(Number(repeatCount) || 1)), + ); + const first = new Date(now.getTime() + delayMs(startValue, startUnit)); + const gap = delayMs(Math.max(1, intervalValue), intervalUnit); + return Array.from({ length: count }, (_, i) => new Date(first.getTime() + i * gap)); +} + +export function applyMessageTemplate(text: string, title: string): string { + return text.replace(/\{title\}/g, title).replace(/\{عنوان\}/g, title); +} + +export function clipSms(text: string): string { + const trimmed = text.trim(); + if (trimmed.length <= MAX_MESSAGE_SMS) return trimmed; + return `${trimmed.slice(0, MAX_MESSAGE_SMS - 1)}…`; +} + +/** When this event happens, cancel leftover messages of these earlier triggers. */ +export function cancelTriggersFor( + trigger: (typeof MESSAGE_AUTOMATION_TRIGGERS)[number], +): (typeof MESSAGE_AUTOMATION_TRIGGERS)[number][] { + if (trigger === 'invoice_paid') return ['invoice_issued']; + if (trigger === 'ticket_resolved') return ['ticket_created']; + if (trigger === 'deal_won' || trigger === 'deal_lost') { + return ['deal_created', 'deal_stage_changed']; + } + if (trigger === 'activity_done') return ['activity_created']; + return []; +} diff --git a/apps/api/src/message-automations/message-automations.controller.ts b/apps/api/src/message-automations/message-automations.controller.ts new file mode 100644 index 0000000..005dedc --- /dev/null +++ b/apps/api/src/message-automations/message-automations.controller.ts @@ -0,0 +1,57 @@ +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 { Roles } from '../auth/roles.decorator'; +import { RolesGuard } from '../auth/roles.guard'; +import { + CreateMessageAutomationRuleDto, + UpdateMessageAutomationRuleDto, +} from './dto'; +import { MessageAutomationsService } from './message-automations.service'; + +@Controller('message-automations') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('owner', 'admin') +export class MessageAutomationsController { + constructor(private readonly messages: MessageAutomationsService) {} + + @Get() + list(@CurrentUser() user: { organizationId: string }) { + return this.messages.list(user.organizationId); + } + + @Post() + create( + @CurrentUser() user: { organizationId: string }, + @Body() dto: CreateMessageAutomationRuleDto, + ) { + return this.messages.create(user.organizationId, dto); + } + + @Patch(':id') + update( + @CurrentUser() user: { organizationId: string }, + @Param('id') id: string, + @Body() dto: UpdateMessageAutomationRuleDto, + ) { + return this.messages.update(user.organizationId, id, dto); + } + + @Delete(':id') + async remove( + @CurrentUser() user: { organizationId: string }, + @Param('id') id: string, + ) { + await this.messages.remove(user.organizationId, id); + return { ok: true }; + } +} diff --git a/apps/api/src/message-automations/message-automations.module.ts b/apps/api/src/message-automations/message-automations.module.ts new file mode 100644 index 0000000..82b4f91 --- /dev/null +++ b/apps/api/src/message-automations/message-automations.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { SmsService } from '../common/sms.service'; +import { MessageAutomationsController } from './message-automations.controller'; +import { MessageAutomationsService } from './message-automations.service'; + +@Module({ + imports: [AuthModule, NotificationsModule], + controllers: [MessageAutomationsController], + providers: [MessageAutomationsService, SmsService], + exports: [MessageAutomationsService], +}) +export class MessageAutomationsModule {} diff --git a/apps/api/src/message-automations/message-automations.service.ts b/apps/api/src/message-automations/message-automations.service.ts new file mode 100644 index 0000000..e6eee33 --- /dev/null +++ b/apps/api/src/message-automations/message-automations.service.ts @@ -0,0 +1,464 @@ +import { + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import type { + CreateMessageAutomationRuleRequest, + MessageAutomationRuleDto, + MessageAutomationTrigger, + UpdateMessageAutomationRuleRequest, +} from '@mento/shared'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { SmsService } from '../common/sms.service'; +import { + applyMessageTemplate, + buildSendAts, + cancelTriggersFor, + clipSms, +} from './message-automation.util'; + +export type MessageAutomationContext = { + organizationId: string; + entityType: 'deal' | 'ticket' | 'person' | 'invoice' | 'activity'; + entityId: string; + title: string; + personId?: string | null; + companyId?: string | null; + dealId?: string | null; + ownerId?: string | null; + assigneeId?: string | null; +}; + +type Trigger = MessageAutomationTrigger; + +@Injectable() +export class MessageAutomationsService { + private readonly logger = new Logger(MessageAutomationsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, + private readonly sms: SmsService, + ) {} + + async list(organizationId: string): Promise { + const rows = await this.prisma.messageAutomationRule.findMany({ + where: { organizationId }, + orderBy: [{ isActive: 'desc' }, { updatedAt: 'desc' }], + }); + return rows.map((row) => this.toDto(row)); + } + + async create( + organizationId: string, + dto: CreateMessageAutomationRuleRequest, + ): Promise { + const row = await this.prisma.messageAutomationRule.create({ + data: this.toData(organizationId, dto), + }); + return this.toDto(row); + } + + async update( + organizationId: string, + id: string, + dto: UpdateMessageAutomationRuleRequest, + ): Promise { + const existing = await this.prisma.messageAutomationRule.findFirst({ + where: { id, organizationId }, + }); + if (!existing) throw new NotFoundException('قانون پیدا نشد'); + + const row = await this.prisma.messageAutomationRule.update({ + where: { id }, + data: { + ...(dto.name !== undefined ? { name: dto.name.trim() } : {}), + ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}), + ...(dto.trigger !== undefined ? { trigger: dto.trigger } : {}), + ...(dto.startDelayValue !== undefined + ? { startDelayValue: dto.startDelayValue } + : {}), + ...(dto.startDelayUnit !== undefined + ? { startDelayUnit: dto.startDelayUnit } + : {}), + ...(dto.repeatCount !== undefined ? { repeatCount: dto.repeatCount } : {}), + ...(dto.intervalValue !== undefined + ? { intervalValue: dto.intervalValue } + : {}), + ...(dto.intervalUnit !== undefined + ? { intervalUnit: dto.intervalUnit } + : {}), + ...(dto.notifyTarget !== undefined + ? { notifyTarget: dto.notifyTarget } + : {}), + ...(dto.channel !== undefined ? { channel: dto.channel } : {}), + ...(dto.title !== undefined ? { title: dto.title.trim() } : {}), + ...(dto.body !== undefined ? { body: dto.body.trim() } : {}), + }, + }); + + if (dto.isActive === false) { + await this.cancelRuleJobs(id); + } + return this.toDto(row); + } + + async remove(organizationId: string, id: string): Promise { + const existing = await this.prisma.messageAutomationRule.findFirst({ + where: { id, organizationId }, + }); + if (!existing) throw new NotFoundException('قانون پیدا نشد'); + await this.prisma.messageAutomationRule.delete({ where: { id } }); + } + + async onEvent(trigger: Trigger, ctx: MessageAutomationContext): Promise { + try { + await this.cancelForEvent(trigger, ctx); + await this.enqueue(trigger, ctx); + } catch (err) { + this.logger.warn( + `message automation event failed ${trigger}: ${ + err instanceof Error ? err.message : 'unknown' + }`, + ); + } + } + + @Cron('* * * * *') + async handleDue(): Promise { + await this.processDue(); + } + + async processDue(now: Date = new Date()): Promise { + const jobs = await this.prisma.messageAutomationJob.findMany({ + where: { status: 'pending', sendAt: { lte: now } }, + include: { rule: true }, + orderBy: { sendAt: 'asc' }, + take: 80, + }); + let sent = 0; + for (const job of jobs) { + try { + if (!job.rule.isActive) { + await this.markCancelled(job.id); + continue; + } + if (await this.isObsolete(job)) { + await this.cancelEntityJobs( + job.organizationId, + job.entityType, + job.entityId, + [job.trigger], + ); + continue; + } + await this.deliver(job); + await this.prisma.messageAutomationJob.update({ + where: { id: job.id }, + data: { status: 'sent', sentAt: new Date() }, + }); + sent += 1; + } catch (err) { + this.logger.warn( + `message job failed ${job.id}: ${ + err instanceof Error ? err.message : 'unknown' + }`, + ); + await this.prisma.messageAutomationJob.update({ + where: { id: job.id }, + data: { status: 'failed' }, + }); + } + } + return sent; + } + + private async enqueue( + trigger: Trigger, + ctx: MessageAutomationContext, + ): Promise { + const rules = await this.prisma.messageAutomationRule.findMany({ + where: { + organizationId: ctx.organizationId, + isActive: true, + trigger, + }, + }); + const now = new Date(); + for (const rule of rules) { + const times = buildSendAts( + now, + rule.startDelayValue, + rule.startDelayUnit, + rule.repeatCount, + rule.intervalValue, + rule.intervalUnit, + ); + try { + await this.prisma.messageAutomationJob.createMany({ + data: times.map((sendAt, index) => ({ + organizationId: ctx.organizationId, + ruleId: rule.id, + trigger, + entityType: ctx.entityType, + entityId: ctx.entityId, + entityTitle: ctx.title, + personId: ctx.personId ?? null, + ownerId: ctx.ownerId ?? null, + assigneeId: ctx.assigneeId ?? null, + attemptIndex: index + 1, + sendAt, + })), + skipDuplicates: true, + }); + } catch (err) { + this.logger.warn( + `enqueue failed ${rule.id}: ${ + err instanceof Error ? err.message : 'unknown' + }`, + ); + } + } + await this.processDue(now); + } + + private async cancelForEvent( + trigger: Trigger, + ctx: MessageAutomationContext, + ): Promise { + const triggers = cancelTriggersFor(trigger); + if (!triggers.length) return; + await this.cancelEntityJobs( + ctx.organizationId, + ctx.entityType, + ctx.entityId, + triggers, + ); + } + + private async cancelEntityJobs( + organizationId: string, + entityType: string, + entityId: string, + triggers: Trigger[], + ) { + if (!triggers.length) return; + await this.prisma.messageAutomationJob.updateMany({ + where: { + organizationId, + entityType, + entityId, + status: 'pending', + trigger: { in: triggers }, + }, + data: { status: 'cancelled' }, + }); + } + + private async cancelRuleJobs(ruleId: string) { + await this.prisma.messageAutomationJob.updateMany({ + where: { ruleId, status: 'pending' }, + data: { status: 'cancelled' }, + }); + } + + private async markCancelled(id: string) { + await this.prisma.messageAutomationJob.update({ + where: { id }, + data: { status: 'cancelled' }, + }); + } + + private async isObsolete(job: { + organizationId: string; + trigger: string; + entityType: string; + entityId: string; + }): Promise { + if (job.trigger === 'invoice_issued' && job.entityType === 'invoice') { + const row = await this.prisma.invoice.findFirst({ + where: { id: job.entityId, organizationId: job.organizationId }, + select: { status: true }, + }); + return row?.status === 'paid'; + } + if (job.trigger === 'ticket_created' && job.entityType === 'ticket') { + const row = await this.prisma.ticket.findFirst({ + where: { id: job.entityId, organizationId: job.organizationId }, + select: { status: true }, + }); + return row?.status === 'resolved' || row?.status === 'closed'; + } + if ( + (job.trigger === 'deal_created' || job.trigger === 'deal_stage_changed') && + job.entityType === 'deal' + ) { + const row = await this.prisma.deal.findFirst({ + where: { id: job.entityId, organizationId: job.organizationId }, + select: { status: true }, + }); + return row?.status === 'won' || row?.status === 'lost'; + } + if (job.trigger === 'activity_created' && job.entityType === 'activity') { + const row = await this.prisma.activity.findFirst({ + where: { id: job.entityId, organizationId: job.organizationId }, + select: { status: true }, + }); + return row?.status === 'done'; + } + return false; + } + + private async deliver(job: { + organizationId: string; + entityType: string; + entityId: string; + entityTitle: string; + personId: string | null; + ownerId: string | null; + assigneeId: string | null; + rule: { + title: string; + body: string; + channel: string; + notifyTarget: string; + }; + }) { + const title = applyMessageTemplate(job.rule.title, job.entityTitle).slice( + 0, + 200, + ); + const body = applyMessageTemplate(job.rule.body, job.entityTitle); + 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'; + const targetStaff = + job.rule.notifyTarget === 'staff' || job.rule.notifyTarget === 'both'; + const targetPerson = + job.rule.notifyTarget === 'person' || job.rule.notifyTarget === 'both'; + const link = entityLink(job.entityType, job.entityId); + + if (targetStaff) { + const userId = job.assigneeId || job.ownerId; + if (userId && wantApp) { + await this.notifications.create({ + organizationId: job.organizationId, + userId, + type: 'automation', + title, + body, + link, + entityType: job.entityType, + entityId: job.entityId, + }); + } + if (userId && wantSms) { + const user = await this.prisma.user.findFirst({ + where: { id: userId, organizationId: job.organizationId }, + select: { mobile: true }, + }); + await this.sendSms(user?.mobile, smsText); + } + } + + 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); + } + } + + private async sendSms(mobile: string | null | undefined, text: string) { + if (!mobile) { + this.logger.warn('Message automation SMS skipped: no mobile'); + return; + } + if (!this.sms.isConfigured()) { + this.logger.warn('Message automation SMS skipped: SMS not configured'); + return; + } + const result = await this.sms.sendSmsDetailed(mobile, text); + if (!result.ok) { + this.logger.warn( + `Message automation SMS failed: ${result.reason ?? 'unknown'}`, + ); + } + } + + private toData( + organizationId: string, + dto: CreateMessageAutomationRuleRequest, + ): Prisma.MessageAutomationRuleUncheckedCreateInput { + return { + organizationId, + name: dto.name.trim(), + isActive: dto.isActive ?? true, + trigger: dto.trigger, + startDelayValue: dto.startDelayValue ?? 0, + startDelayUnit: dto.startDelayUnit ?? 'minutes', + repeatCount: dto.repeatCount ?? 1, + intervalValue: dto.intervalValue ?? 1, + intervalUnit: dto.intervalUnit ?? 'days', + notifyTarget: dto.notifyTarget ?? 'person', + channel: dto.channel ?? 'sms', + title: dto.title.trim(), + body: dto.body.trim(), + }; + } + + private toDto(row: { + id: string; + name: string; + isActive: boolean; + trigger: string; + startDelayValue: number; + startDelayUnit: string; + repeatCount: number; + intervalValue: number; + intervalUnit: string; + notifyTarget: string; + channel: string; + title: string; + body: string; + createdAt: Date; + updatedAt: Date; + }): MessageAutomationRuleDto { + return { + id: row.id, + name: row.name, + isActive: row.isActive, + trigger: row.trigger as MessageAutomationRuleDto['trigger'], + startDelayValue: row.startDelayValue, + startDelayUnit: row.startDelayUnit as MessageAutomationRuleDto['startDelayUnit'], + repeatCount: row.repeatCount, + intervalValue: row.intervalValue, + intervalUnit: row.intervalUnit as MessageAutomationRuleDto['intervalUnit'], + notifyTarget: row.notifyTarget as MessageAutomationRuleDto['notifyTarget'], + channel: row.channel as MessageAutomationRuleDto['channel'], + title: row.title, + body: row.body, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } +} + +function entityLink(entityType: string, entityId: string) { + if (entityType === 'deal') return `/deals/${entityId}`; + if (entityType === 'ticket') return `/tickets/${entityId}`; + if (entityType === 'invoice') return `/invoices/${entityId}`; + if (entityType === 'person') return `/contacts/people/${entityId}`; + if (entityType === 'activity') return `/activities/${entityId}`; + return '/'; +} diff --git a/apps/api/src/pipelines/deals.service.ts b/apps/api/src/pipelines/deals.service.ts index 9c03492..73362db 100644 --- a/apps/api/src/pipelines/deals.service.ts +++ b/apps/api/src/pipelines/deals.service.ts @@ -16,6 +16,7 @@ import { PipelinesService } from './pipelines.service'; import { toJsonCustomFields } from '../common/custom-fields'; import { NotificationsService } from '../notifications/notifications.service'; import { AutomationsService } from '../automations/automations.service'; +import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { type Actor, canReassignOwner, @@ -39,6 +40,7 @@ export class DealsService { private readonly pipelines: PipelinesService, private readonly notifications: NotificationsService, private readonly automations: AutomationsService, + private readonly messageAutomations: MessageAutomationsService, ) {} async get( @@ -147,16 +149,18 @@ export class DealsService { include: dealInclude, }); - await this.automations.runTrigger('deal_created', { + const createdCtx = { organizationId, - entityType: 'deal', + entityType: 'deal' as const, entityId: created.id, title: created.title, personId: created.personId, companyId: created.companyId, dealId: created.id, ownerId: created.ownerId, - }); + }; + await this.automations.runTrigger('deal_created', createdCtx); + await this.messageAutomations.onEvent('deal_created', createdCtx); return this.pipelines.toDealDto(created); } @@ -313,11 +317,14 @@ export class DealsService { ownerId: deal.ownerId, }; await this.automations.runTrigger('deal_stage_changed', ctx); + await this.messageAutomations.onEvent('deal_stage_changed', ctx); if (status === 'won' && deal.status !== 'won') { await this.automations.runTrigger('deal_won', ctx); + await this.messageAutomations.onEvent('deal_won', ctx); } if (status === 'lost' && deal.status !== 'lost') { await this.automations.runTrigger('deal_lost', ctx); + await this.messageAutomations.onEvent('deal_lost', ctx); } } diff --git a/apps/api/src/pipelines/pipelines.module.ts b/apps/api/src/pipelines/pipelines.module.ts index f59a2f8..734d2cb 100644 --- a/apps/api/src/pipelines/pipelines.module.ts +++ b/apps/api/src/pipelines/pipelines.module.ts @@ -6,9 +6,15 @@ import { PipelinesService } from './pipelines.service'; import { NotificationsModule } from '../notifications/notifications.module'; import { AuthModule } from '../auth/auth.module'; import { AutomationsModule } from '../automations/automations.module'; +import { MessageAutomationsModule } from '../message-automations/message-automations.module'; @Module({ - imports: [NotificationsModule, AuthModule, forwardRef(() => AutomationsModule)], + imports: [ + NotificationsModule, + AuthModule, + forwardRef(() => AutomationsModule), + MessageAutomationsModule, + ], controllers: [PipelinesController, DealsController], providers: [PipelinesService, DealsService], exports: [PipelinesService, DealsService], diff --git a/apps/api/src/tickets/tickets.module.ts b/apps/api/src/tickets/tickets.module.ts index e6d61a5..43689fa 100644 --- a/apps/api/src/tickets/tickets.module.ts +++ b/apps/api/src/tickets/tickets.module.ts @@ -1,11 +1,16 @@ import { Module, forwardRef } from '@nestjs/common'; import { NotificationsModule } from '../notifications/notifications.module'; import { AutomationsModule } from '../automations/automations.module'; +import { MessageAutomationsModule } from '../message-automations/message-automations.module'; import { TicketsController } from './tickets.controller'; import { TicketsService } from './tickets.service'; @Module({ - imports: [NotificationsModule, forwardRef(() => AutomationsModule)], + imports: [ + NotificationsModule, + forwardRef(() => AutomationsModule), + MessageAutomationsModule, + ], controllers: [TicketsController], providers: [TicketsService], exports: [TicketsService], diff --git a/apps/api/src/tickets/tickets.service.ts b/apps/api/src/tickets/tickets.service.ts index 1a4be79..38ee321 100644 --- a/apps/api/src/tickets/tickets.service.ts +++ b/apps/api/src/tickets/tickets.service.ts @@ -21,6 +21,7 @@ import { } from '../common/record-scope'; import { NotificationsService } from '../notifications/notifications.service'; import { AutomationsService } from '../automations/automations.service'; +import { MessageAutomationsService } from '../message-automations/message-automations.service'; const include = { person: { select: { id: true, firstName: true, lastName: true } }, @@ -42,6 +43,7 @@ export class TicketsService { private readonly prisma: PrismaService, private readonly notifications: NotificationsService, private readonly automations: AutomationsService, + private readonly messageAutomations: MessageAutomationsService, ) {} async list( @@ -228,9 +230,9 @@ export class TicketsService { }); } - await this.automations.runTrigger('ticket_created', { + const createdCtx = { organizationId: actor.organizationId, - entityType: 'ticket', + entityType: 'ticket' as const, entityId: row.id, title: row.subject, personId: row.personId, @@ -238,7 +240,9 @@ export class TicketsService { dealId: row.dealId, assigneeId: row.assigneeId, ownerId: row.assigneeId, - }); + }; + await this.automations.runTrigger('ticket_created', createdCtx); + await this.messageAutomations.onEvent('ticket_created', createdCtx); return this.toDto(row); } @@ -345,9 +349,9 @@ export class TicketsService { existing.status !== 'resolved' && existing.status !== 'closed'; if (becameResolved) { - await this.automations.runTrigger('ticket_resolved', { + const resolvedCtx = { organizationId: actor.organizationId, - entityType: 'ticket', + entityType: 'ticket' as const, entityId: existing.id, title: existing.subject, personId: existing.personId, @@ -355,7 +359,9 @@ export class TicketsService { dealId: existing.dealId, assigneeId: existing.assigneeId, ownerId: existing.assigneeId, - }); + }; + await this.automations.runTrigger('ticket_resolved', resolvedCtx); + await this.messageAutomations.onEvent('ticket_resolved', resolvedCtx); } return this.get(actor, id); diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index f2635d5..1fd8d78 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -49,6 +49,9 @@ 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 toolsActive = computed(() => pathIn(['/settings/lead-forms', '/projects', '/project-cards', '/inventory', '/cheques', '/attachments']), ); @@ -411,6 +414,17 @@ onUnmounted(() => { اتوماسیون + + + پیام خودکار +