497 lines
15 KiB
TypeScript
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 '/';
|
|
}
|