/** * apps/worker — CREATE_NOTIFICATION executor (TASK-0112 §5). * * Create an IN_APP Notification row for a user. `to` resolves via the shared * target resolver (literal cuid | trigger_user | automation_creator). The * target must be a MEMBER of this workspace (a cross-workspace notification * is meaningless → fatal). The body is interpolated; the link is an INTERNAL * path only (the schema rejects anything not starting with a single `/`). The * automation creator is the acting user (actorId). No domain event is emitted * (notifications are not triggerable sources — that would loop). */ import type { NotificationType } from "@prisma/client"; import { asJson } from "../../../lib/prisma.js"; import { FatalError } from "./classify.js"; import { interpolateConfig, resolveUserId } from "./helpers.js"; import type { ActionExecutor, ActionResult } from "./types.js"; /** * Parse an internal path (the config `link`) into the Notification deep-link * object the web inbox understands (`{type, id, pageId?}`). Unrecognized * shapes → a non-navigable `{type:"path", path}` (recorded — the row is * still created, the inbox just won't deep-link it). */ function toLink(path: string): { type: string; id: string; pageId?: string } | { type: "path"; path: string } { const m = /^\/w\/[^/]+\/(page|databases|records|record)\/([\w-]+)/.exec(path); const kind = m?.[1]; const id = m?.[2]; if (kind && id) { const type = kind === "record" || kind === "records" ? "record" : kind; return { type, id }; } return { type: "path", path }; } const executor: ActionExecutor = { type: "CREATE_NOTIFICATION", async execute(cfg, ctx, deps): Promise { const warnings: string[] = []; const toRaw = (cfg.to as string) ?? "automation_creator"; const userId = resolveUserId(toRaw, ctx); // The target must exist and be a member of THIS workspace (User has no // workspaceId — the membership row is the source of truth). const user = await deps.prisma.user.findUnique({ where: { id: userId }, select: { id: true } }); if (!user) throw new FatalError("USER_NOT_FOUND", `notification target user ${userId} not found`); const membership = await deps.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId: ctx.workspaceId, userId } }, select: { role: true } }); if (!membership) { throw new FatalError("PERMISSION_DENIED", "notification target is not a member of the workspace"); } const body = interpolateConfig(cfg.body as string, ctx.vars, 500, "body", warnings); const link = typeof cfg.link === "string" ? cfg.link : undefined; const row = await deps.prisma.notification.create({ data: { workspaceId: ctx.workspaceId, userId, type: cfg.type as NotificationType, channel: "IN_APP", title: ctx.automationName, body, // exactOptionalPropertyTypes + Prisma JSON: omit `link` when absent // (the column defaults to SQL NULL). ...(link ? { link: asJson(toLink(link)) } : {}), actorId: ctx.actorUserId }, select: { id: true } }); return { output: { notificationId: row.id, userId, title: ctx.automationName }, emittedEvents: [], audit: { action: "automation.notification_created", resourceType: "notification", resourceId: row.id, metadata: { userId } } }; } }; export default executor;