Adding SMS automation

This commit is contained in:
2026-09-13 19:11:47 +03:30
parent bd63d2ab76
commit 45b0d46cfe
26 changed files with 1724 additions and 31 deletions
@@ -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;
+89
View File
@@ -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())
+2 -1
View File
@@ -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],
+19 -3
View File
@@ -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<Actor, 'userId' | 'role'>;
@@ -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(
+2
View File
@@ -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,
],
+2 -1
View File
@@ -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],
+7 -3
View File
@@ -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,
+2 -1
View File
@@ -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],
+12 -6
View File
@@ -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(
+2 -1
View File
@@ -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],
@@ -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<LeadFormDto[]> {
@@ -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,
@@ -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');
+149
View File
@@ -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;
}
@@ -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 [];
}
@@ -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 };
}
}
@@ -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 {}
@@ -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<MessageAutomationRuleDto[]> {
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<MessageAutomationRuleDto> {
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<MessageAutomationRuleDto> {
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<void> {
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<void> {
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<void> {
await this.processDue();
}
async processDue(now: Date = new Date()): Promise<number> {
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<void> {
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<void> {
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<boolean> {
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 '/';
}
+10 -3
View File
@@ -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);
}
}
+7 -1
View File
@@ -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],
+6 -1
View File
@@ -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],
+12 -6
View File
@@ -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);