diff --git a/apps/api/package.json b/apps/api/package.json index d266e29..8fb799e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 28afa7b..41c67cb 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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); diff --git a/apps/api/src/middleware/activity.ts b/apps/api/src/middleware/activity.ts index 9215127..42f82ae 100644 --- a/apps/api/src/middleware/activity.ts +++ b/apps/api/src/middleware/activity.ts @@ -22,5 +22,5 @@ export async function recordActivity(params: RecordActivityParams): Promise { + 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 { + 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, + }; +} diff --git a/apps/api/src/routes/notes.ts b/apps/api/src/routes/notes.ts new file mode 100644 index 0000000..018591c --- /dev/null +++ b/apps/api/src/routes/notes.ts @@ -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 = { + 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`count(*)` }) + .from(notes) + .where(and(...conditions)), + ]); + + const totalItems = Number(countResult[0]?.count || 0); + + // Fetch tags for all notes + let noteTagMap = new Map(); + 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 = {}; + 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); + } +}); diff --git a/apps/api/src/routes/wikilink-parser.ts b/apps/api/src/routes/wikilink-parser.ts new file mode 100644 index 0000000..c12f6e6 --- /dev/null +++ b/apps/api/src/routes/wikilink-parser.ts @@ -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(); + 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; +}