1
34import type { PrismaClient } from "@prisma/client";
35import type { Redis } from "ioredis";
36import { Worker, type Job } from "bullmq";
37import { withJobOtel } from "../lib/job-otel.js";
38import { getLogger } from "../logger.js";
39import type { WorkerEnv } from "../config.js";
40import { QUEUES } from "../queues.js";
41import { StreamConsumer } from "../lib/redis-stream.js";
42import { tryParseEvent } from "../events.js";
43import { handleRunAutomation, runAutomationJobSchema } from "./automations/pipeline.js";
44import { matchAndEnqueue } from "./automations/matcher.js";
45import { executeScheduleFire, isScheduleFireJobName } from "./automations/schedule.js";
46
47const log = getLogger("automations");
48
49
54export function createAutomationsWorker(opts: {
55 connection: Redis;
56 prisma: PrismaClient;
57 env: WorkerEnv;
58 concurrency?: number;
59}): Worker {
60 const { connection, prisma, env } = opts;
61 return new Worker(
62 QUEUES.automations,
63 withJobOtel(QUEUES.automations, async (job: Job) => {
64
65 if (isScheduleFireJobName(job.name)) {
66 return executeScheduleFire(connection, prisma, env, (job.data ?? {}) as {
67 automationId?: string;
68 cron?: string;
69 timeZone?: string;
70 });
71 }
72
73 const data = runAutomationJobSchema.safeParse(job.data);
74 if (!data.success) {
75 log.error({ jobId: job.id, err: data.error.message }, "malformed run-automation job (dropped)");
76 return { status: "FAILED" };
77 }
78 const status = await handleRunAutomation({ prisma, redis: connection, env }, { data: job.data });
79 log.info({ runId: data.data.runId, jobId: job.id, status }, "run-automation job settled");
80 return { status };
81 }),
82 {
83 connection,
84 concurrency: opts.concurrency ?? env.AUTOMATIONS_WORKER_CONCURRENCY,
85
86
87 lockDuration: 60_000,
88 stalledInterval: 30_000
89 }
90 );
91}
92
93
102export function createAutomationsConsumer(opts: {
103 redis: Redis;
104 prisma: PrismaClient;
105 consumerId: string;
106}): StreamConsumer {
107 const { redis, prisma, consumerId } = opts;
108 return new StreamConsumer(
109 redis,
110 "factory:events",
111 "automations-matcher",
112 consumerId,
113 async (entry) => {
114 const parsed = tryParseEvent(entry);
115 if (!parsed) return;
116 const { envelope } = parsed;
117 const triggerTypes = triggerTypesFor(envelope.event);
118 if (triggerTypes.length === 0) return;
119 const payload = (envelope.payload ?? {}) as Record<string, unknown>;
120 for (const tt of triggerTypes) {
121 try {
122 await matchAndEnqueue({ prisma, redis }, tt, { id: parsed.id, event: envelope.event, payload });
123 } catch (err) {
124
125
126 log.error({ err: (err as Error).message, triggerType: tt, entryId: parsed.id }, "matcher pass failed");
127 }
128 }
129 }
130 );
131}
132
133
134function triggerTypesFor(eventName: string): string[] {
135 switch (eventName) {
136 case "record.created":
137 return ["RECORD_CREATED"];
138 case "record.updated":
139 return ["RECORD_UPDATED", "PROPERTY_CHANGED"];
140 case "page.created":
141 return ["PAGE_CREATED"];
142 case "form.submitted":
143 return ["FORM_SUBMITTED"];
144 default:
145 return [];
146 }
147}
148