Give automations a visual chain with conditions and SMS, and match the pipeline board to the sales mockup.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 {}
|
||||
|
||||
@@ -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<AutomationRuleDto[]> {
|
||||
@@ -44,14 +72,17 @@ export class AutomationsService {
|
||||
organizationId: string,
|
||||
dto: CreateAutomationRuleRequest,
|
||||
): Promise<AutomationRuleDto> {
|
||||
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<AutomationFacts> {
|
||||
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}`;
|
||||
|
||||
Reference in New Issue
Block a user