T3/Phase 2B-4: port notes routes to Hono (~7 routes) + wikilink service + route registration

This commit is contained in:
Hermes
2026-08-01 01:38:25 +00:00
parent ac90c1bd6d
commit 23d2a96dd1
6 changed files with 699 additions and 5 deletions
+5 -4
View File
@@ -10,11 +10,12 @@
},
"dependencies": {
"@project-e/db": "^0.1.0",
"hono": "^4.6.0",
"drizzle-orm": "^0.45.2",
"postgres": "^3.4.9",
"bcryptjs": "^2.4.3",
"jose": "^5.9.6"
"drizzle-orm": "^0.45.2",
"hono": "^4.6.0",
"jose": "^5.9.6",
"postgres": "^3.4.9",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^22.19.0",
+8
View File
@@ -6,6 +6,10 @@ import { authRoutes } from "./routes/auth";
import { mcpRoutes } from "./routes/mcp";
import { realtimeRoutes } from "./routes/realtime";
import { domainRoutes } from "./routes/domains";
import { taskRoutes } from "./routes/tasks";
import { habitRoutes } from "./routes/habits";
import { projectRoutes } from "./routes/projects";
import { noteRoutes } from "./routes/notes";
import { healthHandler } from "./routes/health";
const app = new Hono();
@@ -24,6 +28,10 @@ app.get("/api/health", async (c) => {
// Routes
app.route("/api/auth", authRoutes);
app.route("/api/domains", domainRoutes);
app.route("/api/tasks", taskRoutes);
app.route("/api/habits", habitRoutes);
app.route("/api/projects", projectRoutes);
app.route("/api/notes", noteRoutes);
app.route("/api", realtimeRoutes);
app.route("/mcp", mcpRoutes);
+1 -1
View File
@@ -22,5 +22,5 @@ export async function recordActivity(params: RecordActivityParams): Promise<void
});
const payload = JSON.stringify({ type: entityType, action, id: entityId, workspace_id: workspaceId });
await sql`SELECT pg_notify(project_e_events, ${payload}::text)`;
await sql`SELECT pg_notify('project_e_events', ${payload}::text)`;
}
+256
View File
@@ -0,0 +1,256 @@
/**
* Note Link Service
*
* Handles wikilink resolution and note_links / note_entity_links management.
* On note save, parses content for [[wikilinks]], resolves each to a note_id or entity_id,
* and diffs the existing links to produce idempotent deletes+inserts.
*/
import { db, noteLinks, noteEntityLinks, notes, tasks, habits, projects, sections, tags as tagsTable } from "@project-e/db";
import { and, eq, inArray, isNull, desc } from "drizzle-orm";
import { extractLinkTargets } from "./wikilink-parser";
/**
* Resolve a single link target to its entity ID.
*/
async function resolveTarget(entityType: string, title: string): Promise<{ entityId: string; entityType: string } | null> {
const trimmedTitle = title.trim();
if (!entityType) {
const [note] = await db
.select({ id: notes.id })
.from(notes)
.where(and(eq(notes.title, trimmedTitle), isNull(notes.deletedAt)))
.limit(1);
if (note) return { entityId: note.id, entityType: "note" };
return null;
}
switch (entityType) {
case "note": {
const [note] = await db
.select({ id: notes.id })
.from(notes)
.where(and(eq(notes.title, trimmedTitle), isNull(notes.deletedAt)))
.limit(1);
if (note) return { entityId: note.id, entityType: "note" };
return null;
}
case "task": {
const [task] = await db
.select({ id: tasks.id })
.from(tasks)
.where(and(eq(tasks.title, trimmedTitle), isNull(tasks.deletedAt)))
.limit(1);
if (task) return { entityId: task.id, entityType: "task" };
return null;
}
case "habit": {
const [habit] = await db
.select({ id: habits.id })
.from(habits)
.where(and(eq(habits.name, trimmedTitle), isNull(habits.deletedAt)))
.limit(1);
if (habit) return { entityId: habit.id, entityType: "habit" };
return null;
}
case "project": {
const [project] = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.name, trimmedTitle), isNull(projects.deletedAt)))
.limit(1);
if (project) return { entityId: project.id, entityType: "project" };
return null;
}
case "section": {
const [section] = await db
.select({ id: sections.id })
.from(sections)
.where(eq(sections.name, trimmedTitle))
.limit(1);
if (section) return { entityId: section.id, entityType: "section" };
return null;
}
case "tag": {
const [tag] = await db
.select({ id: tagsTable.id })
.from(tagsTable)
.where(eq(tagsTable.name, trimmedTitle))
.limit(1);
if (tag) return { entityId: tag.id, entityType: "tag" };
return null;
}
default:
return null;
}
}
/**
* Sync wikilinks for a note: parse content, resolve targets, diff existing links.
*/
export async function syncNoteLinks(noteId: string, content: string): Promise<void> {
const targets = extractLinkTargets(content);
const resolvedTargets: { entityType: string; entityId: string }[] = [];
for (const target of targets) {
const resolved = await resolveTarget(target.entityType, target.title);
if (resolved) {
resolvedTargets.push(resolved);
}
}
const noteToNoteLinks = resolvedTargets.filter(t => t.entityType === "note");
const entityLinks = resolvedTargets.filter(t => t.entityType !== "note");
// --- Sync note_links ---
const existingNoteLinks = await db
.select({ targetNoteId: noteLinks.targetNoteId })
.from(noteLinks)
.where(eq(noteLinks.sourceNoteId, noteId));
const existingTargetIds = new Set(existingNoteLinks.map(l => l.targetNoteId));
const newTargetIds = new Set(noteToNoteLinks.map(l => l.entityId));
const staleTargetIds = [...existingTargetIds].filter(id => !newTargetIds.has(id));
if (staleTargetIds.length > 0) {
await db
.delete(noteLinks)
.where(and(
eq(noteLinks.sourceNoteId, noteId),
inArray(noteLinks.targetNoteId, staleTargetIds),
));
}
const missingTargetIds = [...newTargetIds].filter(id => !existingTargetIds.has(id));
if (missingTargetIds.length > 0) {
await db.insert(noteLinks).values(
missingTargetIds.map(targetNoteId => ({ sourceNoteId: noteId, targetNoteId }))
);
}
// --- Sync note_entity_links ---
const existingEntityLinks = await db
.select({ entityType: noteEntityLinks.entityType, entityId: noteEntityLinks.entityId })
.from(noteEntityLinks)
.where(eq(noteEntityLinks.noteId, noteId));
const existingEntityKeySet = new Set(existingEntityLinks.map(l => `${l.entityType}:${l.entityId}`));
const newEntityKeySet = new Set(entityLinks.map(l => `${l.entityType}:${l.entityId}`));
const staleEntityLinks = existingEntityLinks.filter(l => !newEntityKeySet.has(`${l.entityType}:${l.entityId}`));
for (const link of staleEntityLinks) {
await db
.delete(noteEntityLinks)
.where(and(
eq(noteEntityLinks.noteId, noteId),
eq(noteEntityLinks.entityType, link.entityType),
eq(noteEntityLinks.entityId, link.entityId),
));
}
const missingEntityLinks = entityLinks.filter(l => !existingEntityKeySet.has(`${l.entityType}:${l.entityId}`));
if (missingEntityLinks.length > 0) {
await db.insert(noteEntityLinks).values(
missingEntityLinks.map(l => ({ noteId, entityType: l.entityType, entityId: l.entityId }))
);
}
}
/**
* Get backlinks for a note — notes that link to this note.
*/
export async function getBacklinks(noteId: string): Promise<{ id: string; title: string; excerpt: string }[]> {
const rows = await db
.select({
id: notes.id,
title: notes.title,
content: notes.content,
})
.from(noteLinks)
.innerJoin(notes, eq(noteLinks.sourceNoteId, notes.id))
.where(and(
eq(noteLinks.targetNoteId, noteId),
isNull(notes.deletedAt),
));
return rows.map(row => ({
id: row.id,
title: row.title,
excerpt: extractExcerpt(row.content || "", row.title),
}));
}
function extractExcerpt(content: string, title: string): string {
const linkMatch = content.match(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/);
if (linkMatch) {
const idx = content.indexOf(linkMatch[0]);
const start = Math.max(0, idx - 40);
const end = Math.min(content.length, idx + linkMatch[0].length + 40);
let excerpt = content.slice(start, end).replace(/\n/g, " ");
if (start > 0) excerpt = "..." + excerpt;
if (end < content.length) excerpt = excerpt + "...";
return excerpt;
}
return content.slice(0, 100).replace(/\n/g, " ") + (content.length > 100 ? "..." : "");
}
/**
* Get all outgoing links for a note.
*/
export async function getOutgoingLinks(noteId: string): Promise<{
noteLinks: { id: string; title: string }[];
entityLinks: { entityType: string; entityId: string; title: string | null }[];
}> {
const noteLinkRows = await db
.select({ id: notes.id, title: notes.title })
.from(noteLinks)
.innerJoin(notes, eq(noteLinks.targetNoteId, notes.id))
.where(and(
eq(noteLinks.sourceNoteId, noteId),
isNull(notes.deletedAt),
));
const entityLinkRows = await db
.select({ entityType: noteEntityLinks.entityType, entityId: noteEntityLinks.entityId })
.from(noteEntityLinks)
.where(eq(noteEntityLinks.noteId, noteId));
const entityLinksWithTitles: { entityType: string; entityId: string; title: string | null }[] = [];
for (const link of entityLinkRows) {
let title: string | null = null;
switch (link.entityType) {
case "task": {
const [t] = await db.select({ title: tasks.title }).from(tasks).where(eq(tasks.id, link.entityId)).limit(1);
title = t?.title ?? null;
break;
}
case "habit": {
const [h] = await db.select({ name: habits.name }).from(habits).where(eq(habits.id, link.entityId)).limit(1);
title = h?.name ?? null;
break;
}
case "project": {
const [p] = await db.select({ name: projects.name }).from(projects).where(eq(projects.id, link.entityId)).limit(1);
title = p?.name ?? null;
break;
}
case "section": {
const [s] = await db.select({ name: sections.name }).from(sections).where(eq(sections.id, link.entityId)).limit(1);
title = s?.name ?? null;
break;
}
case "tag": {
const [t] = await db.select({ name: tagsTable.name }).from(tagsTable).where(eq(tagsTable.id, link.entityId)).limit(1);
title = t?.name ?? null;
break;
}
}
entityLinksWithTitles.push({ entityType: link.entityType, entityId: link.entityId, title });
}
return {
noteLinks: noteLinkRows,
entityLinks: entityLinksWithTitles,
};
}
+365
View File
@@ -0,0 +1,365 @@
import { Hono } from "hono";
import { db, notes, noteTags, tags as tagsTable, activityFeed } from "@project-e/db";
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { syncNoteLinks, getBacklinks, getOutgoingLinks } from "./note-link-service";
import { z } from "zod";
export const noteRoutes = new Hono();
const createNoteSchema = z.object({
title: z.string().min(1, "Title is required"),
content: z.string().optional().nullable(),
domain: z.string().min(1, "Domain is required"),
isPinned: z.boolean().optional().default(false),
isArchived: z.boolean().optional().default(false),
tagIds: z.array(z.string().uuid()).optional(),
});
const updateNoteSchema = z.object({
title: z.string().min(1).optional(),
content: z.string().optional().nullable(),
isPinned: z.boolean().optional(),
isArchived: z.boolean().optional(),
});
// GET /api/notes — List notes with filtering, sorting, pagination
noteRoutes.get("/", async (c) => {
try {
const user = await requireAuth(c);
const url = new URL(c.req.url);
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1"));
const perPage = Math.min(100, Math.max(1, parseInt(url.searchParams.get("perPage") || "50")));
const filter = url.searchParams.get("filter") || undefined;
const sort = url.searchParams.get("sort") || "-updated_at";
const pinned = url.searchParams.get("pinned");
const archived = url.searchParams.get("archived");
const search = url.searchParams.get("search");
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200);
const offset = parseInt(url.searchParams.get("offset") || "0");
const order = url.searchParams.get("order") || "desc";
let domainId = url.searchParams.get("domain") || undefined;
if (!domainId) {
const active = await resolveActiveDomain(user);
domainId = active.id;
}
const conditions: any[] = [
eq(notes.domainId, domainId),
isNull(notes.deletedAt),
];
if (pinned === "true") conditions.push(eq(notes.isPinned, true));
if (archived === "true") conditions.push(eq(notes.isArchived, true));
else if (archived !== "all") conditions.push(eq(notes.isArchived, false));
if (search) conditions.push(ilike(notes.title, `%${search}%`));
if (filter) conditions.push(ilike(notes.title, `%${filter}%`));
const sortDir = sort.startsWith("-") ? "desc" : "asc";
const sortField = sort.replace(/^-/, "");
const sortColumns: Record<string, any> = {
title: notes.title,
created_at: notes.createdAt,
updated_at: notes.updatedAt,
is_pinned: notes.isPinned,
};
const orderColumn = sortDir === "asc"
? asc(sortColumns[sortField] || notes.updatedAt)
: desc(sortColumns[sortField] || notes.updatedAt);
const [items, countResult] = await Promise.all([
db.select()
.from(notes)
.where(and(...conditions))
.orderBy(orderColumn)
.limit(limit || perPage)
.offset(offset || (page - 1) * perPage),
db.select({ count: sql<number>`count(*)` })
.from(notes)
.where(and(...conditions)),
]);
const totalItems = Number(countResult[0]?.count || 0);
// Fetch tags for all notes
let noteTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
if (items.length > 0) {
const noteIds = items.map(n => n.id);
const tagRows = await db.select({
noteId: noteTags.noteId,
id: tagsTable.id,
name: tagsTable.name,
color: tagsTable.color,
})
.from(noteTags)
.innerJoin(tagsTable, eq(noteTags.tagId, tagsTable.id))
.where(inArray(noteTags.noteId, noteIds));
for (const row of tagRows) {
if (!noteTagMap.has(row.noteId)) noteTagMap.set(row.noteId, []);
noteTagMap.get(row.noteId)!.push({ id: row.id, name: row.name, color: row.color });
}
}
const itemsWithTags = items.map(n => ({
...n,
tags: noteTagMap.get(n.id) || [],
}));
return c.json({
items: itemsWithTags,
totalItems,
totalPages: Math.ceil(totalItems / (limit || perPage)),
page,
perPage: limit || perPage,
limit: limit || perPage,
offset: offset || (page - 1) * perPage,
});
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[notes] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list notes" } }, 500);
}
});
// POST /api/notes — Create a note
noteRoutes.post("/", async (c) => {
try {
const user = await requireAuth(c);
const body = await c.req.json();
const data = createNoteSchema.parse({
...body,
domain: body.domain || (await resolveActiveDomain(user)).id,
});
const [note] = await db.insert(notes).values({
title: data.title,
content: data.content ?? null,
domainId: data.domain,
isPinned: data.isPinned,
isArchived: data.isArchived,
}).returning();
if (data.tagIds && data.tagIds.length > 0) {
await db.insert(noteTags).values(
data.tagIds.map(tagId => ({ noteId: note.id, tagId }))
);
}
// Sync wikilinks from content
if (data.content) {
await syncNoteLinks(note.id, data.content);
}
await recordActivity({
actor: user.name,
action: "created",
entityType: "note",
entityId: note.id,
changes: { title: note.title },
workspaceId: data.domain,
});
return c.json(note, 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("[notes] POST error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create note" } }, 500);
}
});
// GET /api/notes/:id — Get a single note with backlinks
noteRoutes.get("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
const [note] = await db.select()
.from(notes)
.where(and(eq(notes.id, id), isNull(notes.deletedAt)))
.limit(1);
if (!note) {
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
}
// Fetch tags
const tagRows = await db.select({
id: tagsTable.id,
name: tagsTable.name,
color: tagsTable.color,
})
.from(noteTags)
.innerJoin(tagsTable, eq(noteTags.tagId, tagsTable.id))
.where(eq(noteTags.noteId, id));
// Fetch backlinks and outgoing links
const [backlinks, outgoingLinks] = await Promise.all([
getBacklinks(id),
getOutgoingLinks(id),
]);
return c.json({
...note,
tags: tagRows,
backlinks,
outgoingLinks,
});
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[notes] GET/:id error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get note" } }, 500);
}
});
// PATCH /api/notes/:id — Update a note
noteRoutes.patch("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
const body = await c.req.json();
const data = updateNoteSchema.parse(body);
const [existing] = await db.select()
.from(notes)
.where(and(eq(notes.id, id), isNull(notes.deletedAt)))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
}
const updateValues: Record<string, unknown> = {};
if (data.title !== undefined) updateValues.title = data.title;
if (data.content !== undefined) updateValues.content = data.content;
if (data.isPinned !== undefined) updateValues.isPinned = data.isPinned;
if (data.isArchived !== undefined) updateValues.isArchived = data.isArchived;
updateValues.updatedAt = new Date();
const [updated] = await db.update(notes)
.set(updateValues)
.where(eq(notes.id, id))
.returning();
// Re-sync wikilinks if content changed
const content = data.content ?? existing.content;
if (content) {
await syncNoteLinks(id, content);
}
await recordActivity({
actor: user.name,
action: "updated",
entityType: "note",
entityId: id,
changes: { ...data, previousTitle: existing.title },
workspaceId: existing.domainId,
});
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("[notes] PATCH error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update note" } }, 500);
}
});
// DELETE /api/notes/:id — Soft delete a note
noteRoutes.delete("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
const [existing] = await db.select()
.from(notes)
.where(and(eq(notes.id, id), isNull(notes.deletedAt)))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
}
await db.update(notes)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(notes.id, id));
await recordActivity({
actor: user.name,
action: "deleted",
entityType: "note",
entityId: id,
changes: { title: existing.title },
workspaceId: existing.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("[notes] DELETE error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete note" } }, 500);
}
});
// GET /api/notes/:id/backlinks — Notes that link TO this one
noteRoutes.get("/:id/backlinks", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
const backlinks = await getBacklinks(id);
return c.json({
items: backlinks,
totalItems: backlinks.length,
});
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[notes] GET /:id/backlinks error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get backlinks" } }, 500);
}
});
// GET /api/notes/:id/versions — Edit history (from activity feed)
noteRoutes.get("/:id/versions", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
const versions = await db.select()
.from(activityFeed)
.where(and(
eq(activityFeed.entityId, id),
eq(activityFeed.entityType, "note"),
))
.orderBy(desc(activityFeed.createdAt))
.limit(100);
return c.json({ items: versions, totalItems: versions.length });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[notes] GET /:id/versions error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get versions" } }, 500);
}
});
+64
View File
@@ -0,0 +1,64 @@
/**
* Wikilink Parser
*
* Parses note content for [[wikilink]] patterns:
* - [[Title]] — links to a note by title
* - [[Title|Display]] — links to a note with custom display text
* - [[entity_type:Title]] — cross-entity links (e.g. [[task:Buy milk]], [[habit:Exercise]])
*
* Supported entity types: task, habit, project, note, section, tag
*/
export interface WikilinkMatch {
/** The full matched text including brackets, e.g. "[[Buy milk]]" */
raw: string;
/** Entity type prefix (empty for note links), e.g. "task", "habit" */
entityType: string;
/** The target title (after entity type prefix), e.g. "Buy milk" */
title: string;
/** Optional display text (after | separator), e.g. "Buy milk" */
displayText: string | null;
}
const WIKILINK_REGEX = /\[\[(?:([a-zA-Z_]+):)?([^\]|]+)(?:\|([^\]]+))?\]\]/g;
/**
* Parse wikilinks from note content.
*/
export function parseWikilinks(content: string): WikilinkMatch[] {
const matches: WikilinkMatch[] = [];
let match: RegExpExecArray | null;
while ((match = WIKILINK_REGEX.exec(content)) !== null) {
const entityType = (match[1] || "").toLowerCase();
const title = match[2].trim();
const displayText = match[3]?.trim() || null;
matches.push({
raw: match[0],
entityType,
title,
displayText,
});
}
return matches;
}
/**
* Extract unique link targets from content.
*/
export function extractLinkTargets(content: string): { entityType: string; title: string }[] {
const seen = new Set<string>();
const targets: { entityType: string; title: string }[] = [];
for (const match of parseWikilinks(content)) {
const key = `${match.entityType}:${match.title}`;
if (!seen.has(key)) {
seen.add(key);
targets.push({ entityType: match.entityType, title: match.title });
}
}
return targets;
}