Phase 3: Dow seed + rejection-routing automation

prisma/seed-dow.ts — idempotent seed for the Dow Jones tenant:
- Organization "Dow Jones" (dowjones.com)
- 6 ClientTeams: Brand, Events, B2B, Content, Briefing Team, Performance
- 3 placeholder Pods (Sergio, Deborah, Shared) — replace with real roster
  when it's available
- Dow Pipeline Template "Dow Jones Standard" with 11 stages:
  Pipeline → New → Copywriter → Client Review (Copy) → In Progress
  Creative → Internal Review → Client Feedback → Final Approval →
  Completed (+ On Hold, Canceled as terminal parking)
- Stage dependencies wired (optional stages bypass cleanly so
  In Progress Creative reaches from New when Copywriter is skipped)
- Automation rule "Client Feedback → reopen In Progress Creative":
  trigger on stage.status_changed where stageSlug=client-feedback and
  newStatus=CHANGES_REQUESTED. Actions: reopen sibling stage +
  increment revisionRound, send notification to assignee+producer.
- Initial admin user (DOW_ADMIN_EMAIL, default admin@dowjones.com)
  with bcrypt password and mustChangePassword=true. If
  DOW_ADMIN_PASSWORD env is unset a secure random is generated and
  logged once for handoff.
- RBAC defaults seeded per role including CLIENT_VIEWER.
- Legacy global PipelineStageTemplate rows seeded as FK scaffolding.

New action type "reopen_sibling_stage" in action-executor.ts:
- Given event.payload.deliverableId + params.siblingSlug, finds the
  sibling stage (matching either stageDefinition.slug or template.slug)
  and sets it to params.reopenStatus (default IN_PROGRESS). If
  params.incrementRound=true, bumps the stage's revisionRound counter
  and clears completedDate. Added to validateActions' allow-list.

Wiring:
- package.json db:seed → tsx prisma/seed-dow.ts (HP seed kept at
  db:seed-legacy for reference until deleted)
- prisma.config.ts migrations.seed → seed-dow.ts
- bcryptjs + @types/bcryptjs added

Verified: tsc --noEmit ✓ zero errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-20 18:53:20 -04:00
parent d953cee7ad
commit 2c64356ffd
5 changed files with 613 additions and 4 deletions

17
package-lock.json generated
View file

@ -20,7 +20,9 @@
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.19",
"@types/bcryptjs": "^2.4.6",
"@xyflow/react": "^12.10.1",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -5200,6 +5202,12 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"license": "MIT"
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@ -6494,6 +6502,15 @@
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/better-result": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.8.1.tgz",

View file

@ -12,10 +12,10 @@
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:seed": "prisma db seed",
"db:seed": "tsx prisma/seed-dow.ts",
"db:seed-legacy": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"db:seed-tracker": "tsx prisma/seed-tracker-data.ts",
"db:seed-team": "tsx prisma/seed.ts",
"db:backfill-embeddings": "tsx scripts/backfill-embeddings.ts",
"db:clean-slate": "tsx scripts/clean-slate.ts"
},
@ -32,7 +32,9 @@
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.19",
"@types/bcryptjs": "^2.4.6",
"@xyflow/react": "^12.10.1",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",

View file

