diff --git a/apps/api/src/routes/graph.ts b/apps/api/src/routes/graph.ts new file mode 100644 index 0000000..0686588 --- /dev/null +++ b/apps/api/src/routes/graph.ts @@ -0,0 +1,201 @@ +import { Hono } from "hono"; +import { db, domains, notes, noteLinks, noteEntityLinks, tasks, taskDependencies, habits, projects, sections, tags as tagsTable } from "@project-e/db"; +import { and, eq, inArray, isNull } from "drizzle-orm"; +import { requireAuth, AuthError } from "../middleware/auth"; +import { recordActivity } from "../middleware/activity"; +import { z } from "zod"; + +export const graphRoutes = new Hono(); + +const ENTITY_COLORS: Record = { + task: '#3b82f6', + habit: '#10b981', + project: '#8b5cf6', + note: '#f59e0b', + section: '#ec4899', + tag: '#6b7280', + domain: '#6366f1', +}; + +interface GraphNode { id: string; label: string; type: string; color: string; } +interface GraphEdge { source: string; target: string; type: string; } + +async function getGraphData(domainId: string): Promise<{ nodes: GraphNode[]; edges: GraphEdge[] }> { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const nodeIds = new Set(); + + function addNode(id: string, label: string, type: string) { + if (!nodeIds.has(id)) { + nodeIds.add(id); + nodes.push({ id, label, type, color: ENTITY_COLORS[type] || '#6b7280' }); + } + } + + function addEdge(source: string, target: string, type: string) { + if (source !== target) edges.push({ source, target, type }); + } + + const projectIds = (await db.select({ id: projects.id }).from(projects).where(eq(projects.domainId, domainId))).map(p => p.id); + const [noteRows, taskRows, habitRows, projectRows, sectionRows, tagRows, domainRows] = await Promise.all([ + db.select({ id: notes.id, title: notes.title }).from(notes).where(and(eq(notes.domainId, domainId), isNull(notes.deletedAt))), + db.select({ id: tasks.id, title: tasks.title, projectId: tasks.projectId }).from(tasks).where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt))), + db.select({ id: habits.id, name: habits.name }).from(habits).where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))), + db.select({ id: projects.id, name: projects.name }).from(projects).where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt))), + projectIds.length > 0 + ? db.select({ id: sections.id, name: sections.name, projectId: sections.projectId }).from(sections).where(inArray(sections.projectId, projectIds)) + : Promise.resolve([]), + db.select({ id: tagsTable.id, name: tagsTable.name }).from(tagsTable), + db.select({ id: domains.id, name: domains.name }).from(domains).where(eq(domains.id, domainId)), + ]); + + for (const d of domainRows) addNode(d.id, d.name, 'domain'); + for (const n of noteRows) addNode(n.id, n.title, 'note'); + for (const t of taskRows) addNode(t.id, t.title, 'task'); + for (const h of habitRows) addNode(h.id, h.name, 'habit'); + for (const p of projectRows) addNode(p.id, p.name, 'project'); + for (const s of sectionRows) addNode(s.id, s.name, 'section'); + for (const t of tagRows) addNode(t.id, t.name, 'tag'); + + const noteIds = noteRows.map(n => n.id); + if (noteIds.length > 0) { + const linkRows = await db.select().from(noteLinks).where(inArray(noteLinks.sourceNoteId, noteIds)); + for (const l of linkRows) addEdge(l.sourceNoteId, l.targetNoteId, 'note_link'); + const entityLinkRows = await db.select().from(noteEntityLinks).where(inArray(noteEntityLinks.noteId, noteIds)); + for (const l of entityLinkRows) addEdge(l.noteId, l.entityId, 'note_' + l.entityType); + } + + const taskIds = taskRows.map(t => t.id); + if (taskIds.length > 0) { + const depRows = await db.select().from(taskDependencies).where(inArray(taskDependencies.taskId, taskIds)); + for (const d of depRows) addEdge(d.taskId, d.dependsOnTaskId, 'depends_on'); + } + + for (const t of taskRows) { if (t.projectId) addEdge(t.id, t.projectId, 'task_project'); addEdge(t.id, domainId, 'task_domain'); } + for (const h of habitRows) addEdge(h.id, domainId, 'habit_domain'); + for (const p of projectRows) addEdge(p.id, domainId, 'project_domain'); + for (const n of noteRows) addEdge(n.id, domainId, 'note_domain'); + for (const s of sectionRows) { if (s.projectId) addEdge(s.id, s.projectId, 'section_project'); } + + return { nodes, edges }; +} + +// GET /api/graph/nodes — All nodes +graphRoutes.get("/nodes", async (c) => { + try { + await requireAuth(c); + const url = new URL(c.req.url); + const domainId = url.searchParams.get("domain"); + if (!domainId) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "domain parameter is required" } }, 400); + } + const data = await getGraphData(domainId); + return c.json({ items: data.nodes, totalItems: data.nodes.length }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[graph] GET /nodes error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get graph nodes" } }, 500); + } +}); + +// GET /api/graph/edges — All edges +graphRoutes.get("/edges", async (c) => { + try { + await requireAuth(c); + const url = new URL(c.req.url); + const domainId = url.searchParams.get("domain"); + if (!domainId) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "domain parameter is required" } }, 400); + } + const data = await getGraphData(domainId); + return c.json({ items: data.edges, totalItems: data.edges.length }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[graph] GET /edges error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get graph edges" } }, 500); + } +}); + +// POST /api/graph/edges — Create a relationship (note link) +graphRoutes.post("/edges", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const { sourceId, targetId, type } = z.object({ + sourceId: z.string().uuid(), + targetId: z.string().uuid(), + type: z.string().default("note_link"), + }).parse(body); + + if (type === "note_link") { + await db.insert(noteLinks).values({ sourceNoteId: sourceId, targetNoteId: targetId }); + } else if (type === "note_entity") { + await db.insert(noteEntityLinks).values({ noteId: sourceId, entityType: "task", entityId: targetId }); + } else if (type === "task_dependency") { + await db.insert(taskDependencies).values({ taskId: sourceId, dependsOnTaskId: targetId }); + } else { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Unknown edge type: " + type } }, 400); + } + + await recordActivity({ + actor: user.name, + action: "created", + entityType: "graph_edge", + entityId: sourceId + "-" + targetId, + changes: { type, sourceId, targetId }, + workspaceId: "", + }); + + 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("[graph] POST /edges error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create edge" } }, 500); + } +}); + +// DELETE /api/graph/edges/:id — Remove +graphRoutes.delete("/edges/:id", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + const [sourceId, targetId] = id.split("-"); + + // Try deleting from note_links first + const result = await db.delete(noteLinks) + .where(and(eq(noteLinks.sourceNoteId, sourceId), eq(noteLinks.targetNoteId, targetId))) + .returning(); + + if (result.length === 0) { + // Try task_dependencies + await db.delete(taskDependencies) + .where(and(eq(taskDependencies.taskId, sourceId), eq(taskDependencies.dependsOnTaskId, targetId))); + } + + await recordActivity({ + actor: user.name, + action: "deleted", + entityType: "graph_edge", + entityId: id, + changes: {}, + 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("[graph] DELETE /edges/:id error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete edge" } }, 500); + } +});