feat: implement PL-7, PL-8, PL-9 — state-driven board, module/cycle views, link panels + graph edges

PL-7 — Task Board Columns from States:
- Add State, Module, Cycle, Link TypeScript types to types/index.ts
- Add stateId, moduleId, cycleId, trackedMinutes to Task type
- Rewrite tasks.tsx: fetch states from API, render 5 state-group columns
  (backlog/unstarted/started/completed/cancelled), drag-and-drop updates
  stateId via PATCH /tasks/:id, colored state badges, state filter dropdown,
  project filter
- Fix deprecated POST /tasks/:id/status → PATCH /tasks/:id with stateId
- Update tasks/.tsx: state selector dropdown replaces hardcoded status enum,
  toggle complete uses state-based approach, dependencies replaced with
  link-based UI using /api/links

PL-8 — Module + Cycle Views:
- Create apps/api/src/routes/cycles.ts: full CRUD + task assignment/removal
- Create apps/api/src/routes/links.ts: list/create/delete links between entities
- Register cycleRoutes and linkRoutes in API index
- Add Modules tab to project detail: list modules, expand to show tasks,
  add/remove tasks from modules, create/edit/delete module dialogs
- Add Cycles tab to project detail: sprint board grid, backlog lane,
  manual task transfer between cycles and backlog, create/edit cycle dialogs
- Fix ProjectTasks toggle to use PATCH with stateId instead of deprecated endpoint

