From 2c64356ffdc9ed4bfb54194c8d26c5dae30226f3 Mon Sep 17 00:00:00 2001 From: DJP Date: Mon, 20 Apr 2026 18:53:20 -0400 Subject: [PATCH] Phase 3: Dow seed + rejection-routing automation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- package-lock.json | 17 + package.json | 6 +- prisma.config.ts | 2 +- prisma/seed-dow.ts | 516 ++++++++++++++++++++++++++ src/lib/automation/action-executor.ts | 76 +++- 5 files changed, 613 insertions(+), 4 deletions(-) create mode 100644 prisma/seed-dow.ts diff --git a/package-lock.json b/package-lock.json index 12b5f4c..26bb2ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2893c56..982d1ac 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma.config.ts b/prisma.config.ts index 846b38d..205527e 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -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"], diff --git a/prisma/seed-dow.ts b/prisma/seed-dow.ts new file mode 100644 index 0000000..9b1b0f2 --- /dev/null +++ b/prisma/seed-dow.ts @@ -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(); + 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 = { + 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(); + }); diff --git a/src/lib/automation/action-executor.ts b/src/lib/automation/action-executor.ts index 7935ce3..4a61789 100644 --- a/src/lib/automation/action-executor.ts +++ b/src/lib/automation/action-executor.ts @@ -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; } @@ -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 { + 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",