diff --git a/apps/api/src/automations/automations.module.ts b/apps/api/src/automations/automations.module.ts index 106d878..9551e10 100644 --- a/apps/api/src/automations/automations.module.ts +++ b/apps/api/src/automations/automations.module.ts @@ -2,13 +2,14 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { LoyaltyModule } from '../loyalty/loyalty.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { SmsService } from '../common/sms.service'; import { AutomationsController } from './automations.controller'; import { AutomationsService } from './automations.service'; @Module({ imports: [AuthModule, NotificationsModule, LoyaltyModule], controllers: [AutomationsController], - providers: [AutomationsService], + providers: [AutomationsService, SmsService], exports: [AutomationsService], }) export class AutomationsModule {} diff --git a/apps/api/src/automations/automations.service.ts b/apps/api/src/automations/automations.service.ts index 730f0f2..3fff50d 100644 --- a/apps/api/src/automations/automations.service.ts +++ b/apps/api/src/automations/automations.service.ts @@ -1,16 +1,34 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +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; @@ -24,12 +42,22 @@ export type AutomationContext = { 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 { @@ -44,14 +72,17 @@ export class AutomationsService { 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: dto.action, + action, isActive: dto.isActive ?? true, - config: (dto.config ?? {}) as Prisma.InputJsonValue, + config: packAutomationConfig(steps) as Prisma.InputJsonValue, }, }); return this.toDto(row); @@ -67,15 +98,30 @@ export class AutomationsService { }); 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.action !== undefined ? { action: dto.action } : {}), ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}), - ...(dto.config !== undefined - ? { config: dto.config as Prisma.InputJsonValue } + ...(steps && nextAction + ? { + action: nextAction, + config: packAutomationConfig(steps) as Prisma.InputJsonValue, + } : {}), }, }); @@ -101,15 +147,53 @@ export class AutomationsService { trigger, }, }); + const facts = await this.loadFacts(ctx); for (const rule of rules) { try { - await this.execute(rule.action as AutomationAction, asConfig(rule.config), ctx); + 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, @@ -155,25 +239,7 @@ export class AutomationsService { } if (action === 'create_notification') { - const userId = - config.notifyUserId?.trim() || - ctx.assigneeId || - ctx.ownerId || - null; - if (!userId) return; - await this.notifications.create({ - organizationId: ctx.organizationId, - userId, - type: 'automation', - title, - body: config.body?.trim() || ctx.title, - link: - ctx.entityType === 'deal' - ? `/deals/${ctx.entityId}` - : `/tickets/${ctx.entityId}`, - entityType: ctx.entityType, - entityId: ctx.entityId, - }); + await this.executeNotify(title, config, ctx); return; } @@ -188,6 +254,83 @@ export class AutomationsService { } } + 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; @@ -216,6 +359,55 @@ function asConfig(raw: unknown): AutomationRuleConfig { 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}`; diff --git a/apps/web/src/components/automations/AutomationCanvas.vue b/apps/web/src/components/automations/AutomationCanvas.vue new file mode 100644 index 0000000..9000d71 --- /dev/null +++ b/apps/web/src/components/automations/AutomationCanvas.vue @@ -0,0 +1,551 @@ + + + + + diff --git a/apps/web/src/components/automations/AutomationInspector.vue b/apps/web/src/components/automations/AutomationInspector.vue new file mode 100644 index 0000000..5e0dc5d --- /dev/null +++ b/apps/web/src/components/automations/AutomationInspector.vue @@ -0,0 +1,338 @@ + + +