PL-9 — Link Panels + Graph:
- Update graph API to read links bidirectionally (source OR target)
- Add link type color map (LINK_TYPE_COLORS) for edge rendering
- Graph edges now colored by linkType (blocks=red, relates=gray, etc.)
- Filter panel shows link types with color indicators
- Task detail Dependencies tab now uses /api/links for add/remove links
- Added link type selector (blocks/relates/parent-child/created-from)
This commit is contained in:
2026-09-07 20:24:54 +00:00
parent 14bb1aa1a8
commit c6328c120a
9 changed files with 1525 additions and 228 deletions
+5
View File
@@ -28,6 +28,8 @@ import { importExportRoutes } from "./routes/import-export";
import { notificationRoutes } from "./routes/notifications";
import { stateRoutes } from "./routes/states";
import { moduleRoutes } from "./routes/modules";
import { cycleRoutes } from "./routes/cycles";
import { linkRoutes } from "./routes/links";
import { healthHandler } from "./routes/health";
const app = new Hono();
@@ -47,6 +49,7 @@ app.get("/api/health", async (c) => {
app.route("/api/auth", authRoutes);
app.route("/api/domains", domainRoutes);
app.route("/api/projects/:projectId/modules", moduleRoutes);
app.route("/api/projects/:projectId/cycles", cycleRoutes);
app.route("/api/modules", moduleRoutes);
app.route("/api/tasks", taskRoutes);
app.route("/api/habits", habitRoutes);
@@ -67,6 +70,8 @@ app.route("/api/analytics", analyticsRoutes);
app.route("/api/activity", activityRoutes);
app.route("/api/notifications", notificationRoutes);
app.route("/api/states", stateRoutes);
app.route("/api/cycles", cycleRoutes);
app.route("/api/links", linkRoutes);
app.route("/api", importExportRoutes);
app.route("/api", realtimeRoutes);
app.route("/api/mcp", mcpRoutes);
+374
View File
@@ -0,0 +1,374 @@
import { Hono } from "hono";
import { db, cycles, 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 cycleRoutes = new Hono();
const createCycleSchema = z.object({
name: z.string().min(1, "Name is required"),
startDate: z.string().datetime().optional().nullable(),
endDate: z.string().datetime().optional().nullable(),
active: z.boolean().optional().default(false),
});
const updateCycleSchema = z.object({
name: z.string().min(1).optional(),
startDate: z.string().datetime().optional().nullable(),
endDate: z.string().datetime().optional().nullable(),
active: z.boolean().optional(),
});
// GET / — List cycles for a project
cycleRoutes.get("/", async (c) => {
try {
const user = await requireAuth(c);
const url = new URL(c.req.url);
const projectId = c.req.param("projectId") || url.searchParams.get("project_id");
if (!projectId || !isUuid(projectId)) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "project_id is required" } }, 400);
}
const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
.from(projects)
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
const items = await db.select()
.from(cycles)
.where(eq(cycles.projectId, projectId))
.orderBy(asc(cycles.createdAt));
return c.json({ items });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[cycles] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list cycles" } }, 500);
}
});
// POST / — Create a cycle
cycleRoutes.post("/", async (c) => {
try {
const user = await requireAuth(c);
const body = await c.req.json();
const data = createCycleSchema.parse(body);
const projectId = c.req.param("projectId");
if (!projectId || !isUuid(projectId)) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "project_id is required in URL path" } }, 400);
}
const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
.from(projects)
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
const [cycle] = await db.insert(cycles).values({
name: data.name,
projectId,
startDate: data.startDate ? new Date(data.startDate) : null,
endDate: data.endDate ? new Date(data.endDate) : null,
active: data.active,
}).returning();
await recordActivity({
actor: user.name,
action: "created",
entityType: "cycle",
entityId: cycle.id,
changes: { name: cycle.name, projectId },
workspaceId: project.domainId,
});
await enqueueWebhooks({ workspaceId: project.domainId, event: "cycle.created", entityType: "cycle", entityId: cycle.id, data: { name: cycle.name, projectId } });
return c.json(cycle, 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("[cycles] POST error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create cycle" } }, 500);
}
});
// GET /:id — Get a single cycle with its tasks
cycleRoutes.get("/:id", async (c) => {
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 [cycle] = await db.select()
.from(cycles)
.where(eq(cycles.id, id))
.limit(1);
if (!cycle) {
return c.json({ error: { code: "NOT_FOUND", message: "Cycle not found" } }, 404);
}
const [project] = await db.select({ domainId: projects.domainId })
.from(projects)
.where(eq(projects.id, cycle.projectId))
.limit(1);
await requireWorkspaceAccess(c, project?.domainId || "");
const cycleTasks = await db.select()
.from(tasks)
.where(and(eq(tasks.cycleId, id), isNull(tasks.deletedAt)))
.orderBy(asc(tasks.order));
return c.json({ ...cycle, tasks: cycleTasks });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[cycles] GET/:id error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get cycle" } }, 500);
}
});
// PATCH /:id — Update a cycle
cycleRoutes.patch("/:id", async (c) => {
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 data = updateCycleSchema.parse(body);
const [existing] = await db.select()
.from(cycles)
.where(eq(cycles.id, id))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "Cycle not found" } }, 404);
}
const [project] = await db.select({ domainId: projects.domainId })
.from(projects)
.where(eq(projects.id, existing.projectId))
.limit(1);
await requireWorkspaceAccess(c, project?.domainId || "");
const updateValues: Record<string, unknown> = {};
if (data.name !== undefined) updateValues.name = data.name;
if (data.startDate !== undefined) updateValues.startDate = data.startDate ? new Date(data.startDate) : null;
if (data.endDate !== undefined) updateValues.endDate = data.endDate ? new Date(data.endDate) : null;
if (data.active !== undefined) updateValues.active = data.active;
updateValues.updatedAt = new Date();
const [updated] = await db.update(cycles)
.set(updateValues)
.where(eq(cycles.id, id))
.returning();
await recordActivity({
actor: user.name,
action: "updated",
entityType: "cycle",
entityId: id,
changes: { ...data, previousName: existing.name },
workspaceId: project?.domainId || "",
});
await enqueueWebhooks({ workspaceId: project?.domainId || "", event: "cycle.updated", entityType: "cycle", entityId: id, data: { ...data } });
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("[cycles] PATCH error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update cycle" } }, 500);
}
});
// DELETE /:id — Delete a cycle
cycleRoutes.delete("/:id", async (c) => {
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 [existing] = await db.select()
.from(cycles)
.where(eq(cycles.id, id))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "Cycle not found" } }, 404);
}
const [project] = await db.select({ domainId: projects.domainId })
.from(projects)
.where(eq(projects.id, existing.projectId))
.limit(1);
const workspaceId = project?.domainId || "";
await requireWorkspaceAccess(c, workspaceId);
// Clear cycleId on tasks belonging to this cycle
await db.update(tasks)
.set({ cycleId: null, updatedAt: new Date() })
.where(eq(tasks.cycleId, id));
await db.delete(cycles).where(eq(cycles.id, id));
await recordActivity({
actor: user.name,
action: "deleted",
entityType: "cycle",
entityId: id,
changes: { name: existing.name, projectId: existing.projectId },
workspaceId,
});
await enqueueWebhooks({ workspaceId, event: "cycle.deleted", entityType: "cycle", 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("[cycles] DELETE error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete cycle" } }, 500);
}
});
// POST /:id/tasks — Add a task to this cycle
cycleRoutes.post("/:id/tasks", async (c) => {
try {
const user = await requireAuth(c);
const cycleId = c.req.param("id");
if (!isUuid(cycleId)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const { taskId } = z.object({ taskId: z.string().uuid("Invalid task id") }).parse(body);
const [cycle] = await db.select()
.from(cycles)
.where(eq(cycles.id, cycleId))
.limit(1);
if (!cycle) {
return c.json({ error: { code: "NOT_FOUND", message: "Cycle not found" } }, 404);
}
const [project] = await db.select({ domainId: projects.domainId })
.from(projects)
.where(eq(projects.id, cycle.projectId))
.limit(1);
await requireWorkspaceAccess(c, project?.domainId || "");
await db.update(tasks)
.set({ cycleId, updatedAt: new Date() })
.where(eq(tasks.id, taskId));
await recordActivity({
actor: user.name,
action: "added_task",
entityType: "cycle",
entityId: cycleId,
changes: { taskId },
workspaceId: project?.domainId || "",
});
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("[cycles] POST /:id/tasks error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to add task to cycle" } }, 500);
}
});
// DELETE /:id/tasks/:taskId — Remove a task from this cycle
cycleRoutes.delete("/:id/tasks/:taskId", async (c) => {
try {
const user = await requireAuth(c);
const cycleId = c.req.param("id");
const taskId = c.req.param("taskId");
if (!isUuid(cycleId) || !isUuid(taskId)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [cycle] = await db.select()
.from(cycles)
.where(eq(cycles.id, cycleId))
.limit(1);
if (!cycle) {
return c.json({ error: { code: "NOT_FOUND", message: "Cycle not found" } }, 404);
}
const [project] = await db.select({ domainId: projects.domainId })
.from(projects)
.where(eq(projects.id, cycle.projectId))
.limit(1);
await requireWorkspaceAccess(c, project?.domainId || "");
await db.update(tasks)
.set({ cycleId: null, updatedAt: new Date() })
.where(eq(tasks.id, taskId));
await recordActivity({
actor: user.name,
action: "removed_task",
entityType: "cycle",
entityId: cycleId,
changes: { taskId },
workspaceId: project?.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("[cycles] DELETE /:id/tasks/:taskId error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove task from cycle" } }, 500);
}
});
+4 -4
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { db, domains, notes, tasks, habits, projects, sections, tags as tagsTable, links } from "@project-e/db";
import { and, eq, inArray, isNull } from "drizzle-orm";
import { and, eq, inArray, isNull, or } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, AuthError } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { z } from "zod";
@@ -57,11 +57,11 @@ async function getGraphData(domainId: string): Promise<{ nodes: GraphNode[]; edg
for (const s of sectionRows) addNode(s.id, s.name, 'section');
for (const t of tagRows) addNode(t.id, t.name, 'tag');
// Read links from the canonical links table
const allIds = [...noteRows.map(n => n.id), ...taskRows.map(t => t.id)];
// Read links from the canonical links table (both directions)
const allIds = [...noteRows.map(n => n.id), ...taskRows.map(t => t.id), ...projectRows.map(p => p.id), ...sectionRows.map(s => s.id)];
if (allIds.length > 0) {
const linkRows = await db.select().from(links)
.where(inArray(links.sourceId, allIds));
.where(or(inArray(links.sourceId, allIds), inArray(links.targetId, allIds)));
for (const l of linkRows) addEdge(l.sourceId, l.targetId, l.linkType);
}
+173
View File
@@ -0,0 +1,173 @@
import { Hono } from "hono";
import { db, links, tasks, notes, projects } from "@project-e/db";
import { and, eq, inArray, isNull, or } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { z } from "zod";
export const linkRoutes = new Hono();
const createLinkSchema = z.object({
sourceType: z.string().min(1),
sourceId: z.string().uuid(),
targetType: z.string().min(1),
targetId: z.string().uuid(),
linkType: z.enum(["relates", "blocks", "parent-child", "created-from"]),
direction: z.string().optional().nullable(),
});
async function resolveWorkspaceId(entityType: string, entityId: string): Promise<string | null> {
if (entityType === "task") {
const [row] = await db.select({ domainId: tasks.domainId }).from(tasks).where(eq(tasks.id, entityId)).limit(1);
return row?.domainId ?? null;
}
if (entityType === "note") {
const [row] = await db.select({ domainId: notes.domainId }).from(notes).where(eq(notes.id, entityId)).limit(1);
return row?.domainId ?? null;
}
return null;
}
// GET /api/links — List links for an entity (either source OR target)
linkRoutes.get("/", async (c) => {
try {
const user = await requireAuth(c);
const url = new URL(c.req.url);
const entityType = url.searchParams.get("entityType");
const entityId = url.searchParams.get("entityId");
if (!entityType || !entityId) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "entityType and entityId query parameters are required" } }, 400);
}
const workspaceId = await resolveWorkspaceId(entityType, entityId);
if (workspaceId) {
await requireWorkspaceAccess(c, workspaceId);
}
const items = await db.select()
.from(links)
.where(or(
and(eq(links.sourceType, entityType), eq(links.sourceId, entityId)),
and(eq(links.targetType, entityType), eq(links.targetId, entityId)),
));
return c.json({ items });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[links] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list links" } }, 500);
}
});
// POST /api/links — Create a link between two entities
linkRoutes.post("/", async (c) => {
try {
const user = await requireAuth(c);
const body = await c.req.json();
const data = createLinkSchema.parse(body);
// Prevent self-links
if (data.sourceId === data.targetId) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Cannot link an entity to itself" } }, 400);
}
// Check for duplicate link
const [existing] = await db.select({ id: links.id })
.from(links)
.where(and(
eq(links.sourceId, data.sourceId),
eq(links.targetId, data.targetId),
eq(links.linkType, data.linkType),
))
.limit(1);
if (existing) {
return c.json({ error: { code: "CONFLICT", message: "Link already exists" } }, 409);
}
const workspaceId = await resolveWorkspaceId(data.sourceType, data.sourceId);
if (workspaceId) {
await requireWorkspaceAccess(c, workspaceId);
}
const [link] = await db.insert(links).values({
sourceType: data.sourceType,
sourceId: data.sourceId,
targetType: data.targetType,
targetId: data.targetId,
linkType: data.linkType,
direction: data.direction ?? null,
}).returning();
if (workspaceId) {
await recordActivity({
actor: user.name,
action: "created",
entityType: "link",
entityId: link.id,
changes: { sourceType: data.sourceType, sourceId: data.sourceId, targetType: data.targetType, targetId: data.targetId, linkType: data.linkType },
workspaceId,
});
}
return c.json(link, 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("[links] POST error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create link" } }, 500);
}
});
// DELETE /api/links/:id — Remove a link
linkRoutes.delete("/:id", async (c) => {
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 [existing] = await db.select()
.from(links)
.where(eq(links.id, id))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "Link not found" } }, 404);
}
const workspaceId = await resolveWorkspaceId(existing.sourceType, existing.sourceId);
if (workspaceId) {
await requireWorkspaceAccess(c, workspaceId);
}
await db.delete(links).where(eq(links.id, id));
if (workspaceId) {
await recordActivity({
actor: user.name,
action: "deleted",
entityType: "link",
entityId: id,
changes: { sourceType: existing.sourceType, sourceId: existing.sourceId, targetType: existing.targetType, targetId: existing.targetId },
workspaceId,
});
}
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("[links] DELETE error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete link" } }, 500);
}
});