From 92fabb1a15f3f8ab4604a477ed7f25844040ca78 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Mon, 7 Sep 2026 20:16:08 +0000 Subject: [PATCH] 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 {