/** * Automations UI (TASK-0114) — shared types, enum mirrors, labels and * payload builders. * * The enums mirror `@factory/shared/automation` (single source of truth). The * web app deliberately does NOT import that module here: it pulls in * `cron-parser` + node:crypto, which the client bundle has no business * loading — the shared file documents this exact split ("the UI renders from * these"). Keep the lists in sync if the shared enums change. */ /* ------------------------------------------------------------- enums */ export const AUTOMATION_TRIGGER_TYPES = [ "RECORD_CREATED", "RECORD_UPDATED", "PROPERTY_CHANGED", "PAGE_CREATED", "FORM_SUBMITTED", "SCHEDULE", "WEBHOOK", "MANUAL" ] as const; export type AutomationTriggerType = (typeof AUTOMATION_TRIGGER_TYPES)[number]; /** * Mirrors the canonical `AUTOMATION_ACTION_TYPES` in @factory/shared/automation * (the API's single source of truth — a type not in this list is rejected by * the DTO's strict zod enum). Note: the spec's "CLASSIFY" action has no * separate API type — it is `RUN_AI_ACTION` with `task: "classify"`. */ export const AUTOMATION_ACTION_TYPES = [ "UPDATE_RECORD", "CREATE_PAGE", "SEND_EMAIL", "SEND_WEBHOOK", "CREATE_NOTIFICATION", "ASSIGN_USER", "CHANGE_STATUS", "CALL_INTEGRATION", "RUN_AI_ACTION" ] as const; export type AutomationActionType = (typeof AUTOMATION_ACTION_TYPES)[number]; export const AUTOMATION_STATUSES = ["DRAFT", "ACTIVE", "PAUSED", "ARCHIVED"] as const; export type AutomationStatus = (typeof AUTOMATION_STATUSES)[number]; export const AUTOMATION_RUN_STATUSES = ["PENDING", "RUNNING", "SUCCEEDED", "FAILED", "DEAD_LETTER"] as const; export type AutomationRunStatus = (typeof AUTOMATION_RUN_STATUSES)[number]; /** * Valid status transitions (mirrors `AUTOMATION_STATUS_TRANSITIONS` in * @factory/shared). DRAFT→ACTIVE; ACTIVE↔PAUSED; ACTIVE/PAUSED→ARCHIVED; * ARCHIVED→ACTIVE. DRAFT→ARCHIVED is NOT a transition (delete archives). */ export const AUTOMATION_STATUS_TRANSITIONS: Record = { DRAFT: ["ACTIVE"], ACTIVE: ["PAUSED", "ARCHIVED"], PAUSED: ["ACTIVE", "ARCHIVED"], ARCHIVED: ["ACTIVE"] }; /* ----------------------------------------------------------- metadata */ export const TRIGGER_LABELS: Record = { RECORD_CREATED: "Record created", RECORD_UPDATED: "Record updated", PROPERTY_CHANGED: "Property changed", PAGE_CREATED: "Page created", FORM_SUBMITTED: "Form submitted", SCHEDULE: "Schedule", WEBHOOK: "Webhook", MANUAL: "Manual" }; export const ACTION_LABELS: Record = { UPDATE_RECORD: "Update record", CREATE_PAGE: "Create page", SEND_EMAIL: "Send email", SEND_WEBHOOK: "Send webhook", CREATE_NOTIFICATION: "Notify user", ASSIGN_USER: "Assign user", CHANGE_STATUS: "Change status", CALL_INTEGRATION: "Call integration", RUN_AI_ACTION: "Run AI action" }; /** RUN_AI_ACTION task enum (real schema: summarize | classify | extract). */ export const AI_TASKS = ["summarize", "classify", "extract"] as const; /** CREATE_NOTIFICATION type enum (real schema). */ export const NOTIFICATION_TYPES = [ "MENTION", "FORM_SUBMITTED", "COMMENT", "ASSIGNMENT", "PAGE_SHARED", "TASK_ASSIGNED", "DATABASE_CHANGE", "AUTOMATION", "INTEGRATION" ] as const; /** Operators for the simple leaf-predicate editor (the task's required set). */ export const CONDITION_OPERATORS = ["=", "!=", ">", "<", "contains", "exists"] as const; export type ConditionOperator = (typeof CONDITION_OPERATORS)[number]; /* --------------------------------------------------------- run labels */ /** Lowercase run-status text (the E2E contract renders e.g. "dead-letter"). */ export function runStatusLabel(status: string): string { return status.toLowerCase().replace(/_/g, "-"); } /** Badge classes for a run-status pill (succeeded green, failed/dead red). */ export function runStatusCls(status: string): string { switch (status) { case "SUCCEEDED": return "bg-emerald-500/15 text-emerald-600"; case "FAILED": case "DEAD_LETTER": return "bg-destructive/15 text-destructive"; case "RUNNING": case "PENDING": return "bg-amber-500/15 text-amber-600"; default: return "bg-muted text-muted-foreground"; } } /** Badge classes for an automation-status pill. */ export function statusCls(status: string): string { switch (status) { case "ACTIVE": return "bg-emerald-500/15 text-emerald-600"; case "PAUSED": return "bg-amber-500/15 text-amber-600"; case "DRAFT": return "bg-muted text-muted-foreground"; case "ARCHIVED": return "bg-muted text-muted-foreground/70"; default: return "bg-muted text-muted-foreground"; } } /* ------------------------------------------------------------- limits */ export const AUTOMATION_MAX_TRIGGERS = 3; export const AUTOMATION_MAX_ACTIONS = 50; export const ACTION_RETRY_DEFAULT = 3; export const ACTION_RETRY_MIN = 1; export const ACTION_RETRY_MAX = 8; export const ACTION_TIMEOUT_DEFAULT_MS = 30_000; export const ACTION_TIMEOUT_MIN_MS = 1_000; export const ACTION_TIMEOUT_MAX_MS = 300_000; /* -------------------------------------------------------- API shapes */ /** GET /workspaces/:ws/automations → row (service `toSummary` + latestRun). */ export interface AutomationSummary { id: string; name: string; description: string | null; databaseId: string | null; status: string; /** Trigger type names (string[] in the summary envelope). */ triggers: string[]; hasCondition: boolean; actions: string[]; createdAt: string; updatedAt: string; createdById: string; latestRun: { id: string; status: string; createdAt: string; finishedAt: string | null } | null; } /** GET /automations/:id (service `toDetail` + latestRun). */ export interface AutomationDetail extends Omit { triggers: { type: string; config: Record }[]; condition: unknown; actions: { type: string; config: Record; retries: number; timeoutMs: number }[]; } /** GET /automations/:id/runs → row (service `toRunSummary`). */ export interface RunSummary { id: string; status: string; triggerType: string | null; startedAt: string | null; finishedAt: string | null; retryCount: number; error: unknown; createdAt: string; } /** GET /automations/:id/runs/:runId (detail + logs). */ export interface RunLog { level: string; message: string; data: unknown; createdAt: string; } export interface RunDetail extends RunSummary { input: unknown; output: unknown; logs: RunLog[]; } /* ------------------------------------------------------ draft models */ export interface TriggerDraft { type: AutomationTriggerType; config: Record; } export interface ActionDraft { type: AutomationActionType; config: Record; retries: number; timeoutMs: number; } export interface ConditionDraft { hasCondition: boolean; /** Simple leaf-predicate row (the minimum required editor). */ fieldRef: "property" | "event"; fieldName: string; operator: ConditionOperator; value: string; /** Advanced: raw JSON condition tree (AND/OR/NOT + predicates). */ json: string; } export function emptyCondition(): ConditionDraft { return { hasCondition: false, fieldRef: "property", fieldName: "", operator: "=", value: "", json: "" }; } /* ------------------------------------------------- draft ↔ payloads */ /** * Trigger draft → wire trigger. The UI stores `properties` as a comma list and * the schedule tz as `timezone` for display; the wire schema (strict) wants * `properties: string[]` and `timeZone`. Empty strings are stripped so the * strict per-type schemas (TriggerConfigSchemas in @factory/shared) pass. */ export function triggerPayload(t: TriggerDraft): { type: string; config: Record } { const cfg: Record = {}; for (const [k, v] of Object.entries(t.config)) { if (typeof v === "string" && v.trim() === "") continue; cfg[k] = v; } if (typeof cfg.properties === "string") { cfg.properties = (cfg.properties as string) .split(",") .map((p) => p.trim()) .filter((p) => p.length > 0); } if (cfg.timezone !== undefined) { cfg.timeZone = cfg.timezone; delete cfg.timezone; } return { type: t.type, config: cfg }; } /** * Action draft → the wire `config` object. The real API validates each action * against a STRICT per-type schema (ActionConfigSchemas in * @factory/shared/automation) that rejects unknown keys — so each branch below * emits ONLY the fields that schema accepts (empty inputs are omitted). * * Field mapping vs. the task's illustrative list (recorded deviation): * - CREATE_NOTIFICATION: task says `title + body`; real schema has `body` * (the message text) + `type` (a notification-kind enum) + `to`. We expose * `body` (the "title" is used as the notification body) and `type`. * - CREATE_PAGE: task says `parentPageId/title/body`; real schema has * `title` + `parentId` (a page id, not "parentPageId") — no body field. * - SEND_WEBHOOK: task says `url + method + headersJson + bodyTemplate`; the * real schema has `url` (https only) + `headers` (object) + `payload` * (object) and NO method. We send url/headers/payload (method is accepted * for display but stripped on save — the endpoint always POSTs). * - RUN_AI_ACTION: task says `task + prompt + model + outputProperty`; the * real schema has `prompt` + `task` (summarize|classify|extract) only. * - "CLASSIFY": no such API type — it is RUN_AI_ACTION with task="classify". */ function actionConfigPayload(type: AutomationActionType, c: Record): Record { const s = (key: string) => (c[key] ?? "").trim(); const opt = (key: string): string | undefined => { const v = s(key); return v === "" ? undefined : v; }; switch (type) { case "UPDATE_RECORD": return { ...(opt("recordId") ? { recordId: s("recordId") } : {}), propertyValues: s("propertyName") ? { [s("propertyName")]: s("value") } : {} }; case "CHANGE_STATUS": return { ...(opt("recordId") ? { recordId: s("recordId") } : {}), propertyId: s("propertyId"), value: s("value") }; case "ASSIGN_USER": return { ...(opt("recordId") ? { recordId: s("recordId") } : {}), propertyId: s("propertyId"), userId: s("userId") }; case "CREATE_NOTIFICATION": return { type: (NOTIFICATION_TYPES as readonly string[]).includes(s("type")) ? s("type") : "AUTOMATION", body: s("body") }; case "CREATE_PAGE": return { title: s("title"), ...(opt("parentPageId") ? { parentId: s("parentPageId") } : {}) }; case "SEND_WEBHOOK": { const headersRaw = s("headersJson"); let headers: Record | undefined; if (headersRaw) { try { const parsed = JSON.parse(headersRaw); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { headers = {}; for (const [k, v] of Object.entries(parsed)) { headers[k] = typeof v === "string" ? v : JSON.stringify(v); } } } catch { headers = undefined; } } const payloadRaw = s("bodyTemplate"); let payload: Record | undefined; if (payloadRaw) { try { const parsed = JSON.parse(payloadRaw); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) payload = parsed; } catch { payload = { bodyTemplate: payloadRaw }; } } return { url: s("url"), ...(headers && Object.keys(headers).length > 0 ? { headers } : {}), ...(payload ? { payload } : {}) }; } case "SEND_EMAIL": return { to: s("to"), subject: s("subject"), body: s("body") }; case "CALL_INTEGRATION": return { integrationId: s("integrationId"), operation: s("operation"), ...(opt("params") ? { params: { note: s("params") } } : {}) }; case "RUN_AI_ACTION": return { prompt: s("prompt"), task: (AI_TASKS as readonly string[]).includes(s("task")) ? s("task") : "summarize" }; default: return {}; } } function actionConfigFromWire(type: AutomationActionType, cfg: Record): Record { const str = (v: unknown): string => { if (typeof v === "string") return v; if (typeof v === "number" || typeof v === "boolean") return String(v); if (v == null) return ""; try { return JSON.stringify(v); } catch { return ""; } }; const c: Record = {}; switch (type) { case "UPDATE_RECORD": { c.recordId = str(cfg.recordId ?? "from_trigger"); const pv = (cfg.propertyValues ?? {}) as Record; const entries = Object.entries(pv); const first = entries[0]; if (first) { c.propertyName = first[0]; c.value = str(first[1]); } else { c.propertyName = ""; c.value = ""; } break; } case "CHANGE_STATUS": c.recordId = str(cfg.recordId ?? "from_trigger"); c.propertyId = str(cfg.propertyId); c.value = str(cfg.value); break; case "ASSIGN_USER": c.recordId = str(cfg.recordId ?? "from_trigger"); c.propertyId = str(cfg.propertyId); c.userId = str(cfg.userId); break; case "CREATE_NOTIFICATION": c.type = str(cfg.type ?? "AUTOMATION"); c.body = str(cfg.body); break; case "CREATE_PAGE": c.title = str(cfg.title); c.parentPageId = str(cfg.parentId ?? ""); break; case "SEND_WEBHOOK": c.url = str(cfg.url); c.headersJson = cfg.headers && typeof cfg.headers === "object" ? JSON.stringify(cfg.headers, null, 2) : ""; c.bodyTemplate = cfg.payload && typeof cfg.payload === "object" ? JSON.stringify(cfg.payload, null, 2) : ""; break; case "SEND_EMAIL": c.to = str(cfg.to); c.subject = str(cfg.subject); c.body = str(cfg.body); break; case "CALL_INTEGRATION": c.integrationId = str(cfg.integrationId); c.operation = str(cfg.operation); c.params = str((cfg.params ?? {}) as Record | undefined); break; case "RUN_AI_ACTION": c.task = str(cfg.task ?? "summarize"); c.prompt = str(cfg.prompt); break; default: break; } return c; } /** Action draft → wire action (empty-string fields stripped per type). */ export function actionPayload(a: ActionDraft): { type: string; config: Record; retries: number; timeoutMs: number } { return { type: a.type, config: actionConfigPayload(a.type, a.config), retries: a.retries, timeoutMs: a.timeoutMs }; } /** Wire action (detail row) → editable draft. */ export function actionDraftFromWire(a: { type: string; config: Record; retries: number; timeoutMs: number }): ActionDraft { const type = (AUTOMATION_ACTION_TYPES as readonly string[]).includes(a.type) ? (a.type as AutomationActionType) : "RUN_AI_ACTION"; return { type, config: actionConfigFromWire(type, a.config ?? {}), retries: Number.isFinite(a.retries) ? a.retries : ACTION_RETRY_DEFAULT, timeoutMs: Number.isFinite(a.timeoutMs) ? a.timeoutMs : ACTION_TIMEOUT_DEFAULT_MS }; } /** Wire trigger (detail row) → editable draft. */ export function triggerDraftFromWire(t: { type: string; config: Record }): TriggerDraft { const type = (AUTOMATION_TRIGGER_TYPES as readonly string[]).includes(t.type) ? (t.type as AutomationTriggerType) : "MANUAL"; const cfg = (t.config ?? {}) as Record; const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v)); const base: Record = {}; if (type === "RECORD_CREATED" || type === "RECORD_UPDATED" || type === "PROPERTY_CHANGED" || type === "FORM_SUBMITTED") { base.databaseId = str(cfg.databaseId ?? ""); } if (type === "RECORD_UPDATED" || type === "PROPERTY_CHANGED") { const props = Array.isArray(cfg.properties) ? (cfg.properties as unknown[]).map((p) => String(p)) : []; base.properties = props.join(", "); } if (type === "SCHEDULE") { base.cron = str(cfg.cron ?? ""); base.timezone = str(cfg.timeZone ?? ""); } if (type === "WEBHOOK") { base.webhookId = str(cfg.webhookId ?? ""); base.url = str(cfg.url ?? ""); } return { type, config: base }; } /** Shared operator ↔ wire mapping (simple row → the 14-operator wire enum). */ const OP_TO_WIRE: Record = { "=": "eq", "!=": "neq", ">": "gt", "<": "lt", contains: "contains", exists: "is_not_empty" }; const OP_FROM_WIRE: Record = { eq: "=", neq: "!=", gt: ">", lt: "<", contains: "contains", is_not_empty: "exists" }; /** * Condition detail row (0..1 tree) → draft. A single predicate using one of * the simple-row operators is rendered as the simple row; anything else * (groups, NOT, other operators, …) falls back to the JSON view. */ export function conditionDraftFromWire(tree: unknown): ConditionDraft { const d = emptyCondition(); if (tree == null || typeof tree !== "object") return d; d.hasCondition = true; const n = tree as Record; if (n.op !== "AND" && n.op !== "OR" && n.op !== "NOT" && n.field != null) { const f = n.field as Record; const op = String(n.operator ?? ""); const mapped = OP_FROM_WIRE[op]; if (mapped) { // Representable in the simple row. d.fieldRef = f.ref === "event" ? "event" : "property"; d.fieldName = typeof f.name === "string" ? f.name : ""; d.operator = mapped; if (n.value != null) { d.value = typeof n.value === "string" ? n.value : JSON.stringify(n.value); } } else { // A predicate with an operator outside the simple row's set (e.g. // starts_with) — keep it lossless in the JSON view. d.json = JSON.stringify(tree, null, 2); } } else { d.json = JSON.stringify(tree, null, 2); } return d; } /** * Draft → the PUT /conditions body `{ condition }`. The simple row maps to the * shared predicate shape (operator = → "eq", != → "neq"); advanced mode must * be valid JSON parseable to a condition tree (validated server-side, 422). */ export function conditionPayload(d: ConditionDraft): { condition: unknown } { if (!d.hasCondition) return { condition: null }; const trimmed = d.json.trim(); if (trimmed.length > 0) { return { condition: JSON.parse(trimmed) }; } const predicate: Record = { field: { ref: d.fieldRef, name: d.fieldName }, operator: OP_TO_WIRE[d.operator] }; if (d.operator !== "exists" && d.value.trim() !== "") predicate.value = d.value; return { condition: predicate }; } /** Human summary of a condition draft (for the section header). */ export function conditionSummary(d: ConditionDraft): string { if (!d.hasCondition) return "No condition — runs on every trigger."; const trimmed = d.json.trim(); if (trimmed.length > 0) return "Custom condition (JSON)."; if (!d.fieldName) return "No condition — runs on every trigger."; const op = d.operator === "exists" ? "exists" : `${d.operator}`; return `${d.fieldName} ${op} ${d.value}`; }