learning_ai_notes/backend/src/modules/notes/routes.ts

565 lines
19 KiB
TypeScript

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<FastifyApp, 'setReadyState' | 'isReadyState'>;
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<NoteDoc[]> {
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();
});
}