diff --git a/backend/src/lib/extraction-client.ts b/backend/src/lib/extraction-client.ts new file mode 100644 index 0000000..4f3b31c --- /dev/null +++ b/backend/src/lib/extraction-client.ts @@ -0,0 +1,26 @@ +import { config } from './config.js'; + +export interface ExtractionResult { + summary?: string; + entities?: Array<{ type: string; value: string }>; + tasks?: Array<{ title: string; description?: string }>; +} + +export async function extractFromText( + text: string, + taskType: string, +): Promise { + const url = `${config.EXTRACTION_SERVICE_URL}/api/extract`; + + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, task: taskType }), + }); + + if (!res.ok) { + throw new Error(`Extraction service error: ${res.status} ${res.statusText}`); + } + + return res.json() as Promise; +} diff --git a/backend/src/modules/notes/routes.integration.test.ts b/backend/src/modules/notes/routes.integration.test.ts index 8eb5719..7ad74df 100644 --- a/backend/src/modules/notes/routes.integration.test.ts +++ b/backend/src/modules/notes/routes.integration.test.ts @@ -7,6 +7,9 @@ const { extractAuthMock } = vi.hoisted(() => ({ vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock })); vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' })); +vi.mock('../../lib/extraction-client.js', () => ({ + extractFromText: vi.fn(async () => ({ summary: 'A concise summary.' })), +})); import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js'; import { noteRoutes } from './routes.js'; @@ -129,6 +132,41 @@ describe('notes routes — integration', () => { expect(res.json().items.length).toBeGreaterThanOrEqual(1); }); + it('POST /notes/:id/summarize creates a summary artifact', async () => { + await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); + const res = await app.inject({ + method: 'POST', + url: '/api/notes/note-1/summarize', + payload: { workspaceId: 'ws-1' }, + }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.artifactType).toBe('summary'); + expect(body.description).toBe('A concise summary.'); + }); + + it('GET /notes/export returns JSON by default', async () => { + await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); + const res = await app.inject({ method: 'GET', url: '/api/notes/export' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.notes).toHaveLength(1); + expect(body.exportedAt).toBeDefined(); + }); + + it('GET /notes/export?format=markdown returns markdown', async () => { + await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); + const res = await app.inject({ method: 'GET', url: '/api/notes/export?format=markdown' }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toContain('text/markdown'); + expect(res.body).toContain('# Test Note'); + }); + + it('GET /notes/export rejects invalid format', async () => { + const res = await app.inject({ method: 'GET', url: '/api/notes/export?format=csv' }); + expect(res.statusCode).toBe(400); + }); + it('returns 401 when auth fails', async () => { extractAuthMock.mockRejectedValueOnce(new Error('Unauthorized')); const res = await app.inject({ method: 'GET', url: '/api/notes' }); diff --git a/backend/src/modules/notes/routes.test.ts b/backend/src/modules/notes/routes.test.ts index d9ef6ad..46fe00f 100644 --- a/backend/src/modules/notes/routes.test.ts +++ b/backend/src/modules/notes/routes.test.ts @@ -17,6 +17,8 @@ const { vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock })); vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' })); +vi.mock('../../lib/extraction-client.js', () => ({ extractFromText: vi.fn(async () => ({ summary: 'test' })) })); +vi.mock('../note-artifacts/repository.js', () => ({ createNoteArtifact: vi.fn(async (doc: unknown) => doc) })); vi.mock('./repository.js', () => ({ listNotes: listNotesMock, getNote: getNoteMock, @@ -38,8 +40,8 @@ describe('noteRoutes', () => { await noteRoutes(app as never); - expect(app.get).toHaveBeenCalledTimes(3); - expect(app.post).toHaveBeenCalledTimes(3); + expect(app.get).toHaveBeenCalledTimes(4); + expect(app.post).toHaveBeenCalledTimes(4); expect(app.patch).toHaveBeenCalledTimes(1); }); }); diff --git a/backend/src/modules/notes/routes.ts b/backend/src/modules/notes/routes.ts index bdc63bf..ccb2240 100644 --- a/backend/src/modules/notes/routes.ts +++ b/backend/src/modules/notes/routes.ts @@ -2,7 +2,9 @@ import type { FastifyApp } from '@bytelyst/fastify-core'; import { BadRequestError, NotFoundError } from '../../lib/errors.js'; import { extractAuth } from '../../lib/auth.js'; import { PRODUCT_ID } from '../../lib/product-config.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; @@ -173,4 +175,72 @@ export async function noteRoutes(app: RouteApp) { return updated; }); + + app.post('/notes/:id/summarize', async (req, reply) => { + const auth = await extractAuth(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 }; + }); } diff --git a/web/src/app/(app)/notes/[noteId]/page.tsx b/web/src/app/(app)/notes/[noteId]/page.tsx index d7bcdb2..aa43308 100644 --- a/web/src/app/(app)/notes/[noteId]/page.tsx +++ b/web/src/app/(app)/notes/[noteId]/page.tsx @@ -11,7 +11,7 @@ import { TaskReviewPanel } from "@/components/TaskReviewPanel"; import { ArtifactPanel } from "@/components/ArtifactPanel"; import { AgentTimeline } from "@/components/AgentTimeline"; import { LinkNoteModal } from "@/components/LinkNoteModal"; -import { archiveNote, createNoteArtifact, createNoteTask, getNoteDetail, restoreNote, updateNoteDetail } from "@/lib/notes-client"; +import { archiveNote, createNoteArtifact, createNoteTask, getNoteDetail, restoreNote, summarizeNote, updateNoteDetail } from "@/lib/notes-client"; import type { NoteDetail } from "@/lib/types"; export default function NoteDetailPage() { @@ -111,6 +111,16 @@ export default function NoteDetailPage() { } } + async function handleSummarize() { + if (!note) return; + try { + await summarizeNote(note.id, note.workspaceId); + setNote(await getNoteDetail(note.id, note.workspaceId)); + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to summarize note"); + } + } + async function handleArchive() { if (!note) return; try { @@ -158,6 +168,9 @@ export default function NoteDetailPage() { {`Review: ${note.metadata.reviewState}`} )} + diff --git a/web/src/app/(app)/workspaces/page.tsx b/web/src/app/(app)/workspaces/page.tsx index 1d06e60..5424ce1 100644 --- a/web/src/app/(app)/workspaces/page.tsx +++ b/web/src/app/(app)/workspaces/page.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { Suspense, useEffect, useMemo, useState } from "react"; import { AppShell } from "@/components/AppShell"; -import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client"; +import { exportNotes, listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client"; import type { NoteSummary, WorkspaceSummary } from "@/lib/types"; export default function WorkspacesPage() { @@ -96,7 +96,27 @@ function WorkspacesPageInner() { Saved views derived live} + actions={ + + } >