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
119 lines
4.2 KiB
TypeScript
119 lines
4.2 KiB
TypeScript
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);
|
|
}
|
|
});
|