feat(automations): form-driven branching rules, round-robin assignment, lead-linked activities

This commit is contained in:
2026-10-11 14:12:36 +03:30
parent b30f68563d
commit b3b92abae9
27 changed files with 1160 additions and 91 deletions
@@ -0,0 +1,51 @@
export type FormFieldLabel = { key: string; label: string };
export type TemplateVars = {
title: string;
mobile?: string | null;
formName?: string | null;
formValues?: Record<string, unknown> | null;
};
/** counter یک‌واحد افزایش‌یافته از AutomationCursor است (از ۱ شروع می‌شود) */
export function pickRoundRobin(memberIds: string[], counter: number): string | null {
const ids = [...new Set(memberIds.filter(Boolean))].sort();
if (!ids.length) return null;
const n = Math.max(1, Math.trunc(counter));
return ids[(n - 1) % ids.length] ?? null;
}
/** جایگزینی {{title}}، {{mobile}}، {{form}} و {{field.<key>}} */
export function renderAutomationTemplate(text: string, vars: TemplateVars): string {
return text.replace(/\{\{\s*([\w.\u0600-\u06FF-]+)\s*\}\}/g, (_m, name: string) => {
if (name === 'title') return vars.title ?? '';
if (name === 'mobile') return vars.mobile ?? '';
if (name === 'form') return vars.formName ?? '';
if (name.startsWith('field.')) {
const v = vars.formValues?.[name.slice(6)];
return v == null ? '' : String(v);
}
return '';
});
}
export function formatFormAnswers(
fields: FormFieldLabel[] | null | undefined,
values: Record<string, unknown> | null | undefined,
): string {
if (!values) return '';
const lines: string[] = [];
const seen = new Set<string>();
for (const f of fields ?? []) {
seen.add(f.key);
const raw = values[f.key];
const v = raw == null ? '' : String(raw).trim();
if (v) lines.push(`${f.label}: ${v}`);
}
for (const [key, raw] of Object.entries(values)) {
if (seen.has(key)) continue;
const v = raw == null ? '' : String(raw).trim();
if (v) lines.push(`${key}: ${v}`);
}
return lines.join('\n');
}
+246 -43
View File
@@ -20,30 +20,42 @@ import {
MAX_AUTOMATION_STEPS,
automationStepToConfig,
firstAutomationAction,
isAutomationCondition,
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';
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 کمپین 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 = {
@@ -51,8 +63,11 @@ type AutomationFacts = {
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);
@@ -79,6 +94,7 @@ export class AutomationsService {
const steps = resolveSteps(dto.action, dto.config);
const action = firstAutomationAction(steps);
if (!action) throw new BadRequestException('حداقل یک عمل لازم است');
await this.assertStepRefs(organizationId, steps);
const row = await this.prisma.automationRule.create({
data: {
organizationId,
@@ -86,7 +102,7 @@ export class AutomationsService {
trigger: dto.trigger,
action,
isActive: dto.isActive ?? true,
config: packAutomationConfig(steps) as Prisma.InputJsonValue,
config: packAutomationConfig(steps, chainExtra(dto.config)) as Prisma.InputJsonValue,
},
});
return this.toDto(row);
@@ -114,6 +130,7 @@ export class AutomationsService {
if (steps && !nextAction) {
throw new BadRequestException('حداقل یک عمل لازم است');
}
if (steps) await this.assertStepRefs(organizationId, steps);
const row = await this.prisma.automationRule.update({
where: { id },
@@ -124,7 +141,10 @@ export class AutomationsService {
...(steps && nextAction
? {
action: nextAction,
config: packAutomationConfig(steps) as Prisma.InputJsonValue,
config: packAutomationConfig(
steps,
chainExtra(dto.config ?? asConfig(existing.config)),
) as Prisma.InputJsonValue,
}
: {}),
},
@@ -151,7 +171,11 @@ export class AutomationsService {
trigger,
},
});
const facts = await this.loadFacts(ctx);
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);
@@ -167,13 +191,20 @@ export class AutomationsService {
rule.action as AutomationAction,
config,
);
for (const step of steps) {
if (isAutomationCondition(step)) {
if (!matchCondition(step, facts)) break;
continue;
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}`,
);
}
if (!step.action) continue;
await this.execute(step.action, automationStepToConfig(step), ctx);
}
} catch {
// قوانین نباید شکست عملیات اصلی را ایجاد کنند
@@ -181,7 +212,9 @@ export class AutomationsService {
}
}
private async loadFacts(ctx: AutomationContext): Promise<AutomationFacts> {
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 },
@@ -258,42 +291,26 @@ export class AutomationsService {
private async execute(
action: AutomationAction,
config: AutomationRuleConfig,
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') {
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: entityLink(ctx),
entityType: ctx.entityType,
entityId: ctx.entityId,
});
}
await this.executeCreateActivity(title, config, ctx, ref);
return;
}
@@ -313,6 +330,163 @@ export class AutomationsService {
}
}
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 executeNotify(
title: string,
config: AutomationRuleConfig,
@@ -464,9 +638,37 @@ function matchCondition(step: AutomationStep, facts: AutomationFacts): boolean {
return current === value;
}
if (field === 'form_field') {
const key = step.conditionKey?.trim();
if (!key) return false;
return matchFormFieldCondition(op, facts.formValues[key], step.conditionValue);
}
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}`;
@@ -479,5 +681,6 @@ function entityLink(ctx: AutomationContext) {
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 '/';
}