2026-09-09 13:12:13 +03:30
|
|
|
import {
|
|
|
|
|
BadRequestException,
|
|
|
|
|
Injectable,
|
|
|
|
|
Logger,
|
|
|
|
|
NotFoundException,
|
|
|
|
|
} from '@nestjs/common';
|
2026-08-30 09:42:07 +03:30
|
|
|
import type {
|
|
|
|
|
AutomationAction,
|
2026-09-09 13:12:13 +03:30
|
|
|
AutomationConditionField,
|
|
|
|
|
AutomationConditionOp,
|
|
|
|
|
AutomationNotifyChannel,
|
2026-08-30 09:42:07 +03:30
|
|
|
AutomationRuleConfig,
|
|
|
|
|
AutomationRuleDto,
|
2026-09-09 13:12:13 +03:30
|
|
|
AutomationStep,
|
2026-08-30 09:42:07 +03:30
|
|
|
AutomationTrigger,
|
|
|
|
|
CreateAutomationRuleRequest,
|
|
|
|
|
UpdateAutomationRuleRequest,
|
|
|
|
|
} from '@mento/shared';
|
2026-09-09 13:12:13 +03:30
|
|
|
import {
|
|
|
|
|
MAX_AUTOMATION_STEPS,
|
|
|
|
|
automationStepToConfig,
|
|
|
|
|
firstAutomationAction,
|
|
|
|
|
isAutomationCondition,
|
|
|
|
|
normalizeAutomationSteps,
|
|
|
|
|
packAutomationConfig,
|
|
|
|
|
} from '@mento/shared';
|
2026-08-30 09:42:07 +03:30
|
|
|
import { Prisma } from '@prisma/client';
|
|
|
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
|
import { NotificationsService } from '../notifications/notifications.service';
|
|
|
|
|
import { LoyaltyService } from '../loyalty/loyalty.service';
|
2026-09-09 13:12:13 +03:30
|
|
|
import { SmsService } from '../common/sms.service';
|
2026-08-30 09:42:07 +03:30
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-09 13:12:13 +03:30
|
|
|
type AutomationFacts = {
|
|
|
|
|
amount: number | null;
|
|
|
|
|
hasPerson: boolean;
|
|
|
|
|
hasCompany: boolean;
|
|
|
|
|
ticketPriority: string | null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-30 09:42:07 +03:30
|
|
|
@Injectable()
|
|
|
|
|
export class AutomationsService {
|
2026-09-09 13:12:13 +03:30
|
|
|
private readonly logger = new Logger(AutomationsService.name);
|
|
|
|
|
|
2026-08-30 09:42:07 +03:30
|
|
|
constructor(
|
|
|
|
|
private readonly prisma: PrismaService,
|
|
|
|
|
private readonly notifications: NotificationsService,
|
|
|
|
|
private readonly loyalty: LoyaltyService,
|
2026-09-09 13:12:13 +03:30
|
|
|
private readonly sms: SmsService,
|
2026-08-30 09:42:07 +03:30
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
async list(organizationId: string): Promise<AutomationRuleDto[]> {
|
|
|
|
|
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<AutomationRuleDto> {
|
2026-09-09 13:12:13 +03:30
|
|
|
const steps = resolveSteps(dto.action, dto.config);
|
|
|
|
|
const action = firstAutomationAction(steps);
|
|
|
|
|
if (!action) throw new BadRequestException('حداقل یک عمل لازم است');
|
2026-08-30 09:42:07 +03:30
|
|
|
const row = await this.prisma.automationRule.create({
|
|
|
|
|
data: {
|
|
|
|
|
organizationId,
|
|
|
|
|
name: dto.name.trim(),
|
|
|
|
|
trigger: dto.trigger,
|
2026-09-09 13:12:13 +03:30
|
|
|
action,
|
2026-08-30 09:42:07 +03:30
|
|
|
isActive: dto.isActive ?? true,
|
2026-09-09 13:12:13 +03:30
|
|
|
config: packAutomationConfig(steps) as Prisma.InputJsonValue,
|
2026-08-30 09:42:07 +03:30
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return this.toDto(row);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async update(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
id: string,
|
|
|
|
|
dto: UpdateAutomationRuleRequest,
|
|
|
|
|
): Promise<AutomationRuleDto> {
|
|
|
|
|
const existing = await this.prisma.automationRule.findFirst({
|
|
|
|
|
where: { id, organizationId },
|
|
|
|
|
});
|
|
|
|
|
if (!existing) throw new NotFoundException('قانون پیدا نشد');
|
|
|
|
|
|
2026-09-09 13:12:13 +03:30
|
|
|
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('حداقل یک عمل لازم است');
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 09:42:07 +03:30
|
|
|
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 } : {}),
|
2026-09-09 13:12:13 +03:30
|
|
|
...(steps && nextAction
|
|
|
|
|
? {
|
|
|
|
|
action: nextAction,
|
|
|
|
|
config: packAutomationConfig(steps) as Prisma.InputJsonValue,
|
|
|
|
|
}
|
2026-08-30 09:42:07 +03:30
|
|
|
: {}),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return this.toDto(row);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async remove(organizationId: string, id: string): Promise<void> {
|
|
|
|
|
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<void> {
|
|
|
|
|
const rules = await this.prisma.automationRule.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
organizationId: ctx.organizationId,
|
|
|
|
|
isActive: true,
|
|
|
|
|
trigger,
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-09-09 13:12:13 +03:30
|
|
|
const facts = await this.loadFacts(ctx);
|
2026-08-30 09:42:07 +03:30
|
|
|
for (const rule of rules) {
|
|
|
|
|
try {
|
2026-09-09 13:12:13 +03:30
|
|
|
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);
|
|
|
|
|
}
|
2026-08-30 09:42:07 +03:30
|
|
|
} catch {
|
|
|
|
|
// قوانین نباید شکست عملیات اصلی را ایجاد کنند
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 13:12:13 +03:30
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 09:42:07 +03:30
|
|
|
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') {
|
2026-09-09 13:12:13 +03:30
|
|
|
await this.executeNotify(title, config, ctx);
|
2026-08-30 09:42:07 +03:30
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 13:12:13 +03:30
|
|
|
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'}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 09:42:07 +03:30
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 13:12:13 +03:30
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 09:42:07 +03:30
|
|
|
function defaultTitle(action: AutomationAction, entityTitle: string) {
|
|
|
|
|
if (action === 'create_activity') return `پیگیری خودکار: ${entityTitle}`;
|
|
|
|
|
if (action === 'create_notification') return `اتوماسیون: ${entityTitle}`;
|
|
|
|
|
return entityTitle;
|
|
|
|
|
}
|