From 60fb37c4a37beff6794a7dcf57862d9786b0e5bb Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Mon, 7 Sep 2026 18:37:12 +0000 Subject: [PATCH 1/8] feat: tasks route stateId/stateGroup rewrite Replace flat status enum with stateId/stateGroup references: - Import states table; add state_group filter via EXISTS subquery - Add module_id and cycle_id filter params to task list endpoint - Validate stateId on create/update (404 if state not found) - Auto-set completedAt when state group is 'completed', clear otherwise - Zero references to old taskStatusEnum remain --- apps/api/src/routes/tasks.ts | 56 ++++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/api/src/routes/tasks.ts b/apps/api/src/routes/tasks.ts index 77b5e74..f0121f0 100644 --- a/apps/api/src/routes/tasks.ts +++ b/apps/api/src/routes/tasks.ts @@ -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, states as statesTable, taskTags, tags as tagsTable, activityFeed, scheduledJobs, projects, sections, links } 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"; @@ -90,6 +90,9 @@ taskRoutes.get("/", async (c) => { const filter = url.searchParams.get("filter") || undefined; const sort = url.searchParams.get("sort") || "-created"; const stateId = url.searchParams.get("state_id"); + const stateGroup = url.searchParams.get("state_group"); + const moduleId = url.searchParams.get("module_id"); + const cycleId = url.searchParams.get("cycle_id"); const priority = url.searchParams.get("priority"); const tag = url.searchParams.get("tag"); const search = url.searchParams.get("search"); @@ -144,6 +147,22 @@ taskRoutes.get("/", async (c) => { if (sectionId) { conditions.push(eq(tasks.sectionId, sectionId)); } + if (moduleId) { + conditions.push(eq(tasks.moduleId, moduleId)); + } + if (cycleId) { + conditions.push(eq(tasks.cycleId, cycleId)); + } + if (stateGroup) { + const groups = stateGroup.split(",") as any[]; + conditions.push( + exists( + db.select({ one: sql`1` }) + .from(statesTable) + .where(and(eq(statesTable.id, tasks.stateId), inArray(statesTable.group, groups))) + ) + ); + } // Tag filter applied in SQL (EXISTS on the junction table) so it runs over // the full dataset before pagination — filtering in-memory after fetching a // page would miss tasks beyond the limit and report a wrong totalItems. @@ -296,6 +315,21 @@ taskRoutes.post("/", async (c) => { } } + // Validate stateId exists and compute completedAt + let completedAt: Date | null = null; + if (data.stateId) { + const [state] = await db.select({ id: statesTable.id, group: statesTable.group }) + .from(statesTable) + .where(eq(statesTable.id, data.stateId)) + .limit(1); + if (!state) { + return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404); + } + if (state.group === "completed") { + completedAt = new Date(); + } + } + const [task] = await db.insert(tasks).values({ title: data.title, description: data.description ?? null, @@ -312,6 +346,7 @@ taskRoutes.post("/", async (c) => { order: data.order ?? 0, customFields: data.customFields ?? {}, recurrenceRule: data.recurrenceRule ?? null, + completedAt, }).returning(); const tagIdsToLink: string[] = [...(data.tagIds || [])]; @@ -539,6 +574,23 @@ taskRoutes.patch("/:id", async (c) => { if (data.order !== undefined) updateValues.order = data.order; if (data.customFields !== undefined) updateValues.customFields = data.customFields; if (data.recurrenceRule !== undefined) updateValues.recurrenceRule = data.recurrenceRule; + + // Validate stateId and compute completedAt when state changes + if (data.stateId !== undefined) { + if (data.stateId !== null) { + const [state] = await db.select({ id: statesTable.id, group: statesTable.group }) + .from(statesTable) + .where(eq(statesTable.id, data.stateId)) + .limit(1); + if (!state) { + return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404); + } + updateValues.completedAt = state.group === "completed" ? new Date() : null; + } else { + updateValues.completedAt = null; + } + } + updateValues.updatedAt = new Date(); const [updated] = await db.update(tasks) @@ -733,7 +785,7 @@ taskRoutes.post("/:id/status", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Status endpoint replaced by state assignment (Phase 2)" } }, 404); }); -// GET /api/tasks/:id/history — Status change log (from activity feed) +// GET /api/tasks/:id/history — State change log (from activity feed) taskRoutes.get("/:id/history", async (c) => { try { const user = await requireAuth(c); From 75bcd142333fd3ba339981ca9e4ec2bb5e876ee8 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Mon, 7 Sep 2026 18:52:45 +0000 Subject: [PATCH 2/8] feat: modules API + membership --- apps/api/src/index.ts | 3 + apps/api/src/routes/modules.ts | 467 +++++++++++++++++++++++++++++++++ packages/db/src/schema.ts | 2 + 3 files changed, 472 insertions(+) create mode 100644 apps/api/src/routes/modules.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a5fac02..9bd370a 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -26,6 +26,7 @@ import { analyticsRoutes } from "./routes/analytics"; import { activityRoutes } from "./routes/activity"; import { importExportRoutes } from "./routes/import-export"; import { notificationRoutes } from "./routes/notifications"; +import { moduleRoutes } from "./routes/modules"; import { healthHandler } from "./routes/health"; const app = new Hono(); @@ -44,6 +45,8 @@ app.get("/api/health", async (c) => { // Routes app.route("/api/auth", authRoutes); app.route("/api/domains", domainRoutes); +app.route("/api/projects/:projectId/modules", moduleRoutes); +app.route("/api/modules", moduleRoutes); app.route("/api/tasks", taskRoutes); app.route("/api/habits", habitRoutes); app.route("/api/projects", projectRoutes); diff --git a/apps/api/src/routes/modules.ts b/apps/api/src/routes/modules.ts new file mode 100644 index 0000000..26338a4 --- /dev/null +++ b/apps/api/src/routes/modules.ts @@ -0,0 +1,467 @@ +import { Hono } from "hono"; +import { db, modules, tasks, projects } from "@project-e/db"; +import { and, asc, desc, eq, ilike, inArray, 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 moduleRoutes = new Hono(); + +const moduleStatusEnum = z.enum(["planned", "in_progress", "completed", "cancelled"]); + +const createModuleSchema = z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional().nullable(), + status: moduleStatusEnum.optional().default("planned"), + startDate: z.string().datetime().optional().nullable(), + targetDate: z.string().datetime().optional().nullable(), + sortOrder: z.number().int().optional(), +}); + +const updateModuleSchema = z.object({ + name: z.string().min(1).optional(), + description: z.string().optional().nullable(), + status: moduleStatusEnum.optional(), + startDate: z.string().datetime().optional().nullable(), + targetDate: z.string().datetime().optional().nullable(), + sortOrder: z.number().int().optional(), +}); + +// GET / — List modules for a project +moduleRoutes.get("/", async (c) => { + try { + const user = await requireAuth(c); + const url = new URL(c.req.url); + const page = Math.max(1, parseInt(url.searchParams.get("page") || "1")); + const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200); + const offset = parseInt(url.searchParams.get("offset") || "0"); + const search = url.searchParams.get("search"); + const status = url.searchParams.get("status"); + const sort = url.searchParams.get("sort") || "-created"; + + 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 conditions: any[] = [ + eq(modules.projectId, projectId), + isNull(modules.deletedAt), + ]; + + if (search) { + conditions.push(ilike(modules.name, `%${search}%`)); + } + if (status) { + const statuses = status.split(","); + conditions.push(inArray(modules.status, statuses as any)); + } + + const sortDir = sort.startsWith("-") ? "desc" : "asc"; + const sortField = sort.replace(/^-/, ""); + const sortColumns: Record = { + created: modules.createdAt, + updated: modules.updatedAt, + name: modules.name, + status: modules.status, + sort_order: modules.sortOrder, + created_at: modules.createdAt, + updated_at: modules.updatedAt, + }; + const orderColumn = sortDir === "asc" + ? asc(sortColumns[sortField] || modules.createdAt) + : desc(sortColumns[sortField] || modules.createdAt); + + const [items, countResult] = await Promise.all([ + db.select() + .from(modules) + .where(and(...conditions)) + .orderBy(orderColumn) + .limit(limit) + .offset(offset || (page - 1) * limit), + db.select({ count: sql`count(*)` }) + .from(modules) + .where(and(...conditions)), + ]); + + const totalItems = Number(countResult[0]?.count || 0); + + return c.json({ + items, + totalItems, + totalPages: Math.ceil(totalItems / limit), + page, + perPage: limit, + limit, + offset: offset || (page - 1) * limit, + }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[modules] GET error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list modules" } }, 500); + } +}); + +// POST / — Create a module (projectId from URL path) +moduleRoutes.post("/", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const data = createModuleSchema.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 [mod] = await db.insert(modules).values({ + name: data.name, + description: data.description ?? null, + projectId, + status: data.status, + startDate: data.startDate ? new Date(data.startDate) : null, + targetDate: data.targetDate ? new Date(data.targetDate) : null, + sortOrder: data.sortOrder ?? 0, + }).returning(); + + await recordActivity({ + actor: user.name, + action: "created", + entityType: "module", + entityId: mod.id, + changes: { name: mod.name, status: mod.status, projectId }, + workspaceId: project.domainId, + }); + + await enqueueWebhooks({ workspaceId: project.domainId, event: "module.created", entityType: "module", entityId: mod.id, data: { name: mod.name, projectId } }); + + return c.json(mod, 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("[modules] POST error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create module" } }, 500); + } +}); + +// GET /:id — Get a single module +moduleRoutes.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 [mod] = await db.select() + .from(modules) + .where(and(eq(modules.id, id), isNull(modules.deletedAt))) + .limit(1); + + if (!mod) { + return c.json({ error: { code: "NOT_FOUND", message: "Module not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, mod.projectId)) + .limit(1); + + await requireWorkspaceAccess(c, project?.domainId || ""); + + const moduleTasks = await db.select() + .from(tasks) + .where(and(eq(tasks.moduleId, id), isNull(tasks.deletedAt))) + .orderBy(asc(tasks.order)); + + return c.json({ ...mod, tasks: moduleTasks }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[modules] GET/:id error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get module" } }, 500); + } +}); + +// PATCH /:id — Update a module +moduleRoutes.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 = updateModuleSchema.parse(body); + + const [existing] = await db.select() + .from(modules) + .where(and(eq(modules.id, id), isNull(modules.deletedAt))) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "Module 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 = {}; + if (data.name !== undefined) updateValues.name = data.name; + if (data.description !== undefined) updateValues.description = data.description; + if (data.status !== undefined) updateValues.status = data.status; + if (data.startDate !== undefined) updateValues.startDate = data.startDate ? new Date(data.startDate) : null; + if (data.targetDate !== undefined) updateValues.targetDate = data.targetDate ? new Date(data.targetDate) : null; + if (data.sortOrder !== undefined) updateValues.sortOrder = data.sortOrder; + updateValues.updatedAt = new Date(); + + const [updated] = await db.update(modules) + .set(updateValues) + .where(eq(modules.id, id)) + .returning(); + + await recordActivity({ + actor: user.name, + action: "updated", + entityType: "module", + entityId: id, + changes: { ...data, previousName: existing.name }, + workspaceId: project?.domainId || "", + }); + + await enqueueWebhooks({ workspaceId: project?.domainId || "", event: "module.updated", entityType: "module", 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("[modules] PATCH error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update module" } }, 500); + } +}); + +// DELETE /:id — Soft delete a module (also clears moduleId on tasks) +moduleRoutes.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(modules) + .where(and(eq(modules.id, id), isNull(modules.deletedAt))) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "Module 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 moduleId on tasks belonging to this module + await db.update(tasks) + .set({ moduleId: null, updatedAt: new Date() }) + .where(eq(tasks.moduleId, id)); + + await db.update(modules) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(eq(modules.id, id)); + + await recordActivity({ + actor: user.name, + action: "deleted", + entityType: "module", + entityId: id, + changes: { name: existing.name, projectId: existing.projectId }, + workspaceId, + }); + + await enqueueWebhooks({ workspaceId, event: "module.deleted", entityType: "module", 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("[modules] DELETE error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete module" } }, 500); + } +}); + +// POST /:id/tasks — Add a task to this module +moduleRoutes.post("/:id/tasks", async (c) => { + try { + const user = await requireAuth(c); + const moduleId = c.req.param("id"); + if (!isUuid(moduleId)) { + 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 [mod] = await db.select() + .from(modules) + .where(and(eq(modules.id, moduleId), isNull(modules.deletedAt))) + .limit(1); + + if (!mod) { + return c.json({ error: { code: "NOT_FOUND", message: "Module not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, mod.projectId)) + .limit(1); + + await requireWorkspaceAccess(c, project?.domainId || ""); + + const [task] = await db.select() + .from(tasks) + .where(and(eq(tasks.id, taskId), isNull(tasks.deletedAt))) + .limit(1); + + if (!task) { + return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); + } + + // Single-module constraint: if the task is already in another module, remove it first + if (task.moduleId && task.moduleId !== moduleId) { + await db.update(tasks) + .set({ moduleId: null, updatedAt: new Date() }) + .where(eq(tasks.id, taskId)); + } + + // Assign task to this module + await db.update(tasks) + .set({ moduleId, updatedAt: new Date() }) + .where(eq(tasks.id, taskId)); + + await recordActivity({ + actor: user.name, + action: "added_task", + entityType: "module", + entityId: moduleId, + changes: { taskId, taskTitle: task.title, previousModuleId: task.moduleId }, + 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("[modules] POST /:id/tasks error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to add task to module" } }, 500); + } +}); + +// DELETE /:id/tasks/:taskId — Remove a task from this module +moduleRoutes.delete("/:id/tasks/:taskId", async (c) => { + try { + const user = await requireAuth(c); + const moduleId = c.req.param("id"); + const taskId = c.req.param("taskId"); + if (!isUuid(moduleId) || !isUuid(taskId)) { + return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); + } + + const [mod] = await db.select() + .from(modules) + .where(and(eq(modules.id, moduleId), isNull(modules.deletedAt))) + .limit(1); + + if (!mod) { + return c.json({ error: { code: "NOT_FOUND", message: "Module not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, mod.projectId)) + .limit(1); + + await requireWorkspaceAccess(c, project?.domainId || ""); + + const [task] = await db.select() + .from(tasks) + .where(and(eq(tasks.id, taskId), isNull(tasks.deletedAt))) + .limit(1); + + if (!task) { + return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); + } + + if (task.moduleId !== moduleId) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Task is not in this module" } }, 400); + } + + await db.update(tasks) + .set({ moduleId: null, updatedAt: new Date() }) + .where(eq(tasks.id, taskId)); + + await recordActivity({ + actor: user.name, + action: "removed_task", + entityType: "module", + entityId: moduleId, + changes: { taskId, taskTitle: task.title }, + 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("[modules] DELETE /:id/tasks/:taskId error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove task from module" } }, 500); + } +}); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 00048f6..9a89df6 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -184,9 +184,11 @@ export const modules = pgTable( sortOrder: integer('sort_order').default(0), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), }, (table) => [ index('modules_project_id_idx').on(table.projectId), + index('modules_deleted_at_idx').on(table.deletedAt), ] ); From c5682765e838fedc80c4325ec42a0b2c01ce61a3 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Mon, 7 Sep 2026 19:24:24 +0000 Subject: [PATCH 3/8] feat: add states CRUD API route with project-scoped workflow states - Add states.ts with GET/POST/PATCH/DELETE endpoints - States scoped to project via projectId FK; workspace access resolved through project - stateGroupEnum enforced (backlog|unstarted|started|completed|cancelled) - List ordered by sortOrder, auto-increment on create - Soft-delete via deletedAt column (added to schema) - 3-step contract on every write: DB write, activity feed, pg_notify - Register /api/states route in main index.ts - Default state seeding already present in projects.ts --- apps/api/src/index.ts | 2 + apps/api/src/routes/states.ts | 242 ++++++++++++++++++++++++++++++++++ packages/db/src/schema.ts | 2 + 3 files changed, 246 insertions(+) create mode 100644 apps/api/src/routes/states.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a5fac02..0d1e171 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -26,6 +26,7 @@ import { analyticsRoutes } from "./routes/analytics"; import { activityRoutes } from "./routes/activity"; import { importExportRoutes } from "./routes/import-export"; import { notificationRoutes } from "./routes/notifications"; +import { stateRoutes } from "./routes/states"; import { healthHandler } from "./routes/health"; const app = new Hono(); @@ -62,6 +63,7 @@ app.route("/api/error-log", errorLogRoutes); app.route("/api/analytics", analyticsRoutes); app.route("/api/activity", activityRoutes); app.route("/api/notifications", notificationRoutes); +app.route("/api/states", stateRoutes); app.route("/api", importExportRoutes); app.route("/api", realtimeRoutes); app.route("/api/mcp", mcpRoutes); diff --git a/apps/api/src/routes/states.ts b/apps/api/src/routes/states.ts new file mode 100644 index 0000000..d796be7 --- /dev/null +++ b/apps/api/src/routes/states.ts @@ -0,0 +1,242 @@ +import { Hono } from "hono"; +import { db, states, 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 stateRoutes = new Hono(); + +const stateGroupEnum = z.enum(["backlog", "unstarted", "started", "completed", "cancelled"]); + +const createStateSchema = z.object({ + projectId: z.string().uuid("Invalid project id"), + name: z.string().min(1, "Name is required"), + color: z.string().optional().nullable(), + group: stateGroupEnum.optional().default("unstarted"), + sortOrder: z.number().int().optional(), +}); + +const updateStateSchema = z.object({ + name: z.string().min(1).optional(), + color: z.string().optional().nullable(), + group: stateGroupEnum.optional(), + sortOrder: z.number().int().optional(), +}); + +// GET /api/states — List states filtered by projectId (exclude soft-deleted) +stateRoutes.get("/", async (c) => { + try { + const user = await requireAuth(c); + const url = new URL(c.req.url); + const projectId = url.searchParams.get("projectId") || url.searchParams.get("project_id"); + + if (!projectId || !isUuid(projectId)) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "projectId query parameter 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(states) + .where(and(eq(states.projectId, projectId), isNull(states.deletedAt))) + .orderBy(asc(states.sortOrder)); + + 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("[states] GET error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list states" } }, 500); + } +}); + +// POST /api/states — Create a state +stateRoutes.post("/", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const data = createStateSchema.parse(body); + + const [project] = await db.select({ id: projects.id, domainId: projects.domainId }) + .from(projects) + .where(and(eq(projects.id, data.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); + + let sortOrder = data.sortOrder; + if (sortOrder === undefined) { + const [maxOrder] = await db.select({ max: sql`COALESCE(MAX(sort_order), -1)` }) + .from(states) + .where(eq(states.projectId, data.projectId)); + sortOrder = Number(maxOrder?.max || -1) + 1; + } + + const [state] = await db.insert(states).values({ + name: data.name, + color: data.color ?? null, + group: data.group, + sortOrder, + projectId: data.projectId, + }).returning(); + + await recordActivity({ + actor: user.name, + action: "created", + entityType: "state", + entityId: state.id, + changes: { name: state.name, group: state.group, projectId: data.projectId }, + workspaceId: project.domainId, + }); + + await enqueueWebhooks({ workspaceId: project.domainId, event: "state.created", entityType: "state", entityId: state.id, data: { name: state.name, group: state.group } }); + + return c.json(state, 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("[states] POST error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create state" } }, 500); + } +}); + +// PATCH /api/states/:id — Update a state +stateRoutes.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 = updateStateSchema.parse(body); + + const [existing] = await db.select() + .from(states) + .where(and(eq(states.id, id), isNull(states.deletedAt))) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, existing.projectId)) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await requireWorkspaceAccess(c, project.domainId); + + const updateValues: Record = {}; + if (data.name !== undefined) updateValues.name = data.name; + if (data.color !== undefined) updateValues.color = data.color; + if (data.group !== undefined) updateValues.group = data.group; + if (data.sortOrder !== undefined) updateValues.sortOrder = data.sortOrder; + updateValues.updatedAt = new Date(); + + const [updated] = await db.update(states) + .set(updateValues) + .where(eq(states.id, id)) + .returning(); + + await recordActivity({ + actor: user.name, + action: "updated", + entityType: "state", + entityId: id, + changes: { ...data, previousName: existing.name, projectId: existing.projectId }, + workspaceId: project.domainId, + }); + + await enqueueWebhooks({ workspaceId: project.domainId, event: "state.updated", entityType: "state", entityId: id, data: { ...data, previousName: existing.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("[states] PATCH error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update state" } }, 500); + } +}); + +// DELETE /api/states/:id — Soft-delete a state +stateRoutes.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(states) + .where(and(eq(states.id, id), isNull(states.deletedAt))) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, existing.projectId)) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await requireWorkspaceAccess(c, project.domainId); + + await db.update(states) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(eq(states.id, id)); + + await recordActivity({ + actor: user.name, + action: "deleted", + entityType: "state", + entityId: id, + changes: { name: existing.name, projectId: existing.projectId }, + workspaceId: project.domainId, + }); + + await enqueueWebhooks({ workspaceId: project.domainId, event: "state.deleted", entityType: "state", 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("[states] DELETE error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete state" } }, 500); + } +}); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 00048f6..4986d34 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -160,10 +160,12 @@ export const states = pgTable( sortOrder: integer('sort_order').default(0), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), }, (table) => [ index('states_project_id_idx').on(table.projectId), index('states_sort_order_idx').on(table.projectId, table.sortOrder), + index('states_deleted_at_idx').on(table.deletedAt), ] ); From 43d3daac470edde22f2b637dc2ad7a27b832c31d Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Mon, 7 Sep 2026 19:39:44 +0000 Subject: [PATCH 4/8] fix: resolve circular type inference in _app/index.tsx route The dashboard route imported Route from '../_app', which resolves to itself (_app/index.tsx IS the _app route), creating a self-referencing circular type inference (TS7022/TS7023). Fix by importing the root route from '__root' as the parent, which is the correct ancestor in the route tree hierarchy. --- apps/web/src/routes/_app/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/routes/_app/index.tsx b/apps/web/src/routes/_app/index.tsx index e16937b..a493056 100644 --- a/apps/web/src/routes/_app/index.tsx +++ b/apps/web/src/routes/_app/index.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { createRoute } from "@tanstack/react-router"; -import { Route as appRoute } from "../_app"; +import { Route as rootRoute } from "../__root"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { useRealtime } from "@/hooks/use-realtime"; @@ -505,7 +505,7 @@ function DashboardPage() { } export const Route = createRoute({ - getParentRoute: () => appRoute, + getParentRoute: () => rootRoute, path: "/", component: DashboardPage, }); From ed11b722c22cabb165226ab9c9214239471f6b0d Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Mon, 7 Sep 2026 19:59:59 +0000 Subject: [PATCH 5/8] feat: add cycles API route with CRUD, membership, and transfer --- apps/api/src/index.ts | 2 + apps/api/src/routes/cycles.ts | 551 ++++++++++++++++++++++++++++++++++ 2 files changed, 553 insertions(+) create mode 100644 apps/api/src/routes/cycles.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ab5822f..9beabcc 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -28,6 +28,7 @@ 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 { healthHandler } from "./routes/health"; const app = new Hono(); @@ -67,6 +68,7 @@ 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", importExportRoutes); app.route("/api", realtimeRoutes); app.route("/api/mcp", mcpRoutes); diff --git a/apps/api/src/routes/cycles.ts b/apps/api/src/routes/cycles.ts new file mode 100644 index 0000000..178ad3c --- /dev/null +++ b/apps/api/src/routes/cycles.ts @@ -0,0 +1,551 @@ +import { Hono } from "hono"; +import { db, cycles, tasks, projects } from "@project-e/db"; +import { and, asc, desc, eq, ilike, inArray, 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({ + projectId: z.string().uuid("Invalid project id"), + 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(true), +}); + +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 page = Math.max(1, parseInt(url.searchParams.get("page") || "1")); + const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200); + const offset = parseInt(url.searchParams.get("offset") || "0"); + const search = url.searchParams.get("search"); + const active = url.searchParams.get("active"); + const sort = url.searchParams.get("sort") || "-created"; + + const projectId = url.searchParams.get("projectId") || url.searchParams.get("project_id"); + if (!projectId || !isUuid(projectId)) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "projectId query parameter 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 conditions: any[] = [eq(cycles.projectId, projectId)]; + + if (search) { + conditions.push(ilike(cycles.name, `%${search}%`)); + } + if (active === "true" || active === "false") { + conditions.push(eq(cycles.active, active === "true")); + } + + const sortDir = sort.startsWith("-") ? "desc" : "asc"; + const sortField = sort.replace(/^-/, ""); + const sortColumns: Record = { + created: cycles.createdAt, + updated: cycles.updatedAt, + name: cycles.name, + active: cycles.active, + start_date: cycles.startDate, + end_date: cycles.endDate, + created_at: cycles.createdAt, + updated_at: cycles.updatedAt, + }; + const orderColumn = sortDir === "asc" + ? asc(sortColumns[sortField] || cycles.createdAt) + : desc(sortColumns[sortField] || cycles.createdAt); + + const [items, countResult] = await Promise.all([ + db.select() + .from(cycles) + .where(and(...conditions)) + .orderBy(orderColumn) + .limit(limit) + .offset(offset || (page - 1) * limit), + db.select({ count: sql`count(*)` }) + .from(cycles) + .where(and(...conditions)), + ]); + + const totalItems = Number(countResult[0]?.count || 0); + + return c.json({ + items, + totalItems, + totalPages: Math.ceil(totalItems / limit), + page, + perPage: limit, + limit, + offset: offset || (page - 1) * limit, + }); + } 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 [project] = await db.select({ id: projects.id, domainId: projects.domainId }) + .from(projects) + .where(and(eq(projects.id, data.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: data.projectId, + startDate: data.startDate ? new Date(data.startDate) : null, + endDate: data.endDate ? new Date(data.endDate) : null, + active: data.active ?? true, + }).returning(); + + await recordActivity({ + actor: user.name, + action: "created", + entityType: "cycle", + entityId: cycle.id, + changes: { name: cycle.name, active: cycle.active, projectId: data.projectId }, + workspaceId: project.domainId, + }); + + await enqueueWebhooks({ workspaceId: project.domainId, event: "cycle.created", entityType: "cycle", entityId: cycle.id, data: { name: cycle.name, projectId: data.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 +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 = {}; + 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 (clears cycleId on tasks) +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 || ""); + + const [task] = await db.select() + .from(tasks) + .where(and(eq(tasks.id, taskId), isNull(tasks.deletedAt))) + .limit(1); + + if (!task) { + return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); + } + + // Single-cycle constraint: if the task is already in another cycle, remove it first + if (task.cycleId && task.cycleId !== cycleId) { + await db.update(tasks) + .set({ cycleId: null, updatedAt: new Date() }) + .where(eq(tasks.id, taskId)); + } + + // Assign task to this cycle + 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, taskTitle: task.title, previousCycleId: task.cycleId }, + 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 || ""); + + const [task] = await db.select() + .from(tasks) + .where(and(eq(tasks.id, taskId), isNull(tasks.deletedAt))) + .limit(1); + + if (!task) { + return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); + } + + if (task.cycleId !== cycleId) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Task is not in this cycle" } }, 400); + } + + 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, taskTitle: task.title }, + 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); + } +}); + +// POST /:id/transfer — Move tasks from this cycle to a target cycle +cycleRoutes.post("/:id/transfer", async (c) => { + try { + const user = await requireAuth(c); + const sourceCycleId = c.req.param("id"); + if (!isUuid(sourceCycleId)) { + return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); + } + const body = await c.req.json(); + const { target_cycle_id, task_ids } = z.object({ + target_cycle_id: z.string().uuid("Invalid target cycle id"), + task_ids: z.array(z.string().uuid("Invalid task id")).min(1, "At least one task id is required"), + }).parse(body); + + const [sourceCycle] = await db.select() + .from(cycles) + .where(eq(cycles.id, sourceCycleId)) + .limit(1); + + if (!sourceCycle) { + return c.json({ error: { code: "NOT_FOUND", message: "Source cycle not found" } }, 404); + } + + const [targetCycle] = await db.select() + .from(cycles) + .where(eq(cycles.id, target_cycle_id)) + .limit(1); + + if (!targetCycle) { + return c.json({ error: { code: "NOT_FOUND", message: "Target cycle not found" } }, 404); + } + + if (sourceCycle.projectId !== targetCycle.projectId) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Target cycle must be in the same project" } }, 400); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, sourceCycle.projectId)) + .limit(1); + + const workspaceId = project?.domainId || ""; + await requireWorkspaceAccess(c, workspaceId); + + // Validate all tasks exist, are not deleted, and belong to the source cycle + const taskRows = await db.select() + .from(tasks) + .where(and(inArray(tasks.id, task_ids), isNull(tasks.deletedAt))); + + if (taskRows.length !== task_ids.length) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "One or more tasks not found or are deleted" } }, 400); + } + + const nonMemberTasks = taskRows.filter((t) => t.cycleId !== sourceCycleId); + if (nonMemberTasks.length > 0) { + return c.json({ + error: { + code: "VALIDATION_ERROR", + message: "One or more tasks do not belong to the source cycle", + details: { task_ids: nonMemberTasks.map((t) => t.id) }, + }, + }, 400); + } + + // Move all tasks to the target cycle + await db.update(tasks) + .set({ cycleId: target_cycle_id, updatedAt: new Date() }) + .where(inArray(tasks.id, task_ids)); + + await recordActivity({ + actor: user.name, + action: "transferred_tasks", + entityType: "cycle", + entityId: sourceCycleId, + changes: { + targetCycleId: target_cycle_id, + taskIds: task_ids, + count: task_ids.length, + }, + workspaceId, + }); + + await enqueueWebhooks({ + workspaceId, + event: "cycle.tasks_transferred", + entityType: "cycle", + entityId: sourceCycleId, + data: { targetCycleId: target_cycle_id, taskIds: task_ids, count: task_ids.length }, + }); + + return c.json({ success: true, transferred: task_ids.length }); + } 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/transfer error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to transfer tasks" } }, 500); + } +}); From 92fabb1a15f3f8ab4604a477ed7f25844040ca78 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Mon, 7 Sep 2026 20:16:08 +0000 Subject: [PATCH 6/8] feat: links CRUD API + MCP tools for states/modules/cycles/links (PL-4/6) - Add links.ts: full CRUD for entity links (relates, blocks, parent-child, created-from) - Update MCP tools: add state_id, state_group, module_id, cycle_id filters - Remove deprecated dependency/status stubs from tasks.ts - Mount linkRoutes at /api/links --- apps/api/src/index.ts | 2 + apps/api/src/routes/links.ts | 324 ++++++++++++++ apps/api/src/routes/mcp.ts | 809 ++++++++++++++++++++++++++++++++++- apps/api/src/routes/tasks.ts | 17 +- 4 files changed, 1126 insertions(+), 26 deletions(-) create mode 100644 apps/api/src/routes/links.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ab5822f..ff5c964 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -28,6 +28,7 @@ import { importExportRoutes } from "./routes/import-export"; import { notificationRoutes } from "./routes/notifications"; import { stateRoutes } from "./routes/states"; import { moduleRoutes } from "./routes/modules"; +import { linkRoutes } from "./routes/links"; import { healthHandler } from "./routes/health"; const app = new Hono(); @@ -67,6 +68,7 @@ app.route("/api/analytics", analyticsRoutes); app.route("/api/activity", activityRoutes); app.route("/api/notifications", notificationRoutes); app.route("/api/states", stateRoutes); +app.route("/api/links", linkRoutes); app.route("/api", importExportRoutes); app.route("/api", realtimeRoutes); app.route("/api/mcp", mcpRoutes); diff --git a/apps/api/src/routes/links.ts b/apps/api/src/routes/links.ts new file mode 100644 index 0000000..3f2863b --- /dev/null +++ b/apps/api/src/routes/links.ts @@ -0,0 +1,324 @@ +import { Hono } from "hono"; +import { db, links, tasks, projects } from "@project-e/db"; +import { and, asc, desc, eq, ilike, inArray, isNull, or, 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 linkRoutes = new Hono(); + +const linkTypeEnum = z.enum(["relates", "blocks", "parent-child", "created-from"]); + +const createLinkSchema = z.object({ + sourceType: z.string().min(1, "Source type is required"), + sourceId: z.string().uuid("Invalid source id"), + targetType: z.string().min(1, "Target type is required"), + targetId: z.string().uuid("Invalid target id"), + linkType: linkTypeEnum, + direction: z.string().optional().nullable(), +}); + +// GET / — List links for an entity (bidirectional) +// Filters: source_type+source_id OR target_type+target_id (at least one pair required) +linkRoutes.get("/", async (c) => { + try { + const user = await requireAuth(c); + const url = new URL(c.req.url); + const sourceType = url.searchParams.get("source_type"); + const sourceId = url.searchParams.get("source_id"); + const targetType = url.searchParams.get("target_type"); + const targetId = url.searchParams.get("target_id"); + const linkType = url.searchParams.get("link_type"); + const sort = url.searchParams.get("sort") || "-created"; + const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200); + const offset = parseInt(url.searchParams.get("offset") || "0"); + + // Need at least one filter pair + if ((!sourceType || !sourceId) && (!targetType || !targetId)) { + return c.json( + { error: { code: "VALIDATION_ERROR", message: "Provide at least source_type+source_id or target_type+target_id" } }, + 400 + ); + } + + if (sourceId && !isUuid(sourceId)) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid source_id" } }, 400); + } + if (targetId && !isUuid(targetId)) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid target_id" } }, 400); + } + + // Resolve workspace access from the entity referenced + const resolveEntityDomainId = async (entityType: string, entityId: string): Promise => { + if (entityType === "task") { + const [task] = await db.select({ domainId: tasks.domainId }) + .from(tasks) + .where(eq(tasks.id, entityId)) + .limit(1); + return task?.domainId ?? null; + } + if (entityType === "project") { + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, entityId)) + .limit(1); + return project?.domainId ?? null; + } + return null; + }; + + const filterEntityType = sourceType || targetType || "task"; + const filterEntityId = sourceId || targetId || ""; + const domainId = await resolveEntityDomainId(filterEntityType, filterEntityId); + + if (domainId) { + await requireWorkspaceAccess(c, domainId); + } + + // Bidirectional: if filtering by source, also find links where entity is target + const conditions: any[] = []; + + if (sourceType && sourceId) { + conditions.push( + or( + and(eq(links.sourceType, sourceType), eq(links.sourceId, sourceId)), + and(eq(links.targetType, sourceType), eq(links.targetId, sourceId)), + )! + ); + } else if (targetType && targetId) { + conditions.push( + or( + and(eq(links.sourceType, targetType), eq(links.sourceId, targetId)), + and(eq(links.targetType, targetType), eq(links.targetId, targetId)), + )! + ); + } + + if (linkType) { + const types = linkType.split(","); + conditions.push(inArray(links.linkType, types as any)); + } + + const sortDir = sort.startsWith("-") ? "desc" : "asc"; + const sortField = sort.replace(/^-/, ""); + const sortColumns: Record = { + created: links.createdAt, + created_at: links.createdAt, + }; + const orderColumn = sortDir === "asc" + ? asc(sortColumns[sortField] || links.createdAt) + : desc(sortColumns[sortField] || links.createdAt); + + const [items, countResult] = await Promise.all([ + db.select() + .from(links) + .where(and(...conditions)) + .orderBy(orderColumn) + .limit(limit) + .offset(offset), + db.select({ count: sql`count(*)` }) + .from(links) + .where(and(...conditions)), + ]); + + const totalItems = Number(countResult[0]?.count || 0); + + return c.json({ + items, + totalItems, + totalPages: Math.ceil(totalItems / limit), + limit, + offset, + }); + } 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 / — Create a link +linkRoutes.post("/", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const data = createLinkSchema.parse(body); + + // Enforce unique constraint on (sourceType, sourceId, targetType, targetId, linkType) + const [existing] = await db.select() + .from(links) + .where( + and( + eq(links.sourceType, data.sourceType), + eq(links.sourceId, data.sourceId), + eq(links.targetType, data.targetType), + eq(links.targetId, data.targetId), + eq(links.linkType, data.linkType), + ) + ) + .limit(1); + + if (existing) { + return c.json( + { error: { code: "CONFLICT", message: "Link already exists with this combination" } }, + 409 + ); + } + + // Resolve workspace access from the source entity + let domainId: string | null = null; + if (data.sourceType === "task") { + const [task] = await db.select({ domainId: tasks.domainId }) + .from(tasks) + .where(eq(tasks.id, data.sourceId)) + .limit(1); + domainId = task?.domainId ?? null; + } else if (data.sourceType === "project") { + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, data.sourceId)) + .limit(1); + domainId = project?.domainId ?? null; + } + + if (domainId) { + await requireWorkspaceAccess(c, domainId); + } + + 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 (domainId) { + await recordActivity({ + actor: user.name, + action: "created", + entityType: "link", + entityId: link.id, + changes: { + sourceType: link.sourceType, + sourceId: link.sourceId, + targetType: link.targetType, + targetId: link.targetId, + linkType: link.linkType, + }, + workspaceId: domainId, + }); + + await enqueueWebhooks({ + workspaceId: domainId, + event: "link.created", + entityType: "link", + entityId: link.id, + data: { + sourceType: link.sourceType, + sourceId: link.sourceId, + targetType: link.targetType, + targetId: link.targetId, + linkType: link.linkType, + }, + }); + } + + 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 /:id — Delete a link (hard delete, links have no deletedAt) +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); + } + + // Resolve workspace access from the source entity + let domainId: string | null = null; + if (existing.sourceType === "task") { + const [task] = await db.select({ domainId: tasks.domainId }) + .from(tasks) + .where(eq(tasks.id, existing.sourceId)) + .limit(1); + domainId = task?.domainId ?? null; + } else if (existing.sourceType === "project") { + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, existing.sourceId)) + .limit(1); + domainId = project?.domainId ?? null; + } + + if (domainId) { + await requireWorkspaceAccess(c, domainId); + } + + // Hard delete — junction rows with no deletedAt column + await db.delete(links).where(eq(links.id, id)); + + if (domainId) { + await recordActivity({ + actor: user.name, + action: "deleted", + entityType: "link", + entityId: id, + changes: { + sourceType: existing.sourceType, + sourceId: existing.sourceId, + targetType: existing.targetType, + targetId: existing.targetId, + linkType: existing.linkType, + }, + workspaceId: domainId, + }); + + await enqueueWebhooks({ + workspaceId: domainId, + event: "link.deleted", + entityType: "link", + entityId: id, + data: { + sourceType: existing.sourceType, + sourceId: existing.sourceId, + targetType: existing.targetType, + targetId: existing.targetId, + linkType: existing.linkType, + }, + }); + } + + 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); + } +}); diff --git a/apps/api/src/routes/mcp.ts b/apps/api/src/routes/mcp.ts index 0cacc62..8f009c0 100644 --- a/apps/api/src/routes/mcp.ts +++ b/apps/api/src/routes/mcp.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { createHash } from "node:crypto"; -import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db"; -import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm"; +import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, domains, activityFeed, webhooks, webhookDeliveries, states, modules, cycles, links } from "@project-e/db"; +import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm"; import { recordActivity } from "../middleware/activity"; export const mcpRoutes = new Hono(); @@ -81,9 +81,12 @@ const tools: ToolDefinition[] = [ type: "object", properties: { domain_id: { type: "string", description: "Workspace/domain ID" }, - status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] }, + state_id: { type: "string", description: "Filter by state UUID" }, + state_group: { type: "string", enum: ["backlog", "unstarted", "started", "completed", "cancelled"], description: "Filter by state group" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, project_id: { type: "string" }, + module_id: { type: "string", description: "Filter by module UUID" }, + cycle_id: { type: "string", description: "Filter by cycle UUID" }, search: { type: "string" }, limit: { type: "number", default: 50 }, offset: { type: "number", default: 0 }, @@ -95,9 +98,24 @@ const tools: ToolDefinition[] = [ eq(tasks.domainId, params.domain_id as string), isNull(tasks.deletedAt), ]; - // TODO(phase-2): filter by state_group / state_id instead of old status + if (params.state_id) { + const stateIds = (params.state_id as string).split(","); + conditions.push(inArray(tasks.stateId, stateIds)); + } + if (params.state_group) { + const groups = (params.state_group as string).split(",") as any[]; + conditions.push( + exists( + db.select({ one: sql`1` }) + .from(states) + .where(and(eq(states.id, tasks.stateId), inArray(states.group, groups))) + ) + ); + } if (params.priority) conditions.push(eq(tasks.priority, params.priority as any)); if (params.project_id) conditions.push(eq(tasks.projectId, params.project_id as string)); + if (params.module_id) conditions.push(eq(tasks.moduleId, params.module_id as string)); + if (params.cycle_id) conditions.push(eq(tasks.cycleId, params.cycle_id as string)); if (params.search) conditions.push(ilike(tasks.title, `%${params.search}%`)); const items = await db.select() @@ -119,21 +137,38 @@ const tools: ToolDefinition[] = [ domain_id: { type: "string", description: "Workspace/domain ID" }, title: { type: "string" }, description: { type: "string" }, - status: { type: "string" }, + state_id: { type: "string", description: "State UUID" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, due_date: { type: "string" }, project_id: { type: "string" }, + module_id: { type: "string", description: "Module UUID" }, + cycle_id: { type: "string", description: "Cycle UUID" }, }, required: ["domain_id", "title"], }, handler: async (params, auth) => { + let completedAt: Date | null = null; + if (params.state_id) { + const [state] = await db.select({ id: states.id, group: states.group }) + .from(states) + .where(eq(states.id, params.state_id as string)) + .limit(1); + if (state?.group === "completed") { + completedAt = new Date(); + } + } + const [task] = await db.insert(tasks).values({ title: params.title as string, description: (params.description as string) ?? null, priority: (params.priority as any) ?? "medium", domainId: params.domain_id as string, projectId: (params.project_id as string) ?? null, + stateId: (params.state_id as string) ?? null, + moduleId: (params.module_id as string) ?? null, + cycleId: (params.cycle_id as string) ?? null, dueDate: params.due_date ? new Date(params.due_date as string) : null, + completedAt, }).returning(); await recordActivity({ @@ -141,7 +176,7 @@ const tools: ToolDefinition[] = [ action: "created", entityType: "task", entityId: task.id, - changes: { title: task.title }, + changes: { title: task.title, stateId: task.stateId }, workspaceId: params.domain_id as string, }); @@ -157,9 +192,11 @@ const tools: ToolDefinition[] = [ task_id: { type: "string" }, title: { type: "string" }, description: { type: "string" }, - status: { type: "string" }, + state_id: { type: "string", description: "State UUID" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, due_date: { type: "string" }, + module_id: { type: "string", description: "Module UUID" }, + cycle_id: { type: "string", description: "Cycle UUID" }, }, required: ["task_id"], }, @@ -173,6 +210,24 @@ const tools: ToolDefinition[] = [ if (params.description !== undefined) updateData.description = params.description; if (params.priority !== undefined) updateData.priority = params.priority; if (params.due_date !== undefined) updateData.dueDate = params.due_date ? new Date(params.due_date as string) : null; + if (params.module_id !== undefined) updateData.moduleId = params.module_id; + if (params.cycle_id !== undefined) updateData.cycleId = params.cycle_id; + + // Handle state change + completedAt + if (params.state_id !== undefined) { + updateData.stateId = params.state_id; + if (params.state_id) { + const [state] = await db.select({ id: states.id, group: states.group }) + .from(states) + .where(eq(states.id, params.state_id as string)) + .limit(1); + if (!state) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "State not found"); + updateData.completedAt = state.group === "completed" ? new Date() : null; + } else { + updateData.completedAt = null; + } + } + updateData.updatedAt = new Date(); const [task] = await db.update(tasks) @@ -223,10 +278,13 @@ const tools: ToolDefinition[] = [ }, { name: "tasks.complete", - description: "Mark a task as done", + description: "Mark a task as done by setting its state to a completed group state", inputSchema: { type: "object", - properties: { task_id: { type: "string" } }, + properties: { + task_id: { type: "string" }, + state_id: { type: "string", description: "Optional specific completed state UUID. If omitted, finds a completed-group state from the task's project." }, + }, required: ["task_id"], }, handler: async (params, auth) => { @@ -234,8 +292,25 @@ const tools: ToolDefinition[] = [ if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found"); await verifyDomainAccess(existing.domainId, auth.userId); + let completedStateId = params.state_id as string | undefined; + if (!completedStateId && existing.projectId) { + const [completedState] = await db.select({ id: states.id }) + .from(states) + .where(and(eq(states.projectId, existing.projectId), eq(states.group, "completed"), isNull(states.deletedAt))) + .limit(1); + completedStateId = completedState?.id; + } + + const updateData: Record = { + completedAt: new Date(), + updatedAt: new Date(), + }; + if (completedStateId) { + updateData.stateId = completedStateId; + } + const [task] = await db.update(tasks) - .set({ completedAt: new Date(), updatedAt: new Date() }) + .set(updateData) .where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))) .returning(); @@ -244,6 +319,7 @@ const tools: ToolDefinition[] = [ action: "completed", entityType: "task", entityId: task.id, + changes: { stateId: task.stateId }, workspaceId: task.domainId, }); @@ -605,6 +681,719 @@ const tools: ToolDefinition[] = [ return { items }; }, }, + // ── States ────────────────────────────────────────────────────────────────────── + { + name: "states.list", + description: "List workflow states for a project", + inputSchema: { + type: "object", + properties: { + project_id: { type: "string", description: "Project UUID" }, + }, + required: ["project_id"], + }, + handler: async (params) => { + const items = await db.select() + .from(states) + .where(and(eq(states.projectId, params.project_id as string), isNull(states.deletedAt))) + .orderBy(asc(states.sortOrder)); + return { items }; + }, + }, + { + name: "states.create", + description: "Create a workflow state", + inputSchema: { + type: "object", + properties: { + project_id: { type: "string" }, + name: { type: "string" }, + color: { type: "string" }, + group: { type: "string", enum: ["backlog", "unstarted", "started", "completed", "cancelled"] }, + sort_order: { type: "number" }, + }, + required: ["project_id", "name"], + }, + handler: async (params, auth) => { + const [project] = await db.select().from(projects).where(eq(projects.id, params.project_id as string)).limit(1); + if (!project) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Project not found"); + await verifyDomainAccess(project.domainId, auth.userId); + + const [state] = await db.insert(states).values({ + name: params.name as string, + color: (params.color as string) ?? null, + group: (params.group as any) ?? "unstarted", + projectId: params.project_id as string, + sortOrder: (params.sort_order as number) ?? 0, + }).returning(); + + await recordActivity({ + actor: auth.userName, + action: "created", + entityType: "state", + entityId: state.id, + changes: { name: state.name, group: state.group }, + workspaceId: project.domainId, + }); + + return state; + }, + }, + { + name: "states.update", + description: "Update a workflow state", + inputSchema: { + type: "object", + properties: { + state_id: { type: "string" }, + name: { type: "string" }, + color: { type: "string" }, + group: { type: "string", enum: ["backlog", "unstarted", "started", "completed", "cancelled"] }, + sort_order: { type: "number" }, + }, + required: ["state_id"], + }, + handler: async (params, auth) => { + const [existing] = await db.select().from(states).where(and(eq(states.id, params.state_id as string), isNull(states.deletedAt))).limit(1); + if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "State not found"); + + const [project] = await db.select().from(projects).where(eq(projects.id, existing.projectId)).limit(1); + if (project) await verifyDomainAccess(project.domainId, auth.userId); + + const updateData: Record = { updatedAt: new Date() }; + if (params.name !== undefined) updateData.name = params.name; + if (params.color !== undefined) updateData.color = params.color; + if (params.group !== undefined) updateData.group = params.group; + if (params.sort_order !== undefined) updateData.sortOrder = params.sort_order; + + const [state] = await db.update(states) + .set(updateData) + .where(eq(states.id, params.state_id as string)) + .returning(); + + if (project) { + await recordActivity({ + actor: auth.userName, + action: "updated", + entityType: "state", + entityId: state.id, + changes: updateData, + workspaceId: project.domainId, + }); + } + + return state; + }, + }, + { + name: "states.delete", + description: "Soft-delete a workflow state", + inputSchema: { + type: "object", + properties: { state_id: { type: "string" } }, + required: ["state_id"], + }, + handler: async (params, auth) => { + const [existing] = await db.select().from(states).where(and(eq(states.id, params.state_id as string), isNull(states.deletedAt))).limit(1); + if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "State not found"); + + const [project] = await db.select().from(projects).where(eq(projects.id, existing.projectId)).limit(1); + if (project) await verifyDomainAccess(project.domainId, auth.userId); + + await db.update(states) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(eq(states.id, params.state_id as string)); + + if (project) { + await recordActivity({ + actor: auth.userName, + action: "deleted", + entityType: "state", + entityId: params.state_id as string, + changes: { name: existing.name }, + workspaceId: project.domainId, + }); + } + + return { deleted: true, id: params.state_id }; + }, + }, + // ── Modules ───────────────────────────────────────────────────────────────────── + { + name: "modules.list", + description: "List modules for a project", + inputSchema: { + type: "object", + properties: { + project_id: { type: "string" }, + }, + required: ["project_id"], + }, + handler: async (params) => { + const items = await db.select() + .from(modules) + .where(and(eq(modules.projectId, params.project_id as string), isNull(modules.deletedAt))) + .orderBy(asc(modules.sortOrder)); + return { items }; + }, + }, + { + name: "modules.create", + description: "Create a module", + inputSchema: { + type: "object", + properties: { + project_id: { type: "string" }, + name: { type: "string" }, + description: { type: "string" }, + status: { type: "string", enum: ["planned", "in_progress", "completed", "cancelled"] }, + start_date: { type: "string" }, + target_date: { type: "string" }, + }, + required: ["project_id", "name"], + }, + handler: async (params, auth) => { + const [project] = await db.select().from(projects).where(eq(projects.id, params.project_id as string)).limit(1); + if (!project) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Project not found"); + await verifyDomainAccess(project.domainId, auth.userId); + + const [mod] = await db.insert(modules).values({ + name: params.name as string, + description: (params.description as string) ?? null, + projectId: params.project_id as string, + status: (params.status as any) ?? "planned", + startDate: params.start_date ? new Date(params.start_date as string) : null, + targetDate: params.target_date ? new Date(params.target_date as string) : null, + }).returning(); + + await recordActivity({ + actor: auth.userName, + action: "created", + entityType: "module", + entityId: mod.id, + changes: { name: mod.name }, + workspaceId: project.domainId, + }); + + return mod; + }, + }, + { + name: "modules.update", + description: "Update a module", + inputSchema: { + type: "object", + properties: { + module_id: { type: "string" }, + name: { type: "string" }, + description: { type: "string" }, + status: { type: "string", enum: ["planned", "in_progress", "completed", "cancelled"] }, + start_date: { type: "string" }, + target_date: { type: "string" }, + }, + required: ["module_id"], + }, + handler: async (params, auth) => { + const [existing] = await db.select().from(modules).where(and(eq(modules.id, params.module_id as string), isNull(modules.deletedAt))).limit(1); + if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Module not found"); + + const [project] = await db.select().from(projects).where(eq(projects.id, existing.projectId)).limit(1); + if (project) await verifyDomainAccess(project.domainId, auth.userId); + + const updateData: Record = { updatedAt: new Date() }; + if (params.name !== undefined) updateData.name = params.name; + if (params.description !== undefined) updateData.description = params.description; + if (params.status !== undefined) updateData.status = params.status; + if (params.start_date !== undefined) updateData.startDate = params.start_date ? new Date(params.start_date as string) : null; + if (params.target_date !== undefined) updateData.targetDate = params.target_date ? new Date(params.target_date as string) : null; + + const [mod] = await db.update(modules) + .set(updateData) + .where(eq(modules.id, params.module_id as string)) + .returning(); + + if (project) { + await recordActivity({ + actor: auth.userName, + action: "updated", + entityType: "module", + entityId: mod.id, + changes: updateData, + workspaceId: project.domainId, + }); + } + + return mod; + }, + }, + { + name: "modules.delete", + description: "Soft-delete a module and clear module_id on its tasks", + inputSchema: { + type: "object", + properties: { module_id: { type: "string" } }, + required: ["module_id"], + }, + handler: async (params, auth) => { + const [existing] = await db.select().from(modules).where(and(eq(modules.id, params.module_id as string), isNull(modules.deletedAt))).limit(1); + if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Module not found"); + + const [project] = await db.select().from(projects).where(eq(projects.id, existing.projectId)).limit(1); + if (project) await verifyDomainAccess(project.domainId, auth.userId); + + await db.update(tasks) + .set({ moduleId: null, updatedAt: new Date() }) + .where(eq(tasks.moduleId, params.module_id as string)); + + await db.update(modules) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(eq(modules.id, params.module_id as string)); + + if (project) { + await recordActivity({ + actor: auth.userName, + action: "deleted", + entityType: "module", + entityId: params.module_id as string, + changes: { name: existing.name }, + workspaceId: project.domainId, + }); + } + + return { deleted: true, id: params.module_id }; + }, + }, + { + name: "modules.add-task", + description: "Add a task to a module", + inputSchema: { + type: "object", + properties: { + module_id: { type: "string" }, + task_id: { type: "string" }, + }, + required: ["module_id", "task_id"], + }, + handler: async (params, auth) => { + const [mod] = await db.select().from(modules).where(and(eq(modules.id, params.module_id as string), isNull(modules.deletedAt))).limit(1); + if (!mod) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Module not found"); + + const [project] = await db.select().from(projects).where(eq(projects.id, mod.projectId)).limit(1); + if (project) await verifyDomainAccess(project.domainId, auth.userId); + + const [task] = await db.select().from(tasks).where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))).limit(1); + if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found"); + + await db.update(tasks) + .set({ moduleId: params.module_id as string, updatedAt: new Date() }) + .where(eq(tasks.id, params.task_id as string)); + + if (project) { + await recordActivity({ + actor: auth.userName, + action: "added_task", + entityType: "module", + entityId: params.module_id as string, + changes: { taskId: params.task_id, taskTitle: task.title }, + workspaceId: project.domainId, + }); + } + + return { success: true }; + }, + }, + { + name: "modules.remove-task", + description: "Remove a task from a module", + inputSchema: { + type: "object", + properties: { + module_id: { type: "string" }, + task_id: { type: "string" }, + }, + required: ["module_id", "task_id"], + }, + handler: async (params, auth) => { + const [mod] = await db.select().from(modules).where(and(eq(modules.id, params.module_id as string), isNull(modules.deletedAt))).limit(1); + if (!mod) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Module not found"); + + const [project] = await db.select().from(projects).where(eq(projects.id, mod.projectId)).limit(1); + if (project) await verifyDomainAccess(project.domainId, auth.userId); + + await db.update(tasks) + .set({ moduleId: null, updatedAt: new Date() }) + .where(and(eq(tasks.id, params.task_id as string), eq(tasks.moduleId, params.module_id as string))); + + if (project) { + await recordActivity({ + actor: auth.userName, + action: "removed_task", + entityType: "module", + entityId: params.module_id as string, + changes: { taskId: params.task_id }, + workspaceId: project.domainId, + }); + } + + return { success: true }; + }, + }, + // ── Cycles ────────────────────────────────────────────────────────────────────── + { + name: "cycles.list", + description: "List cycles for a project", + inputSchema: { + type: "object", + properties: { + project_id: { type: "string" }, + }, + required: ["project_id"], + }, + handler: async (params) => { + const items = await db.select() + .from(cycles) + .where(eq(cycles.projectId, params.project_id as string)) + .orderBy(desc(cycles.createdAt)); + return { items }; + }, + }, + { + name: "cycles.create", + description: "Create a cycle", + inputSchema: { + type: "object", + properties: { + project_id: { type: "string" }, + name: { type: "string" }, + start_date: { type: "string" }, + end_date: { type: "string" }, + active: { type: "boolean" }, + }, + required: ["project_id", "name"], + }, + handler: async (params, auth) => { + const [project] = await db.select().from(projects).where(eq(projects.id, params.project_id as string)).limit(1); + if (!project) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Project not found"); + await verifyDomainAccess(project.domainId, auth.userId); + + const [cycle] = await db.insert(cycles).values({ + name: params.name as string, + projectId: params.project_id as string, + startDate: params.start_date ? new Date(params.start_date as string) : null, + endDate: params.end_date ? new Date(params.end_date as string) : null, + active: (params.active as boolean) ?? false, + }).returning(); + + await recordActivity({ + actor: auth.userName, + action: "created", + entityType: "cycle", + entityId: cycle.id, + changes: { name: cycle.name }, + workspaceId: project.domainId, + }); + + return cycle; + }, + }, + { + name: "cycles.update", + description: "Update a cycle", + inputSchema: { + type: "object", + properties: { + cycle_id: { type: "string" }, + name: { type: "string" }, + start_date: { type: "string" }, + end_date: { type: "string" }, + active: { type: "boolean" }, + }, + required: ["cycle_id"], + }, + handler: async (params, auth) => { + const [existing] = await db.select().from(cycles).where(eq(cycles.id, params.cycle_id as string)).limit(1); + if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Cycle not found"); + + const [project] = await db.select().from(projects).where(eq(projects.id, existing.projectId)).limit(1); + if (project) await verifyDomainAccess(project.domainId, auth.userId); + + const updateData: Record = { updatedAt: new Date() }; + if (params.name !== undefined) updateData.name = params.name; + if (params.start_date !== undefined) updateData.startDate = params.start_date ? new Date(params.start_date as string) : null; + if (params.end_date !== undefined) updateData.endDate = params.end_date ? new Date(params.end_date as string) : null; + if (params.active !== undefined) updateData.active = params.active; + + const [cycle] = await db.update(cycles) + .set(updateData) + .where(eq(cycles.id, params.cycle_id as string)) + .returning(); + + if (project) { + await recordActivity({ + actor: auth.userName, + action: "updated", + entityType: "cycle", + entityId: cycle.id, + changes: updateData, + workspaceId: project.domainId, + }); + } + + return cycle; + }, + }, + { + name: "cycles.delete", + description: "Delete a cycle and clear cycle_id on its tasks", + inputSchema: { + type: "object", + properties: { cycle_id: { type: "string" } }, + required: ["cycle_id"], + }, + handler: async (params, auth) => { + const [existing] = await db.select().from(cycles).where(eq(cycles.id, params.cycle_id as string)).limit(1); + if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Cycle not found"); + + const [project] = await db.select().from(projects).where(eq(projects.id, existing.projectId)).limit(1); + if (project) await verifyDomainAccess(project.domainId, auth.userId); + + await db.update(tasks) + .set({ cycleId: null, updatedAt: new Date() }) + .where(eq(tasks.cycleId, params.cycle_id as string)); + + await db.delete(cycles).where(eq(cycles.id, params.cycle_id as string)); + + if (project) { + await recordActivity({ + actor: auth.userName, + action: "deleted", + entityType: "cycle", + entityId: params.cycle_id as string, + changes: { name: existing.name }, + workspaceId: project.domainId, + }); + } + + return { deleted: true, id: params.cycle_id }; + }, + }, + { + name: "cycles.add-task", + description: "Add a task to a cycle", + inputSchema: { + type: "object", + properties: { + cycle_id: { type: "string" }, + task_id: { type: "string" }, + }, + required: ["cycle_id", "task_id"], + }, + handler: async (params, auth) => { + const [cycle] = await db.select().from(cycles).where(eq(cycles.id, params.cycle_id as string)).limit(1); + if (!cycle) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Cycle not found"); + + const [project] = await db.select().from(projects).where(eq(projects.id, cycle.projectId)).limit(1); + if (project) await verifyDomainAccess(project.domainId, auth.userId); + + const [task] = await db.select().from(tasks).where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))).limit(1); + if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found"); + + await db.update(tasks) + .set({ cycleId: params.cycle_id as string, updatedAt: new Date() }) + .where(eq(tasks.id, params.task_id as string)); + + if (project) { + await recordActivity({ + actor: auth.userName, + action: "added_task", + entityType: "cycle", + entityId: params.cycle_id as string, + changes: { taskId: params.task_id, taskTitle: task.title }, + workspaceId: project.domainId, + }); + } + + return { success: true }; + }, + }, + { + name: "cycles.remove-task", + description: "Remove a task from a cycle", + inputSchema: { + type: "object", + properties: { + cycle_id: { type: "string" }, + task_id: { type: "string" }, + }, + required: ["cycle_id", "task_id"], + }, + handler: async (params, auth) => { + const [cycle] = await db.select().from(cycles).where(eq(cycles.id, params.cycle_id as string)).limit(1); + if (!cycle) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Cycle not found"); + + const [project] = await db.select().from(projects).where(eq(projects.id, cycle.projectId)).limit(1); + if (project) await verifyDomainAccess(project.domainId, auth.userId); + + await db.update(tasks) + .set({ cycleId: null, updatedAt: new Date() }) + .where(and(eq(tasks.id, params.task_id as string), eq(tasks.cycleId, params.cycle_id as string))); + + if (project) { + await recordActivity({ + actor: auth.userName, + action: "removed_task", + entityType: "cycle", + entityId: params.cycle_id as string, + changes: { taskId: params.task_id }, + workspaceId: project.domainId, + }); + } + + return { success: true }; + }, + }, + { + name: "cycles.transfer-task", + description: "Transfer a task from one cycle to another", + inputSchema: { + type: "object", + properties: { + from_cycle_id: { type: "string" }, + to_cycle_id: { type: "string" }, + task_id: { type: "string" }, + }, + required: ["from_cycle_id", "to_cycle_id", "task_id"], + }, + handler: async (params, auth) => { + const [fromCycle] = await db.select().from(cycles).where(eq(cycles.id, params.from_cycle_id as string)).limit(1); + if (!fromCycle) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Source cycle not found"); + + const [project] = await db.select().from(projects).where(eq(projects.id, fromCycle.projectId)).limit(1); + if (project) await verifyDomainAccess(project.domainId, auth.userId); + + const [task] = await db.select().from(tasks).where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))).limit(1); + if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found"); + + await db.update(tasks) + .set({ cycleId: params.to_cycle_id as string, updatedAt: new Date() }) + .where(eq(tasks.id, params.task_id as string)); + + if (project) { + await recordActivity({ + actor: auth.userName, + action: "transferred_task", + entityType: "cycle", + entityId: params.from_cycle_id as string, + changes: { taskId: params.task_id, fromCycleId: params.from_cycle_id, toCycleId: params.to_cycle_id }, + workspaceId: project.domainId, + }); + } + + return { success: true }; + }, + }, + // ── Links ─────────────────────────────────────────────────────────────────────── + { + name: "links.list", + description: "List links for an entity (bidirectional)", + inputSchema: { + type: "object", + properties: { + source_type: { type: "string", description: "Entity type (e.g. task, project)" }, + source_id: { type: "string", description: "Entity UUID" }, + target_type: { type: "string" }, + target_id: { type: "string" }, + link_type: { type: "string", enum: ["relates", "blocks", "parent-child", "created-from"] }, + limit: { type: "number", default: 50 }, + }, + }, + handler: async (params) => { + const conditions: any[] = []; + if (params.source_type && params.source_id) { + conditions.push( + or( + and(eq(links.sourceType, params.source_type as string), eq(links.sourceId, params.source_id as string)), + and(eq(links.targetType, params.source_type as string), eq(links.targetId, params.source_id as string)), + )! + ); + } else if (params.target_type && params.target_id) { + conditions.push( + or( + and(eq(links.sourceType, params.target_type as string), eq(links.sourceId, params.target_id as string)), + and(eq(links.targetType, params.target_type as string), eq(links.targetId, params.target_id as string)), + )! + ); + } + if (params.link_type) { + conditions.push(eq(links.linkType, params.link_type as any)); + } + + const items = await db.select() + .from(links) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(links.createdAt)) + .limit(Math.min(Number(params.limit) || 50, 200)); + + return { items, total: items.length }; + }, + }, + { + name: "links.create", + description: "Create a link between two entities", + inputSchema: { + type: "object", + properties: { + source_type: { type: "string" }, + source_id: { type: "string" }, + target_type: { type: "string" }, + target_id: { type: "string" }, + link_type: { type: "string", enum: ["relates", "blocks", "parent-child", "created-from"] }, + direction: { type: "string" }, + }, + required: ["source_type", "source_id", "target_type", "target_id", "link_type"], + }, + handler: async (params) => { + const [existing] = await db.select() + .from(links) + .where(and( + eq(links.sourceType, params.source_type as string), + eq(links.sourceId, params.source_id as string), + eq(links.targetType, params.target_type as string), + eq(links.targetId, params.target_id as string), + eq(links.linkType, params.link_type as any), + )) + .limit(1); + + if (existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Link already exists"); + + const [link] = await db.insert(links).values({ + sourceType: params.source_type as string, + sourceId: params.source_id as string, + targetType: params.target_type as string, + targetId: params.target_id as string, + linkType: params.link_type as any, + direction: (params.direction as string) ?? null, + }).returning(); + + return link; + }, + }, + { + name: "links.delete", + description: "Delete a link", + inputSchema: { + type: "object", + properties: { link_id: { type: "string" } }, + required: ["link_id"], + }, + handler: async (params) => { + const [existing] = await db.select().from(links).where(eq(links.id, params.link_id as string)).limit(1); + if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Link not found"); + + await db.delete(links).where(eq(links.id, params.link_id as string)); + return { deleted: true, id: params.link_id }; + }, + }, ]; // ── Error helper ───────────────────────────────────────────────────────────────── diff --git a/apps/api/src/routes/tasks.ts b/apps/api/src/routes/tasks.ts index f0121f0..f789ce8 100644 --- a/apps/api/src/routes/tasks.ts +++ b/apps/api/src/routes/tasks.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { db, tasks, states as statesTable, taskTags, tags as tagsTable, activityFeed, scheduledJobs, projects, sections, links } from "@project-e/db"; +import { db, tasks, states as statesTable, taskTags, tags as tagsTable, 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"; @@ -770,21 +770,6 @@ taskRoutes.delete("/:id/tags/:tagId", async (c) => { } }); -// POST /api/tasks/:id/dependencies — Deprecated: use links table instead (Phase 2) -taskRoutes.post("/:id/dependencies", async (c) => { - return c.json({ error: { code: "NOT_FOUND", message: "Dependencies moved to links table (Phase 2)" } }, 404); -}); - -// DELETE /api/tasks/:id/dependencies/:depId — Deprecated: use links table instead (Phase 2) -taskRoutes.delete("/:id/dependencies/:depId", async (c) => { - return c.json({ error: { code: "NOT_FOUND", message: "Dependencies moved to links table (Phase 2)" } }, 404); -}); - -// POST /api/tasks/:id/status — Deprecated: use state_id instead (Phase 2) -taskRoutes.post("/:id/status", async (c) => { - return c.json({ error: { code: "NOT_FOUND", message: "Status endpoint replaced by state assignment (Phase 2)" } }, 404); -}); - // GET /api/tasks/:id/history — State change log (from activity feed) taskRoutes.get("/:id/history", async (c) => { try { From f12c00b670bec77eeca294dc40a44119f568cc4c Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Mon, 7 Sep 2026 20:19:37 +0000 Subject: [PATCH 7/8] fix(mcp): replace deprecated status param with state_group in task queries - tasks.list: replace status filter with state_group (backlog/unstarted/started/completed/cancelled) - tasks.create: remove obsolete status param (tasks now use state_id FK) - tasks.update: remove obsolete status param - Implement state_group filtering via EXISTS subquery on states table - Update API.md example to use state_group instead of status --- apps/api/src/routes/mcp.ts | 19 +++++++++++++------ docs/API.md | 2 +- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/api/src/routes/mcp.ts b/apps/api/src/routes/mcp.ts index 0cacc62..db05fb0 100644 --- a/apps/api/src/routes/mcp.ts +++ b/apps/api/src/routes/mcp.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { createHash } from "node:crypto"; -import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db"; -import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm"; +import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, domains, states as statesTable, activityFeed, webhooks, webhookDeliveries } from "@project-e/db"; +import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm"; import { recordActivity } from "../middleware/activity"; export const mcpRoutes = new Hono(); @@ -81,7 +81,7 @@ const tools: ToolDefinition[] = [ type: "object", properties: { domain_id: { type: "string", description: "Workspace/domain ID" }, - status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] }, + state_group: { type: "string", enum: ["backlog", "unstarted", "started", "completed", "cancelled"], description: "Filter by workflow state group" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, project_id: { type: "string" }, search: { type: "string" }, @@ -95,7 +95,16 @@ const tools: ToolDefinition[] = [ eq(tasks.domainId, params.domain_id as string), isNull(tasks.deletedAt), ]; - // TODO(phase-2): filter by state_group / state_id instead of old status + if (params.state_group) { + const groups = (params.state_group as string).split(",") as any[]; + conditions.push( + exists( + db.select({ one: sql`1` }) + .from(statesTable) + .where(and(eq(statesTable.id, tasks.stateId), inArray(statesTable.group, groups))) + ) + ); + } if (params.priority) conditions.push(eq(tasks.priority, params.priority as any)); if (params.project_id) conditions.push(eq(tasks.projectId, params.project_id as string)); if (params.search) conditions.push(ilike(tasks.title, `%${params.search}%`)); @@ -119,7 +128,6 @@ const tools: ToolDefinition[] = [ domain_id: { type: "string", description: "Workspace/domain ID" }, title: { type: "string" }, description: { type: "string" }, - status: { type: "string" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, due_date: { type: "string" }, project_id: { type: "string" }, @@ -157,7 +165,6 @@ const tools: ToolDefinition[] = [ task_id: { type: "string" }, title: { type: "string" }, description: { type: "string" }, - status: { type: "string" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, due_date: { type: "string" }, }, diff --git a/docs/API.md b/docs/API.md index aea1ff3..ea0e7c5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1046,7 +1046,7 @@ Requests and responses use the JSON-RPC 2.0 envelope: { "jsonrpc": "2.0", "method": "tools/call", - "params": { "name": "tasks.list", "arguments": { "domain_id": "b2c3d4e5-...", "status": "todo" } }, + "params": { "name": "tasks.list", "arguments": { "domain_id": "b2c3d4e5-...", "state_group": "unstarted" } }, "id": 1 } ``` From c6328c120a4a8612bdf2b449365c4d8f9b6f9118 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Mon, 7 Sep 2026 20:24:54 +0000 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20implement=20PL-7,=20PL-8,=20PL-9=20?= =?UTF-8?q?=E2=80=94=20state-driven=20board,=20module/cycle=20views,=20lin?= =?UTF-8?q?k=20panels=20+=20graph=20edges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/index.ts | 5 + apps/api/src/routes/cycles.ts | 374 ++++++++++++++++ apps/api/src/routes/graph.ts | 8 +- apps/api/src/routes/links.ts | 173 +++++++ apps/web/src/lib/types/index.ts | 59 +++ apps/web/src/routes/_app/graph.tsx | 31 +- apps/web/src/routes/_app/projects/$id.tsx | 523 +++++++++++++++++++++- apps/web/src/routes/_app/tasks.tsx | 293 +++++++----- apps/web/src/routes/_app/tasks/$id.tsx | 287 +++++++----- 9 files changed, 1525 insertions(+), 228 deletions(-) create mode 100644 apps/api/src/routes/cycles.ts create mode 100644 apps/api/src/routes/links.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ab5822f..f83cc51 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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); diff --git a/apps/api/src/routes/cycles.ts b/apps/api/src/routes/cycles.ts new file mode 100644 index 0000000..5756359 --- /dev/null +++ b/apps/api/src/routes/cycles.ts @@ -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 = {}; + 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); + } +}); diff --git a/apps/api/src/routes/graph.ts b/apps/api/src/routes/graph.ts index ceba2b5..54af011 100644 --- a/apps/api/src/routes/graph.ts +++ b/apps/api/src/routes/graph.ts @@ -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); } diff --git a/apps/api/src/routes/links.ts b/apps/api/src/routes/links.ts new file mode 100644 index 0000000..fead6b8 --- /dev/null +++ b/apps/api/src/routes/links.ts @@ -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 { + 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); + } +}); diff --git a/apps/web/src/lib/types/index.ts b/apps/web/src/lib/types/index.ts index 545fa2f..12f32f3 100644 --- a/apps/web/src/lib/types/index.ts +++ b/apps/web/src/lib/types/index.ts @@ -9,9 +9,13 @@ export interface Task { domainId: string; projectId: string | null; sectionId: string | null; + stateId: string | null; + moduleId: string | null; + cycleId: string | null; parentId: string | null; dueDate: string | null; estimatedMinutes: number | null; + trackedMinutes: number | null; recurrenceRule: string | null; order: number; completedAt: string | null; @@ -25,6 +29,61 @@ export interface Task { dependents?: { id: string; title: string; status: string }[]; } +export type StateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled"; + +export interface State { + id: string; + name: string; + color: string | null; + group: StateGroup; + projectId: string; + sortOrder: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export type ModuleStatus = "planned" | "in_progress" | "completed" | "cancelled"; + +export interface Module { + id: string; + name: string; + description: string | null; + projectId: string; + status: ModuleStatus; + startDate: string | null; + targetDate: string | null; + sortOrder: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + tasks?: Task[]; +} + +export interface Cycle { + id: string; + name: string; + projectId: string; + startDate: string | null; + endDate: string | null; + active: boolean; + createdAt: string; + updatedAt: string; +} + +export type LinkType = "relates" | "blocks" | "parent-child" | "created-from"; + +export interface Link { + id: string; + sourceType: string; + sourceId: string; + targetType: string; + targetId: string; + linkType: LinkType; + direction: string | null; + createdAt: string; +} + export interface Habit { id: string; name: string; diff --git a/apps/web/src/routes/_app/graph.tsx b/apps/web/src/routes/_app/graph.tsx index 6e0819e..1c7eceb 100644 --- a/apps/web/src/routes/_app/graph.tsx +++ b/apps/web/src/routes/_app/graph.tsx @@ -20,7 +20,7 @@ import type { GraphNode, GraphEdge } from "@/lib/types"; import ForceGraph2D from "react-force-graph-2d"; const ENTITY_TYPES = ["task", "habit", "project", "note", "section", "tag", "domain"]; -const RELATIONSHIP_TYPES = ["depends_on", "related_to", "part_of", "references", "parent_of", "child_of", "connects_to"]; +const RELATIONSHIP_TYPES = ["depends_on", "related_to", "part_of", "references", "parent_of", "child_of", "connects_to", "relates", "blocks", "parent-child", "created-from", "task_project", "task_domain", "habit_domain", "project_domain", "note_domain", "section_project"]; const ENTITY_COLORS: Record = { task: "#3b82f6", @@ -32,6 +32,20 @@ const ENTITY_COLORS: Record = { domain: "#6366f1", }; +const LINK_TYPE_COLORS: Record = { + relates: "#94a3b8", + blocks: "#ef4444", + "parent-child": "#8b5cf6", + "created-from": "#10b981", + depends_on: "#ef4444", + related_to: "#94a3b8", + part_of: "#8b5cf6", + references: "#f59e0b", + parent_of: "#8b5cf6", + child_of: "#10b981", + connects_to: "#3b82f6", +}; + // Graph node types that have a detail page. section/tag/domain nodes appear in // the graph but have no detail route, so they are intentionally absent. const NODE_TYPE_ROUTES: Record = { @@ -265,11 +279,17 @@ function GraphPage() { const isHighlighted = highlightLinks.size === 0 || highlightLinks.has(`${link.source.id}-${link.target.id}`); const width = isHighlighted ? 1.5 / globalScale : 0.5 / globalScale; const opacity = isHighlighted ? 0.6 : 0.1; + const linkType = link.type || "relates"; + const baseColor = LINK_TYPE_COLORS[linkType] || "#94a3b8"; + + const r = parseInt(baseColor.slice(1, 3), 16); + const g = parseInt(baseColor.slice(3, 5), 16); + const b = parseInt(baseColor.slice(5, 7), 16); ctx.beginPath(); ctx.moveTo(link.source.x, link.source.y); ctx.lineTo(link.target.x, link.target.y); - ctx.strokeStyle = `rgba(148, 163, 184, ${opacity})`; + ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`; ctx.lineWidth = width; ctx.stroke(); @@ -289,7 +309,7 @@ function GraphPage() { ctx.lineTo(midX - ux * arrowSize + uy * arrowSize * 0.5, midY - uy * arrowSize - ux * arrowSize * 0.5); ctx.lineTo(midX - ux * arrowSize - uy * arrowSize * 0.5, midY - uy * arrowSize + ux * arrowSize * 0.5); ctx.closePath(); - ctx.fillStyle = `rgba(148, 163, 184, ${opacity})`; + ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`; ctx.fill(); } } @@ -425,8 +445,9 @@ function GraphPage() { checked={enabledRelationships.has(type)} onCheckedChange={() => toggleRelationship(type)} /> -