/** * TASK-0110 — automation service: CRUD, sub-resource upserts, status machine, * manual runs, runs listing/detail/retry, and the incoming-webhook secret * lifecycle (generate-once / KMS-wrapped / regenerate). * * Permission model (spec §1): automations are an EDIT capability — * Owner/Admin always (FULL_ACCESS); Member with EDIT on the scoped database * when `databaseId` is set. Enforcement via `assertCan` (server-side only). * * The BullMQ enqueue degrades best-effort (same pattern as the files * pipeline): a queue outage logs a WARN and the run row stays PENDING — * the TASK-0113 watchdog reclaims stale PENDING/RUNNING rows, so nothing is * lost (documented in docs/architecture/automation.md). */ import { Prisma } from "@prisma/client"; import { Inject, Injectable, Logger } from "@nestjs/common"; import type { Request } from "express"; import { MANUAL_RUN_INPUT_MAX_BYTES, RUN_INPUT_MAX_BYTES, TERMINAL_RUN_STATUSES, generateWebhookSecret, isValidAutomationTransition, truncateJson, validateActionDto, webhookSecretHash, type AutomationStatus } from "@factory/shared"; import { v4 as uuid } from "uuid"; import { jobTraceMetadata } from "../../common/trace-metadata.js"; import { EventBus } from "../../common/event-bus.js"; import { ApiError } from "../../common/errors.js"; import { RedisService } from "../../common/redis.service.js"; import { AuditService } from "../../common/audit.service.js"; import { assertCan } from "../../lib/authorize.js"; import { PrismaService } from "../../common/prisma.service.js"; import { assertValidActions, assertValidCondition, assertValidTriggers, type CreateAutomationInput, type ListRunsQueryInput, type PutActionsInput, type PutConditionsInput, type PutTriggersInput, type RunInputInput, type SetStatusInput, type UpdateAutomationInput } from "./dto.js"; import { aes256gcmDecrypt, aes256gcmEncrypt, keyFromSecret } from "../../lib/crypto.js"; import { loadEnv } from "../../config/env.js"; type AutomationRow = { id: string; workspaceId: string; name: string; description: string | null; databaseId: string | null; status: string; createdById: string; createdAt: Date; updatedAt: Date; triggers: { id: string; type: string; config: unknown; position: number }[]; conditions: { id: string; expression: unknown; position: number }[]; actions: { id: string; type: string; config: unknown; position: number; retries: number; timeoutMs: number; }[]; }; /** Coerce an unknown value to a Prisma Json input (the API's DTOs hand us * plain JS from Zod; Prisma's `InputJsonValue` type is narrower). */ function toJson(v: unknown): Prisma.InputJsonValue { return v as Prisma.InputJsonValue; } @Injectable() export class AutomationsService { private readonly log = new Logger(AutomationsService.name); constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(RedisService) private readonly redis: RedisService, @Inject(EventBus) private readonly events: EventBus, @Inject(AuditService) private readonly audit: AuditService ) {} // --------------------------------------------------------------------- // Permission helpers // --------------------------------------------------------------------- private async requireAutomation(id: string, userId: string, level: "VIEW" | "EDIT"): Promise { const a = await this.prisma.automation.findUnique({ where: { id }, include: { triggers: { orderBy: { position: "asc" } }, conditions: { orderBy: { position: "asc" } }, actions: { orderBy: { position: "asc" } } } }); if (!a) throw ApiError.notFound("Automation not found."); const resource = a.databaseId ? { databaseId: a.databaseId } : undefined; await assertCan(this.prisma, { workspaceId: a.workspaceId, userId, resource }, level); return a; } private async requireWorkspaceView(workspaceId: string, userId: string): Promise { await assertCan(this.prisma, { workspaceId, userId }, "VIEW"); } // --------------------------------------------------------------------- // CRUD // --------------------------------------------------------------------- async list(workspaceId: string, userId: string, query: { status?: string; cursor?: string; limit?: number }): Promise<{ data: unknown[]; nextCursor: string | null }> { await this.requireWorkspaceView(workspaceId, userId); const limit = Math.min(Math.max(query.limit ?? 50, 1), 200); const cursor = query.cursor ? (() => { // Invalid cursor (bad base64url / non-JSON) → 400, not 500. let c: { createdAt?: string; id?: string }; try { c = JSON.parse(Buffer.from(query.cursor as string, "base64url").toString("utf8")) as { createdAt?: string; id?: string }; } catch { throw ApiError.validation("invalid cursor"); } if (!c || typeof c.createdAt !== "string" || typeof c.id !== "string") throw ApiError.validation("invalid cursor"); return { createdAt: c.createdAt, id: c.id }; })() : undefined; const where: Record = { workspaceId }; if (query.status) where.status = query.status; if (cursor) { where.OR = [ { createdAt: { lt: new Date(cursor.createdAt) } }, { createdAt: { equals: new Date(cursor.createdAt) }, id: { lt: cursor.id } } ]; } const rows = await this.prisma.automation.findMany({ where: where as Prisma.AutomationWhereInput, include: { triggers: true, conditions: true, actions: true }, orderBy: { createdAt: "desc" }, take: limit + 1 }); const more = rows.length > limit; const page = rows.slice(0, limit); // Latest run per automation in this page (one query, grouped in JS). const ids = page.map((p) => p.id); const runs = ids.length ? await this.prisma.automationRun.findMany({ where: { automationId: { in: ids } }, orderBy: { createdAt: "desc" }, select: { id: true, automationId: true, status: true, createdAt: true, finishedAt: true } }) : []; const latest = new Map(); for (const r of runs) if (!latest.has(r.automationId)) latest.set(r.automationId, r); return { data: page.map((a) => ({ ...this.toSummary(a), latestRun: latest.get(a.id) ?? null })), nextCursor: more && page.length ? Buffer.from(JSON.stringify({ createdAt: page[page.length - 1]!.createdAt.toISOString(), id: page[page.length - 1]!.id })).toString("base64url") : null }; } async get(id: string, userId: string): Promise { const a = await this.requireAutomation(id, userId, "VIEW"); const [run] = await this.prisma.automationRun.findMany({ where: { automationId: id }, orderBy: { createdAt: "desc" }, take: 1, select: { id: true, status: true, createdAt: true, finishedAt: true, retryCount: true, error: true } }); return { ...this.toDetail(a), latestRun: run ?? null }; } async create(workspaceId: string, userId: string, input: CreateAutomationInput, req: Request): Promise { // databaseId (optional) must exist in this workspace and not be archived. if (input.databaseId) { const db = await this.prisma.database.findUnique({ where: { id: input.databaseId } }); if (!db || db.workspaceId !== workspaceId) throw ApiError.unprocessable("databaseId must reference a database in this workspace", [{ field: "databaseId", issue: "unknown database" }]); if (db.isArchived) throw ApiError.unprocessable("databaseId must reference an active database", [{ field: "databaseId", issue: "archived" }]); } await assertCan(this.prisma, { workspaceId, userId, resource: input.databaseId ? { databaseId: input.databaseId } : undefined }, "EDIT"); // Validate optional inline sub-resources (same rules as the PUTs). if (input.triggers) assertValidTriggers(input.triggers as PutTriggersInput["triggers"]); if (input.condition !== undefined) assertValidCondition(input.condition); if (input.actions) assertValidActions(input.actions as PutActionsInput["actions"]); const created = await this.prisma.$transaction(async (tx) => { const a = await tx.automation.create({ data: { workspaceId, name: input.name, description: input.description ?? null, databaseId: input.databaseId ?? null, createdById: userId } }); await this.writeSubResources(tx, a.id, input.triggers, input.condition, input.actions); return a; }); await this.audit.audit(req, req.auth, { action: "automation.created", resourceType: "automation", resourceId: created.id, workspaceId, metadata: { name: created.name } }); return this.get(created.id, userId); } async update(id: string, userId: string, input: UpdateAutomationInput, req: Request): Promise { const a = await this.requireAutomation(id, userId, "EDIT"); if (input.databaseId !== undefined) { if (input.databaseId === null) { // un-scope — allowed. } else { const db = await this.prisma.database.findUnique({ where: { id: input.databaseId } }); if (!db || db.workspaceId !== a.workspaceId) throw ApiError.unprocessable("databaseId must reference a database in this workspace", [{ field: "databaseId", issue: "unknown database" }]); if (db.isArchived) throw ApiError.unprocessable("databaseId must reference an active database", [{ field: "databaseId", issue: "archived" }]); await assertCan(this.prisma, { workspaceId: a.workspaceId, userId, resource: { databaseId: input.databaseId } }, "EDIT"); } } await this.prisma.automation.update({ where: { id }, data: { ...(input.name !== undefined ? { name: input.name } : {}), ...(input.description !== undefined ? { description: input.description } : {}), ...(input.databaseId !== undefined ? { databaseId: input.databaseId } : {}) } }); await this.audit.audit(req, req.auth, { action: "automation.updated", resourceType: "automation", resourceId: id, workspaceId: a.workspaceId, metadata: { changed: Object.keys(input) } }); return this.get(id, userId); } async setStatus(id: string, userId: string, to: SetStatusInput["status"], req: Request): Promise { const a = await this.requireAutomation(id, userId, "EDIT"); const from = a.status as AutomationStatus; if (from === to) throw ApiError.conflict(`Automation is already ${from}.`, [{ reason: "no_op" }]); if (!isValidAutomationTransition(from, to)) { throw ApiError.conflict(`Invalid status transition ${from} → ${to}.`, [{ reason: "invalid_transition", from, to }]); } // Spec §2: the transition itself is the ONLY gate (6 valid pairs, 409 // otherwise). A DRAFT/ACTIVE automation without triggers simply matches // nothing — the matcher skips it; no completeness gate here (recorded). await this.prisma.automation.update({ where: { id }, data: { status: to } }); await this.audit.audit(req, req.auth, { action: "automation.status_changed", resourceType: "automation", resourceId: id, workspaceId: a.workspaceId, metadata: { from, to } }); this.log.log(`automation ${id} status ${from} → ${to}`); return this.get(id, userId); } /** DELETE → ARCHIVED (soft; runs retained for audit). */ async remove(id: string, userId: string, req: Request): Promise { const a = await this.requireAutomation(id, userId, "EDIT"); if (a.status === "ARCHIVED") return; await this.prisma.automation.update({ where: { id }, data: { status: "ARCHIVED" } }); await this.audit.audit(req, req.auth, { action: "automation.status_changed", resourceType: "automation", resourceId: id, workspaceId: a.workspaceId, metadata: { from: a.status, to: "ARCHIVED" } }); } // --------------------------------------------------------------------- // Sub-resource PUTs (editor contract — replace-set semantics) // --------------------------------------------------------------------- async putTriggers(id: string, userId: string, body: PutTriggersInput, req: Request): Promise { const a = await this.requireAutomation(id, userId, "EDIT"); assertValidTriggers(body.triggers); // WEBHOOK triggers: mint the one-time secret on FIRST create (the // plaintext is returned exactly once, in this response). On RE-SAVE the // client echoes back only the public `webhookId` (the KMS-wrapped secret // is stripped on read and can never be sent back), so the stored // `secretHash`/`secretEncrypted` must be preserved from the previous // row — otherwise the editor's core operation (PUT triggers) would // silently clobber the secret and break the live endpoint. const storedByWebhookId = new Map>(); for (const t of a.triggers) if (t.type === "WEBHOOK") { const wid = this.webhookIdOf(t.config); if (wid) storedByWebhookId.set(String(wid), t.config as Record); } const generated: { webhookId: string; secret: string }[] = []; const triggers = body.triggers.map((t) => { const config = { ...(t.config as Record) } as Record; if (t.type === "WEBHOOK" && !config.webhookId) { const secret = generateWebhookSecret(); config.webhookId = uuid().replace(/-/g, "").slice(0, 24); config.secretHash = webhookSecretHash(secret); config.secretEncrypted = this.wrapSecret(secret); generated.push({ webhookId: String(config.webhookId), secret }); } else if (t.type === "WEBHOOK" && config.webhookId) { const wid = String(config.webhookId); const stored = storedByWebhookId.get(wid); if (!stored) { throw ApiError.validation("WEBHOOK trigger references an unknown webhookId — create a new webhook trigger instead.", [ { field: "triggers", issue: "unknown_webhook_id", received: wid } ]); } // Preserve the server-managed secret material (never client-supplied). config.secretHash = stored.secretHash; config.secretEncrypted = stored.secretEncrypted; } return { type: t.type, config }; }); await this.prisma.$transaction(async (tx) => { await tx.automationTrigger.deleteMany({ where: { automationId: id } }); await tx.automationTrigger.createMany({ data: triggers.map((t, i) => ({ automationId: id, type: t.type, config: toJson(t.config), position: i })) }); }); await this.audit.audit(req, req.auth, { action: "automation.updated", resourceType: "automation", resourceId: id, workspaceId: a.workspaceId, metadata: { subresource: "triggers", count: triggers.length } }); return { automationId: id, triggers: this.sanitizeTriggers(a.id, triggers), webhooks: generated.map((g) => ({ webhookId: g.webhookId, /** Plaintext — shown ONCE. It is KMS-wrapped at rest and never returned again. */ secret: g.secret, url: `${loadEnv().WEBHOOK_BASE_URL}/webhooks/incoming/${g.webhookId}` })) }; } async putConditions(id: string, userId: string, body: PutConditionsInput, req: Request): Promise { const a = await this.requireAutomation(id, userId, "EDIT"); assertValidCondition(body.condition ?? null); const tree = body.condition ?? null; await this.prisma.$transaction(async (tx) => { await tx.automationCondition.deleteMany({ where: { automationId: id } }); if (tree) await tx.automationCondition.create({ data: { automationId: id, expression: toJson(tree), position: 0 } }); }); await this.audit.audit(req, req.auth, { action: "automation.updated", resourceType: "automation", resourceId: id, workspaceId: a.workspaceId, metadata: { subresource: "conditions", present: Boolean(tree) } }); return { automationId: id, condition: tree }; } /** * Shared `validateActionDto` + normalization: applies the schema defaults * (e.g. UPDATE_RECORD.recordId → "from_trigger") so the stored row is * self-contained for the worker (which re-validates but trusts the stored * shape). The DTO layer has already 422'd invalid entries — this never * throws for reachable input. */ private normalizeActions(actions: PutActionsInput["actions"]) { return actions.map((act) => { const r = validateActionDto({ type: act.type as never, config: act.config, retries: act.retries, timeoutMs: act.timeoutMs }); if (!r.ok) throw ApiError.unprocessable(`Invalid action config.`, r.errors.map((e) => ({ field: e.path, issue: e.message }))); return r.normalized; }); } async putActions(id: string, userId: string, body: PutActionsInput, req: Request): Promise { const a = await this.requireAutomation(id, userId, "EDIT"); assertValidActions(body.actions); const normalized = this.normalizeActions(body.actions); await this.prisma.$transaction(async (tx) => { await tx.automationAction.deleteMany({ where: { automationId: id } }); await tx.automationAction.createMany({ data: normalized.map((act, i) => ({ automationId: id, type: act.type, config: toJson(act.config), position: i, retries: act.retries, timeoutMs: act.timeoutMs })) }); }); await this.audit.audit(req, req.auth, { action: "automation.updated", resourceType: "automation", resourceId: id, workspaceId: a.workspaceId, metadata: { subresource: "actions", count: body.actions.length } }); return this.get(id, userId); } // --------------------------------------------------------------------- // Webhook secret lifecycle // --------------------------------------------------------------------- /** Regenerate the webhook trigger's secret (invalidates the old one). */ async regenerateWebhookSecret(id: string, userId: string, req: Request): Promise { const a = await this.requireAutomation(id, userId, "EDIT"); const wt = a.triggers.find((t) => t.type === "WEBHOOK"); if (!wt) throw ApiError.notFound("Automation has no WEBHOOK trigger."); const cfg = (wt.config ?? {}) as Record; const secret = generateWebhookSecret(); const next = { ...cfg, secretHash: webhookSecretHash(secret), secretEncrypted: this.wrapSecret(secret) }; await this.prisma.automationTrigger.update({ where: { id: wt.id }, data: { config: toJson(next) } }); await this.audit.audit(req, req.auth, { action: "automation.webhook_secret_regenerated", resourceType: "automation", resourceId: id, workspaceId: a.workspaceId, metadata: { webhookId: cfg.webhookId } }); return { webhookId: String(cfg.webhookId), secret, url: `${loadEnv().WEBHOOK_BASE_URL}/webhooks/incoming/${cfg.webhookId}` }; } /** * KMS key for webhook secret wrapping (D5): `AUTOMATION_WEBHOOK_KMS_KEY` * when set, else `MASTER_ENCRYPTION_KEY` (single-key deployments). */ private kmsKey(): Buffer { const env = loadEnv(); const key = env.AUTOMATION_WEBHOOK_KMS_KEY || env.MASTER_ENCRYPTION_KEY; if (!key) throw new Error("AUTOMATION_WEBHOOK_KMS_KEY (or MASTER_ENCRYPTION_KEY) is required for WEBHOOK triggers"); return keyFromSecret(key); } private wrapSecret(secret: string): string { return aes256gcmEncrypt(secret, this.kmsKey()); } /** KMS-unwrap the stored secret (used by the incoming-webhook endpoint * for signature verification). */ unwrapSecret(encrypted: string): string { return aes256gcmDecrypt(encrypted, this.kmsKey()); } private webhookIdOf(config: unknown): string | undefined { return config && typeof config === "object" ? (config as Record).webhookId as string | undefined : undefined; } /** Strip server-managed fields from trigger configs before returning them. */ private sanitizeTriggers(_automationId: string, triggers: { type: string; config: Record }[]) { return triggers.map((t) => { const config = { ...t.config }; if (t.type === "WEBHOOK") { delete config.secretEncrypted; delete config.secretHash; const wh = config.webhookId as string | undefined; config.url = wh ? `${loadEnv().WEBHOOK_BASE_URL}/webhooks/incoming/${wh}` : undefined; } return { type: t.type, config }; }); } // --------------------------------------------------------------------- // Manual run (202 { runId }) — DRAFT dry-run path included // --------------------------------------------------------------------- async manualRun(id: string, userId: string, body: RunInputInput, req: Request): Promise<{ runId: string }> { const a = await this.requireAutomation(id, userId, "EDIT"); if (a.actions.length === 0) throw ApiError.unprocessable("Cannot run: automation has no actions.", [{ reason: "no_actions" }]); const triggerEvent: Record = { type: "manual", actorId: userId }; if (body.input !== undefined) { const { value, truncated } = truncateJson(body.input, MANUAL_RUN_INPUT_MAX_BYTES); triggerEvent.input = value; if (truncated) triggerEvent.inputTruncated = true; } const run = await this.prisma.automationRun.create({ data: { workspaceId: a.workspaceId, automationId: id, status: "PENDING", triggerEvent: toJson(triggerEvent), input: toJson(truncateJson(triggerEvent, RUN_INPUT_MAX_BYTES).value), retryCount: 0 } }); await this.enqueueRun(run.id, id, a.workspaceId, triggerEvent); await this.audit.audit(req, req.auth, { action: "automation.manual_triggered", resourceType: "automation", resourceId: id, workspaceId: a.workspaceId, metadata: { runId: run.id } }); return { runId: run.id }; } /** * Enqueue `run-automation` on the `automations` BullMQ queue (TASK-0113 * job shape: `{ runId, automationId, workspaceId, triggerEvent, eventId }`). * Best-effort: on queue failure the run stays PENDING and the watchdog * reclaims it (documented degrade, same as the files pipeline). */ private async enqueueRun(runId: string, automationId: string, workspaceId: string, triggerEvent: Record): Promise { await this.enqueueRunPublic(runId, automationId, workspaceId, triggerEvent, uuid()); } /** Public variant (the incoming-webhook controller supplies its own eventId). */ async enqueueRunPublic(runId: string, automationId: string, workspaceId: string, triggerEvent: Record, eventId: string): Promise { try { const { Queue } = await import("bullmq"); const q = new Queue("automations", { connection: this.redis.getRedis().options as never }); await q.add("run-automation", { runId, automationId, workspaceId, triggerEvent, eventId }, { removeOnComplete: 1000, removeOnFail: 500, ...jobTraceMetadata() }); await q.close(); } catch (err) { this.log.warn(`automation run ${runId} enqueue failed (run stays PENDING; watchdog reclaims): ${(err as Error).message}`); } } /** * Create a PENDING run for a webhook trigger (the incoming controller * already verified signature + rate limit). `input` is the truncated * triggerEvent for run storage. */ async createIncomingRun(opts: { workspaceId: string; automationId: string; triggerEvent: Record; input: unknown; }): Promise<{ id: string }> { const run = await this.prisma.automationRun.create({ data: { workspaceId: opts.workspaceId, automationId: opts.automationId, status: "PENDING", triggerEvent: toJson(opts.triggerEvent), input: toJson(opts.input), retryCount: 0 } }); return { id: run.id }; } /** Minimal webhook hit log (no cookie/auth/signature values ever). */ logWebhookHit(webhookId: string, automationId: string, runId: string, safeHeaders: Record): void { this.log.log(`webhook ${webhookId} → automation ${automationId} → run ${runId} headers=${JSON.stringify(safeHeaders)}`); } // --------------------------------------------------------------------- // Runs (list / detail / retry) — the TASK-0114 history UI contract // --------------------------------------------------------------------- async listRuns( id: string, userId: string, query: ListRunsQueryInput ): Promise<{ data: unknown[]; nextCursor: string | null }> { const a = await this.requireAutomation(id, userId, "EDIT"); const limit = query.limit ?? 50; const where: Record = { automationId: id, ...(query.status ? { status: query.status } : {}) }; if (query.cursor) { // Invalid cursor (bad base64url / non-JSON) → 400, not 500. let c: { createdAt?: string; id?: string }; try { c = JSON.parse(Buffer.from(query.cursor, "base64url").toString("utf8")) as { createdAt?: string; id?: string }; } catch { throw ApiError.validation("invalid cursor"); } if (!c || typeof c.createdAt !== "string" || typeof c.id !== "string") throw ApiError.validation("invalid cursor"); where.OR = [ { createdAt: { lt: new Date(c.createdAt) } }, { createdAt: { equals: new Date(c.createdAt) }, id: { lt: c.id } } ]; } const rows = await this.prisma.automationRun.findMany({ where: where as Prisma.AutomationRunWhereInput, orderBy: { createdAt: "desc" }, take: limit + 1, select: { id: true, status: true, triggerEvent: true, startedAt: true, finishedAt: true, retryCount: true, error: true, createdAt: true } }); const more = rows.length > limit; const page = rows.slice(0, limit); return { data: page.map((r) => this.toRunSummary(r, a.workspaceId)), nextCursor: more && page.length ? Buffer.from(JSON.stringify({ createdAt: page[page.length - 1]!.createdAt.toISOString(), id: page[page.length - 1]!.id })).toString("base64url") : null }; } async getRun(id: string, runId: string, userId: string): Promise { const a = await this.requireAutomation(id, userId, "EDIT"); const run = await this.prisma.automationRun.findFirst({ where: { id: runId, automationId: id }, select: { id: true, status: true, triggerEvent: true, input: true, output: true, error: true, startedAt: true, finishedAt: true, retryCount: true, createdAt: true, logs: { orderBy: { createdAt: "asc" }, select: { level: true, message: true, data: true, createdAt: true } } } }); if (!run) throw ApiError.notFound("Run not found."); return { ...this.toRunSummary(run as never, a.workspaceId), input: run.input, output: run.output, logs: run.logs }; } /** Retry a FAILED/DEAD_LETTER run: reset retryCount, re-enqueue (spec §4e). */ async retryRun(id: string, runId: string, userId: string, req: Request): Promise<{ runId: string; status: string }> { const a = await this.requireAutomation(id, userId, "EDIT"); const run = await this.prisma.automationRun.findFirst({ where: { id: runId, automationId: id } }); if (!run) throw ApiError.notFound("Run not found."); if (!(TERMINAL_RUN_STATUSES as readonly string[]).includes(run.status) || run.status === "SUCCEEDED") { throw ApiError.conflict(`Only FAILED/DEAD_LETTER runs can be retried (run is ${run.status}).`, [{ reason: "not_retryable", status: run.status }]); } await this.prisma.automationRun.update({ where: { id: run.id }, data: { status: "PENDING", retryCount: 0, startedAt: null, finishedAt: null, error: Prisma.JsonNull } }); const te = run.triggerEvent as Record | null; await this.enqueueRun(run.id, id, a.workspaceId, te ?? { type: "manual" }); await this.audit.audit(req, req.auth, { action: "automation.run_retried", resourceType: "automation", resourceId: id, workspaceId: a.workspaceId, metadata: { runId: run.id } }); return { runId: run.id, status: "PENDING" }; } // --------------------------------------------------------------------- // DTO shaping // --------------------------------------------------------------------- private toSummary(a: { id: string; name: string; description: string | null; databaseId: string | null; status: string; createdAt: Date; updatedAt: Date; createdById: string; triggers: { type: string }[]; conditions: unknown[]; actions: { type: string }[]; }) { return { id: a.id, name: a.name, description: a.description, databaseId: a.databaseId, status: a.status, triggers: a.triggers.map((t) => t.type), hasCondition: a.conditions.length > 0, actions: a.actions.map((t) => t.type), createdAt: a.createdAt, updatedAt: a.updatedAt, createdById: a.createdById }; } private toDetail(a: AutomationRow) { return { ...this.toSummary(a), triggers: this.sanitizeTriggers(a.id, a.triggers.map((t) => ({ type: t.type, config: (t.config ?? {}) as Record }))), condition: a.conditions[0]?.expression ?? null, actions: a.actions.map((act) => ({ type: act.type, config: act.config, retries: act.retries, timeoutMs: act.timeoutMs })) }; } private toRunSummary( r: { id: string; status: string; triggerEvent: unknown; startedAt: Date | null; finishedAt: Date | null; retryCount: number; error: unknown; createdAt: Date; }, _workspaceId: string ) { const te = (r.triggerEvent ?? {}) as Record; return { id: r.id, status: r.status, triggerType: typeof te.type === "string" ? te.type : null, startedAt: r.startedAt, finishedAt: r.finishedAt, retryCount: r.retryCount, error: r.error ? truncateJson(r.error, 8 * 1024).value : null, createdAt: r.createdAt }; } /** Write optional inline sub-resources (create flow). */ private async writeSubResources( tx: Prisma.TransactionClient, automationId: string, triggers?: PutTriggersInput["triggers"], condition?: unknown, actions?: PutActionsInput["actions"] ): Promise { if (triggers && triggers.length) { const normalized = triggers.map((t) => { const config = { ...(t.config ?? {}) } as Record; if (t.type === "WEBHOOK" && !config.webhookId) { const secret = generateWebhookSecret(); config.webhookId = uuid().replace(/-/g, "").slice(0, 24); config.secretHash = webhookSecretHash(secret); config.secretEncrypted = this.wrapSecret(secret); } return { type: t.type, config }; }); await tx.automationTrigger.createMany({ data: normalized.map((t, i) => ({ automationId, type: t.type, config: toJson(t.config), position: i })) }); } if (condition !== undefined && condition !== null) { await tx.automationCondition.create({ data: { automationId, expression: toJson(condition), position: 0 } }); } if (actions && actions.length) { // Same normalization as the PUT (schema defaults persisted, not // re-derived at read time). const normalized = this.normalizeActions(actions); await tx.automationAction.createMany({ data: normalized.map((act, i) => ({ automationId, type: act.type, config: toJson(act.config), position: i, retries: act.retries, timeoutMs: act.timeoutMs })) }); } } /** Exposed for the incoming-webhook controller (validation only — the * controller owns the HTTP concerns). */ findActiveWebhook(webhookId: string) { return this.prisma.automationTrigger.findFirst({ where: { type: "WEBHOOK", config: { path: ["webhookId"], equals: webhookId }, automation: { status: "ACTIVE", workspace: { deletedAt: null } } }, include: { automation: { select: { id: true, name: true, workspaceId: true, status: true, createdById: true } } } }); } }