Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
16
wacrm/src/lib/automations/admin-client.ts
Normal file
16
wacrm/src/lib/automations/admin-client.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
// Lazy, shared service-role client for automation engine work.
|
||||
// Mirrors the pattern used by the webhook handler
|
||||
// (src/app/api/whatsapp/webhook/route.ts).
|
||||
let _adminClient: SupabaseClient | null = null
|
||||
|
||||
export function supabaseAdmin(): SupabaseClient {
|
||||
if (!_adminClient) {
|
||||
_adminClient = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||
)
|
||||
}
|
||||
return _adminClient
|
||||
}
|
||||
258
wacrm/src/lib/automations/engine.test.ts
Normal file
258
wacrm/src/lib/automations/engine.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
// Shared mock state for the service-role client. Lives in a hoisted block
|
||||
// so the vi.mock factory below can close over it.
|
||||
const h = vi.hoisted(() => ({
|
||||
state: {
|
||||
owned: null as { id: string } | null,
|
||||
ownedCustomField: null as { id: string } | null,
|
||||
automations: [] as Record<string, unknown>[],
|
||||
steps: [] as Record<string, unknown>[],
|
||||
fromCalls: [] as string[],
|
||||
updateCalls: [] as { table: string; filters: [string, string, unknown][] }[],
|
||||
upsertCalls: [] as { table: string; payload: unknown }[],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./admin-client", () => {
|
||||
const { state } = h;
|
||||
|
||||
function resolve(ops: {
|
||||
table: string;
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
filters: [string, string, unknown][];
|
||||
}) {
|
||||
const { table, type } = ops;
|
||||
if (table === "contacts") {
|
||||
if (type === "update") {
|
||||
state.updateCalls.push({ table, filters: ops.filters });
|
||||
return { data: null, error: null };
|
||||
}
|
||||
// ownership guard / condition read
|
||||
return { data: state.owned, error: null };
|
||||
}
|
||||
if (table === "custom_fields") {
|
||||
// account-scoped ownership lookup for a custom field definition
|
||||
return { data: state.ownedCustomField, error: null };
|
||||
}
|
||||
if (table === "contact_custom_values") {
|
||||
if (type === "upsert") {
|
||||
state.upsertCalls.push({ table, payload: ops.payload });
|
||||
return { data: null, error: null };
|
||||
}
|
||||
return { data: null, error: null };
|
||||
}
|
||||
if (table === "automations") return { data: state.automations, error: null };
|
||||
if (table === "automation_logs") {
|
||||
if (type === "insert") return { data: { id: "log1" }, error: null };
|
||||
if (type === "update") return { data: null, error: null };
|
||||
return { data: { steps_executed: [], status: "success" }, error: null };
|
||||
}
|
||||
if (table === "automation_steps") return { data: state.steps, error: null };
|
||||
return { data: null, error: null };
|
||||
}
|
||||
|
||||
function builder(table: string) {
|
||||
const ops = {
|
||||
table,
|
||||
type: "select",
|
||||
payload: undefined as unknown,
|
||||
filters: [] as [string, string, unknown][],
|
||||
};
|
||||
const b: Record<string, unknown> = {
|
||||
select: () => b,
|
||||
insert: (p: unknown) => ((ops.type = "insert"), (ops.payload = p), b),
|
||||
update: (p: unknown) => ((ops.type = "update"), (ops.payload = p), b),
|
||||
delete: () => ((ops.type = "delete"), b),
|
||||
upsert: (p: unknown) => ((ops.type = "upsert"), (ops.payload = p), b),
|
||||
eq: (k: string, v: unknown) => (ops.filters.push(["eq", k, v]), b),
|
||||
gte: () => b,
|
||||
is: () => b,
|
||||
order: () => b,
|
||||
limit: () => b,
|
||||
single: () => Promise.resolve(resolve(ops)),
|
||||
maybeSingle: () => Promise.resolve(resolve(ops)),
|
||||
then: (onF: (v: unknown) => unknown, onR?: (e: unknown) => unknown) =>
|
||||
Promise.resolve(resolve(ops)).then(onF, onR),
|
||||
};
|
||||
return b;
|
||||
}
|
||||
|
||||
return {
|
||||
supabaseAdmin: () => ({
|
||||
from: (t: string) => {
|
||||
state.fromCalls.push(t);
|
||||
return builder(t);
|
||||
},
|
||||
rpc: () => Promise.resolve({ error: null }),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./meta-send", () => ({
|
||||
engineSendText: vi.fn(async () => ({ whatsapp_message_id: "m1" })),
|
||||
engineSendTemplate: vi.fn(async () => ({ whatsapp_message_id: "m1" })),
|
||||
}));
|
||||
|
||||
import { runAutomationsForTrigger } from "./engine";
|
||||
|
||||
const ACCOUNT = "acct-1";
|
||||
|
||||
beforeEach(() => {
|
||||
h.state.owned = null;
|
||||
h.state.ownedCustomField = null;
|
||||
h.state.automations = [];
|
||||
h.state.steps = [];
|
||||
h.state.fromCalls = [];
|
||||
h.state.updateCalls = [];
|
||||
h.state.upsertCalls = [];
|
||||
});
|
||||
|
||||
describe("runAutomationsForTrigger — tenant isolation", () => {
|
||||
it("refuses to dispatch when the contact is not in the account (GHSA-63cv-2c49-m5v3)", async () => {
|
||||
// Ownership lookup returns nothing — the contact belongs to another tenant.
|
||||
h.state.owned = null;
|
||||
// If the guard failed, this automation would run an update_contact_field step.
|
||||
h.state.automations = [automationWithUpdateStep()];
|
||||
h.state.steps = [updateStep()];
|
||||
|
||||
await runAutomationsForTrigger({
|
||||
accountId: ACCOUNT,
|
||||
triggerType: "new_message_received",
|
||||
contactId: "victim-contact-uuid",
|
||||
context: { message_text: "manual trigger" },
|
||||
});
|
||||
|
||||
// Bailed at the guard: never fetched automations, never wrote a contact.
|
||||
expect(h.state.fromCalls).toContain("contacts");
|
||||
expect(h.state.fromCalls).not.toContain("automations");
|
||||
expect(h.state.updateCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("proceeds past the guard when the contact belongs to the account", async () => {
|
||||
h.state.owned = { id: "c1" };
|
||||
h.state.automations = []; // no matching automations; just prove we got past the guard
|
||||
|
||||
await runAutomationsForTrigger({
|
||||
accountId: ACCOUNT,
|
||||
triggerType: "new_message_received",
|
||||
contactId: "c1",
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(h.state.fromCalls).toContain("automations");
|
||||
});
|
||||
|
||||
it("scopes the update_contact_field write to the automation's account", async () => {
|
||||
h.state.owned = { id: "c1" };
|
||||
h.state.automations = [automationWithUpdateStep()];
|
||||
h.state.steps = [updateStep()];
|
||||
|
||||
await runAutomationsForTrigger({
|
||||
accountId: ACCOUNT,
|
||||
triggerType: "new_message_received",
|
||||
contactId: "c1",
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(h.state.updateCalls).toHaveLength(1);
|
||||
const filters = h.state.updateCalls[0].filters;
|
||||
expect(filters).toContainEqual(["eq", "id", "c1"]);
|
||||
expect(filters).toContainEqual(["eq", "account_id", ACCOUNT]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update_contact_field — custom fields", () => {
|
||||
it("upserts contact_custom_values when the field is account-owned", async () => {
|
||||
h.state.owned = { id: "c1" };
|
||||
h.state.ownedCustomField = { id: "cf1" };
|
||||
h.state.automations = [automationWithUpdateStep()];
|
||||
h.state.steps = [customStep("custom:cf1", "Premium")];
|
||||
|
||||
await runAutomationsForTrigger({
|
||||
accountId: ACCOUNT,
|
||||
triggerType: "new_message_received",
|
||||
contactId: "c1",
|
||||
context: {},
|
||||
});
|
||||
|
||||
// No direct contacts column write for a custom field.
|
||||
expect(h.state.updateCalls).toHaveLength(0);
|
||||
expect(h.state.upsertCalls).toHaveLength(1);
|
||||
expect(h.state.upsertCalls[0].payload).toEqual({
|
||||
contact_id: "c1",
|
||||
custom_field_id: "cf1",
|
||||
value: "Premium",
|
||||
});
|
||||
});
|
||||
|
||||
it("interpolates {{ vars.* }} into the custom value", async () => {
|
||||
h.state.owned = { id: "c1" };
|
||||
h.state.ownedCustomField = { id: "cf1" };
|
||||
h.state.automations = [automationWithUpdateStep()];
|
||||
h.state.steps = [customStep("custom:cf1", "{{ vars.source }}")];
|
||||
|
||||
await runAutomationsForTrigger({
|
||||
accountId: ACCOUNT,
|
||||
triggerType: "new_message_received",
|
||||
contactId: "c1",
|
||||
context: { vars: { source: "WhatsApp Ad" } },
|
||||
});
|
||||
|
||||
expect(h.state.upsertCalls).toHaveLength(1);
|
||||
expect(
|
||||
(h.state.upsertCalls[0].payload as { value: string }).value,
|
||||
).toBe("WhatsApp Ad");
|
||||
});
|
||||
|
||||
it("refuses to write a custom field from another account", async () => {
|
||||
h.state.owned = { id: "c1" };
|
||||
h.state.ownedCustomField = null; // account-scoped lookup finds nothing
|
||||
h.state.automations = [automationWithUpdateStep()];
|
||||
h.state.steps = [customStep("custom:foreign-cf", "x")];
|
||||
|
||||
await runAutomationsForTrigger({
|
||||
accountId: ACCOUNT,
|
||||
triggerType: "new_message_received",
|
||||
contactId: "c1",
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(h.state.upsertCalls).toHaveLength(0);
|
||||
expect(h.state.updateCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
function automationWithUpdateStep() {
|
||||
return {
|
||||
id: "a1",
|
||||
account_id: ACCOUNT,
|
||||
user_id: "u1",
|
||||
trigger_type: "new_message_received",
|
||||
trigger_config: {},
|
||||
is_active: true,
|
||||
};
|
||||
}
|
||||
|
||||
function updateStep() {
|
||||
return {
|
||||
id: "s1",
|
||||
automation_id: "a1",
|
||||
step_type: "update_contact_field",
|
||||
position: 0,
|
||||
parent_step_id: null,
|
||||
step_config: { field: "company", value: "pwned-by-automation" },
|
||||
};
|
||||
}
|
||||
|
||||
function customStep(field: string, value: string) {
|
||||
return {
|
||||
id: "s1",
|
||||
automation_id: "a1",
|
||||
step_type: "update_contact_field",
|
||||
position: 0,
|
||||
parent_step_id: null,
|
||||
step_config: { field, value },
|
||||
};
|
||||
}
|
||||
717
wacrm/src/lib/automations/engine.ts
Normal file
717
wacrm/src/lib/automations/engine.ts
Normal file
@@ -0,0 +1,717 @@
|
||||
import type {
|
||||
Automation,
|
||||
AutomationLogStepResult,
|
||||
AutomationStep,
|
||||
AutomationTriggerType,
|
||||
ConditionStepConfig,
|
||||
KeywordMatchTriggerConfig,
|
||||
SendMessageStepConfig,
|
||||
SendTemplateStepConfig,
|
||||
SendWebhookStepConfig,
|
||||
TagStepConfig,
|
||||
UpdateContactFieldStepConfig,
|
||||
WaitStepConfig,
|
||||
CreateDealStepConfig,
|
||||
AssignConversationStepConfig,
|
||||
} from '@/types'
|
||||
import { supabaseAdmin } from './admin-client'
|
||||
import { engineSendText, engineSendTemplate } from './meta-send'
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Public API
|
||||
// ------------------------------------------------------------
|
||||
|
||||
export interface AutomationContext {
|
||||
/** Raw message text, for keyword_match + message_content conditions. */
|
||||
message_text?: string
|
||||
/** Conversation the event belongs to, if any. */
|
||||
conversation_id?: string
|
||||
/** Arbitrary variables accumulated during execution. */
|
||||
vars?: Record<string, unknown>
|
||||
/** The tag id that was added, for tag_added trigger. */
|
||||
tag_id?: string
|
||||
/** Agent the conversation was assigned to, for conversation_assigned. */
|
||||
agent_id?: string
|
||||
/** Phone number of the contact that triggered the automation. */
|
||||
contact_phone?: string
|
||||
/** Contact id that triggered the automation. */
|
||||
contact_id?: string
|
||||
}
|
||||
|
||||
export interface DispatchInput {
|
||||
/** Account-level tenancy key. Drives the lookup of which active
|
||||
* automations to fire — `automations.account_id` is the tenant
|
||||
* isolation after migration 017. Replaces the previous `userId`
|
||||
* field; the per-automation user_id is read off each row when
|
||||
* needed (sender identity for outbound messages, log audit). */
|
||||
accountId: string
|
||||
triggerType: AutomationTriggerType
|
||||
contactId?: string | null
|
||||
context?: AutomationContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire all active automations matching the given trigger for an
|
||||
* account.
|
||||
*
|
||||
* Must never throw — callers use fire-and-forget from the webhook.
|
||||
* All errors are caught and logged; per-automation failures are
|
||||
* recorded into automation_logs with status='failed'.
|
||||
*/
|
||||
export async function runAutomationsForTrigger(input: DispatchInput): Promise<void> {
|
||||
try {
|
||||
const db = supabaseAdmin()
|
||||
|
||||
// Tenant isolation. `contactId` can be caller-supplied (the manual
|
||||
// POST /api/automations/engine entrypoint reads it straight from the
|
||||
// request body), and every step below runs through the service-role
|
||||
// client, which bypasses RLS. So before any step can touch the
|
||||
// contact, verify it actually belongs to this account. A foreign or
|
||||
// forged id is refused silently — callers are fire-and-forget, and a
|
||||
// distinct error would leak whether a given contact UUID exists.
|
||||
if (input.contactId) {
|
||||
const { data: owned, error: ownErr } = await db
|
||||
.from('contacts')
|
||||
.select('id')
|
||||
.eq('id', input.contactId)
|
||||
.eq('account_id', input.accountId)
|
||||
.maybeSingle()
|
||||
if (ownErr) {
|
||||
console.error('[automations] contact ownership check failed:', ownErr)
|
||||
return
|
||||
}
|
||||
if (!owned) {
|
||||
console.warn('[automations] contact not in account, refusing dispatch', input.contactId)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const { data: automations, error } = await db
|
||||
.from('automations')
|
||||
.select('*')
|
||||
.eq('account_id', input.accountId)
|
||||
.eq('trigger_type', input.triggerType)
|
||||
.eq('is_active', true)
|
||||
|
||||
if (error) {
|
||||
console.error('[automations] fetch failed:', error)
|
||||
return
|
||||
}
|
||||
if (!automations || automations.length === 0) return
|
||||
|
||||
for (const automation of automations as Automation[]) {
|
||||
if (!triggerMatches(automation, input.context)) continue
|
||||
try {
|
||||
await executeAutomation(automation, input)
|
||||
} catch (err) {
|
||||
console.error('[automations] execute failed:', automation.id, err)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[automations] dispatch failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a run that was parked at a wait step. Called from the cron
|
||||
* endpoint after it grabs a due `automation_pending_executions` row.
|
||||
*/
|
||||
export async function resumePendingExecution(pending: {
|
||||
id: string
|
||||
automation_id: string
|
||||
/** Audit-only; the automation row carries account_id for tenancy. */
|
||||
user_id: string
|
||||
/** Account-scoped lookups read from the automation row, so this
|
||||
* field is just here to mirror the row shape and keep the cron's
|
||||
* pass-through self-documenting. */
|
||||
account_id: string
|
||||
contact_id: string | null
|
||||
log_id: string | null
|
||||
parent_step_id: string | null
|
||||
branch: 'yes' | 'no' | null
|
||||
next_step_position: number
|
||||
context: AutomationContext
|
||||
}): Promise<void> {
|
||||
const db = supabaseAdmin()
|
||||
const { data: automation, error } = await db
|
||||
.from('automations')
|
||||
.select('*')
|
||||
.eq('id', pending.automation_id)
|
||||
.single()
|
||||
|
||||
if (error || !automation) {
|
||||
console.error('[automations] resume: missing automation', pending.automation_id, error)
|
||||
await markPending(pending.id, 'failed')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await executeStepsFrom({
|
||||
automation: automation as Automation,
|
||||
contactId: pending.contact_id,
|
||||
context: pending.context ?? {},
|
||||
parentStepId: pending.parent_step_id,
|
||||
branch: pending.branch,
|
||||
startPosition: pending.next_step_position,
|
||||
logId: pending.log_id,
|
||||
triggerEvent: 'resumed_wait',
|
||||
})
|
||||
await markPending(pending.id, 'done')
|
||||
} catch (err) {
|
||||
console.error('[automations] resume failed:', err)
|
||||
await markPending(pending.id, 'failed')
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Internal execution
|
||||
// ------------------------------------------------------------
|
||||
|
||||
async function executeAutomation(automation: Automation, input: DispatchInput) {
|
||||
const db = supabaseAdmin()
|
||||
|
||||
const { data: log, error: logErr } = await db
|
||||
.from('automation_logs')
|
||||
.insert({
|
||||
automation_id: automation.id,
|
||||
// Tenancy: matches automation.account_id (NOT NULL post-017).
|
||||
account_id: automation.account_id,
|
||||
// Audit: keeps the historical "author of this automation"
|
||||
// pointer so logs still attribute to the right user even
|
||||
// after teammates join the account.
|
||||
user_id: automation.user_id,
|
||||
contact_id: input.contactId ?? null,
|
||||
trigger_event: input.triggerType,
|
||||
steps_executed: [],
|
||||
status: 'success',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (logErr || !log) {
|
||||
console.error('[automations] cannot create log:', logErr)
|
||||
return
|
||||
}
|
||||
|
||||
await executeStepsFrom({
|
||||
automation,
|
||||
contactId: input.contactId ?? null,
|
||||
context: input.context ?? {},
|
||||
parentStepId: null,
|
||||
branch: null,
|
||||
startPosition: 0,
|
||||
logId: log.id,
|
||||
triggerEvent: input.triggerType,
|
||||
})
|
||||
|
||||
// Atomic counter update via the SQL function from migration 007.
|
||||
// Doing this with a client-side read-modify-write raced when the
|
||||
// same automation fired for two contacts simultaneously — both
|
||||
// would read N and both write N+1, losing one count permanently.
|
||||
const { error: rpcErr } = await db.rpc('increment_automation_execution_count', {
|
||||
p_automation_id: automation.id,
|
||||
})
|
||||
if (rpcErr) {
|
||||
console.error('[automations] increment counter failed:', rpcErr)
|
||||
}
|
||||
}
|
||||
|
||||
interface ExecuteArgs {
|
||||
automation: Automation
|
||||
contactId: string | null
|
||||
context: AutomationContext
|
||||
parentStepId: string | null
|
||||
branch: 'yes' | 'no' | null
|
||||
startPosition: number
|
||||
logId: string | null
|
||||
triggerEvent: string
|
||||
}
|
||||
|
||||
async function executeStepsFrom(args: ExecuteArgs): Promise<void> {
|
||||
const db = supabaseAdmin()
|
||||
|
||||
const baseQuery = db
|
||||
.from('automation_steps')
|
||||
.select('*')
|
||||
.eq('automation_id', args.automation.id)
|
||||
.gte('position', args.startPosition)
|
||||
.order('position', { ascending: true })
|
||||
|
||||
const scoped =
|
||||
args.parentStepId === null
|
||||
? baseQuery.is('parent_step_id', null)
|
||||
: baseQuery.eq('parent_step_id', args.parentStepId).eq('branch', args.branch ?? 'yes')
|
||||
|
||||
const { data: steps, error: stepsErr } = await scoped
|
||||
|
||||
if (stepsErr) {
|
||||
await finalizeLog(args.logId, 'failed', stepsErr.message)
|
||||
return
|
||||
}
|
||||
if (!steps || steps.length === 0) {
|
||||
if (args.parentStepId === null && args.logId) {
|
||||
await finalizeLog(args.logId, 'success', null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const results: AutomationLogStepResult[] = []
|
||||
let status: 'success' | 'partial' | 'failed' = 'success'
|
||||
let errorMessage: string | null = null
|
||||
|
||||
for (const step of steps as AutomationStep[]) {
|
||||
// `wait` is the suspension point: enqueue and stop processing this
|
||||
// scope. The cron endpoint will pick it up later.
|
||||
if (step.step_type === 'wait') {
|
||||
const cfg = step.step_config as WaitStepConfig
|
||||
const ms = waitMs(cfg)
|
||||
await db.from('automation_pending_executions').insert({
|
||||
automation_id: args.automation.id,
|
||||
// Tenancy: account_id required NOT NULL post-017.
|
||||
account_id: args.automation.account_id,
|
||||
user_id: args.automation.user_id,
|
||||
contact_id: args.contactId,
|
||||
log_id: args.logId,
|
||||
parent_step_id: args.parentStepId,
|
||||
branch: args.branch,
|
||||
next_step_position: step.position + 1,
|
||||
context: args.context,
|
||||
run_at: new Date(Date.now() + ms).toISOString(),
|
||||
status: 'pending',
|
||||
})
|
||||
results.push({
|
||||
step_id: step.id,
|
||||
step_type: step.step_type,
|
||||
status: 'success',
|
||||
detail: `waiting ${cfg.amount} ${cfg.unit}`,
|
||||
})
|
||||
status = 'partial'
|
||||
await appendResults(args.logId, results, status, errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (step.step_type === 'condition') {
|
||||
const cfg = step.step_config as ConditionStepConfig
|
||||
const taken = await evaluateCondition(cfg, args)
|
||||
results.push({
|
||||
step_id: step.id,
|
||||
step_type: 'condition',
|
||||
status: 'success',
|
||||
detail: `branch=${taken ? 'yes' : 'no'}`,
|
||||
})
|
||||
// Recurse into the chosen branch at position 0 (children use their
|
||||
// own ordering within the branch scope).
|
||||
await executeStepsFrom({
|
||||
...args,
|
||||
parentStepId: step.id,
|
||||
branch: taken ? 'yes' : 'no',
|
||||
startPosition: 0,
|
||||
logId: args.logId,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const detail = await runStep(step, args)
|
||||
results.push({
|
||||
step_id: step.id,
|
||||
step_type: step.step_type,
|
||||
status: 'success',
|
||||
detail,
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
results.push({
|
||||
step_id: step.id,
|
||||
step_type: step.step_type,
|
||||
status: 'failed',
|
||||
detail: msg,
|
||||
})
|
||||
status = 'failed'
|
||||
errorMessage = msg
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (args.parentStepId === null) {
|
||||
await appendResults(args.logId, results, status, errorMessage)
|
||||
} else {
|
||||
// Nested branch — just append results; parent scope decides final status.
|
||||
await appendResults(args.logId, results, null, errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
async function runStep(step: AutomationStep, args: ExecuteArgs): Promise<string> {
|
||||
const db = supabaseAdmin()
|
||||
|
||||
switch (step.step_type) {
|
||||
case 'send_message': {
|
||||
const cfg = step.step_config as SendMessageStepConfig
|
||||
if (!args.contactId) throw new Error('send_message needs a contact')
|
||||
const text = interpolate(cfg.text, args)
|
||||
if (!text.trim()) throw new Error('send_message has empty text')
|
||||
const conversationId = await resolveConversationId(args)
|
||||
const { whatsapp_message_id } = await engineSendText({
|
||||
accountId: args.automation.account_id,
|
||||
userId: args.automation.user_id,
|
||||
conversationId,
|
||||
contactId: args.contactId,
|
||||
text,
|
||||
})
|
||||
return `sent via Meta (${whatsapp_message_id})`
|
||||
}
|
||||
|
||||
case 'send_template': {
|
||||
const cfg = step.step_config as SendTemplateStepConfig
|
||||
if (!args.contactId) throw new Error('send_template needs a contact')
|
||||
if (!cfg.template_name) throw new Error('send_template needs template_name')
|
||||
const conversationId = await resolveConversationId(args)
|
||||
// Meta templates use positional {{1}}, {{2}}, … placeholders, so
|
||||
// we MUST emit params in strict numeric order. Lexicographic sort
|
||||
// of "1", "2", …, "10" yields "1", "10", "2", … which silently
|
||||
// scrambles every template with ≥10 variables.
|
||||
const params = cfg.variables
|
||||
? Object.keys(cfg.variables)
|
||||
.sort((a, b) => {
|
||||
const na = Number(a)
|
||||
const nb = Number(b)
|
||||
const aNum = Number.isFinite(na)
|
||||
const bNum = Number.isFinite(nb)
|
||||
if (aNum && bNum) return na - nb
|
||||
if (aNum) return -1
|
||||
if (bNum) return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
.map((k) => String(cfg.variables![k]))
|
||||
: []
|
||||
const { whatsapp_message_id } = await engineSendTemplate({
|
||||
accountId: args.automation.account_id,
|
||||
userId: args.automation.user_id,
|
||||
conversationId,
|
||||
contactId: args.contactId,
|
||||
templateName: cfg.template_name,
|
||||
language: cfg.language,
|
||||
params,
|
||||
})
|
||||
return `template sent via Meta (${whatsapp_message_id})`
|
||||
}
|
||||
|
||||
case 'add_tag': {
|
||||
// contact_tags has no account_id column; cross-tenant protection for
|
||||
// the attacker-supplied contactId comes from the ownership guard in
|
||||
// runAutomationsForTrigger.
|
||||
const cfg = step.step_config as TagStepConfig
|
||||
if (!args.contactId || !cfg.tag_id) throw new Error('add_tag needs contact + tag_id')
|
||||
await db
|
||||
.from('contact_tags')
|
||||
.upsert(
|
||||
{ contact_id: args.contactId, tag_id: cfg.tag_id },
|
||||
{ onConflict: 'contact_id,tag_id', ignoreDuplicates: true },
|
||||
)
|
||||
return `tag ${cfg.tag_id} added`
|
||||
}
|
||||
|
||||
case 'remove_tag': {
|
||||
// See add_tag: tenant scoping relies on the runAutomationsForTrigger
|
||||
// ownership guard, since contact_tags carries no account_id.
|
||||
const cfg = step.step_config as TagStepConfig
|
||||
if (!args.contactId || !cfg.tag_id) throw new Error('remove_tag needs contact + tag_id')
|
||||
await db
|
||||
.from('contact_tags')
|
||||
.delete()
|
||||
.eq('contact_id', args.contactId)
|
||||
.eq('tag_id', cfg.tag_id)
|
||||
return `tag ${cfg.tag_id} removed`
|
||||
}
|
||||
|
||||
case 'assign_conversation': {
|
||||
const cfg = step.step_config as AssignConversationStepConfig
|
||||
if (!args.contactId) throw new Error('assign_conversation needs a contact')
|
||||
let agentId = cfg.agent_id
|
||||
if (cfg.mode === 'round_robin') {
|
||||
// Pick any member of the account. The existing implementation
|
||||
// only ever returned the automation's author; preserving that
|
||||
// shape until a real round-robin algorithm replaces it.
|
||||
const { data: profiles } = await db
|
||||
.from('profiles')
|
||||
.select('user_id')
|
||||
.eq('account_id', args.automation.account_id)
|
||||
.limit(1)
|
||||
agentId = profiles?.[0]?.user_id
|
||||
}
|
||||
if (!agentId) return 'no agent resolved'
|
||||
await db
|
||||
.from('conversations')
|
||||
.update({ assigned_agent_id: agentId })
|
||||
.eq('account_id', args.automation.account_id)
|
||||
.eq('contact_id', args.contactId)
|
||||
return `assigned to ${agentId}`
|
||||
}
|
||||
|
||||
case 'update_contact_field': {
|
||||
const cfg = step.step_config as UpdateContactFieldStepConfig
|
||||
if (!args.contactId) throw new Error('update_contact_field needs a contact')
|
||||
// Resolve workflow variables ({{ vars.* }}, {{ message.text }}) so custom
|
||||
// values can be populated dynamically from the triggering context.
|
||||
const value = interpolate(cfg.value, args)
|
||||
|
||||
// Custom fields are encoded as `custom:<custom_field_id>`; anything else
|
||||
// is a built-in contact column.
|
||||
if (cfg.field.startsWith('custom:')) {
|
||||
const customFieldId = cfg.field.slice('custom:'.length)
|
||||
if (!customFieldId) {
|
||||
return `field ${cfg.field} not writable from automations`
|
||||
}
|
||||
// Defense in depth: the service-role client bypasses RLS, so confirm
|
||||
// the field definition belongs to this account before writing.
|
||||
const { data: field } = await db
|
||||
.from('custom_fields')
|
||||
.select('id')
|
||||
.eq('id', customFieldId)
|
||||
.eq('account_id', args.automation.account_id)
|
||||
.maybeSingle()
|
||||
if (!field) {
|
||||
return `field ${cfg.field} not writable from automations`
|
||||
}
|
||||
// Upsert on the table's UNIQUE(contact_id, custom_field_id) so repeated
|
||||
// runs overwrite rather than duplicate. Tenancy is enforced above and,
|
||||
// for the contact side, by the entry-point ownership guard.
|
||||
await db
|
||||
.from('contact_custom_values')
|
||||
.upsert(
|
||||
{ contact_id: args.contactId, custom_field_id: customFieldId, value },
|
||||
{ onConflict: 'contact_id,custom_field_id' },
|
||||
)
|
||||
return `custom field updated`
|
||||
}
|
||||
|
||||
const allowed = new Set(['name', 'email', 'company'])
|
||||
if (!allowed.has(cfg.field)) {
|
||||
return `field ${cfg.field} not writable from automations`
|
||||
}
|
||||
// Defense in depth: scope the service-role write to the account so
|
||||
// a future caller that skips the entry-point ownership guard still
|
||||
// cannot write across tenants.
|
||||
await db
|
||||
.from('contacts')
|
||||
.update({ [cfg.field]: value, updated_at: new Date().toISOString() })
|
||||
.eq('id', args.contactId)
|
||||
.eq('account_id', args.automation.account_id)
|
||||
return `${cfg.field} updated`
|
||||
}
|
||||
|
||||
case 'create_deal': {
|
||||
const cfg = step.step_config as CreateDealStepConfig
|
||||
if (!cfg.pipeline_id || !cfg.stage_id) throw new Error('create_deal needs pipeline + stage')
|
||||
// Match the account's configured default currency rather than
|
||||
// the static `deals.currency` DB default — keeps automation-
|
||||
// created deals consistent with the one-currency-per-account
|
||||
// rule (issue #218). Fall back to USD if the row is somehow
|
||||
// missing the value (pre-021 forks).
|
||||
const { data: acct } = await db
|
||||
.from('accounts')
|
||||
.select('default_currency')
|
||||
.eq('id', args.automation.account_id)
|
||||
.maybeSingle()
|
||||
await db.from('deals').insert({
|
||||
// Tenancy + audit, same split as automation_logs above.
|
||||
account_id: args.automation.account_id,
|
||||
user_id: args.automation.user_id,
|
||||
pipeline_id: cfg.pipeline_id,
|
||||
stage_id: cfg.stage_id,
|
||||
contact_id: args.contactId,
|
||||
title: interpolate(cfg.title, args),
|
||||
value: cfg.value ?? 0,
|
||||
currency: acct?.default_currency ?? 'USD',
|
||||
status: 'open',
|
||||
})
|
||||
return 'deal created'
|
||||
}
|
||||
|
||||
case 'send_webhook': {
|
||||
const cfg = step.step_config as SendWebhookStepConfig
|
||||
if (!cfg.url) throw new Error('send_webhook needs url')
|
||||
const body = cfg.body_template ? interpolate(cfg.body_template, args) : JSON.stringify(args.context)
|
||||
// Normalize headers so a user-supplied Content-Type does not duplicate
|
||||
// the default application/json (case-insensitive header names).
|
||||
const userHeaders = cfg.headers ?? {}
|
||||
const hasContentType = Object.keys(userHeaders).some(
|
||||
(k) => k.toLowerCase() === 'content-type'
|
||||
)
|
||||
const headers: Record<string, string> = { ...userHeaders }
|
||||
if (!hasContentType) {
|
||||
headers['content-type'] = 'application/json'
|
||||
}
|
||||
const res = await fetch(cfg.url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
if (!res.ok) throw new Error(`webhook returned ${res.status}`)
|
||||
return `webhook ${res.status}`
|
||||
}
|
||||
|
||||
case 'close_conversation': {
|
||||
if (!args.contactId) throw new Error('close_conversation needs a contact')
|
||||
await db
|
||||
.from('conversations')
|
||||
.update({ status: 'closed', updated_at: new Date().toISOString() })
|
||||
.eq('account_id', args.automation.account_id)
|
||||
.eq('contact_id', args.contactId)
|
||||
return 'conversation closed'
|
||||
}
|
||||
|
||||
default:
|
||||
return `unknown step: ${step.step_type}`
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Helpers
|
||||
// ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pick the conversation a send-type step should use. Prefer the id the
|
||||
* webhook handed us (it's the one that just got the inbound message);
|
||||
* fall back to the contact's conversation for resumed/wait paths and
|
||||
* manual engine POSTs. Throws if none exists — send steps have
|
||||
* no meaningful target without a conversation.
|
||||
*/
|
||||
async function resolveConversationId(args: ExecuteArgs): Promise<string> {
|
||||
const fromCtx = args.context.conversation_id
|
||||
if (fromCtx) return fromCtx
|
||||
if (!args.contactId) throw new Error('cannot resolve conversation: no contact')
|
||||
const { data, error } = await supabaseAdmin()
|
||||
.from('conversations')
|
||||
.select('id')
|
||||
.eq('account_id', args.automation.account_id)
|
||||
.eq('contact_id', args.contactId)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`conversation lookup failed: ${error.message}`)
|
||||
if (!data?.id) throw new Error('no conversation for contact')
|
||||
return data.id as string
|
||||
}
|
||||
|
||||
function triggerMatches(automation: Automation, ctx: AutomationContext | undefined): boolean {
|
||||
if (automation.trigger_type !== 'keyword_match') return true
|
||||
const cfg = automation.trigger_config as KeywordMatchTriggerConfig
|
||||
if (!cfg?.keywords || cfg.keywords.length === 0) return false
|
||||
const text = (ctx?.message_text ?? '').toString()
|
||||
if (!text) return false
|
||||
const haystack = cfg.case_sensitive ? text : text.toLowerCase()
|
||||
return cfg.keywords.some((raw) => {
|
||||
const k = cfg.case_sensitive ? raw : raw.toLowerCase()
|
||||
return cfg.match_type === 'exact' ? haystack === k : haystack.includes(k)
|
||||
})
|
||||
}
|
||||
|
||||
async function evaluateCondition(cfg: ConditionStepConfig, args: ExecuteArgs): Promise<boolean> {
|
||||
const db = supabaseAdmin()
|
||||
switch (cfg.subject) {
|
||||
case 'tag_presence': {
|
||||
if (!args.contactId || !cfg.operand) return false
|
||||
// contact_tags has no account_id column (its RLS keys off the parent
|
||||
// contact), so tenant scoping here relies on the contact-ownership
|
||||
// guard in runAutomationsForTrigger.
|
||||
const { count } = await db
|
||||
.from('contact_tags')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('contact_id', args.contactId)
|
||||
.eq('tag_id', cfg.operand)
|
||||
return (count ?? 0) > 0
|
||||
}
|
||||
case 'contact_field': {
|
||||
if (!args.contactId || !cfg.operand) return false
|
||||
// Scope to the account so the condition can't be turned into a
|
||||
// cross-tenant read oracle via the service-role client.
|
||||
const { data } = await db
|
||||
.from('contacts')
|
||||
.select(cfg.operand)
|
||||
.eq('id', args.contactId)
|
||||
.eq('account_id', args.automation.account_id)
|
||||
.maybeSingle()
|
||||
const v = (data as Record<string, unknown> | null)?.[cfg.operand]
|
||||
return v != null && String(v) === String(cfg.value ?? '')
|
||||
}
|
||||
case 'message_content': {
|
||||
const text = (args.context.message_text ?? '').toString()
|
||||
return text.toLowerCase().includes((cfg.value ?? '').toLowerCase())
|
||||
}
|
||||
case 'time_of_day': {
|
||||
// operand form "HH:mm-HH:mm" — true if now is within that window
|
||||
// (supports over-midnight ranges like "18:00-09:00").
|
||||
const [from, to] = (cfg.operand ?? '').split('-')
|
||||
if (!from || !to) return false
|
||||
const now = new Date()
|
||||
const mins = now.getHours() * 60 + now.getMinutes()
|
||||
const parse = (s: string) => {
|
||||
const [h, m] = s.split(':').map(Number)
|
||||
return (h || 0) * 60 + (m || 0)
|
||||
}
|
||||
const f = parse(from)
|
||||
const t = parse(to)
|
||||
return f <= t ? mins >= f && mins < t : mins >= f || mins < t
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function waitMs(cfg: WaitStepConfig): number {
|
||||
const unitMs = cfg.unit === 'days' ? 86_400_000 : cfg.unit === 'hours' ? 3_600_000 : 60_000
|
||||
return Math.max(1_000, cfg.amount * unitMs)
|
||||
}
|
||||
|
||||
function interpolate(s: string, args: ExecuteArgs): string {
|
||||
return s.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key) => {
|
||||
const [ns, prop] = String(key).split('.')
|
||||
if (ns === 'message' && prop === 'text') return String(args.context.message_text ?? '')
|
||||
if (ns === 'vars' && prop) return String(args.context.vars?.[prop] ?? '')
|
||||
return ''
|
||||
})
|
||||
}
|
||||
|
||||
async function appendResults(
|
||||
logId: string | null,
|
||||
newItems: AutomationLogStepResult[],
|
||||
status: 'success' | 'partial' | 'failed' | null,
|
||||
errorMessage: string | null,
|
||||
) {
|
||||
if (!logId) return
|
||||
const db = supabaseAdmin()
|
||||
const { data: existing } = await db
|
||||
.from('automation_logs')
|
||||
.select('steps_executed, status')
|
||||
.eq('id', logId)
|
||||
.single()
|
||||
const merged = [
|
||||
...((existing?.steps_executed as AutomationLogStepResult[] | undefined) ?? []),
|
||||
...newItems,
|
||||
]
|
||||
const update: Record<string, unknown> = { steps_executed: merged }
|
||||
// Only overwrite status on the outermost scope — nested branches pass null.
|
||||
if (status !== null) {
|
||||
update.status = status
|
||||
}
|
||||
if (errorMessage) update.error_message = errorMessage
|
||||
await db.from('automation_logs').update(update).eq('id', logId)
|
||||
}
|
||||
|
||||
async function finalizeLog(
|
||||
logId: string | null,
|
||||
status: 'success' | 'partial' | 'failed',
|
||||
errorMessage: string | null,
|
||||
) {
|
||||
if (!logId) return
|
||||
await supabaseAdmin()
|
||||
.from('automation_logs')
|
||||
.update({ status, error_message: errorMessage })
|
||||
.eq('id', logId)
|
||||
}
|
||||
|
||||
async function markPending(id: string, status: 'done' | 'failed') {
|
||||
await supabaseAdmin()
|
||||
.from('automation_pending_executions')
|
||||
.update({ status })
|
||||
.eq('id', id)
|
||||
}
|
||||
176
wacrm/src/lib/automations/meta-send.ts
Normal file
176
wacrm/src/lib/automations/meta-send.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api'
|
||||
import { decrypt } from '@/lib/whatsapp/encryption'
|
||||
import {
|
||||
sanitizePhoneForMeta,
|
||||
isValidE164,
|
||||
phoneVariants,
|
||||
isRecipientNotAllowedError,
|
||||
} from '@/lib/whatsapp/phone-utils'
|
||||
import { supabaseAdmin } from './admin-client'
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Automation-side Meta sender.
|
||||
//
|
||||
// Mirrors the logic in src/app/api/whatsapp/send/route.ts but uses
|
||||
// the service-role client (engine has no cookies) and accepts the
|
||||
// user / conversation / contact identifiers the engine already has
|
||||
// on hand. Kept here (rather than refactoring the user-facing send
|
||||
// route) to avoid risk to the working manual-send path — they can
|
||||
// converge in a later refactor.
|
||||
// ------------------------------------------------------------
|
||||
|
||||
interface SendTextArgs {
|
||||
/** Account-level tenancy key. Drives contact + whatsapp_config
|
||||
* lookups so an automation authored by user A still sends through
|
||||
* the WhatsApp number user B saved on the same account. */
|
||||
accountId: string
|
||||
/** Original author of the automation/flow — used for INSERT audit
|
||||
* columns (messages.sender_id-ish) and for resolving the agent's
|
||||
* identity in logs. Not consulted for tenancy. */
|
||||
userId: string
|
||||
conversationId: string
|
||||
contactId: string
|
||||
text: string
|
||||
}
|
||||
|
||||
interface SendTemplateArgs {
|
||||
accountId: string
|
||||
userId: string
|
||||
conversationId: string
|
||||
contactId: string
|
||||
templateName: string
|
||||
language?: string
|
||||
params?: string[]
|
||||
}
|
||||
|
||||
export async function engineSendText(args: SendTextArgs): Promise<{ whatsapp_message_id: string }> {
|
||||
return sendViaMeta({ ...args, kind: 'text' })
|
||||
}
|
||||
|
||||
export async function engineSendTemplate(
|
||||
args: SendTemplateArgs,
|
||||
): Promise<{ whatsapp_message_id: string }> {
|
||||
return sendViaMeta({ ...args, kind: 'template' })
|
||||
}
|
||||
|
||||
type SendInput =
|
||||
| (SendTextArgs & { kind: 'text' })
|
||||
| (SendTemplateArgs & { kind: 'template' })
|
||||
|
||||
async function sendViaMeta(input: SendInput): Promise<{ whatsapp_message_id: string }> {
|
||||
const db = supabaseAdmin()
|
||||
|
||||
// Scope the contact + config lookups by account_id, not user_id.
|
||||
// The engine uses the service-role client (bypassing RLS); without
|
||||
// this filter, an authenticated user could fire their own
|
||||
// automations against another tenant's contact UUID and send via
|
||||
// their own WhatsApp config to that contact's phone. The 017
|
||||
// migration moved both tables to account-scoped tenancy, so the
|
||||
// check is the same defense-in-depth as before, just keyed on the
|
||||
// new tenancy column.
|
||||
const { data: contact, error: contactErr } = await db
|
||||
.from('contacts')
|
||||
.select('id, phone')
|
||||
.eq('id', input.contactId)
|
||||
.eq('account_id', input.accountId)
|
||||
.maybeSingle()
|
||||
if (contactErr || !contact?.phone) {
|
||||
throw new Error('contact not found for this account')
|
||||
}
|
||||
|
||||
const sanitized = sanitizePhoneForMeta(contact.phone)
|
||||
if (!isValidE164(sanitized)) {
|
||||
throw new Error(`contact phone invalid: ${contact.phone}`)
|
||||
}
|
||||
|
||||
const { data: config, error: configErr } = await db
|
||||
.from('whatsapp_config')
|
||||
.select('*')
|
||||
.eq('account_id', input.accountId)
|
||||
.single()
|
||||
if (configErr || !config) {
|
||||
throw new Error('WhatsApp not configured for this account')
|
||||
}
|
||||
|
||||
const accessToken = decrypt(config.access_token)
|
||||
|
||||
const attempt = async (phone: string): Promise<string> => {
|
||||
if (input.kind === 'template') {
|
||||
const r = await sendTemplateMessage({
|
||||
phoneNumberId: config.phone_number_id,
|
||||
accessToken,
|
||||
to: phone,
|
||||
templateName: input.templateName,
|
||||
language: input.language,
|
||||
params: input.params,
|
||||
})
|
||||
return r.messageId
|
||||
}
|
||||
const r = await sendTextMessage({
|
||||
phoneNumberId: config.phone_number_id,
|
||||
accessToken,
|
||||
to: phone,
|
||||
text: input.text,
|
||||
})
|
||||
return r.messageId
|
||||
}
|
||||
|
||||
// Same phone-variant retry as /api/whatsapp/send — Meta sandbox and
|
||||
// numbers registered with/without a trunk 0 both require this to
|
||||
// reliably land a message.
|
||||
const variants = phoneVariants(sanitized)
|
||||
let workingPhone = sanitized
|
||||
let waMessageId = ''
|
||||
let lastError: unknown = null
|
||||
for (const v of variants) {
|
||||
try {
|
||||
waMessageId = await attempt(v)
|
||||
workingPhone = v
|
||||
lastError = null
|
||||
break
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (!isRecipientNotAllowedError(msg)) throw err
|
||||
lastError = err
|
||||
}
|
||||
}
|
||||
if (lastError) throw lastError
|
||||
|
||||
if (workingPhone !== sanitized) {
|
||||
await db.from('contacts').update({ phone: workingPhone }).eq('id', contact.id)
|
||||
}
|
||||
|
||||
// Persist the sent message so it appears in the inbox with a real
|
||||
// Meta message id. sender_type='bot' distinguishes automation sends
|
||||
// from manual agent sends.
|
||||
const content_type = input.kind === 'template' ? 'template' : 'text'
|
||||
const content_text = input.kind === 'text' ? input.text : null
|
||||
const template_name = input.kind === 'template' ? input.templateName : null
|
||||
|
||||
const { error: msgErr } = await db.from('messages').insert({
|
||||
conversation_id: input.conversationId,
|
||||
sender_type: 'bot',
|
||||
content_type,
|
||||
content_text,
|
||||
template_name,
|
||||
message_id: waMessageId,
|
||||
status: 'sent',
|
||||
})
|
||||
if (msgErr) {
|
||||
// Meta already has the message; record the DB error but don't pretend
|
||||
// the send failed. The engine wraps this in a log line.
|
||||
throw new Error(`sent to Meta but DB insert failed: ${msgErr.message}`)
|
||||
}
|
||||
|
||||
await db
|
||||
.from('conversations')
|
||||
.update({
|
||||
last_message_text:
|
||||
input.kind === 'template' ? `[template:${input.templateName}]` : input.text,
|
||||
last_message_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', input.conversationId)
|
||||
|
||||
return { whatsapp_message_id: waMessageId }
|
||||
}
|
||||
162
wacrm/src/lib/automations/steps-tree.ts
Normal file
162
wacrm/src/lib/automations/steps-tree.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { supabaseAdmin } from './admin-client'
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Builder payload → flat rows for automation_steps.
|
||||
// Root steps arrive in order. A Condition step carries its children
|
||||
// under `branches: { yes: [...], no: [...] }`. We walk the tree and
|
||||
// assign stable UUIDs so parent_step_id references resolve in a
|
||||
// single INSERT.
|
||||
// ------------------------------------------------------------
|
||||
|
||||
export interface BuilderStepInput {
|
||||
id?: string
|
||||
step_type: string
|
||||
step_config: Record<string, unknown>
|
||||
branches?: { yes?: BuilderStepInput[]; no?: BuilderStepInput[] }
|
||||
// Legacy flat form (from template seeds):
|
||||
branch?: 'yes' | 'no' | null
|
||||
parent_index?: number | null
|
||||
}
|
||||
|
||||
interface InsertRow {
|
||||
id: string
|
||||
automation_id: string
|
||||
parent_step_id: string | null
|
||||
branch: 'yes' | 'no' | null
|
||||
step_type: string
|
||||
step_config: Record<string, unknown>
|
||||
position: number
|
||||
}
|
||||
|
||||
const uid = () =>
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2) + Date.now().toString(36)
|
||||
|
||||
export async function replaceSteps(
|
||||
automationId: string,
|
||||
input: BuilderStepInput[],
|
||||
): Promise<string | null> {
|
||||
const admin = supabaseAdmin()
|
||||
const { error: delErr } = await admin
|
||||
.from('automation_steps')
|
||||
.delete()
|
||||
.eq('automation_id', automationId)
|
||||
if (delErr) return delErr.message
|
||||
return insertSteps(automationId, input)
|
||||
}
|
||||
|
||||
export async function insertSteps(
|
||||
automationId: string,
|
||||
input: BuilderStepInput[],
|
||||
): Promise<string | null> {
|
||||
if (!input || input.length === 0) return null
|
||||
|
||||
const looksFlat = input.some(
|
||||
(s) => s.branch !== undefined || s.parent_index !== undefined,
|
||||
)
|
||||
const tree = looksFlat ? seedsToTree(input) : input
|
||||
|
||||
const rows: InsertRow[] = []
|
||||
function walk(
|
||||
steps: BuilderStepInput[],
|
||||
parentId: string | null,
|
||||
branch: 'yes' | 'no' | null,
|
||||
) {
|
||||
steps.forEach((s, idx) => {
|
||||
const id = s.id ?? uid()
|
||||
rows.push({
|
||||
id,
|
||||
automation_id: automationId,
|
||||
parent_step_id: parentId,
|
||||
branch,
|
||||
step_type: s.step_type,
|
||||
step_config: s.step_config ?? {},
|
||||
position: idx,
|
||||
})
|
||||
if (s.step_type === 'condition' && s.branches) {
|
||||
if (s.branches.yes) walk(s.branches.yes, id, 'yes')
|
||||
if (s.branches.no) walk(s.branches.no, id, 'no')
|
||||
}
|
||||
})
|
||||
}
|
||||
walk(tree, null, null)
|
||||
|
||||
if (rows.length === 0) return null
|
||||
const { error } = await supabaseAdmin().from('automation_steps').insert(rows)
|
||||
return error?.message ?? null
|
||||
}
|
||||
|
||||
function seedsToTree(seeds: BuilderStepInput[]): BuilderStepInput[] {
|
||||
const nodes: BuilderStepInput[] = seeds.map((s) => ({
|
||||
...s,
|
||||
branches: { yes: [], no: [] },
|
||||
}))
|
||||
const roots: BuilderStepInput[] = []
|
||||
nodes.forEach((n, i) => {
|
||||
const seed = seeds[i]
|
||||
if (seed.parent_index == null) {
|
||||
roots.push(n)
|
||||
} else {
|
||||
const parent = nodes[seed.parent_index]
|
||||
parent.branches = parent.branches ?? { yes: [], no: [] }
|
||||
const bucket = (seed.branch ?? 'yes') as 'yes' | 'no'
|
||||
;(parent.branches[bucket] ??= []).push(n)
|
||||
}
|
||||
})
|
||||
return roots
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the steps for an automation and rebuild the nested tree shape
|
||||
* the builder UI expects. One query, O(n) assembly.
|
||||
*/
|
||||
export interface BuilderStepNode extends BuilderStepInput {
|
||||
id: string
|
||||
branches: { yes: BuilderStepNode[]; no: BuilderStepNode[] }
|
||||
}
|
||||
|
||||
interface DbStep {
|
||||
id: string
|
||||
parent_step_id: string | null
|
||||
branch: 'yes' | 'no' | null
|
||||
step_type: string
|
||||
step_config: Record<string, unknown>
|
||||
position: number
|
||||
}
|
||||
|
||||
export async function loadStepsTree(automationId: string): Promise<BuilderStepNode[]> {
|
||||
const { data, error } = await supabaseAdmin()
|
||||
.from('automation_steps')
|
||||
.select('*')
|
||||
.eq('automation_id', automationId)
|
||||
.order('position', { ascending: true })
|
||||
|
||||
if (error) throw new Error(error.message)
|
||||
const rows = (data ?? []) as DbStep[]
|
||||
|
||||
const byId = new Map<string, BuilderStepNode>()
|
||||
for (const row of rows) {
|
||||
byId.set(row.id, {
|
||||
id: row.id,
|
||||
step_type: row.step_type,
|
||||
step_config: row.step_config ?? {},
|
||||
branches: { yes: [], no: [] },
|
||||
})
|
||||
}
|
||||
|
||||
const roots: BuilderStepNode[] = []
|
||||
for (const row of rows) {
|
||||
const node = byId.get(row.id)!
|
||||
if (row.parent_step_id) {
|
||||
const parent = byId.get(row.parent_step_id)
|
||||
if (parent) {
|
||||
const bucket = (row.branch ?? 'yes') as 'yes' | 'no'
|
||||
parent.branches[bucket].push(node)
|
||||
}
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
132
wacrm/src/lib/automations/templates.ts
Normal file
132
wacrm/src/lib/automations/templates.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type {
|
||||
AutomationStepConfig,
|
||||
AutomationStepType,
|
||||
AutomationTriggerConfig,
|
||||
AutomationTriggerType,
|
||||
} from '@/types'
|
||||
|
||||
export type TemplateSlug =
|
||||
| 'welcome_message'
|
||||
| 'out_of_office'
|
||||
| 'lead_qualifier'
|
||||
| 'follow_up_reminder'
|
||||
|
||||
export interface TemplateStepSeed {
|
||||
step_type: AutomationStepType
|
||||
step_config: AutomationStepConfig
|
||||
branch?: 'yes' | 'no' | null
|
||||
/** Index (within this seed list) of the Condition parent, if nested. */
|
||||
parent_index?: number | null
|
||||
}
|
||||
|
||||
export interface AutomationTemplateDefinition {
|
||||
slug: TemplateSlug
|
||||
name: string
|
||||
description: string
|
||||
trigger_type: AutomationTriggerType
|
||||
trigger_config: AutomationTriggerConfig
|
||||
steps: TemplateStepSeed[]
|
||||
}
|
||||
|
||||
export const AUTOMATION_TEMPLATES: Record<TemplateSlug, AutomationTemplateDefinition> = {
|
||||
welcome_message: {
|
||||
slug: 'welcome_message',
|
||||
name: 'Welcome Message',
|
||||
description: 'Auto-reply to first-time contacts with a greeting.',
|
||||
// first_inbound_message (added in PR #33) catches both brand-new
|
||||
// contacts AND manually-added/imported contacts on their first-ever
|
||||
// reply, which is what a user setting up a "welcome" automation
|
||||
// almost always wants. new_contact_created would miss the
|
||||
// manually-imported case.
|
||||
trigger_type: 'first_inbound_message',
|
||||
trigger_config: {},
|
||||
steps: [
|
||||
{
|
||||
step_type: 'send_message',
|
||||
step_config: {
|
||||
text: "Hi! 👋 Thanks for reaching out. We'll get back to you shortly.",
|
||||
},
|
||||
},
|
||||
{
|
||||
step_type: 'add_tag',
|
||||
step_config: { tag_id: '' },
|
||||
},
|
||||
],
|
||||
},
|
||||
out_of_office: {
|
||||
slug: 'out_of_office',
|
||||
name: 'Out of Office',
|
||||
description: 'Auto-reply during off-hours so nobody is left waiting.',
|
||||
trigger_type: 'new_message_received',
|
||||
trigger_config: {},
|
||||
steps: [
|
||||
{
|
||||
step_type: 'condition',
|
||||
step_config: {
|
||||
subject: 'time_of_day',
|
||||
operand: '18:00-09:00',
|
||||
},
|
||||
},
|
||||
{
|
||||
step_type: 'send_message',
|
||||
step_config: {
|
||||
text:
|
||||
"Thanks for your message! Our team is offline right now (9am–6pm) and will reply first thing tomorrow.",
|
||||
},
|
||||
parent_index: 0,
|
||||
branch: 'yes',
|
||||
},
|
||||
],
|
||||
},
|
||||
lead_qualifier: {
|
||||
slug: 'lead_qualifier',
|
||||
name: 'Lead Qualifier',
|
||||
description: 'Ask qualification questions to filter inbound leads.',
|
||||
trigger_type: 'keyword_match',
|
||||
trigger_config: {
|
||||
keywords: ['pricing', 'quote', 'buy'],
|
||||
match_type: 'contains',
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
step_type: 'send_message',
|
||||
step_config: {
|
||||
text:
|
||||
"Great — happy to help with pricing! Quick question: roughly how many seats are you looking for?",
|
||||
},
|
||||
},
|
||||
{
|
||||
step_type: 'wait',
|
||||
step_config: { amount: 10, unit: 'minutes' },
|
||||
},
|
||||
{
|
||||
step_type: 'assign_conversation',
|
||||
step_config: { mode: 'round_robin' },
|
||||
},
|
||||
],
|
||||
},
|
||||
follow_up_reminder: {
|
||||
slug: 'follow_up_reminder',
|
||||
name: 'Follow-up Reminder',
|
||||
description: 'Send a nudge if a contact has not replied within 24 hours.',
|
||||
trigger_type: 'new_message_received',
|
||||
trigger_config: {},
|
||||
steps: [
|
||||
{
|
||||
step_type: 'wait',
|
||||
step_config: { amount: 1, unit: 'days' },
|
||||
},
|
||||
{
|
||||
step_type: 'send_message',
|
||||
step_config: {
|
||||
text:
|
||||
"Just circling back — did you have any other questions for us? Happy to help!",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export function getTemplate(slug: string): AutomationTemplateDefinition | null {
|
||||
return AUTOMATION_TEMPLATES[slug as TemplateSlug] ?? null
|
||||
}
|
||||
59
wacrm/src/lib/automations/trigger-meta.ts
Normal file
59
wacrm/src/lib/automations/trigger-meta.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { AutomationTriggerType } from '@/types'
|
||||
|
||||
export interface TriggerMeta {
|
||||
label: string
|
||||
/** Tailwind classes for the Badge pill on the list row. */
|
||||
pillClass: string
|
||||
}
|
||||
|
||||
export const TRIGGER_META: Record<AutomationTriggerType, TriggerMeta> = {
|
||||
new_message_received: {
|
||||
label: 'New Message',
|
||||
pillClass: 'border-blue-500/30 bg-blue-500/10 text-blue-300',
|
||||
},
|
||||
first_inbound_message: {
|
||||
label: 'First Message from Contact',
|
||||
pillClass: 'border-teal-500/30 bg-teal-500/10 text-teal-300',
|
||||
},
|
||||
keyword_match: {
|
||||
label: 'Keyword Match',
|
||||
pillClass: 'border-purple-500/30 bg-purple-500/10 text-purple-300',
|
||||
},
|
||||
new_contact_created: {
|
||||
label: 'New Contact',
|
||||
pillClass: 'border-primary/30 bg-primary/10 text-primary',
|
||||
},
|
||||
conversation_assigned: {
|
||||
label: 'Conversation Assigned',
|
||||
pillClass: 'border-cyan-500/30 bg-cyan-500/10 text-cyan-300',
|
||||
},
|
||||
tag_added: {
|
||||
label: 'Tag Added',
|
||||
pillClass: 'border-amber-500/30 bg-amber-500/10 text-amber-300',
|
||||
},
|
||||
time_based: {
|
||||
label: 'Time-Based',
|
||||
pillClass: 'border-slate-500/30 bg-slate-500/10 text-muted-foreground',
|
||||
},
|
||||
}
|
||||
|
||||
export function triggerMeta(t: AutomationTriggerType | string): TriggerMeta {
|
||||
return (
|
||||
TRIGGER_META[t as AutomationTriggerType] ?? {
|
||||
label: t,
|
||||
pillClass: 'border-slate-500/30 bg-slate-500/10 text-muted-foreground',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function formatRelative(iso: string | null | undefined): string {
|
||||
if (!iso) return 'never'
|
||||
const then = new Date(iso).getTime()
|
||||
if (Number.isNaN(then)) return 'never'
|
||||
const diffSec = Math.round((Date.now() - then) / 1000)
|
||||
if (diffSec < 60) return 'just now'
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
|
||||
if (diffSec < 2_592_000) return `${Math.floor(diffSec / 86400)}d ago`
|
||||
return new Date(iso).toLocaleDateString()
|
||||
}
|
||||
242
wacrm/src/lib/automations/validate.test.ts
Normal file
242
wacrm/src/lib/automations/validate.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
validateStepsForActivation,
|
||||
validateTriggerForActivation,
|
||||
} from "./validate";
|
||||
|
||||
describe("validateStepsForActivation", () => {
|
||||
it("rejects empty or missing step lists", () => {
|
||||
expect(validateStepsForActivation([])).toEqual([
|
||||
{ path: "steps", message: "active automations need at least one step" },
|
||||
]);
|
||||
expect(
|
||||
validateStepsForActivation(undefined as unknown as never[]),
|
||||
).toEqual([
|
||||
{ path: "steps", message: "active automations need at least one step" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes a fully-populated step set", () => {
|
||||
const issues = validateStepsForActivation([
|
||||
{ step_type: "send_message", step_config: { text: "hi" } },
|
||||
{
|
||||
step_type: "wait",
|
||||
step_config: { amount: 5, unit: "minutes" },
|
||||
},
|
||||
{ step_type: "add_tag", step_config: { tag_id: "tag-uuid" } },
|
||||
{ step_type: "close_conversation", step_config: {} },
|
||||
]);
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags every required field that is missing", () => {
|
||||
const issues = validateStepsForActivation([
|
||||
{ step_type: "send_message", step_config: { text: " " } },
|
||||
{ step_type: "send_template", step_config: {} },
|
||||
{ step_type: "add_tag", step_config: { tag_id: "" } },
|
||||
]);
|
||||
expect(issues.map((i) => i.path)).toEqual([
|
||||
"steps[0].text",
|
||||
"steps[1].template_name",
|
||||
"steps[2].tag_id",
|
||||
]);
|
||||
});
|
||||
|
||||
it("checks wait amount and unit boundaries", () => {
|
||||
const issues = validateStepsForActivation([
|
||||
{ step_type: "wait", step_config: { amount: 0, unit: "minutes" } },
|
||||
{ step_type: "wait", step_config: { amount: 5, unit: "seconds" } },
|
||||
{ step_type: "wait", step_config: { amount: -1, unit: "hours" } },
|
||||
{
|
||||
step_type: "wait",
|
||||
step_config: { amount: Number.POSITIVE_INFINITY, unit: "days" },
|
||||
},
|
||||
]);
|
||||
expect(issues.map((i) => i.path)).toEqual([
|
||||
"steps[0].amount",
|
||||
"steps[1].unit",
|
||||
"steps[2].amount",
|
||||
"steps[3].amount",
|
||||
]);
|
||||
});
|
||||
|
||||
it("validates webhook URLs", () => {
|
||||
const good = validateStepsForActivation([
|
||||
{
|
||||
step_type: "send_webhook",
|
||||
step_config: { url: "https://hooks.example.com/in" },
|
||||
},
|
||||
]);
|
||||
expect(good).toEqual([]);
|
||||
|
||||
const noUrl = validateStepsForActivation([
|
||||
{ step_type: "send_webhook", step_config: {} },
|
||||
]);
|
||||
expect(noUrl.map((i) => i.message)).toContain("webhook URL is required");
|
||||
|
||||
const wrongProtocol = validateStepsForActivation([
|
||||
{
|
||||
step_type: "send_webhook",
|
||||
step_config: { url: "ftp://files.example.com" },
|
||||
},
|
||||
]);
|
||||
expect(wrongProtocol.map((i) => i.message)).toContain(
|
||||
"webhook URL must use http or https",
|
||||
);
|
||||
|
||||
const garbage = validateStepsForActivation([
|
||||
{ step_type: "send_webhook", step_config: { url: "not a url" } },
|
||||
]);
|
||||
expect(garbage.map((i) => i.message)).toContain(
|
||||
"webhook URL is not a valid URL",
|
||||
);
|
||||
});
|
||||
|
||||
it("validates assign_conversation only when mode is 'specific'", () => {
|
||||
const roundRobinNoAgent = validateStepsForActivation([
|
||||
{
|
||||
step_type: "assign_conversation",
|
||||
step_config: { mode: "round_robin" },
|
||||
},
|
||||
]);
|
||||
expect(roundRobinNoAgent).toEqual([]);
|
||||
|
||||
const specificMissingAgent = validateStepsForActivation([
|
||||
{ step_type: "assign_conversation", step_config: { mode: "specific" } },
|
||||
]);
|
||||
expect(specificMissingAgent.map((i) => i.path)).toEqual([
|
||||
"steps[0].agent_id",
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags create_deal when required fields are missing", () => {
|
||||
const issues = validateStepsForActivation([
|
||||
{ step_type: "create_deal", step_config: {} },
|
||||
]);
|
||||
expect(issues.map((i) => i.path).sort()).toEqual([
|
||||
"steps[0].pipeline_id",
|
||||
"steps[0].stage_id",
|
||||
"steps[0].title",
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags update_contact_field when field or value is missing", () => {
|
||||
const issues = validateStepsForActivation([
|
||||
{ step_type: "update_contact_field", step_config: { field: "name" } },
|
||||
{
|
||||
step_type: "update_contact_field",
|
||||
step_config: { field: "", value: "x" },
|
||||
},
|
||||
]);
|
||||
expect(issues.map((i) => i.path)).toEqual([
|
||||
"steps[0].value",
|
||||
"steps[1].field",
|
||||
]);
|
||||
});
|
||||
|
||||
it("recursively walks condition branches with stable dot-paths", () => {
|
||||
const issues = validateStepsForActivation([
|
||||
{
|
||||
step_type: "condition",
|
||||
step_config: { subject: "tag", operand: "vip" },
|
||||
branches: {
|
||||
yes: [{ step_type: "add_tag", step_config: { tag_id: "" } }],
|
||||
no: [
|
||||
{
|
||||
step_type: "send_message",
|
||||
step_config: { text: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(issues.map((i) => i.path)).toEqual([
|
||||
"steps[0].yes.steps[0].tag_id",
|
||||
"steps[0].no.steps[0].text",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports an issue for unknown step types", () => {
|
||||
const issues = validateStepsForActivation([
|
||||
{ step_type: "do_a_barrel_roll", step_config: {} },
|
||||
]);
|
||||
expect(issues).toEqual([
|
||||
{ path: "steps[0]", message: "unknown step type: do_a_barrel_roll" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags condition subject/operand independently", () => {
|
||||
const issues = validateStepsForActivation([
|
||||
{ step_type: "condition", step_config: {} },
|
||||
]);
|
||||
expect(issues.map((i) => i.path).sort()).toEqual([
|
||||
"steps[0].operand",
|
||||
"steps[0].subject",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTriggerForActivation", () => {
|
||||
it("accepts a valid keyword_match config", () => {
|
||||
expect(
|
||||
validateTriggerForActivation("keyword_match", {
|
||||
keywords: ["hello", "hi"],
|
||||
match_type: "exact",
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects keyword_match with empty keyword array", () => {
|
||||
const issues = validateTriggerForActivation("keyword_match", {
|
||||
keywords: [],
|
||||
match_type: "exact",
|
||||
});
|
||||
expect(issues.map((i) => i.path)).toContain("trigger.keywords");
|
||||
});
|
||||
|
||||
it("rejects keyword_match with whitespace-only entries", () => {
|
||||
const issues = validateTriggerForActivation("keyword_match", {
|
||||
keywords: ["hi", " "],
|
||||
match_type: "contains",
|
||||
});
|
||||
expect(issues.map((i) => i.message)).toContain(
|
||||
"keywords cannot be empty strings",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects keyword_match with an unknown match_type", () => {
|
||||
const issues = validateTriggerForActivation("keyword_match", {
|
||||
keywords: ["hi"],
|
||||
match_type: "fuzzy",
|
||||
});
|
||||
expect(issues.map((i) => i.path)).toContain("trigger.match_type");
|
||||
});
|
||||
|
||||
it("accepts keyword_match with a missing match_type (defaults to contains)", () => {
|
||||
expect(
|
||||
validateTriggerForActivation("keyword_match", { keywords: ["hi"] }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires schedule on time_based triggers", () => {
|
||||
expect(validateTriggerForActivation("time_based", {})).toEqual([
|
||||
{ path: "trigger.schedule", message: "schedule is required" },
|
||||
]);
|
||||
expect(
|
||||
validateTriggerForActivation("time_based", { schedule: "0 9 * * *" }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires tag_id on tag_added triggers", () => {
|
||||
expect(validateTriggerForActivation("tag_added", {})).toEqual([
|
||||
{ path: "trigger.tag_id", message: "tag is required" },
|
||||
]);
|
||||
expect(
|
||||
validateTriggerForActivation("tag_added", { tag_id: "tag-uuid" }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not flag unknown trigger types (handled elsewhere)", () => {
|
||||
expect(validateTriggerForActivation("some_future_trigger", {})).toEqual([]);
|
||||
});
|
||||
});
|
||||
184
wacrm/src/lib/automations/validate.ts
Normal file
184
wacrm/src/lib/automations/validate.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { AutomationTriggerType } from '@/types'
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Pre-flight config validation for automations about to be activated.
|
||||
//
|
||||
// Activating a broken automation (e.g. an add_tag step with tag_id="")
|
||||
// used to succeed silently — every trigger then produced a failed log
|
||||
// row with a cryptic "add_tag needs contact + tag_id" message, and
|
||||
// users often didn't notice until reviewing logs. This module lets
|
||||
// the API refuse activation with a useful 400 response instead.
|
||||
//
|
||||
// The rules here mirror the runtime checks in engine.ts's runStep;
|
||||
// they're the same invariants, enforced one step earlier so failures
|
||||
// surface at save time.
|
||||
// ------------------------------------------------------------
|
||||
|
||||
export interface ValidationIssue {
|
||||
/** Dot-path for the UI to highlight; stable enough to build a table. */
|
||||
path: string
|
||||
message: string
|
||||
}
|
||||
|
||||
interface StepLike {
|
||||
step_type: string
|
||||
step_config: Record<string, unknown>
|
||||
branches?: { yes?: StepLike[]; no?: StepLike[] }
|
||||
}
|
||||
|
||||
export function validateStepsForActivation(steps: StepLike[]): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = []
|
||||
if (!Array.isArray(steps) || steps.length === 0) {
|
||||
issues.push({
|
||||
path: 'steps',
|
||||
message: 'active automations need at least one step',
|
||||
})
|
||||
return issues
|
||||
}
|
||||
walk(steps, '', issues)
|
||||
return issues
|
||||
}
|
||||
|
||||
function walk(steps: StepLike[], prefix: string, issues: ValidationIssue[]): void {
|
||||
steps.forEach((s, i) => {
|
||||
const path = `${prefix}steps[${i}]`
|
||||
validateOne(s, path, issues)
|
||||
if (s.step_type === 'condition' && s.branches) {
|
||||
if (s.branches.yes) walk(s.branches.yes, `${path}.yes.`, issues)
|
||||
if (s.branches.no) walk(s.branches.no, `${path}.no.`, issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function validateOne(step: StepLike, path: string, issues: ValidationIssue[]): void {
|
||||
const c = step.step_config ?? {}
|
||||
switch (step.step_type) {
|
||||
case 'send_message':
|
||||
if (!nonEmpty(c.text)) {
|
||||
issues.push({ path: `${path}.text`, message: 'message text is required' })
|
||||
}
|
||||
break
|
||||
case 'send_template':
|
||||
if (!nonEmpty(c.template_name)) {
|
||||
issues.push({ path: `${path}.template_name`, message: 'template name is required' })
|
||||
}
|
||||
break
|
||||
case 'add_tag':
|
||||
case 'remove_tag':
|
||||
if (!nonEmpty(c.tag_id)) {
|
||||
issues.push({ path: `${path}.tag_id`, message: 'tag is required' })
|
||||
}
|
||||
break
|
||||
case 'assign_conversation':
|
||||
if (c.mode === 'specific' && !nonEmpty(c.agent_id)) {
|
||||
issues.push({
|
||||
path: `${path}.agent_id`,
|
||||
message: 'agent is required when mode is "specific"',
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'update_contact_field':
|
||||
if (!nonEmpty(c.field)) {
|
||||
issues.push({ path: `${path}.field`, message: 'field name is required' })
|
||||
}
|
||||
if (c.value === undefined || c.value === null || c.value === '') {
|
||||
issues.push({ path: `${path}.value`, message: 'field value is required' })
|
||||
}
|
||||
break
|
||||
case 'create_deal':
|
||||
if (!nonEmpty(c.pipeline_id)) {
|
||||
issues.push({ path: `${path}.pipeline_id`, message: 'pipeline is required' })
|
||||
}
|
||||
if (!nonEmpty(c.stage_id)) {
|
||||
issues.push({ path: `${path}.stage_id`, message: 'stage is required' })
|
||||
}
|
||||
if (!nonEmpty(c.title)) {
|
||||
issues.push({ path: `${path}.title`, message: 'title is required' })
|
||||
}
|
||||
break
|
||||
case 'wait':
|
||||
if (typeof c.amount !== 'number' || !Number.isFinite(c.amount) || c.amount <= 0) {
|
||||
issues.push({ path: `${path}.amount`, message: 'wait amount must be greater than 0' })
|
||||
}
|
||||
if (!['minutes', 'hours', 'days'].includes(String(c.unit))) {
|
||||
issues.push({
|
||||
path: `${path}.unit`,
|
||||
message: 'wait unit must be minutes, hours, or days',
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'condition':
|
||||
if (!nonEmpty(c.subject)) {
|
||||
issues.push({ path: `${path}.subject`, message: 'condition subject is required' })
|
||||
}
|
||||
if (!nonEmpty(c.operand)) {
|
||||
issues.push({ path: `${path}.operand`, message: 'condition operand is required' })
|
||||
}
|
||||
break
|
||||
case 'send_webhook':
|
||||
if (!nonEmpty(c.url)) {
|
||||
issues.push({ path: `${path}.url`, message: 'webhook URL is required' })
|
||||
break
|
||||
}
|
||||
try {
|
||||
const u = new URL(String(c.url))
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||
issues.push({
|
||||
path: `${path}.url`,
|
||||
message: 'webhook URL must use http or https',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
issues.push({ path: `${path}.url`, message: 'webhook URL is not a valid URL' })
|
||||
}
|
||||
break
|
||||
case 'close_conversation':
|
||||
// No config required.
|
||||
break
|
||||
default:
|
||||
issues.push({ path, message: `unknown step type: ${step.step_type}` })
|
||||
}
|
||||
}
|
||||
|
||||
export function validateTriggerForActivation(
|
||||
triggerType: AutomationTriggerType | string,
|
||||
triggerConfig: unknown,
|
||||
): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = []
|
||||
const cfg = (triggerConfig ?? {}) as Record<string, unknown>
|
||||
|
||||
if (triggerType === 'keyword_match') {
|
||||
const k = cfg.keywords
|
||||
if (!Array.isArray(k) || k.length === 0) {
|
||||
issues.push({ path: 'trigger.keywords', message: 'at least one keyword is required' })
|
||||
} else if (k.some((v) => typeof v !== 'string' || v.trim() === '')) {
|
||||
issues.push({ path: 'trigger.keywords', message: 'keywords cannot be empty strings' })
|
||||
}
|
||||
// A missing match_type defaults to "contains" at runtime (see
|
||||
// automations/engine.ts and flows/engine.ts, which both read
|
||||
// `match_type ?? "contains"`), so only an explicit, unrecognised
|
||||
// value is invalid here. This keeps activation validation in step
|
||||
// with the engine and with the builder's "Contains" default — an
|
||||
// automation that shows the default in the UI must not be rejected.
|
||||
if (cfg.match_type != null && cfg.match_type !== 'exact' && cfg.match_type !== 'contains') {
|
||||
issues.push({
|
||||
path: 'trigger.match_type',
|
||||
message: 'match type must be "exact" or "contains"',
|
||||
})
|
||||
}
|
||||
} else if (triggerType === 'time_based') {
|
||||
if (!nonEmpty(cfg.schedule)) {
|
||||
issues.push({ path: 'trigger.schedule', message: 'schedule is required' })
|
||||
}
|
||||
} else if (triggerType === 'tag_added') {
|
||||
if (!nonEmpty(cfg.tag_id)) {
|
||||
issues.push({ path: 'trigger.tag_id', message: 'tag is required' })
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function nonEmpty(v: unknown): boolean {
|
||||
return typeof v === 'string' && v.trim().length > 0
|
||||
}
|
||||
Reference in New Issue
Block a user