import type { FastifyApp } from '@bytelyst/fastify-core'; import { BadRequestError, NotFoundError } from '@bytelyst/errors'; 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 * as repo from './repository.js'; import * as artifactRepo from '../note-artifacts/repository.js'; import { CreateNoteSchema, ListNotesQuerySchema, UpdateNoteSchema, type NoteDoc } from './types.js'; type RouteApp = Omit; 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 }; }); 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.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({ event: 'note.created', userId: auth.sub, properties: { noteId: created.id, workspaceId: created.workspaceId } }); 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'); } 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({ event: 'note.updated', userId: auth.sub, properties: { 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({ event: 'note.archived', userId: auth.sub, properties: { 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'); 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.get('/notes/export', async (req, reply) => { const auth = await extractAuth(req); const query = req.query as { format?: string; workspaceId?: string }; const format = query.format ?? 'json'; if (format !== 'json' && format !== 'markdown') { throw new BadRequestError('format must be json or markdown'); } const listQuery = { workspaceId: query.workspaceId, limit: 100, offset: 0 }; const result = await repo.listNotes(auth.sub, PRODUCT_ID, listQuery); if (format === 'markdown') { const md = result.items .map((n: NoteDoc) => `# ${n.title}\n\n${n.body}\n\n---\n`) .join('\n'); reply.header('Content-Type', 'text/markdown'); reply.header('Content-Disposition', 'attachment; filename="notes-export.md"'); return md; } reply.header('Content-Type', 'application/json'); reply.header('Content-Disposition', 'attachment; filename="notes-export.json"'); return { exportedAt: new Date().toISOString(), notes: result.items }; }); 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(); }); }