Files
ProjectE/apps/api/src/routes/graph.ts
T

202 lines
8.4 KiB
TypeScript

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<string, string> = {
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<string>();
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);
}
});