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
+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);