feat(backend+web): note summarization + export endpoints [B3, B6]

- backend: POST /notes/:id/summarize — calls extraction-service, stores summary artifact
- backend: GET /notes/export — JSON + Markdown format support
- backend: extraction-client.ts for extraction-service integration
- backend: 4 new integration tests (summarize, export JSON, export MD, invalid format)
- web: summarizeNote + exportNotes client functions
- web: Summarize button on note detail page
- web: Export Notes button on workspaces page
- web: exclude e2e/ from vitest config
- Total: 80 backend, 14 web, 23 mobile = 117 tests
This commit is contained in:
saravanakumardb1 2026-03-19 08:59:26 -07:00
parent dd62d3bf5c
commit e5535252c7
8 changed files with 191 additions and 5 deletions

View File

@ -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<ExtractionResult> {
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<ExtractionResult>;
}

View File

@ -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' });

View File

@ -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);
});
});

View File

@ -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<FastifyApp, 'setReadyState' | 'isReadyState'>;
@ -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 };
});
}

View File

@ -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}`}
</Link>
)}
<button className="btn btn-secondary" onClick={handleSummarize}>
Summarize
</button>
<button className="btn btn-secondary" onClick={() => setShowLinkNote(true)}>
Link Note
</button>

View File

@ -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() {
<AppShell
title="Workspaces"
description="Workspace-level organization, filters, and saved-view entry points for note collections."
actions={<div className="badge">Saved views derived live</div>}
actions={
<button
className="btn btn-secondary"
onClick={async () => {
try {
const data = await exportNotes("json");
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "notes-export.json";
a.click();
URL.revokeObjectURL(url);
} catch (err) {
setError(err instanceof Error ? err.message : "Export failed");
}
}}
>
Export Notes
</button>
}
>
<section style={{ display: "grid", gridTemplateColumns: "minmax(260px, 320px) minmax(0, 1fr)", gap: "var(--ml-space-4)" }}>
<aside className="surface-card" style={{ padding: "var(--ml-space-5)", display: "grid", gap: "var(--ml-space-4)" }}>

View File

@ -342,6 +342,22 @@ export async function restoreNote(noteId: string, workspaceId: string): Promise<
});
}
export async function summarizeNote(noteId: string, workspaceId: string): Promise<void> {
const api = createNotesApiClient();
await api.fetch(`/notes/${encodeURIComponent(noteId)}/summarize`, {
method: "POST",
body: JSON.stringify({ workspaceId }),
});
}
export async function exportNotes(format: "json" | "markdown", workspaceId?: string): Promise<string> {
const api = createNotesApiClient();
const params = new URLSearchParams({ format });
if (workspaceId) params.set("workspaceId", workspaceId);
const res = await api.fetch<string>(`/notes/export?${params.toString()}`);
return typeof res === "string" ? res : JSON.stringify(res, null, 2);
}
export async function createNoteRelationship(input: {
id: string;
workspaceId: string;

View File

@ -6,6 +6,7 @@ export default defineConfig({
environment: "jsdom",
setupFiles: ["./src/test/setupTests.ts"],
globals: true,
exclude: ["e2e/**", "node_modules/**"],
},
resolve: {
alias: {