wacrm/src/app/api/automations/[id]/route.ts
Arnas Donauskas 570fc1234c
fix(automations): reject activation of invalid step/trigger config
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>
2026-04-17 21:50:13 +03:00

139 lines
4.3 KiB
TypeScript

import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { supabaseAdmin } from '@/lib/automations/admin-client'
import {
loadStepsTree,
replaceSteps,
type BuilderStepInput,
} from '@/lib/automations/steps-tree'
import {
validateStepsForActivation,
validateTriggerForActivation,
} from '@/lib/automations/validate'
async function requireUser() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
return user
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const user = await requireUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const admin = supabaseAdmin()
const { data: automation, error } = await admin
.from('automations')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.maybeSingle()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (!automation) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const steps = await loadStepsTree(id)
return NextResponse.json({ automation, steps })
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const user = await requireUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await request.json().catch(() => null)
if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
const admin = supabaseAdmin()
// Ownership check before we touch anything. Load the fields we need
// to compute the post-patch "effective" state for validation.
const { data: existing } = await admin
.from('automations')
.select('id, user_id, is_active, trigger_type, trigger_config')
.eq('id', id)
.maybeSingle()
if (!existing || existing.user_id !== user.id) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const update: Record<string, unknown> = {}
for (const k of [
'name',
'description',
'trigger_type',
'trigger_config',
'is_active',
] as const) {
if (k in body) update[k] = body[k]
}
// If this PATCH leaves the automation active (either explicitly
// activating it OR editing an already-active one), validate the
// merged configuration first. Activation is the natural gate — drafts
// are still allowed to be incomplete.
const willBeActive =
typeof update.is_active === 'boolean' ? update.is_active : existing.is_active
if (willBeActive) {
const mergedTriggerType = (update.trigger_type ?? existing.trigger_type) as string
const mergedTriggerConfig = update.trigger_config ?? existing.trigger_config
const mergedSteps = Array.isArray(body.steps)
? (body.steps as { step_type: string; step_config: Record<string, unknown> }[])
: await loadStepsTree(id)
const issues = [
...validateTriggerForActivation(mergedTriggerType, mergedTriggerConfig),
...validateStepsForActivation(mergedSteps),
]
if (issues.length > 0) {
return NextResponse.json(
{
error: 'Cannot keep automation active with invalid configuration',
issues,
},
{ status: 400 },
)
}
}
if (Object.keys(update).length > 0) {
const { error: updErr } = await admin
.from('automations')
.update(update)
.eq('id', id)
if (updErr) return NextResponse.json({ error: updErr.message }, { status: 500 })
}
if (Array.isArray(body.steps)) {
const err = await replaceSteps(id, body.steps as BuilderStepInput[])
if (err) return NextResponse.json({ error: err }, { status: 500 })
}
return NextResponse.json({ ok: true })
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const user = await requireUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { error } = await supabaseAdmin()
.from('automations')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ ok: true })
}