Files
CRM_Montzar/apps/api/src/automations/automations.service.ts
T

715 lines
23 KiB
TypeScript

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<string, unknown> | null;
formFields?: FormFieldLabel[] | null;
};
type AutomationFacts = {
amount: number | null;
hasPerson: boolean;
hasCompany: boolean;
ticketPriority: string | null;
formValues: Record<string, unknown>;
};
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<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> {
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<AutomationRuleDto> {
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<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,
},
});
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<Omit<AutomationFacts, 'formValues'>> {
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<string | null> {
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<AutomationRuleConfig, 'sourceId' | 'chainMode'> {
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 '/';
}