565 lines
19 KiB
TypeScript
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();
|
|
});
|
|
}
|