automation rules added to settings

This commit is contained in:
Leivur Djurhuus 2026-03-17 23:07:44 -05:00
parent 082b91b09e
commit 72d09b95ce
16 changed files with 1626 additions and 15 deletions

View file

@ -21,6 +21,9 @@ ANTHROPIC_API_KEY=""
# Other options: claude-sonnet-4-6 ($3/$15), claude-opus-4-6 ($5/$25)
# ANTHROPIC_MODEL="claude-haiku-4-5-20251001"
# Cron / Scheduler
CRON_SECRET="" # Secret for /api/cron/* endpoints. Generate with: openssl rand -hex 32
# Ollama (AI — embeddings, search, chat fallback)
# Local Ollama instance for embeddings, LLM summarization, and chat fallback.
# No data leaves the network. Zero ongoing AI costs.

View file

@ -808,14 +808,85 @@ The automation rule engine (Phase 7.1) is fully built. These features extend it.
---
#### D1 — Automation Rules UI
#### D1 — Automation Engine Activation
**What:** Admin interface to create, edit, enable/disable automation rules. The engine exists — this is the management UI it's missing.
The automation engine backend is fully built (event bus, rule engine, action executor, execution logging, REST API). What's missing: event emission isn't wired into the service layer, deadline events have no scheduler, and there's no admin UI. This feature activates the engine end-to-end.
**Key files:**
**Three sub-stages, each independently useful:**
---
##### D1.1 — Wire Event Emission into Service Layer `[ ]`
Connect the existing emit helpers to actual service mutations so automation rules fire in production.
**What gets wired:**
- **`stage-service.ts`** — `emitStageStatusChanged()` after `updateStageStatus` and `bulkUpdateStageStatuses` transactions commit. Extend Prisma queries to include `deliverable.project` for event payload context.
- **`revision-service.ts`** — `emitRevisionSubmitted()` after `createRevision`. Add stage context query (deliverable + project + org) since the function only receives `stageId`.
- **`assignment-service.ts`** — `emitAssignmentCreated()` after `assignUserToStage` and `bulkAssignArtists`. Extend stage query to include deliverable/project context.
- **Handler registration** — ensure `automation-service.ts` (which self-registers on import) is imported before events fire.
**Critical constraint:** Events must fire AFTER transaction commit, not inside it. If emitted inside `$transaction` and it rolls back, the event would have already fired.
**Key files (modified):**
- `src/lib/services/stage-service.ts`
- `src/lib/services/revision-service.ts`
- `src/lib/services/assignment-service.ts`
- `src/lib/automation/event-bus.ts` (no changes, but used by all above)
**No new dependencies. No new data models.**
---
##### D1.2 — Deadline Scheduler `[ ]`
Implement the trigger mechanism for `deadline.approaching` and `deadline.passed` events. The existing `deadline-service.ts` already has `checkDeadlines()` — this adds event emission and a scheduler.
**What gets built:**
- **`emitDeadlineEvents()`** in `deadline-service.ts` — calls `checkDeadlines()`, emits automation events per approaching/overdue item
- **24-hour deduplication** — before executing a matched deadline rule, check `AutomationExecution` for recent successful execution of the same rule (prevents repeated firing on every scheduler run)
- **Cron API route** at `/api/cron/deadlines` — protected by `CRON_SECRET` env var, callable by external scheduler or manual testing
- **Next.js instrumentation file**`setInterval` hourly in the Node.js server process as primary trigger mechanism
**Key files (new):**
- `src/app/api/cron/deadlines/route.ts`
- `instrumentation.ts`
**Key files (modified):**
- `src/lib/services/deadline-service.ts`
- `src/lib/services/automation-service.ts` (dedup logic)
**Dependencies:** Requires D1.1
---
##### D1.3 — Automation Rules Admin UI `[ ]`
Settings page at `/settings/automations` for producers and admins to create, edit, and monitor automation rules.
**What gets built:**
- **Rule list** — card per rule with name, enabled `<Switch>`, trigger event badge, action type badges, condition count, execution count, edit/delete actions
- **Rule builder dialog** — form with:
- Trigger event picker (5 event types)
- Condition builder (dynamic rows, contextual field dropdowns per event type, operator select, value input; AND logic)
- Action configurator (dynamic rows, type selector with contextual params per action type)
- `send_webhook` includes Teams Adaptive Card auto-formatting when URL contains `webhook.office.com`
- **Execution log** — dense table of recent executions across all rules with status badges (SUCCESS/PARTIAL_FAILURE/FAILURE), expandable detail rows
- **TanStack Query hooks** for all CRUD + execution history
- **New API endpoint** `GET /api/automations/executions` for org-wide execution log
**Key files (new):**
- `src/app/(app)/settings/automations/page.tsx`
- `src/components/automations/rule-builder.tsx`
- `src/components/automations/execution-log.tsx`
- `src/hooks/use-automations.ts`
- `src/lib/automation/field-options.ts` (event field definitions + status enums for condition builder)
- `src/app/api/automations/executions/route.ts`
**Key files (modified):**
- `src/app/(app)/settings/page.tsx` (add entry to settings index)
- `src/lib/automation/action-executor.ts` (Teams Adaptive Card formatting in webhook action)
- `src/lib/services/automation-service.ts` (add `listAllExecutions()`)
**Dependencies:** Requires D1.1. Enhanced by D1.2 (deadline rules only useful once scheduler exists).
---

View file

