1
11
12
13
14export const AUTOMATION_TRIGGER_TYPES = [
15 "RECORD_CREATED",
16 "RECORD_UPDATED",
17 "PROPERTY_CHANGED",
18 "PAGE_CREATED",
19 "FORM_SUBMITTED",
20 "SCHEDULE",
21 "WEBHOOK",
22 "MANUAL"
23] as const;
24export type AutomationTriggerType = (typeof AUTOMATION_TRIGGER_TYPES)[number];
25
26
32export const AUTOMATION_ACTION_TYPES = [
33 "UPDATE_RECORD",
34 "CREATE_PAGE",
35 "SEND_EMAIL",
36 "SEND_WEBHOOK",
37 "CREATE_NOTIFICATION",
38 "ASSIGN_USER",
39 "CHANGE_STATUS",
40 "CALL_INTEGRATION",
41 "RUN_AI_ACTION"
42] as const;
43export type AutomationActionType = (typeof AUTOMATION_ACTION_TYPES)[number];
44
45export const AUTOMATION_STATUSES = ["DRAFT", "ACTIVE", "PAUSED", "ARCHIVED"] as const;
46export type AutomationStatus = (typeof AUTOMATION_STATUSES)[number];
47
48export const AUTOMATION_RUN_STATUSES = ["PENDING", "RUNNING", "SUCCEEDED", "FAILED", "DEAD_LETTER"] as const;
49export type AutomationRunStatus = (typeof AUTOMATION_RUN_STATUSES)[number];
50
51
56export const AUTOMATION_STATUS_TRANSITIONS: Record<AutomationStatus, AutomationStatus[]> = {
57 DRAFT: ["ACTIVE"],
58 ACTIVE: ["PAUSED", "ARCHIVED"],
59 PAUSED: ["ACTIVE", "ARCHIVED"],
60 ARCHIVED: ["ACTIVE"]
61};
62
63
64
65export const TRIGGER_LABELS: Record<AutomationTriggerType, string> = {
66 RECORD_CREATED: "Record created",
67 RECORD_UPDATED: "Record updated",
68 PROPERTY_CHANGED: "Property changed",
69 PAGE_CREATED: "Page created",
70 FORM_SUBMITTED: "Form submitted",
71 SCHEDULE: "Schedule",
72 WEBHOOK: "Webhook",
73 MANUAL: "Manual"
74};
75
76export const ACTION_LABELS: Record<AutomationActionType, string> = {
77 UPDATE_RECORD: "Update record",
78 CREATE_PAGE: "Create page",
79 SEND_EMAIL: "Send email",
80 SEND_WEBHOOK: "Send webhook",
81 CREATE_NOTIFICATION: "Notify user",
82 ASSIGN_USER: "Assign user",
83 CHANGE_STATUS: "Change status",
84 CALL_INTEGRATION: "Call integration",
85 RUN_AI_ACTION: "Run AI action"
86};
87
88
89export const AI_TASKS = ["summarize", "classify", "extract"] as const;
90
91export const NOTIFICATION_TYPES = [
92 "MENTION",
93 "FORM_SUBMITTED",
94 "COMMENT",
95 "ASSIGNMENT",
96 "PAGE_SHARED",
97 "TASK_ASSIGNED",
98 "DATABASE_CHANGE",
99 "AUTOMATION",
100 "INTEGRATION"
101] as const;
102
103
104export const CONDITION_OPERATORS = ["=", "!=", ">", "<", "contains", "exists"] as const;
105export type ConditionOperator = (typeof CONDITION_OPERATORS)[number];
106
107
108
109
110export function runStatusLabel(status: string): string {
111 return status.toLowerCase().replace(/_/g, "-");
112}
113
114
115export function runStatusCls(status: string): string {
116 switch (status) {
117 case "SUCCEEDED":
118 return "bg-emerald-500/15 text-emerald-600";
119 case "FAILED":
120 case "DEAD_LETTER":
121 return "bg-destructive/15 text-destructive";
122 case "RUNNING":
123 case "PENDING":
124 return "bg-amber-500/15 text-amber-600";
125 default:
126 return "bg-muted text-muted-foreground";
127 }
128}
129
130
131export function statusCls(status: string): string {
132 switch (status) {
133 case "ACTIVE":
134 return "bg-emerald-500/15 text-emerald-600";
135 case "PAUSED":
136 return "bg-amber-500/15 text-amber-600";
137 case "DRAFT":
138 return "bg-muted text-muted-foreground";
139 case "ARCHIVED":
140 return "bg-muted text-muted-foreground/70";
141 default:
142 return "bg-muted text-muted-foreground";
143 }
144}
145
146
147
148export const AUTOMATION_MAX_TRIGGERS = 3;
149export const AUTOMATION_MAX_ACTIONS = 50;
150export const ACTION_RETRY_DEFAULT = 3;
151export const ACTION_RETRY_MIN = 1;
152export const ACTION_RETRY_MAX = 8;
153export const ACTION_TIMEOUT_DEFAULT_MS = 30_000;
154export const ACTION_TIMEOUT_MIN_MS = 1_000;
155export const ACTION_TIMEOUT_MAX_MS = 300_000;
156
157
158
159
160export interface AutomationSummary {
161 id: string;
162 name: string;
163 description: string | null;
164 databaseId: string | null;
165 status: string;
166
167 triggers: string[];
168 hasCondition: boolean;
169 actions: string[];
170 createdAt: string;
171 updatedAt: string;
172 createdById: string;
173 latestRun: { id: string; status: string; createdAt: string; finishedAt: string | null } | null;
174}
175
176
177export interface AutomationDetail extends Omit<AutomationSummary, "triggers" | "actions"> {
178 triggers: { type: string; config: Record<string, unknown> }[];
179 condition: unknown;
180 actions: { type: string; config: Record<string, unknown>; retries: number; timeoutMs: number }[];
181}
182
183
184export interface RunSummary {
185 id: string;
186 status: string;
187 triggerType: string | null;
188 startedAt: string | null;
189 finishedAt: string | null;
190 retryCount: number;
191 error: unknown;
192 createdAt: string;
193}
194
195
196export interface RunLog {
197 level: string;
198 message: string;
199 data: unknown;
200 createdAt: string;
201}
202export interface RunDetail extends RunSummary {
203 input: unknown;
204 output: unknown;
205 logs: RunLog[];
206}
207
208
209
210export interface TriggerDraft {
211 type: AutomationTriggerType;
212 config: Record<string, unknown>;
213}
214
215export interface ActionDraft {
216 type: AutomationActionType;
217 config: Record<string, string>;
218 retries: number;
219 timeoutMs: number;
220}
221
222export interface ConditionDraft {
223 hasCondition: boolean;
224
225 fieldRef: "property" | "event";
226 fieldName: string;
227 operator: ConditionOperator;
228 value: string;
229
230 json: string;
231}
232
233export function emptyCondition(): ConditionDraft {
234 return { hasCondition: false, fieldRef: "property", fieldName: "", operator: "=", value: "", json: "" };
235}
236
237
238
239
245export function triggerPayload(t: TriggerDraft): { type: string; config: Record<string, unknown> } {
246 const cfg: Record<string, unknown> = {};
247 for (const [k, v] of Object.entries(t.config)) {
248 if (typeof v === "string" && v.trim() === "") continue;
249 cfg[k] = v;
250 }
251 if (typeof cfg.properties === "string") {
252 cfg.properties = (cfg.properties as string)
253 .split(",")
254 .map((p) => p.trim())
255 .filter((p) => p.length > 0);
256 }
257 if (cfg.timezone !== undefined) {
258 cfg.timeZone = cfg.timezone;
259 delete cfg.timezone;
260 }
261 return { type: t.type, config: cfg };
262}
263
264
284function actionConfigPayload(type: AutomationActionType, c: Record<string, string>): Record<string, unknown> {
285 const s = (key: string) => (c[key] ?? "").trim();
286 const opt = (key: string): string | undefined => {
287 const v = s(key);
288 return v === "" ? undefined : v;
289 };
290 switch (type) {
291 case "UPDATE_RECORD":
292 return {
293 ...(opt("recordId") ? { recordId: s("recordId") } : {}),
294 propertyValues: s("propertyName") ? { [s("propertyName")]: s("value") } : {}
295 };
296 case "CHANGE_STATUS":
297 return {
298 ...(opt("recordId") ? { recordId: s("recordId") } : {}),
299 propertyId: s("propertyId"),
300 value: s("value")
301 };
302 case "ASSIGN_USER":
303 return {
304 ...(opt("recordId") ? { recordId: s("recordId") } : {}),
305 propertyId: s("propertyId"),
306 userId: s("userId")
307 };
308 case "CREATE_NOTIFICATION":
309 return {
310 type: (NOTIFICATION_TYPES as readonly string[]).includes(s("type")) ? s("type") : "AUTOMATION",
311 body: s("body")
312 };
313 case "CREATE_PAGE":
314 return {
315 title: s("title"),
316 ...(opt("parentPageId") ? { parentId: s("parentPageId") } : {})
317 };
318 case "SEND_WEBHOOK": {
319 const headersRaw = s("headersJson");
320 let headers: Record<string, string> | undefined;
321 if (headersRaw) {
322 try {
323 const parsed = JSON.parse(headersRaw);
324 if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
325 headers = {};
326 for (const [k, v] of Object.entries(parsed)) {
327 headers[k] = typeof v === "string" ? v : JSON.stringify(v);
328 }
329 }
330 } catch {
331 headers = undefined;
332 }
333 }
334 const payloadRaw = s("bodyTemplate");
335 let payload: Record<string, unknown> | undefined;
336 if (payloadRaw) {
337 try {
338 const parsed = JSON.parse(payloadRaw);
339 if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) payload = parsed;
340 } catch {
341 payload = { bodyTemplate: payloadRaw };
342 }
343 }
344 return {
345 url: s("url"),
346 ...(headers && Object.keys(headers).length > 0 ? { headers } : {}),
347 ...(payload ? { payload } : {})
348 };
349 }
350 case "SEND_EMAIL":
351 return { to: s("to"), subject: s("subject"), body: s("body") };
352 case "CALL_INTEGRATION":
353 return {
354 integrationId: s("integrationId"),
355 operation: s("operation"),
356 ...(opt("params") ? { params: { note: s("params") } } : {})
357 };
358 case "RUN_AI_ACTION":
359 return {
360 prompt: s("prompt"),
361 task: (AI_TASKS as readonly string[]).includes(s("task")) ? s("task") : "summarize"
362 };
363 default:
364 return {};
365 }
366}
367
368function actionConfigFromWire(type: AutomationActionType, cfg: Record<string, unknown>): Record<string, string> {
369 const str = (v: unknown): string => {
370 if (typeof v === "string") return v;
371 if (typeof v === "number" || typeof v === "boolean") return String(v);
372 if (v == null) return "";
373 try {
374 return JSON.stringify(v);
375 } catch {
376 return "";
377 }
378 };
379 const c: Record<string, string> = {};
380 switch (type) {
381 case "UPDATE_RECORD": {
382 c.recordId = str(cfg.recordId ?? "from_trigger");
383 const pv = (cfg.propertyValues ?? {}) as Record<string, unknown>;
384 const entries = Object.entries(pv);
385 const first = entries[0];
386 if (first) {
387 c.propertyName = first[0];
388 c.value = str(first[1]);
389 } else {
390 c.propertyName = "";
391 c.value = "";
392 }
393 break;
394 }
395 case "CHANGE_STATUS":
396 c.recordId = str(cfg.recordId ?? "from_trigger");
397 c.propertyId = str(cfg.propertyId);
398 c.value = str(cfg.value);
399 break;
400 case "ASSIGN_USER":
401 c.recordId = str(cfg.recordId ?? "from_trigger");
402 c.propertyId = str(cfg.propertyId);
403 c.userId = str(cfg.userId);
404 break;
405 case "CREATE_NOTIFICATION":
406 c.type = str(cfg.type ?? "AUTOMATION");
407 c.body = str(cfg.body);
408 break;
409 case "CREATE_PAGE":
410 c.title = str(cfg.title);
411 c.parentPageId = str(cfg.parentId ?? "");
412 break;
413 case "SEND_WEBHOOK":
414 c.url = str(cfg.url);
415 c.headersJson = cfg.headers && typeof cfg.headers === "object" ? JSON.stringify(cfg.headers, null, 2) : "";
416 c.bodyTemplate =
417 cfg.payload && typeof cfg.payload === "object" ? JSON.stringify(cfg.payload, null, 2) : "";
418 break;
419 case "SEND_EMAIL":
420 c.to = str(cfg.to);
421 c.subject = str(cfg.subject);
422 c.body = str(cfg.body);
423 break;
424 case "CALL_INTEGRATION":
425 c.integrationId = str(cfg.integrationId);
426 c.operation = str(cfg.operation);
427 c.params = str((cfg.params ?? {}) as Record<string, unknown> | undefined);
428 break;
429 case "RUN_AI_ACTION":
430 c.task = str(cfg.task ?? "summarize");
431 c.prompt = str(cfg.prompt);
432 break;
433 default:
434 break;
435 }
436 return c;
437}
438
439
440export function actionPayload(a: ActionDraft): { type: string; config: Record<string, unknown>; retries: number; timeoutMs: number } {
441 return { type: a.type, config: actionConfigPayload(a.type, a.config), retries: a.retries, timeoutMs: a.timeoutMs };
442}
443
444
445export function actionDraftFromWire(a: { type: string; config: Record<string, unknown>; retries: number; timeoutMs: number }): ActionDraft {
446 const type = (AUTOMATION_ACTION_TYPES as readonly string[]).includes(a.type) ? (a.type as AutomationActionType) : "RUN_AI_ACTION";
447 return {
448 type,
449 config: actionConfigFromWire(type, a.config ?? {}),
450 retries: Number.isFinite(a.retries) ? a.retries : ACTION_RETRY_DEFAULT,
451 timeoutMs: Number.isFinite(a.timeoutMs) ? a.timeoutMs : ACTION_TIMEOUT_DEFAULT_MS
452 };
453}
454
455
456export function triggerDraftFromWire(t: { type: string; config: Record<string, unknown> }): TriggerDraft {
457 const type = (AUTOMATION_TRIGGER_TYPES as readonly string[]).includes(t.type) ? (t.type as AutomationTriggerType) : "MANUAL";
458 const cfg = (t.config ?? {}) as Record<string, unknown>;
459 const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
460 const base: Record<string, unknown> = {};
461 if (type === "RECORD_CREATED" || type === "RECORD_UPDATED" || type === "PROPERTY_CHANGED" || type === "FORM_SUBMITTED") {
462 base.databaseId = str(cfg.databaseId ?? "");
463 }
464 if (type === "RECORD_UPDATED" || type === "PROPERTY_CHANGED") {
465 const props = Array.isArray(cfg.properties) ? (cfg.properties as unknown[]).map((p) => String(p)) : [];
466 base.properties = props.join(", ");
467 }
468 if (type === "SCHEDULE") {
469 base.cron = str(cfg.cron ?? "");
470 base.timezone = str(cfg.timeZone ?? "");
471 }
472 if (type === "WEBHOOK") {
473 base.webhookId = str(cfg.webhookId ?? "");
474 base.url = str(cfg.url ?? "");
475 }
476 return { type, config: base };
477}
478
479
480const OP_TO_WIRE: Record<ConditionOperator, string> = {
481 "=": "eq",
482 "!=": "neq",
483 ">": "gt",
484 "<": "lt",
485 contains: "contains",
486 exists: "is_not_empty"
487};
488const OP_FROM_WIRE: Record<string, ConditionOperator> = {
489 eq: "=",
490 neq: "!=",
491 gt: ">",
492 lt: "<",
493 contains: "contains",
494 is_not_empty: "exists"
495};
496
497
502export function conditionDraftFromWire(tree: unknown): ConditionDraft {
503 const d = emptyCondition();
504 if (tree == null || typeof tree !== "object") return d;
505 d.hasCondition = true;
506 const n = tree as Record<string, unknown>;
507 if (n.op !== "AND" && n.op !== "OR" && n.op !== "NOT" && n.field != null) {
508 const f = n.field as Record<string, unknown>;
509 const op = String(n.operator ?? "");
510 const mapped = OP_FROM_WIRE[op];
511 if (mapped) {
512
513 d.fieldRef = f.ref === "event" ? "event" : "property";
514 d.fieldName = typeof f.name === "string" ? f.name : "";
515 d.operator = mapped;
516 if (n.value != null) {
517 d.value = typeof n.value === "string" ? n.value : JSON.stringify(n.value);
518 }
519 } else {
520
521
522 d.json = JSON.stringify(tree, null, 2);
523 }
524 } else {
525 d.json = JSON.stringify(tree, null, 2);
526 }
527 return d;
528}
529
530
535export function conditionPayload(d: ConditionDraft): { condition: unknown } {
536 if (!d.hasCondition) return { condition: null };
537 const trimmed = d.json.trim();
538 if (trimmed.length > 0) {
539 return { condition: JSON.parse(trimmed) };
540 }
541 const predicate: Record<string, unknown> = {
542 field: { ref: d.fieldRef, name: d.fieldName },
543 operator: OP_TO_WIRE[d.operator]
544 };
545 if (d.operator !== "exists" && d.value.trim() !== "") predicate.value = d.value;
546 return { condition: predicate };
547}
548
549
550export function conditionSummary(d: ConditionDraft): string {
551 if (!d.hasCondition) return "No condition — runs on every trigger.";
552 const trimmed = d.json.trim();
553 if (trimmed.length > 0) return "Custom condition (JSON).";
554 if (!d.fieldName) return "No condition — runs on every trigger.";
555 const op = d.operator === "exists" ? "exists" : `${d.operator}`;
556 return `${d.fieldName} ${op} ${d.value}`;
557}
558