diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 9beabcc..ac57753 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -29,6 +29,7 @@ import { notificationRoutes } from "./routes/notifications"; import { stateRoutes } from "./routes/states"; import { moduleRoutes } from "./routes/modules"; import { cycleRoutes } from "./routes/cycles"; +import { linkRoutes } from "./routes/links"; import { healthHandler } from "./routes/health"; const app = new Hono(); @@ -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); @@ -68,7 +71,7 @@ app.route("/api/analytics", analyticsRoutes); app.route("/api/activity", activityRoutes); app.route("/api/notifications", notificationRoutes); app.route("/api/states", stateRoutes); -app.route("/api/cycles", cycleRoutes); +app.route("/api/links", linkRoutes); app.route("/api", importExportRoutes); app.route("/api", realtimeRoutes); app.route("/api/mcp", mcpRoutes); diff --git a/apps/api/src/routes/graph.ts b/apps/api/src/routes/graph.ts index ceba2b5..54af011 100644 --- a/apps/api/src/routes/graph.ts +++ b/apps/api/src/routes/graph.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import { db, domains, notes, tasks, habits, projects, sections, tags as tagsTable, links } from "@project-e/db"; -import { and, eq, inArray, isNull } from "drizzle-orm"; +import { and, eq, inArray, isNull, or } from "drizzle-orm"; import { requireAuth, requireWorkspaceAccess, AuthError } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; import { z } from "zod"; @@ -57,11 +57,11 @@ async function getGraphData(domainId: string): Promise<{ nodes: GraphNode[]; edg for (const s of sectionRows) addNode(s.id, s.name, 'section'); for (const t of tagRows) addNode(t.id, t.name, 'tag'); - // Read links from the canonical links table - const allIds = [...noteRows.map(n => n.id), ...taskRows.map(t => t.id)]; + // Read links from the canonical links table (both directions) + const allIds = [...noteRows.map(n => n.id), ...taskRows.map(t => t.id), ...projectRows.map(p => p.id), ...sectionRows.map(s => s.id)]; if (allIds.length > 0) { const linkRows = await db.select().from(links) - .where(inArray(links.sourceId, allIds)); + .where(or(inArray(links.sourceId, allIds), inArray(links.targetId, allIds))); for (const l of linkRows) addEdge(l.sourceId, l.targetId, l.linkType); } diff --git a/apps/api/src/routes/links.ts b/apps/api/src/routes/links.ts new file mode 100644 index 0000000..fead6b8 --- /dev/null +++ b/apps/api/src/routes/links.ts @@ -0,0 +1,173 @@ +import { Hono } from "hono"; +import { db, links, tasks, notes, projects } from "@project-e/db"; +import { and, eq, inArray, isNull, or } from "drizzle-orm"; +import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth"; +import { recordActivity } from "../middleware/activity"; +import { z } from "zod"; + +export const linkRoutes = new Hono(); + +const createLinkSchema = z.object({ + sourceType: z.string().min(1), + sourceId: z.string().uuid(), + targetType: z.string().min(1), + targetId: z.string().uuid(), + linkType: z.enum(["relates", "blocks", "parent-child", "created-from"]), + direction: z.string().optional().nullable(), +}); + +async function resolveWorkspaceId(entityType: string, entityId: string): Promise { + if (entityType === "task") { + const [row] = await db.select({ domainId: tasks.domainId }).from(tasks).where(eq(tasks.id, entityId)).limit(1); + return row?.domainId ?? null; + } + if (entityType === "note") { + const [row] = await db.select({ domainId: notes.domainId }).from(notes).where(eq(notes.id, entityId)).limit(1); + return row?.domainId ?? null; + } + return null; +} + +// GET /api/links — List links for an entity (either source OR target) +linkRoutes.get("/", async (c) => { + try { + const user = await requireAuth(c); + const url = new URL(c.req.url); + const entityType = url.searchParams.get("entityType"); + const entityId = url.searchParams.get("entityId"); + + if (!entityType || !entityId) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "entityType and entityId query parameters are required" } }, 400); + } + + const workspaceId = await resolveWorkspaceId(entityType, entityId); + if (workspaceId) { + await requireWorkspaceAccess(c, workspaceId); + } + + const items = await db.select() + .from(links) + .where(or( + and(eq(links.sourceType, entityType), eq(links.sourceId, entityId)), + and(eq(links.targetType, entityType), eq(links.targetId, entityId)), + )); + + return c.json({ items }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[links] GET error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list links" } }, 500); + } +}); + +// POST /api/links — Create a link between two entities +linkRoutes.post("/", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const data = createLinkSchema.parse(body); + + // Prevent self-links + if (data.sourceId === data.targetId) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Cannot link an entity to itself" } }, 400); + } + + // Check for duplicate link + const [existing] = await db.select({ id: links.id }) + .from(links) + .where(and( + eq(links.sourceId, data.sourceId), + eq(links.targetId, data.targetId), + eq(links.linkType, data.linkType), + )) + .limit(1); + + if (existing) { + return c.json({ error: { code: "CONFLICT", message: "Link already exists" } }, 409); + } + + const workspaceId = await resolveWorkspaceId(data.sourceType, data.sourceId); + if (workspaceId) { + await requireWorkspaceAccess(c, workspaceId); + } + + const [link] = await db.insert(links).values({ + sourceType: data.sourceType, + sourceId: data.sourceId, + targetType: data.targetType, + targetId: data.targetId, + linkType: data.linkType, + direction: data.direction ?? null, + }).returning(); + + if (workspaceId) { + await recordActivity({ + actor: user.name, + action: "created", + entityType: "link", + entityId: link.id, + changes: { sourceType: data.sourceType, sourceId: data.sourceId, targetType: data.targetType, targetId: data.targetId, linkType: data.linkType }, + workspaceId, + }); + } + + return c.json(link, 201); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[links] POST error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create link" } }, 500); + } +}); + +// DELETE /api/links/:id — Remove a link +linkRoutes.delete("/:id", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + if (!isUuid(id)) { + return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); + } + + const [existing] = await db.select() + .from(links) + .where(eq(links.id, id)) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "Link not found" } }, 404); + } + + const workspaceId = await resolveWorkspaceId(existing.sourceType, existing.sourceId); + if (workspaceId) { + await requireWorkspaceAccess(c, workspaceId); + } + + await db.delete(links).where(eq(links.id, id)); + + if (workspaceId) { + await recordActivity({ + actor: user.name, + action: "deleted", + entityType: "link", + entityId: id, + changes: { sourceType: existing.sourceType, sourceId: existing.sourceId, targetType: existing.targetType, targetId: existing.targetId }, + workspaceId, + }); + } + + return c.body(null, 204); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[links] DELETE error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete link" } }, 500); + } +}); diff --git a/apps/api/src/routes/mcp.ts b/apps/api/src/routes/mcp.ts index 0cacc62..db05fb0 100644 --- a/apps/api/src/routes/mcp.ts +++ b/apps/api/src/routes/mcp.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { createHash } from "node:crypto"; -import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db"; -import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm"; +import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, domains, states as statesTable, activityFeed, webhooks, webhookDeliveries } from "@project-e/db"; +import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm"; import { recordActivity } from "../middleware/activity"; export const mcpRoutes = new Hono(); @@ -81,7 +81,7 @@ const tools: ToolDefinition[] = [ type: "object", properties: { domain_id: { type: "string", description: "Workspace/domain ID" }, - status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] }, + state_group: { type: "string", enum: ["backlog", "unstarted", "started", "completed", "cancelled"], description: "Filter by workflow state group" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, project_id: { type: "string" }, search: { type: "string" }, @@ -95,7 +95,16 @@ const tools: ToolDefinition[] = [ eq(tasks.domainId, params.domain_id as string), isNull(tasks.deletedAt), ]; - // TODO(phase-2): filter by state_group / state_id instead of old status + if (params.state_group) { + const groups = (params.state_group as string).split(",") as any[]; + conditions.push( + exists( + db.select({ one: sql`1` }) + .from(statesTable) + .where(and(eq(statesTable.id, tasks.stateId), inArray(statesTable.group, groups))) + ) + ); + } if (params.priority) conditions.push(eq(tasks.priority, params.priority as any)); if (params.project_id) conditions.push(eq(tasks.projectId, params.project_id as string)); if (params.search) conditions.push(ilike(tasks.title, `%${params.search}%`)); @@ -119,7 +128,6 @@ const tools: ToolDefinition[] = [ domain_id: { type: "string", description: "Workspace/domain ID" }, title: { type: "string" }, description: { type: "string" }, - status: { type: "string" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, due_date: { type: "string" }, project_id: { type: "string" }, @@ -157,7 +165,6 @@ const tools: ToolDefinition[] = [ task_id: { type: "string" }, title: { type: "string" }, description: { type: "string" }, - status: { type: "string" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, due_date: { type: "string" }, }, diff --git a/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)} /> -