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,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 '/';
}