@ -7,7 +7,7 @@ export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts",
seed: "tsx prisma/seed-dow.ts",
},
datasource: {
url: process.env["DATABASE_URL"],

516
prisma/seed-dow.ts Normal file
View file

@ -0,0 +1,516 @@
/**
* Dow Jones Studio Tracker seed script.
*
* Seeds the canonical Dow Jones organization, its six client teams, a
* placeholder set of production pods, the 11-stage Dow pipeline template
* (Pipeline New Copywriter Client Review (Copy) In Progress Creative
* Internal Review Client Feedback Final Approval Completed +
* On Hold + Canceled), the Client-Feedback-rejection automation rule, and
* an initial admin user with a one-time temp password.
*
* Idempotent safe to re-run. Pass DOW_ADMIN_EMAIL / DOW_ADMIN_PASSWORD
* env vars to override the default admin. If DOW_ADMIN_PASSWORD is unset a
* random one is generated and logged once to stdout for handoff.
*/
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../src/generated/prisma/client";
import { randomBytes } from "node:crypto";
import bcrypt from "bcryptjs";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
const prisma = new PrismaClient({ adapter });
// ─── Seed data ─────────────────────────────────────────
const ORG = {
id: "dow-jones-org",
name: "Dow Jones",
domain: "dowjones.com",
};
const CLIENT_TEAMS = [
{ slug: "brand", name: "Brand" },
{ slug: "events", name: "Events" },
{ slug: "b2b", name: "B2B" },
{ slug: "content", name: "Content" },
{ slug: "briefing-team", name: "Briefing Team" },
{ slug: "performance", name: "Performance" },
];
// Placeholder pods — awaiting the real roster from Zia. Safe to replace.
const PODS = [
{ slug: "sergio-pod", name: "Sergio's Pod" },
{ slug: "deborah-pod", name: "Deborah's Pod" },
{ slug: "shared-pod", name: "Shared / Floating" },
];
const DOW_PIPELINE_ID = "dow-pipeline-standard";
const DOW_STAGES: Array<{
slug: string;
name: string;
order: number;
isCriticalGate?: boolean;
isOptional?: boolean;
color: string;
description: string;
estimatedDays?: number;
}> = [
{
slug: "pipeline",
name: "Pipeline",
order: 1,
color: "#6B7280",
description: "Brief received — awaiting acceptance",
estimatedDays: 1,
},
{
slug: "new",
name: "New",
order: 2,
color: "#3B82F6",
description: "Brief accepted, not yet assigned",
estimatedDays: 1,
},
{
slug: "copywriter",
name: "Copywriter",
order: 3,
isOptional: true,
color: "#8B5CF6",
description: "Optional copywriting phase",
estimatedDays: 2,
},
{
slug: "client-review-copy",
name: "Client Review (Copy)",
order: 4,
isOptional: true,
color: "#F59E0B",
description: "Optional client sign-off on copy before creative kicks off",
estimatedDays: 1,
},
{
slug: "in-progress-creative",
name: "In Progress Creative",
order: 5,
isCriticalGate: true,
color: "#2563EB",
description: "Active creative production",
estimatedDays: 3,
},
{
slug: "internal-review",
name: "Internal Review",
order: 6,
color: "#D97706",
description: "Studio QA before sending to client",
estimatedDays: 1,
},
{
slug: "client-feedback",
name: "Client Feedback",
order: 7,
color: "#F59E0B",
description:
"Out for client review. If rejected (CHANGES_REQUESTED), routes back to In Progress Creative automatically.",
estimatedDays: 2,
},
{
slug: "final-approval",
name: "Final Approval",
order: 8,
isCriticalGate: true,
color: "#16A34A",
description: "Client approved — prepare for delivery",
estimatedDays: 1,
},
{
slug: "completed",
name: "Completed",
order: 9,
color: "#0D9488",
description: "Delivered — done",
estimatedDays: 0,
},
{
slug: "on-hold",
name: "On Hold",
order: 10,
isOptional: true,
color: "#9CA3AF",
description: "Waiting on assets / blocked",
estimatedDays: 0,
},
{
slug: "canceled",
name: "Canceled",
order: 11,
isOptional: true,
color: "#DC2626",
description: "Job canceled",
estimatedDays: 0,
},
];
// [stageSlug, prerequisiteSlug]
const DOW_DEPENDENCIES: [string, string][] = [
["new", "pipeline"],
["copywriter", "new"],
["client-review-copy", "copywriter"],
// in-progress-creative depends on "new" so it's reachable when
// copywriter/client-review-copy are skipped as optional stages.
["in-progress-creative", "new"],
["internal-review", "in-progress-creative"],
["client-feedback", "internal-review"],
["final-approval", "client-feedback"],
["completed", "final-approval"],
// on-hold and canceled are terminal parking states with no dependencies.
];
// ─── Main ───────────────────────────────────────────────
async function main() {
console.log("\n╔══════════════════════════════════════════════╗");
console.log("║ Dow Jones Studio Tracker — Seed ║");
console.log("╚══════════════════════════════════════════════╝\n");
// Organization
const org = await prisma.organization.upsert({
where: { id: ORG.id },
update: { name: ORG.name, domain: ORG.domain },
create: ORG,
});
console.log(`✓ Organization: ${org.name} (${org.domain})`);
// Client Teams
for (const team of CLIENT_TEAMS) {
await prisma.clientTeam.upsert({
where: { organizationId_slug: { organizationId: org.id, slug: team.slug } },
update: { name: team.name },
create: { ...team, organizationId: org.id },
});
}
console.log(`✓ Seeded ${CLIENT_TEAMS.length} client teams`);
// Pods (placeholder — roster TBD)
for (const pod of PODS) {
await prisma.pod.upsert({
where: { organizationId_slug: { organizationId: org.id, slug: pod.slug } },
update: { name: pod.name },
create: { ...pod, organizationId: org.id },
});
}
console.log(`✓ Seeded ${PODS.length} pods (placeholder — update with real roster)`);
// Pipeline Template
const pipeline = await prisma.pipelineTemplate.upsert({
where: { id: DOW_PIPELINE_ID },
update: {
name: "Dow Jones Standard",
description: "Dow Jones 11-stage pipeline (Pipeline → Completed + On Hold/Canceled)",
isDefault: true,
isArchived: false,
},
create: {
id: DOW_PIPELINE_ID,
name: "Dow Jones Standard",
description: "Dow Jones 11-stage pipeline (Pipeline → Completed + On Hold/Canceled)",
organizationId: org.id,
isDefault: true,
isArchived: false,
},
});
console.log(`✓ Pipeline template: ${pipeline.name}`);
// Stage Definitions
const stageMap = new Map<string, string>();
for (const stage of DOW_STAGES) {
const existing = await prisma.pipelineStageDefinition.upsert({
where: {
pipelineId_slug: { pipelineId: pipeline.id, slug: stage.slug },
},
update: {
name: stage.name,
order: stage.order,
isCriticalGate: stage.isCriticalGate ?? false,
isOptional: stage.isOptional ?? false,
description: stage.description,
estimatedDays: stage.estimatedDays ?? null,
color: stage.color,
},
create: {
pipelineId: pipeline.id,
name: stage.name,
slug: stage.slug,
order: stage.order,
isCriticalGate: stage.isCriticalGate ?? false,
isOptional: stage.isOptional ?? false,
description: stage.description,
estimatedDays: stage.estimatedDays ?? null,
color: stage.color,
},
});
stageMap.set(stage.slug, existing.id);
}
console.log(`✓ Seeded ${DOW_STAGES.length} pipeline stages`);
// Dependencies — clear & recreate so removals take effect on re-run
await prisma.pipelineStageDependencyV2.deleteMany({
where: { stage: { pipelineId: pipeline.id } },
});
for (const [stageSlug, prereqSlug] of DOW_DEPENDENCIES) {
await prisma.pipelineStageDependencyV2.create({
data: {
stageId: stageMap.get(stageSlug)!,
prerequisiteId: stageMap.get(prereqSlug)!,
},
});
}
console.log(`✓ Seeded ${DOW_DEPENDENCIES.length} stage dependencies`);
// Rejection-routing automation rule
// "When client-feedback stage goes to CHANGES_REQUESTED, reopen the
// in-progress-creative stage on the same deliverable and bump revisionRound."
//
// We need an admin user to own the rule. Create one up front (below) and
// then bind the rule to that user.
// Also keep the legacy-global "Brief Intake" stage templates around so
// existing code paths that still FK to PipelineStageTemplate don't break.
// (Dow doesn't *use* them — they're compat scaffolding until everything
// migrates to PipelineStageDefinition.)
await ensureGlobalStageTemplates();
// Initial admin user
const adminEmail = (process.env.DOW_ADMIN_EMAIL || "admin@dowjones.com").toLowerCase();
const providedPassword = process.env.DOW_ADMIN_PASSWORD;
const tempPassword = providedPassword || generateTempPassword();
const passwordHash = await bcrypt.hash(tempPassword, 10);
const admin = await prisma.user.upsert({
where: { email: adminEmail },
update: {
name: "Dow Jones Admin",
role: "ADMIN",
organizationId: org.id,
// Only reset password on first seed — if user already has one, keep it.
// Indicated by mustChangePassword still being true.
},
create: {
email: adminEmail,
name: "Dow Jones Admin",
role: "ADMIN",
organizationId: org.id,
passwordHash,
mustChangePassword: true,
isExternal: false,
},
});
// Always ensure the admin has a password set (covers pre-existing users
// imported without one). Only set if passwordHash is null.
const adminRow = await prisma.user.findUnique({
where: { id: admin.id },
select: { passwordHash: true },
});
if (!adminRow?.passwordHash) {
await prisma.user.update({
where: { id: admin.id },
data: { passwordHash, mustChangePassword: true },
});
}
console.log(`✓ Admin user: ${admin.email} (id=${admin.id})`);
// Automation rule: Client Feedback CHANGES_REQUESTED → reopen creative
const rejectionRule = await prisma.automationRule.upsert({
where: { id: "dow-rule-client-feedback-rejection" },
update: {
name: "Client Feedback → reopen In Progress Creative",
description:
"When a deliverable's Client Feedback stage is marked CHANGES_REQUESTED, automatically reopen the In Progress Creative stage on the same deliverable and increment revisionRound.",
isEnabled: true,
trigger: {
event: "stage.status_changed",
conditions: [
{ field: "stageSlug", operator: "equals", value: "client-feedback" },
{ field: "newStatus", operator: "equals", value: "CHANGES_REQUESTED" },
],
},
actions: [
{
type: "reopen_sibling_stage",
params: {
siblingSlug: "in-progress-creative",
reopenStatus: "IN_PROGRESS",
incrementRound: true,
},
},
{
type: "send_notification",
params: {
title: "Client requested changes",
message:
"Client rejected {deliverableName} on {projectName}. Creative has been reopened — a new round is in progress.",
roles: ["ASSIGNEE", "PRODUCER"],
},
},
],
},
create: {
id: "dow-rule-client-feedback-rejection",
name: "Client Feedback → reopen In Progress Creative",
description:
"When a deliverable's Client Feedback stage is marked CHANGES_REQUESTED, automatically reopen the In Progress Creative stage on the same deliverable and increment revisionRound.",
organizationId: org.id,
createdById: admin.id,
isEnabled: true,
trigger: {
event: "stage.status_changed",
conditions: [
{ field: "stageSlug", operator: "equals", value: "client-feedback" },
{ field: "newStatus", operator: "equals", value: "CHANGES_REQUESTED" },
],
},
actions: [
{
type: "reopen_sibling_stage",
params: {
siblingSlug: "in-progress-creative",
reopenStatus: "IN_PROGRESS",
incrementRound: true,
},
},
{
type: "send_notification",
params: {
title: "Client requested changes",
message:
"Client rejected {deliverableName} on {projectName}. Creative has been reopened — a new round is in progress.",
roles: ["ASSIGNEE", "PRODUCER"],
},
},
],
},
});
console.log(`✓ Automation rule: ${rejectionRule.name}`);
// RBAC defaults for the Dow org
await seedRolePermissions(org.id);
// ── Handoff banner ──────────────────────────────────
console.log("\n─────────────────────────────────────────────────");
if (!providedPassword) {
console.log(` Admin login (first run — save this now!):`);
console.log(` Email: ${adminEmail}`);
console.log(` Password: ${tempPassword}`);
console.log(` (mustChangePassword=true — forced reset on first login)`);
} else {
console.log(` Admin: ${adminEmail} — password set from DOW_ADMIN_PASSWORD env`);
}
console.log("─────────────────────────────────────────────────\n");
console.log("Seed complete.\n");
}
// ─── Helpers ───────────────────────────────────────────
function generateTempPassword(): string {
// 16 char alphanumeric — comfortable to copy from the terminal
return randomBytes(12).toString("base64url");
}
/**
* Ensure the global PipelineStageTemplate rows referenced by the legacy
* DeliverableStage.templateId FK exist. Dow's dynamic pipeline uses
* stageDefinitionId; templateId is belt-and-braces scaffolding.
*
* We seed one global row per Dow stage slug so the upsert path in
* deliverable-service can still satisfy the FK. This is cosmetic
* the slugs aren't user-facing for Dow.
*/
async function ensureGlobalStageTemplates() {
for (let i = 0; i < DOW_STAGES.length; i++) {
const stage = DOW_STAGES[i];
await prisma.pipelineStageTemplate.upsert({
where: { slug: stage.slug },
update: {
name: stage.name,
order: i + 1,
isCriticalGate: stage.isCriticalGate ?? false,
isOptional: stage.isOptional ?? false,
description: stage.description,
estimatedDays: stage.estimatedDays ?? null,
},
create: {
name: stage.name,
slug: stage.slug,
order: i + 1,
isCriticalGate: stage.isCriticalGate ?? false,
isOptional: stage.isOptional ?? false,
description: stage.description,
estimatedDays: stage.estimatedDays ?? null,
},
});
}
}
const DEFAULT_PERMISSIONS: Record<string, string[]> = {
ADMIN: [
"PROJECT_CREATE", "PROJECT_UPDATE", "PROJECT_DELETE", "PROJECT_VIEW",
"DELIVERABLE_VIEW", "DELIVERABLE_CREATE", "DELIVERABLE_UPDATE", "DELIVERABLE_DELETE",
"STAGE_VIEW", "STAGE_UPDATE", "STAGE_UPDATE_STATUS", "STAGE_ASSIGN", "STAGE_SCHEDULE",
"REVISION_CREATE", "REVISION_UPDATE", "REVISION_REVIEW",
"COMMENT_CREATE", "COMMENT_DELETE", "COMMENT_DELETE_ANY",
"PIPELINE_MANAGE", "USER_MANAGE", "ROLE_MANAGE", "ORG_SETTINGS",
"AUTOMATION_MANAGE", "FIELD_CUSTOMIZE",
"CLIENT_TEAM_MANAGE", "POD_MANAGE",
],
PRODUCER: [
"PROJECT_CREATE", "PROJECT_UPDATE", "PROJECT_VIEW",
"DELIVERABLE_VIEW", "DELIVERABLE_CREATE", "DELIVERABLE_UPDATE", "DELIVERABLE_DELETE",
"STAGE_VIEW", "STAGE_UPDATE", "STAGE_UPDATE_STATUS", "STAGE_ASSIGN", "STAGE_SCHEDULE",
"REVISION_CREATE", "REVISION_UPDATE", "REVISION_REVIEW",
"COMMENT_CREATE", "COMMENT_DELETE", "COMMENT_DELETE_ANY",
"AUTOMATION_MANAGE",
],
ARTIST: [
"PROJECT_VIEW",
"DELIVERABLE_VIEW",
"STAGE_VIEW", "STAGE_UPDATE_STATUS",
"REVISION_CREATE", "REVISION_UPDATE",
"COMMENT_CREATE",
],
CLIENT_VIEWER: [
"PROJECT_VIEW",
"DELIVERABLE_VIEW",
"STAGE_VIEW",
"COMMENT_CREATE",
],
};
async function seedRolePermissions(organizationId: string) {
await prisma.orgRolePermission.deleteMany({ where: { organizationId } });
const rows: Array<{ organizationId: string; role: any; permission: any }> = [];
for (const [role, perms] of Object.entries(DEFAULT_PERMISSIONS)) {
for (const permission of perms) {
rows.push({ organizationId, role, permission });
}
}
await prisma.orgRolePermission.createMany({ data: rows });
console.log(`✓ Seeded ${rows.length} role-permission rows`);
}
// ─── Run ───────────────────────────────────────────────
main()
.catch((e) => {
console.error("\n❌ Seed failed:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View file

@ -16,7 +16,12 @@ import type { AutomationEvent } from "./event-bus";
import { createNotifications } from "@/lib/services/notification-service";
export interface ActionDefinition {
type: "update_stage_status" | "send_notification" | "create_assignment" | "send_webhook";
type:
| "update_stage_status"
| "reopen_sibling_stage"
| "send_notification"
| "create_assignment"
| "send_webhook";
params: Record<string, any>;
}
@ -38,6 +43,8 @@ async function executeAction(
switch (action.type) {
case "update_stage_status":
return await executeUpdateStageStatus(action, event);
case "reopen_sibling_stage":
return await executeReopenSiblingStage(action, event);
case "send_notification":
return await executeSendNotification(action, event);
case "create_assignment":
@ -92,6 +99,72 @@ async function executeUpdateStageStatus(
};
}
/**
* Action: Reopen a sibling stage (same deliverable) identified by slug.
*
* Used for the Dow Client Feedback In Progress Creative rejection routing:
* when `client-feedback` goes to CHANGES_REQUESTED, put `in-progress-creative`
* back into IN_PROGRESS and bump the stage's revisionRound counter.
*
* params:
* siblingSlug slug of the stage to reopen (e.g. "in-progress-creative")
* reopenStatus status to set (default "IN_PROGRESS")
* incrementRound if true, revisionRound++ on the reopened stage
*/
async function executeReopenSiblingStage(
action: ActionDefinition,
event: AutomationEvent
): Promise<ActionResult> {
const deliverableId = event.payload.deliverableId;
const siblingSlug: string | undefined = action.params.siblingSlug;
const reopenStatus = action.params.reopenStatus || "IN_PROGRESS";
const incrementRound: boolean = action.params.incrementRound === true;
if (!deliverableId || !siblingSlug) {
return {
actionType: action.type,
success: false,
detail: "Missing deliverableId (from event) or siblingSlug (from params)",
};
}
// Find the sibling stage by slug (match either the dynamic pipeline
// definition slug or the legacy global template slug).
const sibling = await prisma.deliverableStage.findFirst({
where: {
deliverableId,
OR: [
{ stageDefinition: { slug: siblingSlug } },
{ template: { slug: siblingSlug } },
],
},
select: { id: true, revisionRound: true, status: true },
});
if (!sibling) {
return {
actionType: action.type,
success: false,
detail: `No sibling stage with slug="${siblingSlug}" on deliverable ${deliverableId}`,
};
}
await prisma.deliverableStage.update({
where: { id: sibling.id },
data: {
status: reopenStatus,
...(incrementRound ? { revisionRound: sibling.revisionRound + 1 } : {}),
completedDate: null,
},
});
return {
actionType: action.type,
success: true,
detail: `Reopened sibling "${siblingSlug}" on deliverable ${deliverableId} (${reopenStatus}${incrementRound ? ", round++" : ""})`,
};
}
/**
* Action: Send a notification to specified users or roles.
*/
@ -436,6 +509,7 @@ export function validateActions(
const validTypes = [
"update_stage_status",
"reopen_sibling_stage",
"send_notification",
"create_assignment",
"send_webhook",