@ -0,0 +1,892 @@
"use client";
import { useState, useMemo } from "react";
import {
Zap,
Plus,
Trash2,
Pencil,
ChevronDown,
ChevronRight,
X,
Activity,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
useAutomationRules,
useCreateAutomationRule,
useUpdateAutomationRule,
useDeleteAutomationRule,
useAutomationExecutions,
} from "@/hooks/use-automations";
import {
EVENT_TYPES,
EVENT_FIELD_OPTIONS,
OPERATORS,
ACTION_TYPES,
STAGE_STATUS_OPTIONS,
} from "@/lib/automation/field-options";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { formatDistanceToNow } from "date-fns";
// ─── Types ───────────────────────────────────────────────
interface Condition {
field: string;
operator: string;
value: any;
}
interface ActionDef {
type: string;
params: Record<string, any>;
}
// ─── Helpers ─────────────────────────────────────────────
function getEventLabel(event: string) {
return EVENT_TYPES.find((e) => e.value === event)?.label || event;
}
function getActionLabel(type: string) {
return ACTION_TYPES.find((a) => a.value === type)?.label || type;
}
function statusBadgeClass(status: string) {
switch (status) {
case "SUCCESS":
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300";
case "PARTIAL_FAILURE":
return "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300";
case "FAILURE":
return "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300";
default:
return "";
}
}
// ─── Condition Row Component ─────────────────────────────
function ConditionRow({
condition,
eventType,
onChange,
onRemove,
}: {
condition: Condition;
eventType: string;
onChange: (c: Condition) => void;
onRemove: () => void;
}) {
const fields = EVENT_FIELD_OPTIONS[eventType] || [];
const selectedField = fields.find((f) => f.field === condition.field);
const availableOperators = OPERATORS.filter(
(op) => !selectedField || op.types.includes(selectedField.type)
);
return (
<div className="flex items-center gap-2">
<Select
value={condition.field}
onValueChange={(v) => onChange({ ...condition, field: v, value: "" })}
>
<SelectTrigger className="h-8 w-[160px] text-xs">
<SelectValue placeholder="Field..." />
</SelectTrigger>
<SelectContent>
{fields.map((f) => (
<SelectItem key={f.field} value={f.field}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={condition.operator}
onValueChange={(v) => onChange({ ...condition, operator: v })}
>
<SelectTrigger className="h-8 w-[120px] text-xs">
<SelectValue placeholder="Op..." />
</SelectTrigger>
<SelectContent>
{availableOperators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedField?.options ? (
<Select
value={String(condition.value)}
onValueChange={(v) => onChange({ ...condition, value: v })}
>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder="Value..." />
</SelectTrigger>
<SelectContent>
{selectedField.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : selectedField?.type === "boolean" ? (
<Select
value={String(condition.value)}
onValueChange={(v) => onChange({ ...condition, value: v === "true" })}
>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder="Value..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : (
<Input
className="h-8 flex-1 text-xs"
placeholder="Value..."
value={condition.value ?? ""}
onChange={(e) =>
onChange({
...condition,
value:
selectedField?.type === "number"
? Number(e.target.value) || ""
: e.target.value,
})
}
/>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={onRemove}
>
<X className="h-3 w-3" />
</Button>
</div>
);
}
// ─── Action Row Component ────────────────────────────────
function ActionRow({
action,
onChange,
onRemove,
}: {
action: ActionDef;
onChange: (a: ActionDef) => void;
onRemove: () => void;
}) {
const updateParam = (key: string, value: any) => {
onChange({ ...action, params: { ...action.params, [key]: value } });
};
return (
<div className="space-y-2 rounded-lg border p-3">
<div className="flex items-center gap-2">
<Select
value={action.type}
onValueChange={(v) => onChange({ type: v, params: {} })}
>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder="Action type..." />
</SelectTrigger>
<SelectContent>
{ACTION_TYPES.map((a) => (
<SelectItem key={a.value} value={a.value}>
{a.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={onRemove}
>
<X className="h-3 w-3" />
</Button>
</div>
{/* Action-specific params */}
{action.type === "update_stage_status" && (
<div className="space-y-1.5">
<Label className="label-upper text-[9px]">New Status</Label>
<Select
value={action.params.status || ""}
onValueChange={(v) => updateParam("status", v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Select status..." />
</SelectTrigger>
<SelectContent>
{STAGE_STATUS_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{action.type === "send_notification" && (
<div className="space-y-2">
<div className="space-y-1.5">
<Label className="label-upper text-[9px]">Title</Label>
<Input
className="h-8 text-xs"
placeholder="e.g. {stageName} approved"
value={action.params.title || ""}
onChange={(e) => updateParam("title", e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label className="label-upper text-[9px]">Message</Label>
<Textarea
className="min-h-[60px] text-xs"
placeholder="Use {fieldName} for event data..."
value={action.params.message || ""}
onChange={(e) => updateParam("message", e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label className="label-upper text-[9px]">Notify Roles</Label>
<div className="flex gap-3">
{["ADMIN", "PRODUCER", "ARTIST"].map((role) => (
<label
key={role}
className="flex items-center gap-1.5 text-xs cursor-pointer"
>
<Checkbox
checked={(action.params.roles || []).includes(role)}
onCheckedChange={(checked) => {
const current = action.params.roles || [];
updateParam(
"roles",
checked
? [...current, role]
: current.filter((r: string) => r !== role)
);
}}
/>
{role}
</label>
))}
</div>
</div>
<div className="space-y-1.5">
<Label className="label-upper text-[9px]">
Link (optional)
</Label>
<Input
className="h-8 text-xs"
placeholder="/projects/{projectId}"
value={action.params.link || ""}
onChange={(e) => updateParam("link", e.target.value)}
/>
</div>
</div>
)}
{action.type === "create_assignment" && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="label-upper text-[9px]">
Auto (skill-based)
</Label>
<Switch
checked={action.params.userId === "auto"}
onCheckedChange={(checked) =>
updateParam("userId", checked ? "auto" : "")
}
/>
</div>
{action.params.userId !== "auto" && (
<div className="space-y-1.5">
<Label className="label-upper text-[9px]">User ID</Label>
<Input
className="h-8 text-xs"
placeholder="User ID to assign..."
value={action.params.userId || ""}
onChange={(e) => updateParam("userId", e.target.value)}
/>
</div>
)}
</div>
)}
{action.type === "send_webhook" && (
<div className="space-y-1.5">
<Label className="label-upper text-[9px]">Webhook URL</Label>
<Input
className="h-8 text-xs"
placeholder="https://..."
value={action.params.url || ""}
onChange={(e) => updateParam("url", e.target.value)}
/>
<p className="text-[10px] text-[var(--muted-foreground)]">
Microsoft Teams incoming webhook URLs are auto-detected and
formatted as Adaptive Cards.
</p>
</div>
)}
</div>
);
}
// ─── Main Page ───────────────────────────────────────────
export default function AutomationRulesPage() {
const [showEditor, setShowEditor] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [expandedExec, setExpandedExec] = useState<string | null>(null);
// Form state
const [formName, setFormName] = useState("");
const [formDescription, setFormDescription] = useState("");
const [formEvent, setFormEvent] = useState("");
const [formConditions, setFormConditions] = useState<Condition[]>([]);
const [formActions, setFormActions] = useState<ActionDef[]>([]);
const [formEnabled, setFormEnabled] = useState(true);
const { data: rules, isLoading } = useAutomationRules();
const { data: executions, isLoading: execLoading } =
useAutomationExecutions();
const createRule = useCreateAutomationRule();
const updateRule = useUpdateAutomationRule();
const deleteRule = useDeleteAutomationRule();
const resetForm = () => {
setEditingId(null);
setFormName("");
setFormDescription("");
setFormEvent("");
setFormConditions([]);
setFormActions([]);
setFormEnabled(true);
};
const openCreate = () => {
resetForm();
setShowEditor(true);
};
const openEdit = (rule: any) => {
setEditingId(rule.id);
setFormName(rule.name);
setFormDescription(rule.description || "");
const trigger = rule.trigger as any;
setFormEvent(trigger.event || "");
setFormConditions(trigger.conditions || []);
setFormActions((rule.actions as ActionDef[]) || []);
setFormEnabled(rule.isEnabled);
setShowEditor(true);
};
const handleSave = async () => {
if (!formName.trim() || !formEvent || formActions.length === 0) return;
const payload = {
name: formName.trim(),
description: formDescription.trim() || undefined,
trigger: {
event: formEvent,
conditions: formConditions.filter((c) => c.field && c.operator),
},
actions: formActions.filter((a) => a.type),
isEnabled: formEnabled,
};
try {
if (editingId) {
await updateRule.mutateAsync({ id: editingId, ...payload });
toast.success("Rule updated");
} else {
await createRule.mutateAsync(payload);
toast.success("Rule created");
}
resetForm();
setShowEditor(false);
} catch (e: any) {
toast.error(e.message || "Failed to save rule");
}
};
const handleToggleEnabled = async (ruleId: string, isEnabled: boolean) => {
try {
await updateRule.mutateAsync({ id: ruleId, isEnabled });
} catch {
toast.error("Failed to update rule");
}
};
const handleDelete = async (ruleId: string) => {
try {
await deleteRule.mutateAsync(ruleId);
setDeleteConfirm(null);
toast.success("Rule deleted");
} catch {
toast.error("Failed to delete rule");
}
};
const updateCondition = (index: number, condition: Condition) => {
setFormConditions((prev) =>
prev.map((c, i) => (i === index ? condition : c))
);
};
const removeCondition = (index: number) => {
setFormConditions((prev) => prev.filter((_, i) => i !== index));
};
const updateAction = (index: number, action: ActionDef) => {
setFormActions((prev) =>
prev.map((a, i) => (i === index ? action : a))
);
};
const removeAction = (index: number) => {
setFormActions((prev) => prev.filter((_, i) => i !== index));
};
const isSaving = createRule.isPending || updateRule.isPending;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Zap className="h-6 w-6 text-[var(--primary)]" />
<div>
<h1 className="font-heading text-2xl font-bold">Automation Rules</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Configure automated actions triggered by pipeline events
</p>
</div>
</div>
{/* Rules List */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="label-upper text-xs">Rules</CardTitle>
<Button size="sm" className="h-8 shrink-0" onClick={openCreate}>
<Plus className="mr-1 h-3 w-3" />
New Rule
</Button>
</CardHeader>
<CardContent className="space-y-2">
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : (rules as any[])?.length === 0 ? (
<p className="py-8 text-center text-sm text-[var(--muted-foreground)]">
No automation rules yet. Create your first rule to automate
pipeline actions.
</p>
) : (
<div className="space-y-1.5">
{(rules as any[])?.map((rule: any) => {
const trigger = rule.trigger as any;
const actions = rule.actions as ActionDef[];
const condCount = trigger?.conditions?.length || 0;
return (
<div
key={rule.id}
className={cn(
"flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors",
rule.isEnabled
? "bg-[var(--primary)]/5 border-[var(--primary)]/20"
: "opacity-60"
)}
>
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium truncate">
{rule.name}
</span>
<Badge
variant="outline"
className="text-[9px] h-4 px-1.5 shrink-0"
>
{getEventLabel(trigger?.event)}
</Badge>
</div>
<div className="flex items-center gap-1.5 text-[10px] text-[var(--muted-foreground)] flex-wrap">
{condCount > 0 && (
<span>
{condCount} condition{condCount !== 1 ? "s" : ""}
</span>
)}
{condCount > 0 && <span>&middot;</span>}
{actions?.map((a, i) => (
<Badge
key={i}
variant="secondary"
className="text-[9px] h-4 px-1.5"
>
{getActionLabel(a.type)}
</Badge>
))}
{rule._count?.executions > 0 && (
<>
<span>&middot;</span>
<span>{rule._count.executions} runs</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0 ml-3">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => openEdit(rule)}
>
<Pencil className="h-3 w-3" />
</Button>
<Switch
checked={rule.isEnabled}
onCheckedChange={(checked) =>
handleToggleEnabled(rule.id, checked)
}
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-[var(--muted-foreground)] hover:text-red-500"
onClick={() => setDeleteConfirm(rule.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Execution Log */}
<Card>
<CardHeader>
<CardTitle className="label-upper text-xs flex items-center gap-2">
<Activity className="h-3.5 w-3.5" />
Execution Log
</CardTitle>
</CardHeader>
<CardContent>
{execLoading ? (
<div className="space-y-1.5">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : !(executions as any[])?.length ? (
<p className="py-4 text-center text-xs text-[var(--muted-foreground)]">
No executions yet. Rules will appear here when they fire.
</p>
) : (
<div className="space-y-0.5">
{(executions as any[])?.map((exec: any) => (
<div key={exec.id}>
<button
className="flex w-full items-center gap-3 rounded px-2 py-1.5 text-left text-xs hover:bg-[var(--muted)]/50 transition-colors"
onClick={() =>
setExpandedExec(
expandedExec === exec.id ? null : exec.id
)
}
>
{expandedExec === exec.id ? (
<ChevronDown className="h-3 w-3 shrink-0 text-[var(--muted-foreground)]" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-[var(--muted-foreground)]" />
)}
<span className="text-[var(--muted-foreground)] w-[90px] shrink-0">
{formatDistanceToNow(new Date(exec.executedAt), {
addSuffix: true,
})}
</span>
<span className="font-medium truncate flex-1">
{exec.rule?.name || "Unknown rule"}
</span>
<Badge
variant="secondary"
className={cn(
"text-[9px] h-4 px-1.5 shrink-0",
statusBadgeClass(exec.status)
)}
>
{exec.status}
</Badge>
</button>
{expandedExec === exec.id && (
<div className="ml-8 mb-2 rounded border bg-[var(--muted)]/30 p-2 text-[10px]">
<div className="space-y-1">
<div>
<span className="font-medium">Event:</span>{" "}
{(exec.triggeredBy as any)?.type}
</div>
{exec.error && (
<div className="text-red-500">
<span className="font-medium">Error:</span>{" "}
{exec.error}
</div>
)}
<details className="cursor-pointer">
<summary className="font-medium text-[var(--muted-foreground)]">
Payload &amp; Results
</summary>
<pre className="mt-1 overflow-auto rounded bg-[var(--muted)] p-1.5 text-[9px] max-h-[200px]">
{JSON.stringify(
{
payload: (exec.triggeredBy as any)?.payload,
results: exec.result,
},
null,
2
)}
</pre>
</details>
</div>
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Rule Editor Dialog */}
<Dialog
open={showEditor}
onOpenChange={(open) => !open && setShowEditor(false)}
>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingId ? "Edit Rule" : "New Automation Rule"}
</DialogTitle>
</DialogHeader>
<div className="space-y-5">
{/* Name */}
<div className="space-y-1.5">
<Label className="label-upper text-[10px]">Name</Label>
<Input
placeholder="e.g. Auto-notify on approval"
value={formName}
onChange={(e) => setFormName(e.target.value)}
className="h-8 text-sm"
/>
</div>
{/* Description */}
<div className="space-y-1.5">
<Label className="label-upper text-[10px]">
Description (optional)
</Label>
<Input
placeholder="What does this rule do?"
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
className="h-8 text-sm"
/>
</div>
{/* Trigger Event */}
<div className="space-y-1.5">
<Label className="label-upper text-[10px]">
Trigger Event
</Label>
<Select
value={formEvent}
onValueChange={(v) => {
setFormEvent(v);
setFormConditions([]);
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="When this event occurs..." />
</SelectTrigger>
<SelectContent>
{EVENT_TYPES.map((evt) => (
<SelectItem key={evt.value} value={evt.value}>
{evt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Conditions */}
{formEvent && (
<div className="space-y-2">
<Label className="label-upper text-[10px]">
Conditions{" "}
<span className="font-normal normal-case text-[var(--muted-foreground)]">
(all must match)
</span>
</Label>
{formConditions.map((cond, i) => (
<ConditionRow
key={i}
condition={cond}
eventType={formEvent}
onChange={(c) => updateCondition(i, c)}
onRemove={() => removeCondition(i)}
/>
))}
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() =>
setFormConditions((prev) => [
...prev,
{ field: "", operator: "equals", value: "" },
])
}
>
<Plus className="mr-1 h-3 w-3" />
Add Condition
</Button>
</div>
)}
{/* Actions */}
<div className="space-y-2">
<Label className="label-upper text-[10px]">Actions</Label>
{formActions.map((action, i) => (
<ActionRow
key={i}
action={action}
onChange={(a) => updateAction(i, a)}
onRemove={() => removeAction(i)}
/>
))}
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() =>
setFormActions((prev) => [
...prev,
{ type: "", params: {} },
])
}
>
<Plus className="mr-1 h-3 w-3" />
Add Action
</Button>
</div>
{/* Enabled */}
<div className="flex items-center justify-between">
<Label className="label-upper text-[10px]">Enabled</Label>
<Switch checked={formEnabled} onCheckedChange={setFormEnabled} />
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={() => setShowEditor(false)}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={
!formName.trim() ||
!formEvent ||
formActions.length === 0 ||
isSaving
}
>
{editingId ? "Update Rule" : "Create Rule"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<Dialog
open={!!deleteConfirm}
onOpenChange={(open) => !open && setDeleteConfirm(null)}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete Automation Rule</DialogTitle>
</DialogHeader>
<p className="text-sm text-[var(--muted-foreground)]">
This will permanently remove this rule and its execution history.
This action cannot be undone.
</p>
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirm(null)}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => deleteConfirm && handleDelete(deleteConfirm)}
disabled={deleteRule.isPending}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -1,5 +1,5 @@
import Link from "next/link";
import { Settings, Wrench, Shield, GitBranch, Users, Columns, Bell } from "lucide-react";
import { Settings, Wrench, Shield, GitBranch, Users, Columns, Bell, Zap } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const settingsPages = [
@ -33,6 +33,12 @@ const settingsPages = [
description: "Configure when and how your team gets notified about pipeline events",
icon: Bell,
},
{
href: "/settings/automations",
label: "Automation Rules",
description: "Configure automated actions triggered by pipeline events — status changes, deadlines, and assignments",
icon: Zap,
},
{
href: "/settings/skills",
label: "Skills Management",

View file

@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { listAllExecutions } from "@/lib/services/automation-service";
export async function GET(req: NextRequest) {
try {
const { session, error } = await requireAuth("PROJECT_VIEW");
if (error) return error;
const limit = Number(req.nextUrl.searchParams.get("limit")) || 50;
const executions = await listAllExecutions(
session.user.organizationId,
{ limit }
);
return NextResponse.json(executions);
} catch (error) {
return serverError(error);
}
}

View file

@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { emitDeadlineEvents } from "@/lib/services/deadline-service";
/**
* GET /api/cron/deadlines?secret=xxx
*
* Trigger deadline event emission for all organizations.
* Protected by CRON_SECRET call from external scheduler or manual testing.
*/
export async function GET(req: NextRequest) {
const secret = req.nextUrl.searchParams.get("secret");
if (!process.env.CRON_SECRET || secret !== process.env.CRON_SECRET) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const orgs = await prisma.organization.findMany({
select: { id: true, name: true },
});
const results = [];
for (const org of orgs) {
const result = await emitDeadlineEvents(org.id);
results.push({ orgId: org.id, orgName: org.name, ...result });
}
return NextResponse.json({
success: true,
orgsChecked: orgs.length,
results,
});
} catch (error) {
console.error("[Cron/Deadlines] Error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,92 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}
export function useAutomationRules() {
return useQuery({
queryKey: ["automation-rules"],
queryFn: () => fetchJson("/api/automations"),
});
}
export function useAutomationRule(ruleId: string) {
return useQuery({
queryKey: ["automation-rules", ruleId],
queryFn: () => fetchJson(`/api/automations/${ruleId}`),
enabled: !!ruleId,
});
}
export function useCreateAutomationRule() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: {
name: string;
description?: string;
trigger: { event: string; conditions: any[] };
actions: { type: string; params: Record<string, any> }[];
isEnabled?: boolean;
}) =>
fetchJson("/api/automations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["automation-rules"] });
},
});
}
export function useUpdateAutomationRule() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
id,
...data
}: {
id: string;
name?: string;
description?: string;
trigger?: { event: string; conditions: any[] };
actions?: { type: string; params: Record<string, any> }[];
isEnabled?: boolean;
}) =>
fetchJson(`/api/automations/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["automation-rules"] });
},
});
}
export function useDeleteAutomationRule() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
fetchJson(`/api/automations/${id}`, { method: "DELETE" }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["automation-rules"] });
},
});
}
export function useAutomationExecutions(limit = 50) {
return useQuery({
queryKey: ["automation-executions", limit],
queryFn: () =>
fetchJson(`/api/automations/executions?limit=${limit}`),
});
}

45
src/instrumentation.ts Normal file
View file

@ -0,0 +1,45 @@
/**
* Next.js Instrumentation runs once on server startup.
*
* Sets up:
* - Automation event handler registration
* - Hourly deadline scheduler
*/
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
// Register automation event handler (side-effect import)
await import("@/lib/services/automation-service");
// Start hourly deadline check
const { emitDeadlineEvents } = await import(
"@/lib/services/deadline-service"
);
const { prisma } = await import("@/lib/prisma");
const HOUR_MS = 60 * 60 * 1000;
const runDeadlineCheck = async () => {
try {
const orgs = await prisma.organization.findMany({
select: { id: true },
});
for (const org of orgs) {
await emitDeadlineEvents(org.id);
}
console.log(
`[Deadline Scheduler] Checked ${orgs.length} org(s) for deadlines`
);
} catch (err) {
console.error("[Deadline Scheduler] Error:", err);
}
};
// Initial check after 30s (let server fully start)
setTimeout(runDeadlineCheck, 30_000);
// Then every hour
setInterval(runDeadlineCheck, HOUR_MS);
console.log("[Instrumentation] Deadline scheduler registered (hourly)");
}
}

View file

@ -262,14 +262,62 @@ async function executeSendWebhook(
}
try {
// Format payload as Teams Adaptive Card when sending to Microsoft Teams webhooks
const isTeamsWebhook =
url.includes("webhook.office.com") ||
url.includes("outlook.office.com");
const interpolate = (str: string) =>
str.replace(/\{(\w+)\}/g, (_, key) => event.payload[key] ?? `{${key}}`);
const body = isTeamsWebhook
? {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: {
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "TextBlock",
text: interpolate(
action.params.title || event.type
),
weight: "bolder",
size: "medium",
},
{
type: "TextBlock",
text: interpolate(
action.params.message ||
JSON.stringify(event.payload, null, 2)
),
wrap: true,
},
{
type: "TextBlock",
text: `Event: ${event.type} | ${event.timestamp.toISOString()}`,
size: "small",
isSubtle: true,
},
],
},
},
],
}
: {
event: event.type,
payload: event.payload,
timestamp: event.timestamp.toISOString(),
};
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event: event.type,
payload: event.payload,
timestamp: event.timestamp.toISOString(),
}),
body: JSON.stringify(body),
signal: AbortSignal.timeout(10000), // 10s timeout
});

View file

@ -56,6 +56,26 @@ export interface AssignmentCreatedPayload {
projectId: string;
}
export interface DeadlineApproachingPayload {
id: string;
name: string;
type: "deliverable" | "stage";
dueDate: string;
daysUntilDue: number;
projectId: string;
projectName: string;
}
export interface DeadlinePassedPayload {
id: string;
name: string;
type: "deliverable" | "stage";
dueDate: string;
daysOverdue: number;
projectId: string;
projectName: string;
}
type EventHandler = (event: AutomationEvent) => Promise<void>;
const handlers: EventHandler[] = [];
@ -128,3 +148,33 @@ export function emitAssignmentCreated(
payload,
});
}
/**
* Helper: create and dispatch a deadline.approaching event.
*/
export function emitDeadlineApproaching(
organizationId: string,
payload: DeadlineApproachingPayload
) {
return dispatchEvent({
type: "deadline.approaching",
organizationId,
timestamp: new Date(),
payload,
});
}
/**
* Helper: create and dispatch a deadline.passed event.
*/
export function emitDeadlinePassed(
organizationId: string,
payload: DeadlinePassedPayload
) {
return dispatchEvent({
type: "deadline.passed",
organizationId,
timestamp: new Date(),
payload,
});
}

View file

@ -0,0 +1,133 @@
/**
* Field options for the automation rule condition builder.
* Defines which fields are available per event type and their data types.
*/
export interface FieldOption {
field: string;
label: string;
type: "string" | "number" | "boolean";
options?: { value: string; label: string }[];
}
export const STAGE_STATUS_OPTIONS = [
{ value: "NOT_STARTED", label: "Not Started" },
{ value: "BLOCKED", label: "Blocked" },
{ value: "IN_PROGRESS", label: "In Progress" },
{ value: "IN_REVIEW", label: "In Review" },
{ value: "NEEDS_CHANGES", label: "Needs Changes" },
{ value: "ON_HOLD", label: "On Hold" },
{ value: "APPROVED", label: "Approved" },
{ value: "DELIVERED", label: "Delivered" },
{ value: "SKIPPED", label: "Skipped" },
];
export const EVENT_TYPES = [
{ value: "stage.status_changed", label: "Stage Status Changed" },
{ value: "revision.submitted", label: "Revision Submitted" },
{ value: "assignment.created", label: "Assignment Created" },
{ value: "deadline.approaching", label: "Deadline Approaching" },
{ value: "deadline.passed", label: "Deadline Passed" },
] as const;
export const EVENT_FIELD_OPTIONS: Record<string, FieldOption[]> = {
"stage.status_changed": [
{
field: "newStatus",
label: "New Status",
type: "string",
options: STAGE_STATUS_OPTIONS,
},
{
field: "oldStatus",
label: "Old Status",
type: "string",
options: STAGE_STATUS_OPTIONS,
},
{ field: "stageSlug", label: "Stage Type (slug)", type: "string" },
{ field: "stageName", label: "Stage Name", type: "string" },
{
field: "isCriticalGate",
label: "Is Critical Gate",
type: "boolean",
},
{ field: "projectName", label: "Project Name", type: "string" },
{ field: "deliverableName", label: "Deliverable Name", type: "string" },
],
"revision.submitted": [
{ field: "stageName", label: "Stage Name", type: "string" },
{ field: "roundNumber", label: "Round Number", type: "number" },
{ field: "deliverableName", label: "Deliverable Name", type: "string" },
],
"assignment.created": [
{ field: "stageName", label: "Stage Name", type: "string" },
{ field: "userName", label: "Assigned User", type: "string" },
],
"deadline.approaching": [
{
field: "type",
label: "Item Type",
type: "string",
options: [
{ value: "deliverable", label: "Deliverable" },
{ value: "stage", label: "Stage" },
],
},
{ field: "daysUntilDue", label: "Days Until Due", type: "number" },
{ field: "projectName", label: "Project Name", type: "string" },
{ field: "name", label: "Item Name", type: "string" },
],
"deadline.passed": [
{
field: "type",
label: "Item Type",
type: "string",
options: [
{ value: "deliverable", label: "Deliverable" },
{ value: "stage", label: "Stage" },
],
},
{ field: "daysOverdue", label: "Days Overdue", type: "number" },
{ field: "projectName", label: "Project Name", type: "string" },
{ field: "name", label: "Item Name", type: "string" },
],
};
export const OPERATORS = [
{ value: "equals", label: "equals", types: ["string", "number", "boolean"] },
{
value: "not_equals",
label: "not equals",
types: ["string", "number", "boolean"],
},
{ value: "in", label: "is one of", types: ["string"] },
{ value: "not_in", label: "is not one of", types: ["string"] },
{ value: "contains", label: "contains", types: ["string"] },
{ value: "gt", label: ">", types: ["number"] },
{ value: "gte", label: ">=", types: ["number"] },
{ value: "lt", label: "<", types: ["number"] },
{ value: "lte", label: "<=", types: ["number"] },
];
export const ACTION_TYPES = [
{
value: "update_stage_status",
label: "Update Stage Status",
description: "Change a stage to a new status",
},
{
value: "send_notification",
label: "Send Notification",
description: "Notify users by role or ID",
},
{
value: "create_assignment",
label: "Auto-Assign Artist",
description: "Assign an artist to a stage",
},
{
value: "send_webhook",
label: "Send Webhook",
description: "POST event to an external URL (e.g., Teams)",
},
];

View file

@ -1,5 +1,9 @@
import { prisma } from "@/lib/prisma";
import type { AssignmentRole } from "@/generated/prisma/client";
import { emitAssignmentCreated } from "@/lib/automation/event-bus";
// Register automation handler (side-effect import)
import "@/lib/services/automation-service";
export async function assignUserToStage(
deliverableStageId: string,
@ -11,7 +15,16 @@ export async function assignUserToStage(
const [stage, user] = await Promise.all([
prisma.deliverableStage.findUnique({
where: { id: deliverableStageId },
include: { template: true },
include: {
template: true,
deliverable: {
select: {
id: true,
name: true,
project: { select: { id: true, name: true, organizationId: true } },
},
},
},
}),
prisma.user.findUnique({
where: { id: userId },
@ -37,7 +50,7 @@ export async function assignUserToStage(
};
}
return prisma.stageAssignment.upsert({
const result = await prisma.stageAssignment.upsert({
where: {
deliverableStageId_userId: { deliverableStageId, userId },
},
@ -45,6 +58,21 @@ export async function assignUserToStage(
create: { deliverableStageId, userId, role },
include: { user: true, deliverableStage: { include: { template: true } } },
});
// Emit automation event (non-blocking)
emitAssignmentCreated(stage!.deliverable.project.organizationId, {
assignmentId: result.id,
stageId: deliverableStageId,
stageName: stage!.template.name,
userId,
userName: user!.name || user!.email,
deliverableId: stage!.deliverable.id,
projectId: stage!.deliverable.project.id,
}).catch((err) => {
console.error("[Automation] Failed to emit assignment.created:", err);
});
return result;
}
export async function removeAssignment(
@ -119,7 +147,16 @@ export async function bulkAssignArtists(
const [stages, users] = await Promise.all([
prisma.deliverableStage.findMany({
where: { id: { in: stageIds } },
include: { template: true },
include: {
template: true,
deliverable: {
select: {
id: true,
name: true,
project: { select: { id: true, name: true, organizationId: true } },
},
},
},
}),
prisma.user.findMany({
where: { id: { in: userIds } },
@ -190,5 +227,24 @@ export async function bulkAssignArtists(
});
}
// Emit automation events AFTER transaction commits
if (succeeded.length > 0 && !options.dryRun) {
for (const item of succeeded) {
const stage = stageMap.get(item.deliverableStageId);
if (!stage) continue;
emitAssignmentCreated(stage.deliverable.project.organizationId, {
assignmentId: `${item.deliverableStageId}:${item.userId}`,
stageId: item.deliverableStageId,
stageName: item.stageName,
userId: item.userId,
userName: item.userName,
deliverableId: stage.deliverable.id,
projectId: stage.deliverable.project.id,
}).catch((err) => {
console.error("[Automation] Failed to emit assignment.created (bulk):", err);
});
}
}
return { total: items.length, succeeded, failed };
}

View file

@ -112,6 +112,18 @@ export async function listExecutions(
});
}
export async function listAllExecutions(
organizationId: string,
options: { limit?: number } = {}
) {
return prisma.automationExecution.findMany({
where: { rule: { organizationId } },
include: { rule: { select: { id: true, name: true } } },
orderBy: { executedAt: "desc" },
take: options.limit || 50,
});
}
// ─── Execution Logging ──────────────────────────────────
async function logExecution(
@ -169,6 +181,18 @@ async function handleAutomationEvent(event: AutomationEvent): Promise<void> {
// Check if this event matches the rule
if (!matchesRule(event, trigger)) continue;
// Deduplicate deadline events: skip if rule already fired in the last 24 hours
if (event.type.startsWith("deadline.")) {
const recentExec = await prisma.automationExecution.findFirst({
where: {
ruleId: rule.id,
executedAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) },
status: { not: "FAILURE" },
},
});
if (recentExec) continue;
}
// Execute all actions
const results = await executeActions(actions, event);

View file

@ -1,8 +1,15 @@
import { prisma } from "@/lib/prisma";
import { createNotifications } from "@/lib/services/notification-service";
import {
emitDeadlineApproaching,
emitDeadlinePassed,
} from "@/lib/automation/event-bus";
import type { NotificationType } from "@/generated/prisma/client";
import { addDays, isBefore, isAfter, startOfDay } from "date-fns";
// Register automation handler (side-effect import)
import "@/lib/services/automation-service";
interface DeadlineResult {
approaching: {
id: string;
@ -331,3 +338,44 @@ export async function generateDeadlineNotifications(organizationId: string) {
overdue: overdue.length,
};
}
/**
* Emit automation events for approaching and overdue deadlines.
* Called by the deadline scheduler (instrumentation.ts or cron endpoint).
* Each item emits a separate event so rules can match on individual items.
*/
export async function emitDeadlineEvents(
organizationId: string
): Promise<{ approaching: number; overdue: number }> {
const { approaching, overdue } = await checkDeadlines(organizationId);
for (const item of approaching) {
emitDeadlineApproaching(organizationId, {
id: item.id,
name: item.name,
type: item.type,
dueDate: item.dueDate.toISOString(),
daysUntilDue: item.daysUntilDue,
projectId: item.projectId,
projectName: item.projectName,
}).catch((err) => {
console.error("[Automation] Failed to emit deadline.approaching:", err);
});
}
for (const item of overdue) {
emitDeadlinePassed(organizationId, {
id: item.id,
name: item.name,
type: item.type,
dueDate: item.dueDate.toISOString(),
daysOverdue: item.daysOverdue,
projectId: item.projectId,
projectName: item.projectName,
}).catch((err) => {
console.error("[Automation] Failed to emit deadline.passed:", err);
});
}
return { approaching: approaching.length, overdue: overdue.length };
}

View file

@ -2,6 +2,10 @@ import { prisma } from "@/lib/prisma";
import type { CreateRevisionInput, UpdateRevisionInput } from "@/lib/validators/revision";
import type { RevisionStatus } from "@/generated/prisma/client";
import { copyProbes } from "@/lib/services/color-probe-service";
import { emitRevisionSubmitted } from "@/lib/automation/event-bus";
// Register automation handler (side-effect import)
import "@/lib/services/automation-service";
/**
* Create a new revision round for a stage.
@ -39,6 +43,35 @@ export async function createRevision(
}
}
// Emit automation event (non-blocking)
prisma.deliverableStage.findUnique({
where: { id: stageId },
select: {
template: { select: { name: true, slug: true } },
deliverableId: true,
deliverable: {
select: {
name: true,
project: { select: { id: true, name: true, organizationId: true } },
},
},
},
}).then((ctx) => {
if (ctx) {
return emitRevisionSubmitted(ctx.deliverable.project.organizationId, {
revisionId: revision.id,
stageId,
stageName: ctx.template.name,
deliverableId: ctx.deliverableId,
deliverableName: ctx.deliverable.name,
projectId: ctx.deliverable.project.id,
roundNumber,
});
}
}).catch((err) => {
console.error("[Automation] Failed to emit revision.submitted:", err);
});
return revision;
}

View file

@ -2,6 +2,10 @@ import { prisma } from "@/lib/prisma";
import { canTransition } from "@/lib/pipeline/stage-machine";
import { canStageStart, getStageIdsToUnblock } from "@/lib/pipeline/dependency-engine";
import type { StageStatus } from "@/generated/prisma/client";
import { emitStageStatusChanged } from "@/lib/automation/event-bus";
// Register automation handler (side-effect import)
import "@/lib/services/automation-service";
// ─── Query Functions ────────────────────────────────────
@ -126,6 +130,7 @@ export async function bulkUpdateStageStatuses(
template: { include: { dependsOn: true } },
deliverable: {
include: {
project: { select: { id: true, name: true, organizationId: true } },
stages: {
include: {
template: { include: { dependsOn: true } },
@ -267,6 +272,28 @@ export async function bulkUpdateStageStatuses(
});
}
// Emit automation events AFTER transaction commits
if (succeeded.length > 0 && !options.dryRun) {
for (const item of succeeded) {
const original = stageMap.get(item.stageId);
if (!original) continue;
emitStageStatusChanged(original.deliverable.project.organizationId, {
stageId: item.stageId,
stageName: item.stageName,
stageSlug: original.template.slug,
deliverableId: original.deliverableId,
deliverableName: original.deliverable.name,
projectId: original.deliverable.project.id,
projectName: original.deliverable.project.name,
oldStatus: original.status,
newStatus: item.newStatus,
isCriticalGate: original.template.isCriticalGate,
}).catch((err) => {
console.error("[Automation] Failed to emit stage.status_changed (bulk):", err);
});
}
}
return { total: items.length, succeeded, failed };
}
@ -290,6 +317,7 @@ export async function updateStageStatus(
},
deliverable: {
include: {
project: { select: { id: true, name: true, organizationId: true } },
stages: {
include: {
template: {
@ -374,7 +402,8 @@ export async function updateStageStatus(
}
// Perform the update
return prisma.$transaction(async (tx) => {
const oldStatus = stage.status;
const result = await prisma.$transaction(async (tx) => {
const now = new Date();
const isReopening =
@ -427,4 +456,24 @@ export async function updateStageStatus(
return { success: true, stage: updated };
});
// Emit automation event AFTER transaction commits
if (result.success) {
emitStageStatusChanged(stage.deliverable.project.organizationId, {
stageId,
stageName: stage.template.name,
stageSlug: stage.template.slug,
deliverableId: stage.deliverableId,
deliverableName: stage.deliverable.name,
projectId: stage.deliverable.project.id,
projectName: stage.deliverable.project.name,
oldStatus,
newStatus,
isCriticalGate: stage.template.isCriticalGate,
}).catch((err) => {
console.error("[Automation] Failed to emit stage.status_changed:", err);
});
}
return result;
}