import { BadRequestException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import type { AutomationAction, AutomationChainMode, AutomationConditionField, AutomationConditionOp, AutomationNotifyChannel, AutomationRuleConfig, AutomationRuleDto, AutomationStep, AutomationTrigger, CreateAutomationRuleRequest, UpdateAutomationRuleRequest, } from '@mento/shared'; import { MAX_AUTOMATION_STEPS, automationStepToConfig, automationUsesAdvanced, firstAutomationAction, formConditionValues, matchFormFieldCondition, normalizeAutomationSteps, packAutomationConfig, planAutomationActions, } 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'; import { formatFormAnswers, pickRoundRobin, renderAutomationTemplate, type FormFieldLabel, } from './automation-engine.util'; export type AutomationContext = { organizationId: string; entityType: 'deal' | 'ticket' | 'person' | 'invoice' | 'activity' | 'lead'; entityId: string; title: string; personId?: string | null; companyId?: string | null; dealId?: string | null; leadId?: string | null; ownerId?: string | null; assigneeId?: string | null; /** مثلاً id کمپین SMS یا id فرم سرنخ برای فیلتر قانون */ sourceId?: string | null; sourceType?: string | null; clickCount?: number | null; mobile?: string | null; formName?: string | null; formValues?: Record | null; formFields?: FormFieldLabel[] | null; }; type AutomationFacts = { amount: number | null; hasPerson: boolean; hasCompany: boolean; ticketPriority: string | null; formValues: Record; }; type StepRef = { ruleId: string; stepId: string }; @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('حداقل یک عمل لازم است'); await this.assertStepRefs(organizationId, steps); await this.assertLevelAllows(organizationId, steps, chainExtra(dto.config).chainMode); const row = await this.prisma.automationRule.create({ data: { organizationId, name: dto.name.trim(), trigger: dto.trigger, action, isActive: dto.isActive ?? true, config: packAutomationConfig(steps, chainExtra(dto.config)) 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('حداقل یک عمل لازم است'); } if (steps) { await this.assertStepRefs(organizationId, steps); await this.assertLevelAllows( organizationId, steps, chainExtra(dto.config ?? asConfig(existing.config)).chainMode, ); } 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, chainExtra(dto.config ?? asConfig(existing.config)), ) 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, }, }); if (!rules.length) return; const facts: AutomationFacts = { ...(await this.loadFacts(ctx)), formValues: ctx.formValues ?? {}, }; for (const rule of rules) { try { const config = asConfig(rule.config); if ( config.sourceId && ctx.sourceId && config.sourceId !== ctx.sourceId ) { continue; } if (config.sourceId && !ctx.sourceId) continue; const steps = normalizeAutomationSteps( rule.action as AutomationAction, config, ); const actions = planAutomationActions(steps, config.chainMode, (step) => matchCondition(step, facts), ); for (const step of actions) { try { await this.execute(step.action!, automationStepToConfig(step), ctx, { ruleId: rule.id, stepId: step.id, }); } catch (e) { this.logger.warn( `Automation ${rule.id} step ${step.id} failed: ${(e as Error)?.message ?? e}`, ); } } } 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, }; } if (ctx.entityType === 'ticket') { 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, }; } if (ctx.entityType === 'invoice') { const invoice = await this.prisma.invoice.findFirst({ where: { id: ctx.entityId, organizationId: ctx.organizationId }, select: { personId: true, companyId: true, discountAmount: true, taxPercent: true, lineItems: { select: { quantity: true, unitPrice: true } }, }, }); let amount: number | null = null; if (invoice) { const subtotal = invoice.lineItems.reduce( (sum, item) => sum + Number(item.quantity) * Number(item.unitPrice), 0, ); const discount = Number(invoice.discountAmount ?? 0); const taxPercent = Number(invoice.taxPercent ?? 0); amount = Math.abs( subtotal - discount + Math.max(0, subtotal - discount) * (taxPercent / 100), ); } return { amount, hasPerson: Boolean(invoice?.personId ?? ctx.personId), hasCompany: Boolean(invoice?.companyId ?? ctx.companyId), ticketPriority: null, }; } if (ctx.entityType === 'activity') { const activity = await this.prisma.activity.findFirst({ where: { id: ctx.entityId, organizationId: ctx.organizationId }, select: { personId: true, companyId: true, dealId: true }, }); return { amount: null, hasPerson: Boolean(activity?.personId ?? ctx.personId), hasCompany: Boolean(activity?.companyId ?? ctx.companyId), ticketPriority: null, }; } return { amount: null, hasPerson: Boolean(ctx.personId), hasCompany: Boolean(ctx.companyId), ticketPriority: null, }; } private async execute( action: AutomationAction, rawConfig: AutomationRuleConfig, ctx: AutomationContext, ref: StepRef, ) { const vars = { title: ctx.title, mobile: ctx.mobile, formName: ctx.formName, formValues: ctx.formValues, }; const config: AutomationRuleConfig = { ...rawConfig, title: rawConfig.title ? renderAutomationTemplate(rawConfig.title, vars) : rawConfig.title, body: rawConfig.body ? renderAutomationTemplate(rawConfig.body, vars) : rawConfig.body, }; const title = (config.title?.trim() || defaultTitle(action, ctx.title)).slice(0, 200); if (action === 'create_activity') { await this.executeCreateActivity(title, config, ctx, ref); 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 executeCreateActivity( title: string, config: AutomationRuleConfig, ctx: AutomationContext, ref: StepRef, ) { const assigneeId = await this.resolveAssignee(config, ctx, ref); let type = (config.activityType as 'call' | 'meeting' | 'task' | 'note') || 'task'; let typeDefId: string | null = null; if (config.activityTypeDefId) { const def = await this.prisma.activityTypeDef.findFirst({ where: { id: config.activityTypeDefId, organizationId: ctx.organizationId }, select: { id: true, baseType: true }, }); if (def) { typeDefId = def.id; type = def.baseType as typeof type; } } const offset = config.dueOffsetMinutes; const dueAt = typeof offset === 'number' && Number.isFinite(offset) ? new Date(Date.now() + Math.max(0, offset) * 60_000) : null; const description = [ config.body?.trim(), leadSummary(ctx), ] .filter(Boolean) .join('\n\n') .slice(0, 5000); const activity = await this.prisma.activity.create({ data: { organizationId: ctx.organizationId, type, typeDefId, title, description: description || null, dueAt, personId: ctx.personId ?? null, companyId: ctx.companyId ?? null, dealId: ctx.dealId ?? (ctx.entityType === 'deal' ? ctx.entityId : null), leadId: ctx.leadId ?? (ctx.entityType === 'lead' ? ctx.entityId : null), assigneeId, assignees: assigneeId ? { connect: [{ id: assigneeId }] } : undefined, tags: ['automation'], }, select: { id: true }, }); if (assigneeId) { await this.notifications.create({ organizationId: ctx.organizationId, userId: assigneeId, type: 'automation', title: 'فعالیت خودکار', body: title, link: `/activities/${activity.id}`, entityType: 'activity', entityId: activity.id, }); } } private async resolveAssignee( config: AutomationRuleConfig, ctx: AutomationContext, ref: StepRef, ): Promise { const fallback = ctx.assigneeId || ctx.ownerId || null; const mode = config.assignMode ?? (config.assigneeId ? 'user' : 'owner'); if (mode === 'user') { const id = config.assigneeId?.trim(); if (id && (await this.isMember(ctx.organizationId, id))) return id; return fallback; } if (mode === 'team_round_robin' || mode === 'users_round_robin') { let candidates: string[] = []; if (mode === 'team_round_robin' && config.assigneeTeamId) { const members = await this.prisma.teamMember.findMany({ where: { teamId: config.assigneeTeamId, team: { organizationId: ctx.organizationId }, }, select: { userId: true }, }); candidates = members.map((m) => m.userId); } else if (mode === 'users_round_robin') { candidates = config.assigneeUserIds ?? []; } const valid = await this.filterMembers(ctx.organizationId, candidates); if (!valid.length) return fallback; const cursor = await this.prisma.automationCursor.upsert({ where: { ruleId_stepId: { ruleId: ref.ruleId, stepId: ref.stepId } }, create: { ruleId: ref.ruleId, stepId: ref.stepId, counter: 1 }, update: { counter: { increment: 1 } }, select: { counter: true }, }); return pickRoundRobin(valid, cursor.counter) ?? fallback; } return fallback; } private async isMember(organizationId: string, userId: string) { return (await this.filterMembers(organizationId, [userId])).length > 0; } private async filterMembers(organizationId: string, userIds: string[]) { const ids = [...new Set(userIds.filter(Boolean))]; if (!ids.length) return []; const rows = await this.prisma.user.findMany({ where: { id: { in: ids }, memberships: { some: { organizationId } }, }, select: { id: true }, }); return rows.map((r) => r.id); } private async assertStepRefs(organizationId: string, steps: AutomationStep[]) { for (const step of steps) { if (step.kind === 'condition' || step.action !== 'create_activity') continue; if (step.assignMode === 'team_round_robin') { if (!step.assigneeTeamId) { throw new BadRequestException('برای تقسیم نوبتی، تیم را انتخاب کنید'); } const team = await this.prisma.team.findFirst({ where: { id: step.assigneeTeamId, organizationId }, select: { id: true }, }); if (!team) throw new BadRequestException('تیم انتخاب‌شده معتبر نیست'); } if (step.assignMode === 'users_round_robin') { const ids = step.assigneeUserIds ?? []; if (!ids.length) { throw new BadRequestException('برای تقسیم نوبتی، حداقل یک کاربر انتخاب کنید'); } const valid = await this.filterMembers(organizationId, ids); if (valid.length !== new Set(ids).size) { throw new BadRequestException('یکی از کاربران انتخاب‌شده معتبر نیست'); } } if (step.assignMode === 'user' && step.assigneeId) { if (!(await this.isMember(organizationId, step.assigneeId))) { throw new BadRequestException('مسئول انتخاب‌شده معتبر نیست'); } } } } private async assertLevelAllows( organizationId: string, steps: AutomationStep[], chainMode: AutomationChainMode | undefined, ) { if (!automationUsesAdvanced(steps, chainMode)) return; const org = await this.prisma.organization.findUnique({ where: { id: organizationId }, select: { enabledModules: true }, }); if (!org?.enabledModules.includes('workflow_automations_advanced')) { throw new BadRequestException( 'شاخه‌ها، شرط روی پاسخ فرم و تقسیم نوبتی فقط در اتوماسیون حرفه‌ای فعال است', ); } } 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 = entityLink(ctx); 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, memberships: { some: { 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; } if (field === 'form_field') { const key = step.conditionKey?.trim(); if (!key) return false; return matchFormFieldCondition(op, facts.formValues[key], formConditionValues(step)); } return false; } function chainExtra( config?: AutomationRuleConfig | null, ): Pick { return { sourceId: config?.sourceId ?? null, chainMode: config?.chainMode === 'branches' ? 'branches' : config?.chainMode === 'stop' ? 'stop' : undefined, }; } function leadSummary(ctx: AutomationContext): string { if (ctx.sourceType !== 'lead_form') return ''; const head = [ ctx.formName ? `فرم: ${ctx.formName}` : '', ctx.title ? `نام: ${ctx.title}` : '', ctx.mobile ? `موبایل: ${ctx.mobile}` : '', ].filter(Boolean); const answers = formatFormAnswers(ctx.formFields, ctx.formValues); return [head.join('\n'), answers ? `پاسخ‌های فرم:\n${answers}` : ''] .filter(Boolean) .join('\n\n'); } function defaultTitle(action: AutomationAction, entityTitle: string) { if (action === 'create_activity') return `پیگیری خودکار: ${entityTitle}`; if (action === 'create_notification') return `اتوماسیون: ${entityTitle}`; return entityTitle; } function entityLink(ctx: AutomationContext) { if (ctx.entityType === 'deal') return `/deals/${ctx.entityId}`; if (ctx.entityType === 'ticket') return `/tickets/${ctx.entityId}`; if (ctx.entityType === 'invoice') return `/invoices/${ctx.entityId}`; if (ctx.entityType === 'person') return `/contacts/people/${ctx.entityId}`; if (ctx.entityType === 'activity') return `/activities/${ctx.entityId}`; if (ctx.entityType === 'lead') return `/leads/${ctx.entityId}`; return '/'; }