feat(automations): form-driven branching rules, round-robin assignment, lead-linked activities
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
"prisma:seed": "tsx prisma/seed.ts",
|
||||
"lint": "echo \"api lint placeholder\"",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
|
||||
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts"
|
||||
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mento/shared": "*",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Activity" ADD COLUMN "leadId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AutomationCursor" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ruleId" TEXT NOT NULL,
|
||||
"stepId" TEXT NOT NULL,
|
||||
"counter" INTEGER NOT NULL DEFAULT 0,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AutomationCursor_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Activity_leadId_idx" ON "Activity"("leadId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AutomationCursor_ruleId_stepId_key" ON "AutomationCursor"("ruleId", "stepId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AutomationCursor" ADD CONSTRAINT "AutomationCursor_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "AutomationRule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -725,11 +725,15 @@ model Activity {
|
||||
fields Json?
|
||||
/// کاربران شرکتکننده؛ فعالیت در لیست آنها هم دیده میشود
|
||||
participantIds String[] @default([])
|
||||
/// لید مرتبط (مثلاً فعالیت ساختهشده از ارسال فرم)
|
||||
leadId String?
|
||||
lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([stageId])
|
||||
@@index([typeDefId])
|
||||
@@index([leadId])
|
||||
@@index([organizationId, status, dueAt])
|
||||
@@index([organizationId, assigneeId])
|
||||
@@index([organizationId, createdById])
|
||||
@@ -1443,12 +1447,25 @@ model AutomationRule {
|
||||
action AutomationAction
|
||||
/// عنوان فعالیت/اعلان، assigneeId، notifyUserId، points، activityType
|
||||
config Json @default("{}")
|
||||
cursors AutomationCursor[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, isActive, trigger])
|
||||
}
|
||||
|
||||
/// شمارندهٔ تقسیم نوبتی برای هر گام عمل در یک قانون اتوماسیون
|
||||
model AutomationCursor {
|
||||
id String @id @default(cuid())
|
||||
ruleId String
|
||||
rule AutomationRule @relation(fields: [ruleId], references: [id], onDelete: Cascade)
|
||||
stepId String
|
||||
counter Int @default(0)
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([ruleId, stepId])
|
||||
}
|
||||
|
||||
enum MessageAutomationTrigger {
|
||||
person_created
|
||||
lead_submitted
|
||||
@@ -2400,6 +2417,7 @@ model Lead {
|
||||
trackingLinks TrackingLink[]
|
||||
trackingEvents TrackingEvent[]
|
||||
routineTasks RoutineTask[]
|
||||
activities Activity[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -173,6 +173,7 @@ export class ActivitiesController {
|
||||
personId: query.personId,
|
||||
companyId: query.companyId,
|
||||
dealId: query.dealId,
|
||||
leadId: query.leadId,
|
||||
},
|
||||
{
|
||||
from: query.from,
|
||||
|
||||
@@ -69,6 +69,7 @@ type ActivityRow = {
|
||||
person: { id: string; firstName: string; lastName: string } | null;
|
||||
company: { id: string; name: string } | null;
|
||||
deal: { id: string; title: string } | null;
|
||||
lead?: { id: string; title: string } | null;
|
||||
assignee: { id: string; fullName: string } | null;
|
||||
assignees: Array<{ id: string; fullName: string }>;
|
||||
createdBy: { id: string; fullName: string } | null;
|
||||
@@ -84,6 +85,7 @@ const include = {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
deal: { select: { id: true, title: true } },
|
||||
lead: { select: { id: true, title: true } },
|
||||
assignee: { select: { id: true, fullName: true } },
|
||||
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' as const } },
|
||||
createdBy: { select: { id: true, fullName: true } },
|
||||
@@ -190,6 +192,7 @@ export class ActivitiesService {
|
||||
personId?: string;
|
||||
companyId?: string;
|
||||
dealId?: string;
|
||||
leadId?: string;
|
||||
},
|
||||
range?: {
|
||||
from?: string;
|
||||
@@ -602,6 +605,7 @@ export class ActivitiesService {
|
||||
personId?: string;
|
||||
companyId?: string;
|
||||
dealId?: string;
|
||||
leadId?: string;
|
||||
},
|
||||
range?: {
|
||||
from?: string;
|
||||
@@ -643,6 +647,7 @@ export class ActivitiesService {
|
||||
...(entity?.personId ? [{ personId: entity.personId }] : []),
|
||||
...(entity?.companyId ? [{ companyId: entity.companyId }] : []),
|
||||
...(entity?.dealId ? [{ dealId: entity.dealId }] : []),
|
||||
...(entity?.leadId ? [{ leadId: entity.leadId }] : []),
|
||||
];
|
||||
const search = buildSearchWhere<Prisma.ActivityWhereInput>(q, (t) => [
|
||||
{ title: ci(t) },
|
||||
@@ -872,6 +877,7 @@ export class ActivitiesService {
|
||||
: null,
|
||||
company: row.company,
|
||||
deal: row.deal,
|
||||
lead: row.lead ?? null,
|
||||
assignee: row.assignee,
|
||||
assignees: (() => {
|
||||
const list = [...(row.assignees ?? [])];
|
||||
|
||||
@@ -91,6 +91,10 @@ export class ListActivitiesQueryDto {
|
||||
@IsString()
|
||||
dealId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
leadId?: string;
|
||||
|
||||
/** Inclusive start (ISO). When set with/without filter, restricts dueAt. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 '/';
|
||||
}
|
||||
|
||||
@@ -385,7 +385,19 @@ export class LeadFormsService {
|
||||
personId,
|
||||
ownerId: ownerId ?? actorId,
|
||||
};
|
||||
await this.automations.runTrigger('lead_submitted', leadCtx);
|
||||
await this.automations.runTrigger('lead_submitted', {
|
||||
...leadCtx,
|
||||
entityType: personId ? 'person' : leadId ? 'lead' : 'person',
|
||||
sourceId: form.id,
|
||||
sourceType: 'lead_form',
|
||||
leadId,
|
||||
mobile: mapped.mobile ?? mapped.phone ?? null,
|
||||
formName: form.name,
|
||||
formValues: values,
|
||||
formFields: form.fields
|
||||
.filter((f) => f.fieldType !== 'heading')
|
||||
.map((f) => ({ key: f.key, label: f.label })),
|
||||
});
|
||||
await this.messageAutomations.onEvent('lead_submitted', leadCtx);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
matchFormFieldCondition,
|
||||
planAutomationActions,
|
||||
splitFormAnswer,
|
||||
type AutomationStep,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
formatFormAnswers,
|
||||
pickRoundRobin,
|
||||
renderAutomationTemplate,
|
||||
} from '../src/automations/automation-engine.util';
|
||||
|
||||
const KEY = 'coop_type';
|
||||
const answers = (raw: string): Record<string, string> => ({ [KEY]: raw });
|
||||
|
||||
function cond(id: string, value: string): AutomationStep {
|
||||
return {
|
||||
id,
|
||||
kind: 'condition',
|
||||
conditionField: 'form_field',
|
||||
conditionKey: KEY,
|
||||
conditionOp: 'contains',
|
||||
conditionValue: value,
|
||||
};
|
||||
}
|
||||
|
||||
function act(id: string): AutomationStep {
|
||||
return { id, kind: 'action', action: 'create_activity' };
|
||||
}
|
||||
|
||||
const BSJ: AutomationStep[] = [
|
||||
cond('c1', 'بنکداری'),
|
||||
act('a1'),
|
||||
cond('c2', 'آنلاین شاپ'),
|
||||
act('a2'),
|
||||
cond('c3', 'ویترین نقره'),
|
||||
act('a3'),
|
||||
];
|
||||
|
||||
function run(steps: AutomationStep[], raw: string, mode: 'branches' | 'stop' = 'branches') {
|
||||
const values = answers(raw);
|
||||
return planAutomationActions(steps, mode, (s) =>
|
||||
matchFormFieldCondition(s.conditionOp, values[s.conditionKey ?? ''], s.conditionValue),
|
||||
).map((s) => s.id);
|
||||
}
|
||||
|
||||
describe('اتوماسیون شاخهای فرم', () => {
|
||||
it('چندانتخابی را میشکند', () => {
|
||||
assert.deepEqual(splitFormAnswer('بنکداری, آنلاین شاپ'), ['بنکداری', 'آنلاین شاپ']);
|
||||
assert.deepEqual(splitFormAnswer('الف، ب'), ['الف', 'ب']);
|
||||
assert.deepEqual(splitFormAnswer(''), []);
|
||||
assert.deepEqual(splitFormAnswer(undefined), []);
|
||||
});
|
||||
|
||||
it('عملگرهای فیلد فرم', () => {
|
||||
assert.equal(matchFormFieldCondition('contains', 'بنکداری, ویترین نقره', 'ویترین نقره'), true);
|
||||
assert.equal(matchFormFieldCondition('contains', 'بنکداری', 'آنلاین شاپ'), false);
|
||||
assert.equal(matchFormFieldCondition('not_contains', 'بنکداری', 'آنلاین شاپ'), true);
|
||||
assert.equal(matchFormFieldCondition('eq', 'بنکداری', 'بنکداری'), true);
|
||||
assert.equal(matchFormFieldCondition('eq', 'بنکداری, آنلاین شاپ', 'بنکداری'), false);
|
||||
assert.equal(matchFormFieldCondition('is_set', 'x', ''), true);
|
||||
assert.equal(matchFormFieldCondition('is_empty', '', ''), true);
|
||||
});
|
||||
|
||||
it('هر گزینه فقط شاخهٔ خودش را اجرا میکند', () => {
|
||||
assert.deepEqual(run(BSJ, 'بنکداری'), ['a1']);
|
||||
assert.deepEqual(run(BSJ, 'آنلاین شاپ'), ['a2']);
|
||||
assert.deepEqual(run(BSJ, 'ویترین نقره'), ['a3']);
|
||||
});
|
||||
|
||||
it('چند تیک = چند فعالیت', () => {
|
||||
assert.deepEqual(run(BSJ, 'بنکداری, ویترین نقره'), ['a1', 'a3']);
|
||||
assert.deepEqual(run(BSJ, 'بنکداری, آنلاین شاپ, ویترین نقره'), ['a1', 'a2', 'a3']);
|
||||
assert.deepEqual(run(BSJ, ''), []);
|
||||
});
|
||||
|
||||
it('شرطهای پشتسرهم با «و» ترکیب میشوند', () => {
|
||||
const steps = [cond('c1', 'بنکداری'), cond('c2', 'آنلاین شاپ'), act('a1'), act('a2')];
|
||||
assert.deepEqual(run(steps, 'بنکداری'), []);
|
||||
assert.deepEqual(run(steps, 'بنکداری, آنلاین شاپ'), ['a1', 'a2']);
|
||||
});
|
||||
|
||||
it('حالت stop رفتار قدیمی را حفظ میکند', () => {
|
||||
assert.deepEqual(run(BSJ, 'آنلاین شاپ', 'stop'), []);
|
||||
assert.deepEqual(run(BSJ, 'بنکداری, آنلاین شاپ, ویترین نقره', 'stop'), ['a1', 'a2', 'a3']);
|
||||
assert.deepEqual(
|
||||
planAutomationActions([act('a0')], undefined, () => false).map((s) => s.id),
|
||||
['a0'],
|
||||
);
|
||||
});
|
||||
|
||||
it('تقسیم نوبتی مساوی و پایدار', () => {
|
||||
const team = ['u3', 'u1', 'u2'];
|
||||
const picks = [1, 2, 3, 4, 5, 6].map((n) => pickRoundRobin(team, n));
|
||||
assert.deepEqual(picks, ['u1', 'u2', 'u3', 'u1', 'u2', 'u3']);
|
||||
assert.equal(pickRoundRobin([], 1), null);
|
||||
assert.equal(pickRoundRobin(['u1', 'u1'], 2), 'u1');
|
||||
});
|
||||
|
||||
it('جایگذاری متغیرها و خلاصهٔ پاسخها', () => {
|
||||
const text = renderAutomationTemplate('تماس با {{title}} ({{mobile}}) — {{field.coop_type}} از {{form}}', {
|
||||
title: 'علی',
|
||||
mobile: '0912',
|
||||
formName: 'BSJ',
|
||||
formValues: answers('بنکداری'),
|
||||
});
|
||||
assert.equal(text, 'تماس با علی (0912) — بنکداری از BSJ');
|
||||
assert.equal(
|
||||
formatFormAnswers([{ key: KEY, label: 'نوع همکاری' }], { [KEY]: 'بنکداری', extra: 'x' }),
|
||||
'نوع همکاری: بنکداری\nextra: x',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user