import { BadRequestException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import type { AutomationAction, AutomationConditionField, AutomationConditionOp, AutomationNotifyChannel, AutomationRuleConfig, AutomationRuleDto, AutomationStep, AutomationTrigger, CreateAutomationRuleRequest, UpdateAutomationRuleRequest, } from '@mento/shared'; import { MAX_AUTOMATION_STEPS, automationStepToConfig, firstAutomationAction, isAutomationCondition, normalizeAutomationSteps, packAutomationConfig, } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { LoyaltyService } from '../loyalty/loyalty.service'; import { SmsService } from '../common/sms.service'; export type AutomationContext = { organizationId: string; entityType: 'deal' | 'ticket'; entityId: string; title: string; personId?: string | null; companyId?: string | null; dealId?: string | null; ownerId?: string | null; assigneeId?: string | null; }; type AutomationFacts = { amount: number | null; hasPerson: boolean; hasCompany: boolean; ticketPriority: string | null; }; @Injectable() export class AutomationsService { private readonly logger = new Logger(AutomationsService.name); constructor( private readonly prisma: PrismaService, private readonly notifications: NotificationsService, private readonly loyalty: LoyaltyService, private readonly sms: SmsService, ) {} async list(organizationId: string): Promise { const rows = await this.prisma.automationRule.findMany({ where: { organizationId }, orderBy: [{ isActive: 'desc' }, { updatedAt: 'desc' }], }); return rows.map((r) => this.toDto(r)); } async create( organizationId: string, dto: CreateAutomationRuleRequest, ): Promise { const steps = resolveSteps(dto.action, dto.config); const action = firstAutomationAction(steps); if (!action) throw new BadRequestException('حداقل یک عمل لازم است'); const row = await this.prisma.automationRule.create({ data: { organizationId, name: dto.name.trim(), trigger: dto.trigger, action, isActive: dto.isActive ?? true, config: packAutomationConfig(steps) as Prisma.InputJsonValue, }, }); return this.toDto(row); } async update( organizationId: string, id: string, dto: UpdateAutomationRuleRequest, ): Promise { const existing = await this.prisma.automationRule.findFirst({ where: { id, organizationId }, }); if (!existing) throw new NotFoundException('قانون پیدا نشد'); const shouldRewriteChain = dto.action !== undefined || dto.config !== undefined; const steps = shouldRewriteChain ? resolveSteps( (dto.action ?? existing.action) as AutomationAction, dto.config ?? asConfig(existing.config), ) : null; const nextAction = steps ? firstAutomationAction(steps) : null; if (steps && !nextAction) { throw new BadRequestException('حداقل یک عمل لازم است'); } const row = await this.prisma.automationRule.update({ where: { id }, data: { ...(dto.name !== undefined ? { name: dto.name.trim() } : {}), ...(dto.trigger !== undefined ? { trigger: dto.trigger } : {}), ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}), ...(steps && nextAction ? { action: nextAction, config: packAutomationConfig(steps) as Prisma.InputJsonValue, } : {}), }, }); return this.toDto(row); } async remove(organizationId: string, id: string): Promise { const existing = await this.prisma.automationRule.findFirst({ where: { id, organizationId }, }); if (!existing) throw new NotFoundException('قانون پیدا نشد'); await this.prisma.automationRule.delete({ where: { id } }); } async runTrigger( trigger: AutomationTrigger, ctx: AutomationContext, ): Promise { const rules = await this.prisma.automationRule.findMany({ where: { organizationId: ctx.organizationId, isActive: true, trigger, }, }); const facts = await this.loadFacts(ctx); for (const rule of rules) { try { const config = asConfig(rule.config); const steps = normalizeAutomationSteps( rule.action as AutomationAction, config, ); for (const step of steps) { if (isAutomationCondition(step)) { if (!matchCondition(step, facts)) break; continue; } if (!step.action) continue; await this.execute(step.action, automationStepToConfig(step), ctx); } } catch { // قوانین نباید شکست عملیات اصلی را ایجاد کنند } } } private async loadFacts(ctx: AutomationContext): Promise { if (ctx.entityType === 'deal') { const deal = await this.prisma.deal.findFirst({ where: { id: ctx.entityId, organizationId: ctx.organizationId }, select: { amount: true, personId: true, companyId: true }, }); return { amount: deal?.amount != null ? Number(deal.amount) : null, hasPerson: Boolean(deal?.personId ?? ctx.personId), hasCompany: Boolean(deal?.companyId ?? ctx.companyId), ticketPriority: null, }; } const ticket = await this.prisma.ticket.findFirst({ where: { id: ctx.entityId, organizationId: ctx.organizationId }, select: { priority: true, personId: true, companyId: true }, }); return { amount: null, hasPerson: Boolean(ticket?.personId ?? ctx.personId), hasCompany: Boolean(ticket?.companyId ?? ctx.companyId), ticketPriority: ticket?.priority ?? null, }; } private async execute( action: AutomationAction, config: AutomationRuleConfig, ctx: AutomationContext, ) { const title = (config.title?.trim() || defaultTitle(action, ctx.title)).slice(0, 200); if (action === 'create_activity') { const assigneeId = config.assigneeId?.trim() || ctx.assigneeId || ctx.ownerId || null; await this.prisma.activity.create({ data: { organizationId: ctx.organizationId, type: (config.activityType as never) || 'task', title, description: config.body?.trim() || null, personId: ctx.personId ?? null, companyId: ctx.companyId ?? null, dealId: ctx.dealId ?? (ctx.entityType === 'deal' ? ctx.entityId : null), assigneeId, }, }); if (assigneeId) { await this.notifications.create({ organizationId: ctx.organizationId, userId: assigneeId, type: 'automation', title: 'فعالیت خودکار', body: title, link: ctx.entityType === 'deal' ? `/deals/${ctx.entityId}` : `/tickets/${ctx.entityId}`, entityType: ctx.entityType, entityId: ctx.entityId, }); } return; } if (action === 'create_notification') { await this.executeNotify(title, config, ctx); return; } if (action === 'add_loyalty_points') { const points = Math.max(1, Math.trunc(config.points ?? 100)); if (!ctx.personId) return; await this.loyalty.addPointsByOrg( ctx.organizationId, ctx.personId, points, ); } } private async executeNotify( title: string, config: AutomationRuleConfig, ctx: AutomationContext, ) { const channel: AutomationNotifyChannel = config.notifyChannel ?? 'app'; const wantApp = channel === 'app' || channel === 'both'; const wantSms = channel === 'sms' || channel === 'both'; const smsText = (config.body?.trim() || title).slice(0, 160); const link = ctx.entityType === 'deal' ? `/deals/${ctx.entityId}` : `/tickets/${ctx.entityId}`; if (config.notifyTarget === 'person') { if (wantSms) { const person = ctx.personId ? await this.prisma.person.findFirst({ where: { id: ctx.personId, organizationId: ctx.organizationId, }, select: { mobile: true, phone: true }, }) : null; await this.sendAutomationSms(person?.mobile || person?.phone, smsText); } return; } const userId = config.notifyUserId?.trim() || ctx.assigneeId || ctx.ownerId || null; if (!userId) return; if (wantApp) { await this.notifications.create({ organizationId: ctx.organizationId, userId, type: 'automation', title, body: config.body?.trim() || ctx.title, link, entityType: ctx.entityType, entityId: ctx.entityId, }); } if (wantSms) { const user = await this.prisma.user.findFirst({ where: { id: userId, organizationId: ctx.organizationId }, select: { mobile: true }, }); await this.sendAutomationSms(user?.mobile, smsText); } } private async sendAutomationSms( mobile: string | null | undefined, text: string, ) { if (!mobile) { this.logger.warn('Automation SMS skipped: no mobile'); return; } if (!this.sms.isConfigured()) { this.logger.warn('Automation SMS skipped: SMS not configured'); return; } const result = await this.sms.sendSmsDetailed(mobile, text); if (!result.ok) { this.logger.warn(`Automation SMS failed: ${result.reason ?? 'unknown'}`); } } private toDto(row: { id: string; name: string; isActive: boolean; trigger: string; action: string; config: unknown; createdAt: Date; updatedAt: Date; }): AutomationRuleDto { return { id: row.id, name: row.name, isActive: row.isActive, trigger: row.trigger as AutomationTrigger, action: row.action as AutomationAction, config: asConfig(row.config), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } } function asConfig(raw: unknown): AutomationRuleConfig { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; return raw as AutomationRuleConfig; } function resolveSteps( action: AutomationAction, config?: AutomationRuleConfig, ): AutomationStep[] { const steps = normalizeAutomationSteps(action, config); if (!firstAutomationAction(steps)) { throw new BadRequestException('حداقل یک عمل لازم است'); } if (steps.length > MAX_AUTOMATION_STEPS) { throw new BadRequestException( `حداکثر ${MAX_AUTOMATION_STEPS} عمل در یک قانون مجاز است`, ); } return steps; } function matchCondition(step: AutomationStep, facts: AutomationFacts): boolean { const field: AutomationConditionField = step.conditionField ?? 'has_person'; const op: AutomationConditionOp = step.conditionOp ?? 'is_set'; if (field === 'deal_amount') { const amount = facts.amount; const value = Number(step.conditionValue); if (amount == null || !Number.isFinite(value)) return false; if (op === 'gte') return amount >= value; if (op === 'lte') return amount <= value; if (op === 'eq') return amount === value; if (op === 'neq') return amount !== value; return false; } if (field === 'has_person') { return op === 'is_empty' ? !facts.hasPerson : facts.hasPerson; } if (field === 'has_company') { return op === 'is_empty' ? !facts.hasCompany : facts.hasCompany; } if (field === 'ticket_priority') { const current = facts.ticketPriority; const value = String(step.conditionValue ?? ''); if (op === 'neq') return current !== value; return current === value; } return false; } function defaultTitle(action: AutomationAction, entityTitle: string) { if (action === 'create_activity') return `پیگیری خودکار: ${entityTitle}`; if (action === 'create_notification') return `اتوماسیون: ${entityTitle}`; return entityTitle; }