automation rules added to settings
This commit is contained in:
parent
082b91b09e
commit
72d09b95ce
16 changed files with 1626 additions and 15 deletions
|
|
@ -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.
|
||||
|
|
|
|||
81
ROADMAP.md
81
ROADMAP.md
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
892
src/app/(app)/settings/automations/page.tsx
Normal file
892
src/app/(app)/settings/automations/page.tsx
Normal 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>·</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>·</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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
21
src/app/api/automations/executions/route.ts
Normal file
21
src/app/api/automations/executions/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
40
src/app/api/cron/deadlines/route.ts
Normal file
40
src/app/api/cron/deadlines/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
92
src/hooks/use-automations.ts
Normal file
92
src/hooks/use-automations.ts
Normal 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
45
src/instrumentation.ts
Normal 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)");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
133
src/lib/automation/field-options.ts
Normal file
133
src/lib/automation/field-options.ts
Normal 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)",
|
||||
},
|
||||
];
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue