527 lines
18 KiB
TypeScript
527 lines
18 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 * 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 { 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),
|
|
});
|
|
|
|
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 ? '…' : ''),
|
|
};
|
|
});
|
|
}
|
|
|
|
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 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.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');
|
|
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);
|
|
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);
|
|
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);
|
|
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();
|
|
});
|
|
}
|