Files
CRM_Montzar/apps/api/src/message-automations/message-automations.service.ts
T

497 lines
15 KiB
TypeScript

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 { dateToJalali, jalaliMonthName } from '../common/jalali';
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.description !== undefined
? { description: dto.description?.trim() || null }
: {}),
...(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 person = job.personId
? await this.prisma.person.findFirst({
where: {
id: job.personId,
organizationId: job.organizationId,
},
select: {
firstName: true,
lastName: true,
mobile: true,
phone: true,
company: { select: { name: true } },
},
})
: null;
const personName = person
? [person.firstName, person.lastName].filter(Boolean).join(' ').trim()
: '';
const vars = {
title: job.entityTitle,
name: personName || job.entityTitle,
phone: person?.mobile || person?.phone || '',
date: formatJalaliDayFa(new Date()),
company: person?.company?.name ?? '',
};
const title = applyMessageTemplate(job.rule.title, vars).slice(0, 200);
const body = applyMessageTemplate(job.rule.body, vars);
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,
memberships: { some: { organizationId: job.organizationId } },
},
select: { mobile: true },
});
await this.sendSms(user?.mobile, smsText);
}
}
if (targetPerson && wantSms) {
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(),
description: dto.description?.trim() || null,
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;
description?: string | null;
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,
description: row.description ?? null,
body: row.body,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function toFaDigits(value: number | string) {
return String(value).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]!);
}
function formatJalaliDayFa(d: Date) {
const { jy, jm, jd } = dateToJalali(d);
return `${toFaDigits(jd)} ${jalaliMonthName(jm)} ${toFaDigits(jy)}`;
}
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 '/';
}