diff --git a/.env.example b/.env.example index 6777d41..7a9a218 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index 3616eea..1e2df78 100644 --- a/ROADMAP.md +++ b/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 ``, 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). --- diff --git a/src/app/(app)/settings/automations/page.tsx b/src/app/(app)/settings/automations/page.tsx new file mode 100644 index 0000000..d8ac1d2 --- /dev/null +++ b/src/app/(app)/settings/automations/page.tsx @@ -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; +} + +// ─── 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 ( +
+ + + + + {selectedField?.options ? ( + + ) : selectedField?.type === "boolean" ? ( + + ) : ( + + onChange({ + ...condition, + value: + selectedField?.type === "number" + ? Number(e.target.value) || "" + : e.target.value, + }) + } + /> + )} + + +
+ ); +} + +// ─── 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 ( +
+
+ + +
+ + {/* Action-specific params */} + {action.type === "update_stage_status" && ( +
+ + +
+ )} + + {action.type === "send_notification" && ( +
+
+ + updateParam("title", e.target.value)} + /> +
+
+ +