715 lines
23 KiB
TypeScript
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 '/';
|
|
}
|