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 { SmsGatewayService } from '../sms-billing/sms-gateway.service'; import { dateToJalali, jalaliMonthName } from '../common/jalali'; import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; import { applyMessageTemplate, buildSendAts, cancelTriggersFor, clipSms, customerPhone, } 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; sourceId?: string | null; sourceType?: string | null; clickCount?: number | 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: SmsGatewayService, private readonly outboundSms: OutboundSmsLogService, ) {} async list(organizationId: string): Promise { 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 { 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 { 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 { 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 { 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 { await this.processDue(); } async processDue(now: Date = new Date()): Promise { 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 { 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 { 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 { 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; ruleId: string; rule: { id?: string; 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 lead = await this.lookupLead(job); const personName = person ? [person.firstName, person.lastName].filter(Boolean).join(' ').trim() : ''; const phone = customerPhone(person, lead); const vars = { title: job.entityTitle, name: personName || lead?.contactName?.trim() || lead?.title || job.entityTitle, 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, { organizationId: job.organizationId, ruleId: job.ruleId || job.rule.id || '', }); } } if (targetPerson && wantSms) { if (!phone) { throw new Error('شماره مشتری یا سرنخ برای پیامک پیدا نشد'); } const sent = await this.sendSms(phone, smsText, { organizationId: job.organizationId, personId: job.personId, leadId: lead?.id ?? null, ruleId: job.ruleId || job.rule.id || '', }); if (!sent) { throw new Error('ارسال پیامک به مشتری یا سرنخ ناموفق بود'); } } } /** سرنخ همان پرونده، یا سرنخی که به این شخص تبدیل شده. */ private async lookupLead(job: { organizationId: string; entityId: string; personId: string | null; }) { return this.prisma.lead.findFirst({ where: { organizationId: job.organizationId, OR: [ { id: job.entityId }, ...(job.personId ? [{ convertedPersonId: job.personId }] : []), ], }, select: { id: true, mobile: true, phone: true, contactName: true, title: true, }, orderBy: { createdAt: 'desc' }, }); } private async sendSms( mobile: string | null | undefined, text: string, log: { organizationId: string; personId?: string | null; leadId?: string | null; ruleId: string; }, ): Promise { if (!mobile?.trim()) { this.logger.warn('Message automation SMS skipped: no mobile'); return false; } const result = await this.sms.send({ organizationId: log.organizationId, phone: mobile, text, source: 'automation', }); if (log.personId || log.leadId) { await this.outboundSms.record({ organizationId: log.organizationId, personId: log.personId, leadId: log.leadId, mobile, body: text, source: 'automation', sourceId: log.ruleId, status: result.ok ? 'sent' : 'failed', error: result.ok ? null : result.reason, }); } if (!result.ok) { this.logger.warn( `Message automation SMS failed: ${result.reason ?? 'unknown'}`, ); return false; } return true; } 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 '/'; }