diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ff5c964..ac57753 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 { linkRoutes } from "./routes/links"; import { healthHandler } from "./routes/health"; @@ -49,6 +50,8 @@ 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/projects/:projectId/cycles", cycleRoutes); +app.route("/api/cycles", cycleRoutes); app.route("/api/tasks", taskRoutes); app.route("/api/habits", habitRoutes); app.route("/api/projects", projectRoutes); 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 index 3f2863b..fead6b8 100644 --- a/apps/api/src/routes/links.ts +++ b/apps/api/src/routes/links.ts @@ -1,136 +1,58 @@ 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 { 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 { 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, + 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(), }); -// GET / — List links for an entity (bidirectional) -// Filters: source_type+source_id OR target_type+target_id (at least one pair required) +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 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"); + const entityType = url.searchParams.get("entityType"); + const entityId = url.searchParams.get("entityId"); - // 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 (!entityType || !entityId) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "entityType and entityId query parameters are required" } }, 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); + const workspaceId = await resolveWorkspaceId(entityType, entityId); + if (workspaceId) { + await requireWorkspaceAccess(c, workspaceId); } - // 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 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)), + )); - 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, - }); + return c.json({ items }); } catch (error) { if (error instanceof AuthError) { return c.json({ error: { code: error.code, message: error.message } }, error.status as any); @@ -140,52 +62,35 @@ linkRoutes.get("/", async (c) => { } }); -// POST / — Create a link +// 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); - // Enforce unique constraint on (sourceType, sourceId, targetType, targetId, linkType) - const [existing] = await db.select() + // 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.sourceType, data.sourceType), - eq(links.sourceId, data.sourceId), - eq(links.targetType, data.targetType), - eq(links.targetId, data.targetId), - eq(links.linkType, data.linkType), - ) - ) + .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 with this combination" } }, - 409 - ); + return c.json({ error: { code: "CONFLICT", message: "Link already exists" } }, 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 workspaceId = await resolveWorkspaceId(data.sourceType, data.sourceId); + if (workspaceId) { + await requireWorkspaceAccess(c, workspaceId); } const [link] = await db.insert(links).values({ @@ -197,34 +102,14 @@ linkRoutes.post("/", async (c) => { direction: data.direction ?? null, }).returning(); - if (domainId) { + if (workspaceId) { 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, - }, + changes: { sourceType: data.sourceType, sourceId: data.sourceId, targetType: data.targetType, targetId: data.targetId, linkType: data.linkType }, + workspaceId, }); } @@ -241,7 +126,7 @@ linkRoutes.post("/", async (c) => { } }); -// DELETE /:id — Delete a link (hard delete, links have no deletedAt) +// DELETE /api/links/:id — Remove a link linkRoutes.delete("/:id", async (c) => { try { const user = await requireAuth(c); @@ -259,57 +144,21 @@ linkRoutes.delete("/:id", async (c) => { 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; + const workspaceId = await resolveWorkspaceId(existing.sourceType, existing.sourceId); + if (workspaceId) { + await requireWorkspaceAccess(c, workspaceId); } - 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) { + 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, - 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, - }, + changes: { sourceType: existing.sourceType, sourceId: existing.sourceId, targetType: existing.targetType, targetId: existing.targetId }, + workspaceId, }); } diff --git a/apps/api/src/routes/mcp.ts b/apps/api/src/routes/mcp.ts index 8f009c0..0cacc62 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, states, modules, cycles, links } from "@project-e/db"; -import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm"; +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 { recordActivity } from "../middleware/activity"; export const mcpRoutes = new Hono(); @@ -81,12 +81,9 @@ const tools: ToolDefinition[] = [ type: "object", properties: { domain_id: { type: "string", description: "Workspace/domain ID" }, - state_id: { type: "string", description: "Filter by state UUID" }, - state_group: { type: "string", enum: ["backlog", "unstarted", "started", "completed", "cancelled"], description: "Filter by state group" }, + status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] }, 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 }, @@ -98,24 +95,9 @@ const tools: ToolDefinition[] = [ eq(tasks.domainId, params.domain_id as string), isNull(tasks.deletedAt), ]; - 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))) - ) - ); - } + // TODO(phase-2): filter by state_group / state_id instead of old status 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() @@ -137,38 +119,21 @@ const tools: ToolDefinition[] = [ domain_id: { type: "string", description: "Workspace/domain ID" }, title: { type: "string" }, description: { type: "string" }, - state_id: { type: "string", description: "State UUID" }, + status: { type: "string" }, 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({ @@ -176,7 +141,7 @@ const tools: ToolDefinition[] = [ action: "created", entityType: "task", entityId: task.id, - changes: { title: task.title, stateId: task.stateId }, + changes: { title: task.title }, workspaceId: params.domain_id as string, }); @@ -192,11 +157,9 @@ const tools: ToolDefinition[] = [ task_id: { type: "string" }, title: { type: "string" }, description: { type: "string" }, - state_id: { type: "string", description: "State UUID" }, + status: { type: "string" }, 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"], }, @@ -210,24 +173,6 @@ 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) @@ -278,13 +223,10 @@ const tools: ToolDefinition[] = [ }, { name: "tasks.complete", - description: "Mark a task as done by setting its state to a completed group state", + description: "Mark a task as done", inputSchema: { type: "object", - 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." }, - }, + properties: { task_id: { type: "string" } }, required: ["task_id"], }, handler: async (params, auth) => { @@ -292,25 +234,8 @@ 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(updateData) + .set({ completedAt: new Date(), updatedAt: new Date() }) .where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))) .returning(); @@ -319,7 +244,6 @@ const tools: ToolDefinition[] = [ action: "completed", entityType: "task", entityId: task.id, - changes: { stateId: task.stateId }, workspaceId: task.domainId, }); @@ -681,719 +605,6 @@ 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 f789ce8..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, states as statesTable, taskTags, tags as tagsTable, activityFeed, scheduledJobs, projects, sections } 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"; @@ -770,6 +770,21 @@ 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 { 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)} /> -