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,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