/** * TASK-0110 — automation API DTOs. * * All config validation is delegated to `@factory/shared/automation` * (single source of truth shared with the worker): * - triggers: `validateTriggerConfig(type, config)` (per-type zod schemas), * - conditions: `validateConditionTree` (depth ≤ 5, nodes ≤ 100), * - actions: `validateActionDto` (9 config schemas + retries/timeoutMs). * * The DTOs below only enforce the ENVELOPE (shape/limits) and throw * 422 `UNPROCESSABLE_ENTITY` with path-located details on config failure * (spec §1: "invalid → 422 with path-located errors"). */ import { z } from "zod"; import { AUTOMATION_ACTION_TIMEOUT_MS, AUTOMATION_DESCRIPTION_MAX, AUTOMATION_MAX_ACTIONS, AUTOMATION_MAX_TRIGGERS, AUTOMATION_NAME_MAX, validateActionDto, validateConditionTree, validateTriggerConfig, type AutomationActionDtoInput, type AutomationActionType, type AutomationStatus, type AutomationTriggerType } from "@factory/shared"; import { ApiError } from "../../common/errors.js"; // --------------------------------------------------------------------------- // Create / update // --------------------------------------------------------------------------- const ACTION_TYPE_ENUM = z.enum([ "UPDATE_RECORD", "CREATE_PAGE", "SEND_EMAIL", "SEND_WEBHOOK", "CREATE_NOTIFICATION", "ASSIGN_USER", "CHANGE_STATUS", "CALL_INTEGRATION", "RUN_AI_ACTION" ]); const ACTION_ENTRY = z .object({ type: ACTION_TYPE_ENUM, config: z.record(z.string(), z.unknown()), // Per-action knobs are validated by the shared `validateActionDto` // (retries 1..8 / timeoutMs 1000..300000 → 422 with path-located errors, // spec §1). The envelope keeps them open so the shared validator is the // single source of truth for the bounds (422, not zod 400). retries: z.number().int().optional(), timeoutMs: z.number().int().optional() }) .strict(); const TRIGGER_ENTRY = z .object({ type: z.enum(["RECORD_CREATED", "RECORD_UPDATED", "PROPERTY_CHANGED", "PAGE_CREATED", "FORM_SUBMITTED", "SCHEDULE", "WEBHOOK", "MANUAL"]), config: z.record(z.string(), z.unknown()) }) .strict(); export const CreateAutomationDto = z .object({ name: z.string().min(1).max(AUTOMATION_NAME_MAX), description: z.string().max(AUTOMATION_DESCRIPTION_MAX).optional(), databaseId: z.string().min(1).optional(), /** Optional: create with triggers/conditions/actions in one call. */ triggers: z.array(TRIGGER_ENTRY).optional(), condition: z.unknown().optional(), actions: z.array(ACTION_ENTRY).optional() }) .strict(); export type CreateAutomationInput = z.infer; export const UpdateAutomationDto = z .object({ name: z.string().min(1).max(AUTOMATION_NAME_MAX).optional(), description: z.string().max(AUTOMATION_DESCRIPTION_MAX).nullable().optional(), databaseId: z.string().min(1).nullable().optional() }) .strict(); export type UpdateAutomationInput = z.infer; export const SetStatusDto = z.object({ status: z.enum(["DRAFT", "ACTIVE", "PAUSED", "ARCHIVED"]) }); export type SetStatusInput = z.infer; // --------------------------------------------------------------------------- // Sub-resource PUTs (the TASK-0114 editor contract) // --------------------------------------------------------------------------- export const PutTriggersDto = z .object({ triggers: z .array( z .object({ type: z.enum(["RECORD_CREATED", "RECORD_UPDATED", "PROPERTY_CHANGED", "PAGE_CREATED", "FORM_SUBMITTED", "SCHEDULE", "WEBHOOK", "MANUAL"]), config: z.record(z.string(), z.unknown()) }) .strict() ) .min(1, "at least 1 trigger") .max(AUTOMATION_MAX_TRIGGERS, `at most ${AUTOMATION_MAX_TRIGGERS} triggers`) }) .strict(); export type PutTriggersInput = z.infer; export const PutConditionsDto = z .object({ /** 0..1 — absent/null = no condition (always true). */ condition: z.unknown().nullable() }) .strict(); export type PutConditionsInput = z.infer; export const PutActionsDto = z .object({ actions: z .array( z .object({ type: z.enum([ "UPDATE_RECORD", "CREATE_PAGE", "SEND_EMAIL", "SEND_WEBHOOK", "CREATE_NOTIFICATION", "ASSIGN_USER", "CHANGE_STATUS", "CALL_INTEGRATION", "RUN_AI_ACTION" ]), config: z.record(z.string(), z.unknown()), // Open here on purpose: the bounds (retries 1..8, timeoutMs // 1000..300000) are enforced by the shared `validateActionDto` // → 422 with path-located errors (spec §1), not by zod (400). retries: z.number().int().optional(), timeoutMs: z.number().int().optional() }) .strict() ) .min(1, "at least 1 action") .max(AUTOMATION_MAX_ACTIONS, `at most ${AUTOMATION_MAX_ACTIONS} actions`) }) .strict(); export type PutActionsInput = z.infer; /** Validate a trigger entry against its per-type config schema (422 on failure). */ export function assertValidTriggers(triggers: PutTriggersInput["triggers"]): void { for (let i = 0; i < triggers.length; i += 1) { const t = triggers[i] as { type: string; config: Record }; const r = validateTriggerConfig(t.type as AutomationTriggerType, t.config ?? {}); if (!r.ok) { const errs = r.errors.slice(0, 5).map((e) => ({ field: `triggers[${i}].config.${e.path}`, issue: e.message })); throw ApiError.unprocessable(`triggers[${i}]: invalid trigger config.`, errs); } } } /** Validate a condition tree (422 on failure). */ export function assertValidCondition(tree: unknown): void { const r = validateConditionTree(tree); if (!r.ok) { throw ApiError.unprocessable(r.message ?? "invalid condition.", [ { field: "condition", issue: r.code ?? "invalid" } ]); } } /** Validate each action entry (422 on failure). */ export function assertValidActions(actions: PutActionsInput["actions"]): void { for (let i = 0; i < actions.length; i += 1) { const a = actions[i] as AutomationActionDtoInput; const r = validateActionDto({ type: a.type as AutomationActionType, config: a.config, ...(a.retries !== undefined ? { retries: a.retries } : {}), ...(a.timeoutMs !== undefined ? { timeoutMs: a.timeoutMs } : {}) }); if (!r.ok) { const errs = r.errors.slice(0, 5).map((e) => ({ field: `actions[${i}].${e.path}`, issue: e.message })); throw ApiError.unprocessable(`actions[${i}]: invalid action config.`, errs); } } } // --------------------------------------------------------------------------- // Runs (list / manual / retry) // --------------------------------------------------------------------------- export const ListRunsQuery = z.object({ status: z.enum(["PENDING", "RUNNING", "SUCCEEDED", "FAILED", "DEAD_LETTER"]).optional(), cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(200).default(50) }); export type ListRunsQueryInput = z.infer; export const RunInputDto = z .object({ /** Optional manual-run input (Json ≤ 64 KB). */ input: z.unknown().optional() }) .strict(); export type RunInputInput = z.infer; export { AUTOMATION_ACTION_TIMEOUT_MS }; export type { AutomationStatus };