Files
ProjectE/apps/api/src/routes/automations.ts
T
Hermes 7041906e7d feat(plane-lift): API routes, UI components, migrations — Phase 2
New API routes:
- statuses.ts: CRUD for custom task statuses
- automations.ts: automation rules engine
- timeline.ts: entity timeline/activity view
- activity.ts: activity feed endpoint

New UI components:
- gantt/: gantt chart (6 files: chart, task-bar, milestone, timeline, deps, utils)
- automation-rule-builder.tsx: visual rule editor
- notification-center.tsx: in-app notifications
- quick-add-bar.tsx: global quick-add
- entities/: detail-page, activity, comments, inline-edit, note-editor
- tasks/: recurrence-picker

New hooks:
- use-optimistic-patch.ts: optimistic UI updates

New libs:
- nlp-parser.ts + test: natural language task parsing
- notify.ts: notification dispatch
- automation-engine.ts: rule evaluation

DB migrations:
- 0007_custom_task_statuses.sql
- 0008_automation_rules.sql
- 0009_notifications.sql
- migrate-task-statuses.ts: backfill script

Modified:
- tasks.ts: plane-lift integration (stateId/moduleId/cycleId)
- analytics.ts: updated for new schema
- canvas/$id.tsx: restored
2026-09-07 18:09:03 +00:00

310 lines
12 KiB
TypeScript

