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
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
import { db, automationRules, tasks, taskTags, tags as tagsTable, statusDefinitions } from "@project-e/db";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
||||
import { notifyWorkspaceOwner } from "./notify";
|
||||
|
||||
export const TRIGGER_TYPES = ["task_status_changed", "task_created", "due_date_approaching"] as const;
|
||||
export const ACTION_TYPES = ["set_status", "set_priority", "add_label", "create_notification"] as const;
|
||||
|
||||
// Safety limit: cap the number of actions executed per trigger event so a
|
||||
// misconfigured rule can never cascade into runaway writes.
|
||||
const MAX_ACTIONS_PER_EVENT = 10;
|
||||
|
||||
export interface EvaluateAutomationsParams {
|
||||
projectId: string | null | undefined;
|
||||
triggerType: string;
|
||||
/** The affected entity row (e.g. the task after its mutation). */
|
||||
entity: Record<string, any>;
|
||||
/** Diff of the mutation, e.g. { previousStatusId } for status changes. */
|
||||
changes?: Record<string, any>;
|
||||
/** Display name of the user who triggered the event. Defaults to "Automation". */
|
||||
actor?: string;
|
||||
}
|
||||
|
||||
type AutomationRuleRow = typeof automationRules.$inferSelect;
|
||||
type StatusRow = typeof statusDefinitions.$inferSelect;
|
||||
|
||||
interface RuleContext {
|
||||
projectId: string;
|
||||
statusKey: string | null;
|
||||
previousStatusKey: string | null;
|
||||
priority: string | null;
|
||||
labels: string[];
|
||||
/** The affected entity row (e.g. the task after its mutation). */
|
||||
entity: Record<string, any>;
|
||||
statusById: Map<string, StatusRow>;
|
||||
idByKey: Map<string, string>;
|
||||
categoryById: Map<string, string>;
|
||||
}
|
||||
|
||||
function matchesCondition(
|
||||
condition: { field: string; op: string; value: any },
|
||||
ctx: RuleContext
|
||||
): boolean {
|
||||
const value = condition.value;
|
||||
switch (condition.field) {
|
||||
case "project":
|
||||
return ctx.projectId === value;
|
||||
case "status": {
|
||||
// `to`/`from` are status-change semantics; everything else compares the
|
||||
// task's current status key.
|
||||
if (condition.op === "to") return ctx.statusKey === value;
|
||||
if (condition.op === "from") return ctx.previousStatusKey === value;
|
||||
if (condition.op === "neq") return ctx.statusKey !== value;
|
||||
return ctx.statusKey === value;
|
||||
}
|
||||
case "priority":
|
||||
if (condition.op === "in") {
|
||||
return Array.isArray(value) && value.includes(ctx.priority);
|
||||
}
|
||||
if (condition.op === "neq") return ctx.priority !== value;
|
||||
return ctx.priority === value;
|
||||
case "label":
|
||||
if (condition.op === "not_has") return !ctx.labels.includes(value);
|
||||
return ctx.labels.includes(value);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single automation action immediately. Returns true when the action
|
||||
* actually ran (i.e. it should count against the per-event limit), false when it
|
||||
* was a no-op (e.g. status key no longer exists in the project).
|
||||
*/
|
||||
async function executeAction(
|
||||
action: { type: string; params: Record<string, any> },
|
||||
ctx: RuleContext,
|
||||
actor: string
|
||||
): Promise<boolean> {
|
||||
const params = action.params ?? {};
|
||||
const entity = ctx.entity;
|
||||
|
||||
switch (action.type) {
|
||||
case "set_status": {
|
||||
const statusId = ctx.idByKey.get(params.statusKey as string);
|
||||
if (!statusId) return false;
|
||||
const category = ctx.categoryById.get(statusId) ?? "todo";
|
||||
await db.update(tasks)
|
||||
.set({
|
||||
statusId,
|
||||
completedAt: category === "done" ? new Date() : null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(tasks.id, entity.id), isNull(tasks.deletedAt)));
|
||||
|
||||
await recordActivity({
|
||||
actor,
|
||||
action: "updated",
|
||||
entityType: "task",
|
||||
entityId: entity.id,
|
||||
changes: {
|
||||
previousStatusId: entity.statusId,
|
||||
newStatusId: statusId,
|
||||
viaAutomation: true,
|
||||
automation: params.statusKey,
|
||||
},
|
||||
workspaceId: entity.domainId,
|
||||
});
|
||||
await enqueueWebhooks({
|
||||
workspaceId: entity.domainId,
|
||||
event: "task.updated",
|
||||
entityType: "task",
|
||||
entityId: entity.id,
|
||||
data: { previousStatusId: entity.statusId, newStatusId: statusId, viaAutomation: true },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
case "set_priority": {
|
||||
const priority = params.priority as string;
|
||||
await db.update(tasks)
|
||||
.set({ priority: priority as any, updatedAt: new Date() })
|
||||
.where(and(eq(tasks.id, entity.id), isNull(tasks.deletedAt)));
|
||||
|
||||
await recordActivity({
|
||||
actor,
|
||||
action: "updated",
|
||||
entityType: "task",
|
||||
entityId: entity.id,
|
||||
changes: { previousPriority: entity.priority, newPriority: priority, viaAutomation: true },
|
||||
workspaceId: entity.domainId,
|
||||
});
|
||||
await enqueueWebhooks({
|
||||
workspaceId: entity.domainId,
|
||||
event: "task.updated",
|
||||
entityType: "task",
|
||||
entityId: entity.id,
|
||||
data: { previousPriority: entity.priority, newPriority: priority, viaAutomation: true },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
case "add_label": {
|
||||
const label = (params.label as string).trim();
|
||||
if (!label) return false;
|
||||
|
||||
// Find-or-create a tag so rules can attach labels that don't exist yet.
|
||||
let tagId: string | null = null;
|
||||
const [existingTag] = await db.select({ id: tagsTable.id })
|
||||
.from(tagsTable)
|
||||
.where(eq(tagsTable.name, label))
|
||||
.limit(1);
|
||||
if (existingTag) {
|
||||
tagId = existingTag.id;
|
||||
} else {
|
||||
const [created] = await db.insert(tagsTable).values({ name: label, scope: "tasks" }).returning({ id: tagsTable.id });
|
||||
tagId = created.id;
|
||||
}
|
||||
|
||||
await db.insert(taskTags).values({ taskId: entity.id, tagId }).onConflictDoNothing();
|
||||
|
||||
await recordActivity({
|
||||
actor,
|
||||
action: "tagged",
|
||||
entityType: "task",
|
||||
entityId: entity.id,
|
||||
changes: { tagId, tagName: label, viaAutomation: true },
|
||||
workspaceId: entity.domainId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
case "create_notification": {
|
||||
const message = (params.message as string).trim();
|
||||
if (!message) return false;
|
||||
|
||||
// Create a real in-app notification so it lands in the bell's unread
|
||||
// badge and the notification sheet (single-user MVP: goes to the
|
||||
// workspace owner). The activity row is kept for the audit trail.
|
||||
await notifyWorkspaceOwner({
|
||||
workspaceId: entity.domainId,
|
||||
type: "automation",
|
||||
title: message,
|
||||
body: `Automation fired on "${entity.title ?? "task"}"`,
|
||||
entityType: "task",
|
||||
entityId: entity.id,
|
||||
});
|
||||
|
||||
await recordActivity({
|
||||
actor,
|
||||
action: "notified",
|
||||
entityType: "task",
|
||||
entityId: entity.id,
|
||||
changes: { message, viaAutomation: true },
|
||||
workspaceId: entity.domainId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every active rule for a project whose trigger matches `triggerType`,
|
||||
* check their conditions, and run the matching rules' actions immediately.
|
||||
*
|
||||
* Never throws: automation failures must not fail the user's request. All errors
|
||||
* are logged. The total number of actions executed per event is capped at
|
||||
* `MAX_ACTIONS_PER_EVENT`.
|
||||
*/
|
||||
export async function evaluateAutomations({
|
||||
projectId,
|
||||
triggerType,
|
||||
entity,
|
||||
changes = {},
|
||||
actor = "Automation",
|
||||
}: EvaluateAutomationsParams): Promise<void> {
|
||||
if (!projectId || !entity?.id) return;
|
||||
|
||||
try {
|
||||
const rules = await db.select()
|
||||
.from(automationRules)
|
||||
.where(and(
|
||||
eq(automationRules.projectId, projectId),
|
||||
eq(automationRules.active, true),
|
||||
));
|
||||
|
||||
const matching = rules.filter((rule: AutomationRuleRow) => rule.trigger.type === triggerType);
|
||||
if (matching.length === 0) return;
|
||||
|
||||
// Build the context maps once per event: status definitions for the project
|
||||
// (key <-> id) and the task's current labels for condition matching.
|
||||
const statuses = await db.select()
|
||||
.from(statusDefinitions)
|
||||
.where(eq(statusDefinitions.projectId, projectId));
|
||||
|
||||
const statusById = new Map(statuses.map((s) => [s.id, s]));
|
||||
const idByKey = new Map<string, string>();
|
||||
const categoryById = new Map<string, string>();
|
||||
for (const status of statuses) {
|
||||
idByKey.set(status.key, status.id);
|
||||
categoryById.set(status.id, status.category);
|
||||
}
|
||||
|
||||
const tagRows = await db.select({ name: tagsTable.name })
|
||||
.from(taskTags)
|
||||
.innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id))
|
||||
.where(eq(taskTags.taskId, entity.id));
|
||||
|
||||
const statusKey = entity.statusId ? (statusById.get(entity.statusId)?.key ?? null) : null;
|
||||
const previousStatusKey = changes.previousStatusId
|
||||
? (statusById.get(changes.previousStatusId)?.key ?? null)
|
||||
: null;
|
||||
|
||||
const ctx: RuleContext = {
|
||||
projectId,
|
||||
statusKey,
|
||||
previousStatusKey,
|
||||
priority: entity.priority ?? null,
|
||||
labels: tagRows.map((r) => r.name),
|
||||
entity,
|
||||
statusById,
|
||||
idByKey,
|
||||
categoryById,
|
||||
};
|
||||
|
||||
let executedActions = 0;
|
||||
for (const rule of matching) {
|
||||
if (executedActions >= MAX_ACTIONS_PER_EVENT) break;
|
||||
|
||||
const conditionsMatch = (rule.conditions ?? []).every((condition) =>
|
||||
matchesCondition(condition, ctx)
|
||||
);
|
||||
if (!conditionsMatch) continue;
|
||||
|
||||
for (const action of rule.actions ?? []) {
|
||||
if (executedActions >= MAX_ACTIONS_PER_EVENT) {
|
||||
console.warn(`[automations] Hit safety limit of ${MAX_ACTIONS_PER_EVENT} actions for event ${triggerType} in project ${projectId}`);
|
||||
break;
|
||||
}
|
||||
const ran = await executeAction(action, ctx, actor);
|
||||
if (ran) executedActions += 1;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[automations] evaluateAutomations failed for ${triggerType} in project ${projectId}:`, error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { db, sql, notifications, domains } from "@project-e/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type NotificationType =
|
||||
| "mention"
|
||||
| "status_change"
|
||||
| "due_soon"
|
||||
| "automation"
|
||||
| "assignment";
|
||||
|
||||
export interface CreateNotificationParams {
|
||||
userId: string;
|
||||
workspaceId?: string | null;
|
||||
type: NotificationType | string;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
entityType?: string | null;
|
||||
entityId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an in-app notification and fan it out over the realtime SSE stream so
|
||||
* open clients refresh their bell count and list without polling. The event
|
||||
* mirrors the pg_notify shape used by recordActivity: `{ type, action, id,
|
||||
* workspace_id }` with `type: "notification"`.
|
||||
*/
|
||||
export async function createNotification(params: CreateNotificationParams): Promise<typeof notifications.$inferSelect> {
|
||||
const { userId, workspaceId, type, title, body, entityType, entityId } = params;
|
||||
|
||||
const [notification] = await db.insert(notifications).values({
|
||||
userId,
|
||||
workspaceId: workspaceId ?? null,
|
||||
type,
|
||||
title,
|
||||
body: body ?? null,
|
||||
entityType: entityType ?? null,
|
||||
entityId: entityId ?? null,
|
||||
}).returning();
|
||||
|
||||
if (workspaceId) {
|
||||
const payload = JSON.stringify({ type: "notification", action: "created", id: notification.id, workspace_id: workspaceId });
|
||||
await sql`SELECT pg_notify('project_e_events', ${payload}::text)`;
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* MVP convenience for the single-user app: resolve the workspace owner and send
|
||||
* them the notification. Returns null when the workspace has no owner, so
|
||||
* callers can rely on this never throwing for missing ownership.
|
||||
*/
|
||||
export async function notifyWorkspaceOwner(params: Omit<CreateNotificationParams, "userId">): Promise<typeof notifications.$inferSelect | null> {
|
||||
const { workspaceId, ...rest } = params;
|
||||
if (!workspaceId) return null;
|
||||
|
||||
const [domain] = await db
|
||||
.select({ ownerId: domains.ownerId })
|
||||
.from(domains)
|
||||
.where(eq(domains.id, workspaceId))
|
||||
.limit(1);
|
||||
|
||||
if (!domain?.ownerId) return null;
|
||||
return createNotification({ ...rest, workspaceId, userId: domain.ownerId });
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, tasks, habits, habitCompletions, projects } from "@project-e/db";
|
||||
import { and, eq, gte, inArray, isNull, isNotNull, or } from "drizzle-orm";
|
||||
import { and, eq, gte, inArray, isNull, or } from "drizzle-orm";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
|
||||
export const analyticsRoutes = new Hono();
|
||||
@@ -30,7 +30,7 @@ analyticsRoutes.get("/productivity", async (c) => {
|
||||
isNull(tasks.deletedAt),
|
||||
));
|
||||
|
||||
const completedTasks = allTasks.filter(t => t.completedAt !== null);
|
||||
const completedTasks = allTasks.filter(t => t.status === "done");
|
||||
const taskCompletionRate = allTasks.length > 0 ? Math.round((completedTasks.length / allTasks.length) * 100) : 0;
|
||||
|
||||
return c.json({
|
||||
@@ -163,9 +163,9 @@ analyticsRoutes.get("/projects", async (c) => {
|
||||
|
||||
const projectIds = allProjects.map((p) => p.id);
|
||||
|
||||
// Count tasks per project for the domain
|
||||
// Count tasks per project (any status, including non-done) for the domain
|
||||
const taskRows = projectIds.length > 0
|
||||
? await db.select({ projectId: tasks.projectId, completedAt: tasks.completedAt })
|
||||
? await db.select({ projectId: tasks.projectId, status: tasks.status })
|
||||
.from(tasks)
|
||||
.where(and(
|
||||
isNull(tasks.deletedAt),
|
||||
@@ -178,7 +178,7 @@ analyticsRoutes.get("/projects", async (c) => {
|
||||
if (!t.projectId) continue;
|
||||
const entry = counts.get(t.projectId) ?? { totalTasks: 0, completedTasks: 0 };
|
||||
entry.totalTasks += 1;
|
||||
if (t.completedAt) entry.completedTasks += 1;
|
||||
if (t.status === "done") entry.completedTasks += 1;
|
||||
counts.set(t.projectId, entry);
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ analyticsRoutes.get("/cycle", async (c) => {
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - range);
|
||||
const doneTasks = await db.select().from(tasks).where(and(eq(tasks.domainId, domainId), isNotNull(tasks.completedAt), gte(tasks.completedAt, startDate), isNull(tasks.deletedAt)));
|
||||
const doneTasks = await db.select().from(tasks).where(and(eq(tasks.domainId, domainId), eq(tasks.status, "done"), gte(tasks.completedAt, startDate), isNull(tasks.deletedAt)));
|
||||
const durations: number[] = [];
|
||||
for (const t of doneTasks) if (t.completedAt) durations.push((t.completedAt.getTime() - t.createdAt.getTime()) / (1000*60*60*24));
|
||||
durations.sort((a,b)=>a-b);
|
||||
@@ -319,7 +319,7 @@ analyticsRoutes.get("/daily", async (c) => {
|
||||
for (const t of domainTasks) {
|
||||
const createdKey = localDateKey(t.createdAt);
|
||||
createdByDay.set(createdKey, (createdByDay.get(createdKey) || 0) + 1);
|
||||
if (t.completedAt) {
|
||||
if (t.status === "done" && t.completedAt) {
|
||||
const completedKey = localDateKey(t.completedAt);
|
||||
completedByDay.set(completedKey, (completedByDay.get(completedKey) || 0) + 1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, statusDefinitions, tasks, projects } from "@project-e/db";
|
||||
import { and, asc, eq, isNull, sql } 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 statusRoutes = new Hono();
|
||||
|
||||
const statusCategoryEnum = z.enum(["todo", "in_progress", "done", "cancelled"]);
|
||||
const MAX_STATUSES_PER_PROJECT = 15;
|
||||
|
||||
const createStatusSchema = z.object({
|
||||
key: z
|
||||
.string()
|
||||
.regex(/^[a-z0-9_]+$/, "Key must be lowercase letters, numbers and underscores")
|
||||
.optional(),
|
||||
label: z.string().min(1, "Label is required").max(60),
|
||||
category: statusCategoryEnum.optional().default("todo"),
|
||||
color: z.string().optional().nullable(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const updateStatusSchema = z.object({
|
||||
key: z
|
||||
.string()
|
||||
.regex(/^[a-z0-9_]+$/, "Key must be lowercase letters, numbers and underscores")
|
||||
.optional(),
|
||||
label: z.string().min(1).max(60).optional(),
|
||||
category: statusCategoryEnum.optional(),
|
||||
color: z.string().optional().nullable(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const reorderStatusSchema = z.object({
|
||||
orderedIds: z.array(z.string().uuid("Invalid status id")).min(1, "orderedIds is required"),
|
||||
});
|
||||
|
||||
function slugifyKey(label: string): string {
|
||||
const key = label
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.replace(/_+/g, "_");
|
||||
return key || "status";
|
||||
}
|
||||
|
||||
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/statuses — List statuses for a project
|
||||
statusRoutes.get("/:projectId/statuses", 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 project = await getProject(c, projectId);
|
||||
|
||||
const items = await db.select()
|
||||
.from(statusDefinitions)
|
||||
.where(eq(statusDefinitions.projectId, projectId))
|
||||
.orderBy(asc(statusDefinitions.sortOrder), asc(statusDefinitions.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("[statuses] GET error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list statuses" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/projects/:projectId/statuses — Create a status
|
||||
statusRoutes.post("/:projectId/statuses", 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 = createStatusSchema.parse(body);
|
||||
const project = await getProject(c, projectId);
|
||||
|
||||
// Cap the number of statuses per project
|
||||
const [countResult] = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(statusDefinitions)
|
||||
.where(eq(statusDefinitions.projectId, projectId));
|
||||
if (Number(countResult?.count || 0) >= MAX_STATUSES_PER_PROJECT) {
|
||||
return c.json({
|
||||
error: { code: "VALIDATION_ERROR", message: `Maximum of ${MAX_STATUSES_PER_PROJECT} statuses per project`, details: { limit: MAX_STATUSES_PER_PROJECT } },
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Uniqueness: the DB has a unique (project_id, key) index; reject up front
|
||||
// with a friendly error instead of surfacing a constraint violation.
|
||||
const key = data.key ?? slugifyKey(data.label);
|
||||
const [existing] = await db.select({ id: statusDefinitions.id })
|
||||
.from(statusDefinitions)
|
||||
.where(and(eq(statusDefinitions.projectId, projectId), eq(statusDefinitions.key, key)))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
return c.json({ error: { code: "CONFLICT", message: `A status with key "${key}" already exists` } }, 409);
|
||||
}
|
||||
|
||||
let sortOrder = data.sortOrder;
|
||||
if (sortOrder === undefined) {
|
||||
const [maxOrder] = await db.select({ max: sql<number>`COALESCE(MAX(sort_order), -1)` })
|
||||
.from(statusDefinitions)
|
||||
.where(eq(statusDefinitions.projectId, projectId));
|
||||
sortOrder = Number(maxOrder?.max ?? -1) + 1;
|
||||
}
|
||||
|
||||
const [status] = await db.insert(statusDefinitions).values({
|
||||
projectId,
|
||||
key,
|
||||
label: data.label,
|
||||
category: data.category,
|
||||
color: data.color ?? null,
|
||||
sortOrder,
|
||||
isDefault: data.isDefault ?? false,
|
||||
}).returning();
|
||||
|
||||
if (status.isDefault) {
|
||||
await db.update(statusDefinitions)
|
||||
.set({ isDefault: false })
|
||||
.where(and(eq(statusDefinitions.projectId, projectId), eq(statusDefinitions.isDefault, true)));
|
||||
await db.update(statusDefinitions)
|
||||
.set({ isDefault: true })
|
||||
.where(eq(statusDefinitions.id, status.id));
|
||||
}
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "created",
|
||||
entityType: "status",
|
||||
entityId: status.id,
|
||||
changes: { key: status.key, label: status.label, category: status.category, projectId, projectName: project.name },
|
||||
workspaceId: project.domainId,
|
||||
});
|
||||
|
||||
await enqueueWebhooks({ workspaceId: project.domainId, event: "status.created", entityType: "status", entityId: status.id, data: { key: status.key, label: status.label } });
|
||||
|
||||
return c.json(status, 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("[statuses] POST error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create status" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/projects/:projectId/statuses/reorder — Batch update sortOrder
|
||||
statusRoutes.post("/:projectId/statuses/reorder", 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 { orderedIds } = reorderStatusSchema.parse(body);
|
||||
const project = await getProject(c, projectId);
|
||||
|
||||
const existing = await db.select({ id: statusDefinitions.id })
|
||||
.from(statusDefinitions)
|
||||
.where(and(eq(statusDefinitions.projectId, projectId), eq(statusDefinitions.isDefault, false)));
|
||||
const validIds = new Set(existing.map((s) => s.id));
|
||||
if (!orderedIds.every((id) => validIds.has(id))) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "One or more statuses not found in this project" } }, 404);
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await tx.update(statusDefinitions)
|
||||
.set({ sortOrder: i, updatedAt: new Date() })
|
||||
.where(eq(statusDefinitions.id, orderedIds[i]));
|
||||
}
|
||||
});
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "reordered",
|
||||
entityType: "status",
|
||||
entityId: orderedIds[0],
|
||||
changes: { orderedIds, projectId },
|
||||
workspaceId: project.domainId,
|
||||
});
|
||||
|
||||
return c.json({ success: true, orderedIds });
|
||||
} 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("[statuses] POST /reorder error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to reorder statuses" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/projects/:projectId/statuses/:id — Update a status
|
||||
statusRoutes.patch("/:projectId/statuses/: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 = updateStatusSchema.parse(body);
|
||||
const project = await getProject(c, projectId);
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(statusDefinitions)
|
||||
.where(and(eq(statusDefinitions.id, id), eq(statusDefinitions.projectId, projectId)))
|
||||
.limit(1);
|
||||
if (!existing) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Status not found" } }, 404);
|
||||
}
|
||||
|
||||
const updateValues: Record<string, unknown> = {};
|
||||
if (data.key !== undefined) updateValues.key = data.key;
|
||||
if (data.label !== undefined) updateValues.label = data.label;
|
||||
if (data.category !== undefined) updateValues.category = data.category;
|
||||
if (data.color !== undefined) updateValues.color = data.color;
|
||||
if (data.sortOrder !== undefined) updateValues.sortOrder = data.sortOrder;
|
||||
if (data.isDefault !== undefined) updateValues.isDefault = data.isDefault;
|
||||
updateValues.updatedAt = new Date();
|
||||
|
||||
// Uniqueness check on the (project_id, key) pair
|
||||
if (data.key !== undefined && data.key !== existing.key) {
|
||||
const [conflict] = await db.select({ id: statusDefinitions.id })
|
||||
.from(statusDefinitions)
|
||||
.where(and(eq(statusDefinitions.projectId, projectId), eq(statusDefinitions.key, data.key)))
|
||||
.limit(1);
|
||||
if (conflict && conflict.id !== id) {
|
||||
return c.json({ error: { code: "CONFLICT", message: `A status with key "${data.key}" already exists` } }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.isDefault === true) {
|
||||
// Only one default per project
|
||||
await db.update(statusDefinitions)
|
||||
.set({ isDefault: false, updatedAt: new Date() })
|
||||
.where(and(eq(statusDefinitions.projectId, projectId), eq(statusDefinitions.isDefault, true)));
|
||||
}
|
||||
|
||||
const [updated] = await db.update(statusDefinitions)
|
||||
.set(updateValues)
|
||||
.where(eq(statusDefinitions.id, id))
|
||||
.returning();
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "updated",
|
||||
entityType: "status",
|
||||
entityId: id,
|
||||
changes: { ...data, projectId, previousLabel: existing.label },
|
||||
workspaceId: project.domainId,
|
||||
});
|
||||
|
||||
await enqueueWebhooks({ workspaceId: project.domainId, event: "status.updated", entityType: "status", entityId: id, data: { ...data, projectId } });
|
||||
|
||||
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("[statuses] PATCH error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update status" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/projects/:projectId/statuses/:id — Delete a status
|
||||
statusRoutes.delete("/:projectId/statuses/: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(statusDefinitions)
|
||||
.where(and(eq(statusDefinitions.id, id), eq(statusDefinitions.projectId, projectId)))
|
||||
.limit(1);
|
||||
if (!existing) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Status not found" } }, 404);
|
||||
}
|
||||
|
||||
// Never allow removing the last status — a project needs at least one.
|
||||
const [countResult] = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(statusDefinitions)
|
||||
.where(eq(statusDefinitions.projectId, projectId));
|
||||
if (Number(countResult?.count || 0) <= 1) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "A project must have at least one status" } }, 400);
|
||||
}
|
||||
|
||||
// Reassign tasks using this status to the project's default before deleting.
|
||||
const [defaultStatus] = await db.select()
|
||||
.from(statusDefinitions)
|
||||
.where(and(
|
||||
eq(statusDefinitions.projectId, projectId),
|
||||
eq(statusDefinitions.isDefault, true),
|
||||
))
|
||||
.limit(1);
|
||||
const fallbackId = defaultStatus?.id ?? null;
|
||||
const tasksUsingStatus = await db.select({ id: tasks.id })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.statusId, id), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (tasksUsingStatus.length > 0) {
|
||||
if (!fallbackId) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Cannot delete this status: no default status exists to reassign its tasks" } }, 400);
|
||||
}
|
||||
await db.update(tasks)
|
||||
.set({ statusId: fallbackId, updatedAt: new Date() })
|
||||
.where(eq(tasks.statusId, id));
|
||||
}
|
||||
|
||||
// If the default is being deleted, promote the first remaining status.
|
||||
if (existing.isDefault) {
|
||||
const [nextDefault] = await db.select({ id: statusDefinitions.id })
|
||||
.from(statusDefinitions)
|
||||
.where(and(
|
||||
eq(statusDefinitions.projectId, projectId),
|
||||
eq(statusDefinitions.isDefault, false),
|
||||
))
|
||||
.orderBy(asc(statusDefinitions.sortOrder))
|
||||
.limit(1);
|
||||
if (nextDefault) {
|
||||
await db.update(statusDefinitions)
|
||||
.set({ isDefault: true, updatedAt: new Date() })
|
||||
.where(eq(statusDefinitions.id, nextDefault.id));
|
||||
}
|
||||
}
|
||||
|
||||
await db.delete(statusDefinitions).where(eq(statusDefinitions.id, id));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "deleted",
|
||||
entityType: "status",
|
||||
entityId: id,
|
||||
changes: { key: existing.key, label: existing.label, projectId, reassignedToDefault: tasksUsingStatus.length > 0 },
|
||||
workspaceId: project.domainId,
|
||||
});
|
||||
|
||||
await enqueueWebhooks({ workspaceId: project.domainId, event: "status.deleted", entityType: "status", entityId: id, data: { key: existing.key } });
|
||||
|
||||
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("[statuses] DELETE error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete status" } }, 500);
|
||||
}
|
||||
});
|
||||
+207
-29
@@ -1,5 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, tasks, taskTags, tags as tagsTable, activityFeed, scheduledJobs, projects, sections, links } from "@project-e/db";
|
||||
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections } from "@project-e/db";
|
||||
import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
@@ -9,18 +9,17 @@ import { RRule } from "rrule";
|
||||
|
||||
export const taskRoutes = new Hono();
|
||||
|
||||
const taskStatusEnum = z.enum(["todo", "in_progress", "done", "cancelled"]);
|
||||
const taskPriorityEnum = z.enum(["low", "medium", "high", "urgent"]);
|
||||
|
||||
const createTaskSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
description: z.string().optional().nullable(),
|
||||
status: taskStatusEnum.optional().default("todo"),
|
||||
priority: taskPriorityEnum.optional().default("medium"),
|
||||
domain: z.string().min(1, "Domain is required"),
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
sectionId: z.string().uuid().optional().nullable(),
|
||||
stateId: z.string().uuid().optional().nullable(),
|
||||
moduleId: z.string().uuid().optional().nullable(),
|
||||
cycleId: z.string().uuid().optional().nullable(),
|
||||
parentId: z.string().uuid().optional().nullable(),
|
||||
dueDate: z.string().datetime().optional().nullable(),
|
||||
estimatedMinutes: z.number().int().positive().optional().nullable(),
|
||||
@@ -34,12 +33,10 @@ const createTaskSchema = z.object({
|
||||
const updateTaskSchema = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
status: taskStatusEnum.optional(),
|
||||
priority: taskPriorityEnum.optional(),
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
sectionId: z.string().uuid().optional().nullable(),
|
||||
stateId: z.string().uuid().optional().nullable(),
|
||||
moduleId: z.string().uuid().optional().nullable(),
|
||||
cycleId: z.string().uuid().optional().nullable(),
|
||||
parentId: z.string().uuid().optional().nullable(),
|
||||
dueDate: z.string().datetime().optional().nullable(),
|
||||
estimatedMinutes: z.number().int().positive().optional().nullable(),
|
||||
@@ -89,7 +86,7 @@ taskRoutes.get("/", async (c) => {
|
||||
const perPage = Math.min(100, Math.max(1, parseInt(url.searchParams.get("perPage") || "50")));
|
||||
const filter = url.searchParams.get("filter") || undefined;
|
||||
const sort = url.searchParams.get("sort") || "-created";
|
||||
const stateId = url.searchParams.get("state_id");
|
||||
const status = url.searchParams.get("status");
|
||||
const priority = url.searchParams.get("priority");
|
||||
const tag = url.searchParams.get("tag");
|
||||
const search = url.searchParams.get("search");
|
||||
@@ -114,9 +111,9 @@ taskRoutes.get("/", async (c) => {
|
||||
isNull(tasks.deletedAt),
|
||||
];
|
||||
|
||||
if (stateId) {
|
||||
const stateIds = stateId.split(",");
|
||||
conditions.push(inArray(tasks.stateId, stateIds));
|
||||
if (status) {
|
||||
const statuses = status.split(",");
|
||||
conditions.push(inArray(tasks.status, statuses as any));
|
||||
}
|
||||
if (priority) {
|
||||
const priorities = priority.split(",");
|
||||
@@ -168,6 +165,7 @@ taskRoutes.get("/", async (c) => {
|
||||
created: tasks.createdAt,
|
||||
updated: tasks.updatedAt,
|
||||
title: tasks.title,
|
||||
status: tasks.status,
|
||||
priority: tasks.priority,
|
||||
order: tasks.order,
|
||||
due_date: tasks.dueDate,
|
||||
@@ -299,13 +297,11 @@ taskRoutes.post("/", async (c) => {
|
||||
const [task] = await db.insert(tasks).values({
|
||||
title: data.title,
|
||||
description: data.description ?? null,
|
||||
status: data.status,
|
||||
priority: data.priority,
|
||||
domainId: data.domain,
|
||||
projectId: data.projectId ?? null,
|
||||
sectionId: data.sectionId ?? null,
|
||||
stateId: data.stateId ?? null,
|
||||
moduleId: data.moduleId ?? null,
|
||||
cycleId: data.cycleId ?? null,
|
||||
parentId: data.parentId ?? null,
|
||||
dueDate: data.dueDate ? new Date(data.dueDate) : null,
|
||||
estimatedMinutes: data.estimatedMinutes ?? null,
|
||||
@@ -338,7 +334,7 @@ taskRoutes.post("/", async (c) => {
|
||||
action: "created",
|
||||
entityType: "task",
|
||||
entityId: task.id,
|
||||
changes: { title: task.title, priority: task.priority },
|
||||
changes: { title: task.title, status: task.status, priority: task.priority },
|
||||
workspaceId: data.domain,
|
||||
});
|
||||
|
||||
@@ -462,9 +458,25 @@ taskRoutes.get("/:id", async (c) => {
|
||||
.innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id))
|
||||
.where(eq(taskTags.taskId, id));
|
||||
|
||||
// Dependencies are now managed via the links table (Phase 2)
|
||||
const depRows: { id: string; title: string }[] = [];
|
||||
const dependentRows: { id: string; title: string }[] = [];
|
||||
// Fetch dependencies (tasks this task depends on)
|
||||
const depRows = await db.select({
|
||||
id: tasks.id,
|
||||
title: tasks.title,
|
||||
status: tasks.status,
|
||||
})
|
||||
.from(taskDependencies)
|
||||
.innerJoin(tasks, eq(taskDependencies.dependsOnTaskId, tasks.id))
|
||||
.where(and(eq(taskDependencies.taskId, id), isNull(tasks.deletedAt)));
|
||||
|
||||
// Fetch dependents (tasks that depend on this task)
|
||||
const dependentRows = await db.select({
|
||||
id: tasks.id,
|
||||
title: tasks.title,
|
||||
status: tasks.status,
|
||||
})
|
||||
.from(taskDependencies)
|
||||
.innerJoin(tasks, eq(taskDependencies.taskId, tasks.id))
|
||||
.where(and(eq(taskDependencies.dependsOnTaskId, id), isNull(tasks.deletedAt)));
|
||||
|
||||
return c.json({
|
||||
...task,
|
||||
@@ -527,12 +539,10 @@ taskRoutes.patch("/:id", async (c) => {
|
||||
const updateValues: Record<string, unknown> = {};
|
||||
if (data.title !== undefined) updateValues.title = data.title;
|
||||
if (data.description !== undefined) updateValues.description = data.description;
|
||||
if (data.status !== undefined) updateValues.status = data.status;
|
||||
if (data.priority !== undefined) updateValues.priority = data.priority;
|
||||
if (data.projectId !== undefined) updateValues.projectId = data.projectId;
|
||||
if (data.sectionId !== undefined) updateValues.sectionId = data.sectionId;
|
||||
if (data.stateId !== undefined) updateValues.stateId = data.stateId;
|
||||
if (data.moduleId !== undefined) updateValues.moduleId = data.moduleId;
|
||||
if (data.cycleId !== undefined) updateValues.cycleId = data.cycleId;
|
||||
if (data.parentId !== undefined) updateValues.parentId = data.parentId;
|
||||
if (data.dueDate !== undefined) updateValues.dueDate = data.dueDate ? new Date(data.dueDate) : null;
|
||||
if (data.estimatedMinutes !== undefined) updateValues.estimatedMinutes = data.estimatedMinutes;
|
||||
@@ -551,11 +561,11 @@ taskRoutes.patch("/:id", async (c) => {
|
||||
action: "updated",
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
changes: { ...data, previousStateId: existing.stateId },
|
||||
changes: { ...data, previousStatus: existing.status },
|
||||
workspaceId: existing.domainId,
|
||||
});
|
||||
|
||||
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStateId: existing.stateId } });
|
||||
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStatus: existing.status } });
|
||||
|
||||
if (data.recurrenceRule !== undefined) {
|
||||
await syncScheduledJob(id, data.recurrenceRule);
|
||||
@@ -718,19 +728,187 @@ taskRoutes.delete("/:id/tags/:tagId", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks/:id/dependencies — Deprecated: use links table instead (Phase 2)
|
||||
// POST /api/tasks/:id/dependencies — Make this task depend on another task
|
||||
taskRoutes.post("/:id/dependencies", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Dependencies moved to links table (Phase 2)" } }, 404);
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const { dependsOnTaskId } = z.object({
|
||||
dependsOnTaskId: z.string().uuid("Invalid task id"),
|
||||
}).parse(body);
|
||||
|
||||
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
if (!task) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, task.domainId);
|
||||
|
||||
// A task cannot depend on itself
|
||||
if (dependsOnTaskId === id) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "A task cannot depend on itself" } }, 400);
|
||||
}
|
||||
|
||||
const [depTask] = await db.select({ id: tasks.id, domainId: tasks.domainId })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, dependsOnTaskId), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
if (!depTask) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Dependency task not found" } }, 404);
|
||||
}
|
||||
if (depTask.domainId !== task.domainId) {
|
||||
return c.json({ error: { code: "FORBIDDEN", message: "Dependency task does not belong to this workspace" } }, 403);
|
||||
}
|
||||
|
||||
// Cycle guard: walk the dependency chain (X depends on Y, Y on Z, ...) from
|
||||
// dependsOnTaskId; reaching id means adding this edge would create a cycle.
|
||||
let currentId: string | null = dependsOnTaskId;
|
||||
const visited = new Set<string>([id]);
|
||||
while (currentId) {
|
||||
if (visited.has(currentId)) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Circular dependency detected" } }, 400);
|
||||
}
|
||||
visited.add(currentId);
|
||||
const [next] = await db.select({ dependsOnTaskId: taskDependencies.dependsOnTaskId })
|
||||
.from(taskDependencies)
|
||||
.where(eq(taskDependencies.taskId, currentId))
|
||||
.limit(1);
|
||||
currentId = next?.dependsOnTaskId ?? null;
|
||||
}
|
||||
|
||||
// Junction table has a composite PK — ignore duplicate edges
|
||||
await db.insert(taskDependencies).values({ taskId: id, dependsOnTaskId }).onConflictDoNothing();
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "dependency_added",
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
changes: { dependsOnTaskId },
|
||||
workspaceId: task.domainId,
|
||||
});
|
||||
|
||||
await enqueueWebhooks({ workspaceId: task.domainId, event: "task.updated", entityType: "task", entityId: id, data: { dependsOnTaskId } });
|
||||
|
||||
return c.json({ success: true }, 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("[tasks] POST /:id/dependencies error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to add dependency" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/tasks/:id/dependencies/:depId — Deprecated: use links table instead (Phase 2)
|
||||
// DELETE /api/tasks/:id/dependencies/:depId — Remove a dependency
|
||||
taskRoutes.delete("/:id/dependencies/:depId", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Dependencies moved to links table (Phase 2)" } }, 404);
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const depId = c.req.param("depId");
|
||||
|
||||
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
if (!task) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, task.domainId);
|
||||
|
||||
// Junction table has no deleted_at — hard delete is correct here
|
||||
await db.delete(taskDependencies).where(and(eq(taskDependencies.taskId, id), eq(taskDependencies.dependsOnTaskId, depId)));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "dependency_removed",
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
changes: { removedDependsOnTaskId: depId },
|
||||
workspaceId: task.domainId,
|
||||
});
|
||||
|
||||
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("[tasks] DELETE /:id/dependencies/:depId error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove dependency" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks/:id/status — Deprecated: use state_id instead (Phase 2)
|
||||
// POST /api/tasks/:id/status — Change task status (Kanban drag)
|
||||
taskRoutes.post("/:id/status", async (c) => {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Status endpoint replaced by state assignment (Phase 2)" } }, 404);
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const { status: newStatus } = z.object({
|
||||
status: taskStatusEnum,
|
||||
}).parse(body);
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
const updateValues: Record<string, unknown> = {
|
||||
status: newStatus,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (newStatus === "done") {
|
||||
updateValues.completedAt = new Date();
|
||||
}
|
||||
|
||||
const [updated] = await db.update(tasks)
|
||||
.set(updateValues)
|
||||
.where(eq(tasks.id, id))
|
||||
.returning();
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: newStatus === "done" ? "completed" : "updated",
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
changes: { previousStatus: existing.status, newStatus },
|
||||
workspaceId: existing.domainId,
|
||||
});
|
||||
|
||||
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("[tasks] POST /:id/status error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update task status" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tasks/:id/history — Status change log (from activity feed)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, projects, sections, statusDefinitions, taskDependencies, tasks } from "@project-e/db";
|
||||
import { and, asc, eq, inArray, isNotNull, isNull } from "drizzle-orm";
|
||||
import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
|
||||
|
||||
export const timelineRoutes = new Hono();
|
||||
|
||||
// Columns of the status_definitions table flattened onto a task row as `status`
|
||||
// (null when the task has no status or its status was deleted).
|
||||
const statusColumns = {
|
||||
id: statusDefinitions.id,
|
||||
projectId: statusDefinitions.projectId,
|
||||
key: statusDefinitions.key,
|
||||
label: statusDefinitions.label,
|
||||
category: statusDefinitions.category,
|
||||
color: statusDefinitions.color,
|
||||
sortOrder: statusDefinitions.sortOrder,
|
||||
isDefault: statusDefinitions.isDefault,
|
||||
};
|
||||
|
||||
// GET /api/domains/:domainId/projects/:projectId/timeline — Gantt data for a project:
|
||||
// task bars (with status + dependencies) and milestone sections. Tasks without a
|
||||
// startDate field use createdAt as the bar start.
|
||||
timelineRoutes.get("/domains/:domainId/projects/:projectId/timeline", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
void user;
|
||||
const domainId = c.req.param("domainId");
|
||||
const projectId = c.req.param("projectId");
|
||||
if (!isUuid(domainId) || !isUuid(projectId)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
|
||||
const [project] = await db.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, projectId), eq(projects.domainId, domainId), isNull(projects.deletedAt)))
|
||||
.limit(1);
|
||||
if (!project) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
|
||||
}
|
||||
|
||||
const [taskRows, milestoneRows] = await Promise.all([
|
||||
db.select({
|
||||
id: tasks.id,
|
||||
title: tasks.title,
|
||||
statusId: tasks.statusId,
|
||||
sectionId: tasks.sectionId,
|
||||
dueDate: tasks.dueDate,
|
||||
createdAt: tasks.createdAt,
|
||||
status: statusColumns,
|
||||
})
|
||||
.from(tasks)
|
||||
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
|
||||
.where(and(eq(tasks.projectId, projectId), isNull(tasks.deletedAt)))
|
||||
.orderBy(asc(tasks.createdAt)),
|
||||
db.select({
|
||||
id: sections.id,
|
||||
name: sections.name,
|
||||
targetDate: sections.targetDate,
|
||||
sortOrder: sections.sortOrder,
|
||||
})
|
||||
.from(sections)
|
||||
.where(and(
|
||||
eq(sections.projectId, projectId),
|
||||
eq(sections.kind, "milestone"),
|
||||
isNotNull(sections.targetDate),
|
||||
))
|
||||
.orderBy(asc(sections.targetDate), asc(sections.sortOrder)),
|
||||
]);
|
||||
|
||||
// Dependency map: taskId → ids of tasks it depends on. Only edges between
|
||||
// tasks in this project are kept so arrows never point outside the chart.
|
||||
const depsByTask = new Map<string, string[]>();
|
||||
if (taskRows.length > 0) {
|
||||
const taskIds = taskRows.map((t) => t.id);
|
||||
const depRows = await db.select({
|
||||
taskId: taskDependencies.taskId,
|
||||
dependsOnTaskId: taskDependencies.dependsOnTaskId,
|
||||
})
|
||||
.from(taskDependencies)
|
||||
.where(and(
|
||||
inArray(taskDependencies.taskId, taskIds),
|
||||
inArray(taskDependencies.dependsOnTaskId, taskIds),
|
||||
));
|
||||
for (const dep of depRows) {
|
||||
const list = depsByTask.get(dep.taskId) ?? [];
|
||||
list.push(dep.dependsOnTaskId);
|
||||
depsByTask.set(dep.taskId, list);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
tasks: taskRows.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
startDate: t.createdAt,
|
||||
dueDate: t.dueDate,
|
||||
statusId: t.statusId,
|
||||
status: t.status,
|
||||
sectionId: t.sectionId,
|
||||
dependencies: depsByTask.get(t.id) ?? [],
|
||||
})),
|
||||
milestones: milestoneRows.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
targetDate: m.targetDate,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
console.error("[timeline] GET error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to load timeline" } }, 500);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user