import { randomUUID } from 'node:crypto'; import type { FastifyApp } from '@bytelyst/fastify-core'; import { BadRequestError, NotFoundError } from '@bytelyst/errors'; import { z } from 'zod'; import { extractAuth, requireWriter } from '../../lib/auth.js'; import { PRODUCT_ID } from '../../lib/product-config.js'; import { trackEvent } from '../../lib/telemetry.js'; import { isFeatureEnabled } from '../../lib/feature-flags.js'; import { extractFromText } from '../../lib/extraction-client.js'; import { rankNotesByQuery } from '../../lib/note-search-rank.js'; import { runCopilotTransform, suggestTitleFromBody } from '../../lib/copilot-transform.js'; import { assertRateLimit, rateLimitKey } from '../../lib/rate-limit.js'; import { getRequestId } from '../../lib/request-context.js'; import * as repo from './repository.js'; import * as artifactRepo from '../note-artifacts/repository.js'; import * as shareRepo from '../note-shares/repository.js'; import * as versionRepo from '../note-versions/repository.js'; import { CreateNoteShareSchema } from '../note-shares/types.js'; import { ListNoteVersionsQuerySchema } from '../note-versions/types.js'; import type { NoteVersionDoc } from '../note-versions/types.js'; import { buildNotesExportPayload, exportFilename, renderNotesMarkdownExport } from './export.js'; import { CreateNoteSchema, ListNotesQuerySchema, UpdateNoteSchema, type NoteDoc } from './types.js'; type RouteApp = Omit; const PostSearchBodySchema = z.object({ q: z.string().max(200).default(''), workspaceId: z.string().min(1).max(128).optional(), mode: z.enum(['lexical', 'hybrid']).default('hybrid'), limit: z.coerce.number().int().min(1).max(100).default(50), offset: z.coerce.number().int().min(0).default(0), }); const CopilotBodySchema = z.object({ workspaceId: z.string().min(1).max(128), action: z.enum(['shorten', 'expand', 'bulletize', 'grammar', 'fix-rewrite', 'change-tone', 'continue', 'explain']), text: z.string().min(1).max(50000), tone: z.enum(['formal', 'casual', 'professional', 'friendly']).optional(), }); const ChatBodySchema = z.object({ workspaceId: z.string().min(1).max(128), message: z.string().min(1).max(2000), }); const ExportNotesQuerySchema = z.object({ format: z.enum(['json', 'markdown']).default('json'), workspaceId: z.string().min(1).max(128).optional(), }); const NOTE_AI_RATE_LIMIT = { label: 'note AI routes', max: 30, windowMs: 10 * 60_000 }; function assertNoteAiRateLimit(userId: string, route: string): void { assertRateLimit(rateLimitKey(route, userId), NOTE_AI_RATE_LIMIT); } function toLexicalHits(items: NoteDoc[]) { return items.map((n) => { const plain = n.body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); return { noteId: n.id, workspaceId: n.workspaceId, title: n.title, score: 1, matchKind: 'lexical' as const, snippet: plain.slice(0, 180) + (plain.length > 180 ? '…' : ''), }; }); } async function listAllNotesForExport( userId: string, workspaceId?: string, ): Promise { const limit = 100; const items: NoteDoc[] = []; let offset = 0; let total = 0; do { const result = await repo.listNotes(userId, PRODUCT_ID, { workspaceId, limit, offset }); items.push(...result.items); total = result.total; offset += limit; } while (items.length < total); return items; } export async function noteRoutes(app: RouteApp) { app.get('/notes/search', async req => { if (!isFeatureEnabled('notes.enabled')) { throw new BadRequestError('Notes feature is currently disabled'); } const auth = await extractAuth(req); const parsed = ListNotesQuerySchema.safeParse(req.query); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); } const result = await repo.listNotes(auth.sub, PRODUCT_ID, parsed.data); return { query: parsed.data.search ?? null, ...result, limit: parsed.data.limit, offset: parsed.data.offset, }; }); app.get('/notes', async req => { const auth = await extractAuth(req); const parsed = ListNotesQuerySchema.safeParse(req.query); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); } const result = await repo.listNotes(auth.sub, PRODUCT_ID, parsed.data); return { ...result, limit: parsed.data.limit, offset: parsed.data.offset }; }); // Must be registered before GET /notes/:id or "export" is captured as :id. app.get('/notes/export', async (req, reply) => { const auth = await extractAuth(req); const parsed = ExportNotesQuerySchema.safeParse(req.query ?? {}); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); } const { format, workspaceId } = parsed.data; const notes = await listAllNotesForExport(auth.sub, workspaceId); const payload = buildNotesExportPayload({ notes, productId: PRODUCT_ID, userId: auth.sub, workspaceId, }); const filename = exportFilename(format, workspaceId); if (format === 'markdown') { reply.header('Content-Type', 'text/markdown'); reply.header('Content-Disposition', `attachment; filename="${filename}"`); return renderNotesMarkdownExport(payload); } reply.header('Content-Type', 'application/json'); reply.header('Content-Disposition', `attachment; filename="${filename}"`); return payload; }); app.get('/notes/:id', async req => { const auth = await extractAuth(req); const { id } = req.params as { id: string }; const workspaceId = (req.query as { workspaceId?: string }).workspaceId; if (!workspaceId) { throw new BadRequestError('workspaceId is required'); } const note = await repo.getNote(id, workspaceId); if (!note || note.userId !== auth.sub || note.productId !== PRODUCT_ID) { throw new NotFoundError('Note not found'); } return note; }); app.get('/notes/:id/versions', async req => { const auth = await extractAuth(req); const { id } = req.params as { id: string }; const parsed = ListNoteVersionsQuerySchema.safeParse(req.query); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); } const note = await repo.getNote(id, parsed.data.workspaceId); if (!note || note.userId !== auth.sub || note.productId !== PRODUCT_ID) { throw new NotFoundError('Note not found'); } return versionRepo.listNoteVersions( auth.sub, PRODUCT_ID, parsed.data.workspaceId, id, parsed.data.limit, parsed.data.offset, ); }); app.post('/notes/search', async req => { if (!isFeatureEnabled('notes.enabled')) { throw new BadRequestError('Notes feature is currently disabled'); } const auth = await extractAuth(req); const parsed = PostSearchBodySchema.safeParse(req.body ?? {}); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); } const q = parsed.data.q.trim(); const hybrid = parsed.data.mode === 'hybrid' && isFeatureEnabled('search.hybrid_enabled'); const { workspaceId, limit, offset } = parsed.data; if (!hybrid) { const result = await repo.listNotes(auth.sub, PRODUCT_ID, { workspaceId, search: q || undefined, limit, offset, }); trackEvent('note.searched', auth.sub, { mode: 'lexical', workspaceId }); return { mode: 'lexical' as const, query: q || null, items: toLexicalHits(result.items), total: result.total, limit, offset, }; } const pool = await repo.listNotes(auth.sub, PRODUCT_ID, { workspaceId, search: q || undefined, limit: 100, offset: 0, }); let candidates = pool.items; if (!q) { const recent = await repo.listNotes(auth.sub, PRODUCT_ID, { workspaceId, limit: 50, offset: 0, }); candidates = recent.items; } const ranked = rankNotesByQuery(candidates, q); const paged = ranked.slice(offset, offset + limit); trackEvent('note.searched', auth.sub, { mode: 'hybrid', workspaceId }); return { mode: 'hybrid' as const, query: q || null, items: paged, total: ranked.length, limit, offset, }; }); app.post('/notes', async (req, reply) => { const auth = await requireWriter(req); const parsed = CreateNoteSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError( parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ') ); } const now = new Date().toISOString(); const doc: NoteDoc = { id: parsed.data.id, productId: PRODUCT_ID, workspaceId: parsed.data.workspaceId, userId: auth.sub, title: parsed.data.title, body: parsed.data.body, status: 'draft', tags: parsed.data.tags, links: parsed.data.links, sourceType: parsed.data.sourceType, sourceUri: parsed.data.sourceUri, createdAt: now, updatedAt: now, createdBy: auth.sub, updatedBy: auth.sub, agentId: parsed.data.agentId, }; const created = await repo.createNote(doc); trackEvent('note.created', auth.sub, { noteId: created.id, workspaceId: created.workspaceId }); if (created.sourceType === 'voice') { const wordCount = (created.body ?? '').split(/\s+/).filter(Boolean).length; trackEvent('voice_capture_completed', auth.sub, { noteId: created.id, wordCount: String(wordCount) }); } reply.code(201); return created; }); app.patch('/notes/:id', async req => { const auth = await requireWriter(req); const { id } = req.params as { id: string }; const workspaceId = (req.query as { workspaceId?: string }).workspaceId; if (!workspaceId) { throw new BadRequestError('workspaceId is required'); } const parsed = UpdateNoteSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError( parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ') ); } const existing = await repo.getNote(id, workspaceId); if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) { throw new NotFoundError('Note not found'); } if (parsed.data.title !== undefined || parsed.data.body !== undefined) { const ver: NoteVersionDoc = { id: `ver-${id}-${Date.now()}`, productId: PRODUCT_ID, workspaceId, userId: auth.sub, noteId: id, title: existing.title, body: existing.body, savedAt: new Date().toISOString(), source: 'user_edit', }; await versionRepo.appendNoteVersion(ver); } const updated = await repo.updateNote(id, workspaceId, { ...parsed.data, updatedAt: new Date().toISOString(), updatedBy: auth.sub, }); if (!updated) { throw new NotFoundError('Note not found'); } trackEvent('note.updated', auth.sub, { noteId: id, workspaceId }); return updated; }); app.post('/notes/:id/restore', async req => { const auth = await requireWriter(req); const { id } = req.params as { id: string }; const workspaceId = (req.body as { workspaceId?: string } | undefined)?.workspaceId; if (!workspaceId) { throw new BadRequestError('workspaceId is required'); } const existing = await repo.getNote(id, workspaceId); if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) { throw new NotFoundError('Note not found'); } const updated = await repo.updateNote(id, workspaceId, { status: 'active', updatedAt: new Date().toISOString(), updatedBy: auth.sub, }); if (!updated) { throw new NotFoundError('Note not found'); } return updated; }); app.post('/notes/:id/archive', async req => { const auth = await requireWriter(req); const { id } = req.params as { id: string }; const workspaceId = (req.body as { workspaceId?: string } | undefined)?.workspaceId; if (!workspaceId) { throw new BadRequestError('workspaceId is required'); } const existing = await repo.getNote(id, workspaceId); if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) { throw new NotFoundError('Note not found'); } const updated = await repo.updateNote(id, workspaceId, { status: 'archived', updatedAt: new Date().toISOString(), updatedBy: auth.sub, }); if (!updated) { throw new NotFoundError('Note not found'); } trackEvent('note.archived', auth.sub, { noteId: id, workspaceId }); return updated; }); app.post('/notes/:id/summarize', async (req, reply) => { const auth = await requireWriter(req); const { id } = req.params as { id: string }; const workspaceId = (req.body as { workspaceId?: string } | undefined)?.workspaceId; if (!workspaceId) { throw new BadRequestError('workspaceId is required'); } const existing = await repo.getNote(id, workspaceId); if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) { throw new NotFoundError('Note not found'); } let summaryText: string; try { const result = await extractFromText(existing.body, 'summarization', { requestId: getRequestId(req) }); summaryText = result.summary ?? 'No summary generated.'; } catch { summaryText = `Auto-summary of: ${existing.title}. ${existing.body.slice(0, 200)}...`; } const now = new Date().toISOString(); const artifact = await artifactRepo.createNoteArtifact({ id: `summary-${id}-${Date.now()}`, productId: PRODUCT_ID, workspaceId, userId: auth.sub, noteId: id, artifactType: 'summary', title: `Summary of ${existing.title}`, description: summaryText, createdAt: now, createdBy: auth.sub, updatedAt: now, updatedBy: auth.sub, }); reply.code(201); return artifact; }); app.post('/notes/:id/share', async (req, reply) => { const auth = await requireWriter(req); const { id } = req.params as { id: string }; const parsed = CreateNoteShareSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); } const { workspaceId } = parsed.data; const existing = await repo.getNote(id, workspaceId); if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) { throw new NotFoundError('Note not found'); } const shareToken = randomUUID(); const now = new Date().toISOString(); await shareRepo.createNoteShare({ id: `sh-${shareToken}`, productId: PRODUCT_ID, workspaceId, userId: auth.sub, noteId: id, shareToken, createdAt: now, }); trackEvent('note.share_created', auth.sub, { noteId: id, workspaceId }); reply.code(201); return { shareToken, path: `/share/${shareToken}` }; }); app.post('/notes/:id/copilot', async req => { if (!isFeatureEnabled('copilot.enabled')) { throw new BadRequestError('Copilot is disabled'); } const auth = await requireWriter(req); assertNoteAiRateLimit(auth.sub, 'copilot'); const { id } = req.params as { id: string }; const parsed = CopilotBodySchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); } const { workspaceId, action, text, tone } = parsed.data; const existing = await repo.getNote(id, workspaceId); if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) { throw new NotFoundError('Note not found'); } const inputText = action === 'change-tone' && tone ? `${text}\n\nTone: ${tone}` : text; const transformed = await runCopilotTransform(action, inputText); trackEvent('note.copilot', auth.sub, { noteId: id, action }); return { text: transformed }; }); app.post('/notes/:id/suggest-title', async req => { if (!isFeatureEnabled('copilot.enabled')) { throw new BadRequestError('Copilot is disabled'); } const auth = await requireWriter(req); assertNoteAiRateLimit(auth.sub, 'suggest-title'); const { id } = req.params as { id: string }; const workspaceId = (req.body as { workspaceId?: string } | undefined)?.workspaceId; if (!workspaceId) { throw new BadRequestError('workspaceId is required'); } const existing = await repo.getNote(id, workspaceId); if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) { throw new NotFoundError('Note not found'); } const title = await suggestTitleFromBody(existing.body); return { title }; }); app.post('/notes/chat', async req => { if (!isFeatureEnabled('chat.rag_enabled')) { throw new BadRequestError('Workspace chat is disabled'); } const auth = await extractAuth(req); assertNoteAiRateLimit(auth.sub, 'chat'); const parsed = ChatBodySchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); } const { workspaceId, message } = parsed.data; const pool = await repo.listNotes(auth.sub, PRODUCT_ID, { workspaceId, search: message.slice(0, 120), limit: 40, offset: 0, }); const ranked = rankNotesByQuery(pool.items, message).slice(0, 8); const citations = ranked.map((r) => ({ noteId: r.noteId, title: r.title, snippet: r.snippet, workspaceId: r.workspaceId, })); const answer = ranked.length > 0 ? `Here are the most relevant notes in this workspace (retrieval-only; verify in editor):\n\n${ranked .map((r, i) => `${i + 1}. **${r.title}** (${r.matchKind}) — ${r.snippet}`) .join('\n')}` : 'No notes matched that question in this workspace. Try different keywords or broaden your search.'; trackEvent('note.chat_query', auth.sub, { workspaceId }); return { answer, citations }; }); app.delete('/notes/:id', async (req, reply) => { const auth = await requireWriter(req); const { id } = req.params as { id: string }; const workspaceId = (req.query as { workspaceId?: string }).workspaceId; if (!workspaceId) { throw new BadRequestError('workspaceId is required'); } const existing = await repo.getNote(id, workspaceId); if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) { throw new NotFoundError('Note not found'); } await repo.deleteNote(id, workspaceId); reply.code(204).send(); }); }