Adding SMS automation
This commit is contained in:
@@ -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');
|
||||
@@ -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 '/';
|
||||
}
|
||||
Reference in New Issue
Block a user