mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Activating an automation with obviously-broken config (add_tag step
with empty tag_id, send_message with empty text, keyword_match
trigger with no keywords, etc.) used to succeed — and then every
dispatch produced a cryptic failed log row that users often missed.
- New validate.ts centralises the checks (mirrors engine.ts's
runtime guards one layer earlier so failures surface at save time).
Returns { path, message } issues so the UI can point users at the
exact problem.
- POST /api/automations rejects with 400 when is_active === true and
the merged config fails validation.
- PATCH /api/automations/[id] computes the post-patch "effective"
state (merging body fields over the existing row, loading current
steps if body.steps isn't provided) and applies the same gate.
Drafts (is_active=false) remain free to be incomplete so users can
still save progress mid-build.
- Builder surfaces the first issue in a toast with path as the
description, so users don't need DevTools to find what to fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
179 lines
6.0 KiB
TypeScript
179 lines
6.0 KiB
TypeScript
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' })
|
|
}
|
|
if (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
|
|
}
|