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); } });