import { Hono } from "hono";
import { db, automationRules, projects } from "@project-e/db";
import { and, asc, eq, isNull } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
import { z } from "zod";
export const automationRoutes = new Hono();
const TRIGGER_TYPES = ["task_status_changed", "task_created", "due_date_approaching"] as const;
const ACTION_TYPES = ["set_status", "set_priority", "add_label", "create_notification"] as const;
const PRIORITIES = ["low", "medium", "high", "urgent"] as const;
const triggerSchema = z.object({
type: z.enum(TRIGGER_TYPES),
params: z.record(z.string(), z.unknown()).optional(),
});
const conditionSchema = z.object({
field: z.enum(["project", "status", "priority", "label"]),
op: z.string().min(1),
value: z.unknown(),
});
const actionSchema = z.object({
type: z.enum(ACTION_TYPES),
params: z.record(z.string(), z.unknown()).optional(),
});
const createAutomationSchema = z.object({
name: z.string().min(1, "Name is required").max(120),
active: z.boolean().optional(),
trigger: triggerSchema,
conditions: z.array(conditionSchema).optional(),
actions: z.array(actionSchema).min(1, "At least one action is required"),
});
const updateAutomationSchema = createAutomationSchema.partial();
// Validate action params against what the evaluation engine expects, so a bad
// rule surfaces at save time instead of silently doing nothing at run time.
function validateActionParams(actions: { type: string; params?: Record<string, unknown> }[]): string | null {
for (const action of actions) {
const params = action.params ?? {};
switch (action.type) {
case "set_status":
if (!params.statusKey) return "set_status action requires a statusKey param";
break;
case "set_priority":
if (!params.priority || !PRIORITIES.includes(params.priority as any)) {
return "set_priority action requires a priority param (low, medium, high or urgent)";
}
break;
case "add_label":
if (!params.label || typeof params.label !== "string" || params.label.trim() === "") {
return "add_label action requires a label param";
}
break;
case "create_notification":
if (!params.message || typeof params.message !== "string" || params.message.trim() === "") {
return "create_notification action requires a message param";
}
break;
}
}
return null;
}
async function getProject(c: any, projectId: string) {
const [project] = await db
.select({ id: projects.id, name: projects.name, domainId: projects.domainId })
.from(projects)
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
throw new AuthError("Project not found", 404, "NOT_FOUND");
}
await requireWorkspaceAccess(c, project.domainId);
return project;
}
// GET /api/projects/:projectId/automations — List rules for a project
automationRoutes.get("/:projectId/automations", async (c) => {
try {
await requireAuth(c);
const projectId = c.req.param("projectId");
if (!isUuid(projectId)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
await getProject(c, projectId);
const items = await db.select()
.from(automationRules)
.where(eq(automationRules.projectId, projectId))
.orderBy(asc(automationRules.createdAt));
return c.json({ items, totalItems: items.length });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[automations] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list automation rules" } }, 500);
}
});
// POST /api/projects/:projectId/automations — Create a rule
automationRoutes.post("/:projectId/automations", async (c) => {
try {
const user = await requireAuth(c);
const projectId = c.req.param("projectId");
if (!isUuid(projectId)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = createAutomationSchema.parse(body);
const project = await getProject(c, projectId);
const actionError = validateActionParams(data.actions);
if (actionError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: actionError } }, 400);
}
const [rule] = await db.insert(automationRules).values({
projectId,
name: data.name,
active: data.active ?? true,
trigger: data.trigger,
conditions: (data.conditions ?? []) as any,
actions: data.actions as any,
}).returning();
await recordActivity({
actor: user.name,
action: "created",
entityType: "automation_rule",
entityId: rule.id,
changes: { name: rule.name, trigger: rule.trigger.type, projectId, projectName: project.name },
workspaceId: project.domainId,
});
await enqueueWebhooks({ workspaceId: project.domainId, event: "automation.created", entityType: "automation_rule", entityId: rule.id, data: { name: rule.name } });
return c.json(rule, 201);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
if (error instanceof z.ZodError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
}
console.error("[automations] POST error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create automation rule" } }, 500);
}
});
// PATCH /api/projects/:projectId/automations/:id — Update a rule
automationRoutes.patch("/:projectId/automations/:id", async (c) => {
try {
const user = await requireAuth(c);
const projectId = c.req.param("projectId");
const id = c.req.param("id");
if (!isUuid(projectId) || !isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = updateAutomationSchema.parse(body);
const project = await getProject(c, projectId);
const [existing] = await db.select()
.from(automationRules)
.where(and(eq(automationRules.id, id), eq(automationRules.projectId, projectId)))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "Automation rule not found" } }, 404);
}
if (data.actions !== undefined) {
const actionError = validateActionParams(data.actions);
if (actionError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: actionError } }, 400);
}
}
const updateValues: Record<string, unknown> = {};
if (data.name !== undefined) updateValues.name = data.name;
if (data.active !== undefined) updateValues.active = data.active;
if (data.trigger !== undefined) updateValues.trigger = data.trigger;
if (data.conditions !== undefined) updateValues.conditions = data.conditions;
if (data.actions !== undefined) updateValues.actions = data.actions;
updateValues.updatedAt = new Date();
const [updated] = await db.update(automationRules)
.set(updateValues)
.where(eq(automationRules.id, id))
.returning();
await recordActivity({
actor: user.name,
action: "updated",
entityType: "automation_rule",
entityId: id,
changes: { name: updated.name, projectId, previousName: existing.name },
workspaceId: project.domainId,
});
await enqueueWebhooks({ workspaceId: project.domainId, event: "automation.updated", entityType: "automation_rule", entityId: id, data: { name: updated.name } });
return c.json(updated);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
if (error instanceof z.ZodError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
}
console.error("[automations] PATCH error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update automation rule" } }, 500);
}
});
// DELETE /api/projects/:projectId/automations/:id — Delete a rule
automationRoutes.delete("/:projectId/automations/:id", async (c) => {
try {
const user = await requireAuth(c);
const projectId = c.req.param("projectId");
const id = c.req.param("id");
if (!isUuid(projectId) || !isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const project = await getProject(c, projectId);
const [existing] = await db.select()
.from(automationRules)
.where(and(eq(automationRules.id, id), eq(automationRules.projectId, projectId)))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "Automation rule not found" } }, 404);
}
// Rules are configuration, not user data — hard delete is correct.
await db.delete(automationRules).where(eq(automationRules.id, id));
await recordActivity({
actor: user.name,
action: "deleted",
entityType: "automation_rule",
entityId: id,
changes: { name: existing.name, projectId },
workspaceId: project.domainId,
});
await enqueueWebhooks({ workspaceId: project.domainId, event: "automation.deleted", entityType: "automation_rule", entityId: id, data: { name: existing.name } });
return c.body(null, 204);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[automations] DELETE error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete automation rule" } }, 500);
}
});
// POST /api/projects/:projectId/automations/:id/toggle — Flip active/inactive
automationRoutes.post("/:projectId/automations/:id/toggle", async (c) => {
try {
const user = await requireAuth(c);
const projectId = c.req.param("projectId");
const id = c.req.param("id");
if (!isUuid(projectId) || !isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const project = await getProject(c, projectId);
const [existing] = await db.select()
.from(automationRules)
.where(and(eq(automationRules.id, id), eq(automationRules.projectId, projectId)))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "Automation rule not found" } }, 404);
}
const [updated] = await db.update(automationRules)
.set({ active: !existing.active, updatedAt: new Date() })
.where(eq(automationRules.id, id))
.returning();
await recordActivity({
actor: user.name,
action: updated.active ? "enabled" : "disabled",
entityType: "automation_rule",
entityId: id,
changes: { active: updated.active, projectId },
workspaceId: project.domainId,
});
await enqueueWebhooks({ workspaceId: project.domainId, event: "automation.toggled", entityType: "automation_rule", entityId: id, data: { active: updated.active } });
return c.json(updated);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[automations] POST /toggle error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to toggle automation rule" } }, 500);
}
});