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:
2026-09-09 13:12:13 +03:30
co-authored by Cursor
parent 48d3469eea
commit 3ec87ff912
17 changed files with 2355 additions and 410 deletions
@@ -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 {}
+218 -26
View File
@@ -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}`;