/** * apps/worker — CREATE_PAGE executor (TASK-0112 §5). * * Create a page (or a record page when `databaseId` is set). Interpolates the * title, resolves `from_trigger_page` → the trigger record's page. A record * page creates the Page + DatabaseRecord rows in one transaction and applies * any `propertyValues` (Phase-5 envelopes). Emits `page.created` (depth+1). * Idempotent-ish: a retry creates a second page (documented — page creation is * not value-keyed; the run log records both ids). */ import { randomUUID } from "node:crypto"; import { coercePropertyWriteValue } from "@factory/shared"; import { asJson } from "../../../lib/prisma.js"; import { getLogger } from "../../../logger.js"; import { FatalError } from "./classify.js"; import { emitDomainEvent, interpolateConfig, loadProperties, runWithTimeout, type PropertyMetaLite } from "./helpers.js"; import { assertAutomationCanEdit } from "./permission.js"; import type { ActionExecutor, ActionResult } from "./types.js"; const log = getLogger("automation-create-page"); const executor: ActionExecutor = { type: "CREATE_PAGE", async execute(cfg, ctx, deps): Promise { const warnings: string[] = []; const title = interpolateConfig(cfg.title as string, ctx.vars, 200, "title", warnings); // Resolve parent: `from_trigger_page` → the trigger record's page; a // literal page id is used as-is (must be in the workspace). let parentId: string | null = null; if (cfg.parentId === "from_trigger_page") { if (!ctx.recordId) throw new FatalError("NO_TRIGGER_RECORD", "CREATE_PAGE parent `from_trigger_page` but the event has no record"); const rec = await deps.prisma.databaseRecord.findUnique({ where: { id: ctx.recordId }, select: { pageId: true } }); parentId = rec?.pageId ?? null; } else if (typeof cfg.parentId === "string" && cfg.parentId.length > 0) { parentId = cfg.parentId; } if (parentId) { const parent = await deps.prisma.page.findUnique({ where: { id: parentId }, select: { id: true, workspaceId: true } }); if (!parent || parent.workspaceId !== ctx.workspaceId) { throw new FatalError("PERMISSION_DENIED", "parent page not found in the workspace"); } } // Record page? Requires the workspace member to have EDIT on the database. const databaseId = typeof cfg.databaseId === "string" ? cfg.databaseId : null; if (databaseId) { const db = await deps.prisma.database.findUnique({ where: { id: databaseId }, select: { id: true, workspaceId: true } }); if (!db || db.workspaceId !== ctx.workspaceId) { throw new FatalError("PERMISSION_DENIED", "database not found in the workspace"); } await assertAutomationCanEdit(deps.prisma, ctx.workspaceId, ctx.actorUserId, { databaseId }); } const propertyValues = (cfg.propertyValues ?? {}) as Record; const props = databaseId ? await loadProperties(deps.prisma, databaseId) : []; const envs = coerceAll(props, propertyValues); const created = await runWithTimeout( 30_000, async () => { const id = await deps.prisma.$transaction(async (tx: any) => { const page = await tx.page.create({ data: { workspaceId: ctx.workspaceId, parentId, title, position: "a0" + randomUUID().slice(0, 8), createdBy: ctx.actorUserId }, select: { id: true } }); let recordId: string | null = null; if (databaseId) { const rec = await tx.databaseRecord.create({ data: { workspaceId: ctx.workspaceId, databaseId, pageId: page.id, position: "a0" + randomUUID().slice(0, 8), createdBy: ctx.actorUserId }, select: { id: true } }); recordId = rec.id; for (const [propId, env] of Object.entries(envs)) { await tx.recordProperty.create({ data: { recordId: rec.id, propertyId: propId, value: asJson(env) } }); } } return { pageId: page.id, recordId }; }); return id; } ); const emitted = await emitDomainEvent( deps.redis, "page.created", { pageId: created.pageId, workspaceId: ctx.workspaceId, databaseId: databaseId ?? undefined, recordId: created.recordId ?? undefined }, ctx ); return { output: { pageId: created.pageId, recordId: created.recordId, title, eventEmitted: emitted }, emittedEvents: emitted ? ["page.created"] : [], audit: { action: "automation.page_created", resourceType: "page", resourceId: created.pageId } }; } }; /** Coerce raw values to envelopes, keyed by property id (the write shape). */ function coerceAll(props: PropertyMetaLite[], raw: Record): Record { const out: Record = {}; for (const [name, value] of Object.entries(raw)) { const prop = props.find((p) => p.name === name); if (!prop) throw new FatalError("PROPERTY_UNKNOWN", `property "${name}" not found`); out[prop.id] = coercePropertyWriteValue(prop.type as string, value); } return out; } export default executor;