feat(memo): add and delete sections via RPC
Add memo.addSection and memo.deleteSection RPC methods with full-stack implementation: contract types, Zod schemas, DB queries, RPC handlers, UI buttons, and tests. - memo.addSection: creates a new empty section, supports positional insert after a given section via afterSectionId - memo.deleteSection: removes a section and cascades cleanup of its annotations, citations, and section reviews - Outline sidebar: per-section delete button, + Add Section button - Article area: + Add Section button after last section - 9 new tests (15 total memo tests, 48 project-wide)
This commit is contained in:
@@ -667,6 +667,48 @@ export function createDefaultMemo(db: Db, companyId: string): void {
|
||||
`).run(companyId);
|
||||
}
|
||||
|
||||
export function addMemoSection(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
title: string,
|
||||
afterSectionId?: string,
|
||||
): MemoSection {
|
||||
createDefaultMemo(db, companyId);
|
||||
const id = `ms-${companyId}-${slugify(title)}-${Date.now()}`;
|
||||
|
||||
// Determine the position: if afterSectionId is given, insert after that section
|
||||
// We use the id ordering since sections are ordered by id in getMemo
|
||||
// For simplicity, we generate an id that sorts after the given section
|
||||
let sectionId = id;
|
||||
if (afterSectionId) {
|
||||
// Append a timestamp suffix to ensure it sorts after
|
||||
sectionId = `${afterSectionId}-z${Date.now()}`;
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO memo_sections (id, company_id, title, content, updated_at, primary_agent)
|
||||
VALUES (?, ?, ?, '', datetime('now'), NULL)
|
||||
RETURNING id, title, content, updated_at as updatedAt, primary_agent as primaryAgent
|
||||
`);
|
||||
|
||||
const row = stmt.get(sectionId, companyId, title) as MemoSectionRow;
|
||||
return mapMemoSectionRow(row);
|
||||
}
|
||||
|
||||
export function deleteMemoSection(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
sectionId: string,
|
||||
): boolean {
|
||||
// Clean up related rows first (no FK cascade on section_id)
|
||||
db.prepare("DELETE FROM memo_annotations WHERE section_id = ? AND company_id = ?").run(sectionId, companyId);
|
||||
db.prepare("DELETE FROM memo_citations WHERE section_id = ? AND company_id = ?").run(sectionId, companyId);
|
||||
db.prepare("DELETE FROM memo_section_reviews WHERE section_id = ? AND company_id = ?").run(sectionId, companyId);
|
||||
const stmt = db.prepare("DELETE FROM memo_sections WHERE id = ? AND company_id = ?");
|
||||
const result = stmt.run(sectionId, companyId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function updateMemoSection(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
|
||||
@@ -121,4 +121,149 @@ describe("memo RPC", () => {
|
||||
});
|
||||
expect(result.data.review).not.toHaveProperty("section_id");
|
||||
});
|
||||
|
||||
describe("memo.addSection", () => {
|
||||
it("adds a new section and returns it", async () => {
|
||||
const result = await rpc("memo.addSection", {
|
||||
companyId: activeCompanyId,
|
||||
title: "Competitive Landscape",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(() => parseRpcResponse("memo.addSection", result.data)).not.toThrow();
|
||||
expect(result.data.section).toMatchObject({
|
||||
title: "Competitive Landscape",
|
||||
content: "",
|
||||
});
|
||||
expect(result.data.section.id).toBeTruthy();
|
||||
expect(result.data.section).not.toHaveProperty("updated_at");
|
||||
});
|
||||
|
||||
it("appends after the given section when afterSectionId is provided", async () => {
|
||||
const result = await rpc("memo.addSection", {
|
||||
companyId: activeCompanyId,
|
||||
title: "Valuation",
|
||||
afterSectionId: memoSections[0].id,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.data.section.title).toBe("Valuation");
|
||||
});
|
||||
|
||||
it("returns NOT_FOUND for unknown company", async () => {
|
||||
const result = await rpc("memo.addSection", {
|
||||
companyId: "unknown",
|
||||
title: "New Section",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } });
|
||||
});
|
||||
|
||||
it("new section appears in memo.get", async () => {
|
||||
const addResult = await rpc("memo.addSection", {
|
||||
companyId: activeCompanyId,
|
||||
title: "Risks",
|
||||
});
|
||||
expect(addResult.ok).toBe(true);
|
||||
|
||||
const getResult = await rpc("memo.get", { companyId: activeCompanyId });
|
||||
expect(getResult.ok).toBe(true);
|
||||
if (!getResult.ok) return;
|
||||
expect(getResult.data.sections.length).toBe(2);
|
||||
expect(getResult.data.sections.some((s) => s.title === "Risks")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memo.deleteSection", () => {
|
||||
it("deletes an existing section", async () => {
|
||||
// Add a second section so we still have one left
|
||||
await rpc("memo.addSection", { companyId: activeCompanyId, title: "Valuation" });
|
||||
|
||||
const result = await rpc("memo.deleteSection", {
|
||||
companyId: activeCompanyId,
|
||||
sectionId: memoSections[0].id,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true, data: { ok: true } });
|
||||
|
||||
// Verify section is gone
|
||||
const getResult = await rpc("memo.get", { companyId: activeCompanyId });
|
||||
expect(getResult.ok).toBe(true);
|
||||
if (!getResult.ok) return;
|
||||
expect(getResult.data.sections.find((s) => s.id === memoSections[0].id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns NOT_FOUND for unknown section", async () => {
|
||||
const result = await rpc("memo.deleteSection", {
|
||||
companyId: activeCompanyId,
|
||||
sectionId: "nonexistent",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } });
|
||||
});
|
||||
|
||||
it("returns NOT_FOUND for unknown company", async () => {
|
||||
const result = await rpc("memo.deleteSection", {
|
||||
companyId: "unknown",
|
||||
sectionId: memoSections[0].id,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } });
|
||||
});
|
||||
|
||||
it("cleans up related annotations when section is deleted", async () => {
|
||||
// Add annotation to the section
|
||||
const ann = await rpc("memo.addAnnotation", {
|
||||
companyId: activeCompanyId,
|
||||
sectionId: memoSections[0].id,
|
||||
kind: "highlight",
|
||||
selectedText: "some text",
|
||||
});
|
||||
expect(ann.ok).toBe(true);
|
||||
|
||||
// Add a second section so delete is allowed
|
||||
await rpc("memo.addSection", { companyId: activeCompanyId, title: "Extra" });
|
||||
|
||||
// Delete the section with the annotation
|
||||
const del = await rpc("memo.deleteSection", {
|
||||
companyId: activeCompanyId,
|
||||
sectionId: memoSections[0].id,
|
||||
});
|
||||
expect(del.ok).toBe(true);
|
||||
|
||||
// Annotation should be gone from memo.get
|
||||
const getResult = await rpc("memo.get", { companyId: activeCompanyId });
|
||||
expect(getResult.ok).toBe(true);
|
||||
if (!getResult.ok) return;
|
||||
expect(getResult.data.annotations.length).toBe(0);
|
||||
});
|
||||
|
||||
it("cleans up section reviews when section is deleted", async () => {
|
||||
// Set a review on the section
|
||||
const review = await rpc("memo.updateSectionReview", {
|
||||
companyId: activeCompanyId,
|
||||
sectionId: memoSections[0].id,
|
||||
status: "approved",
|
||||
});
|
||||
expect(review.ok).toBe(true);
|
||||
|
||||
// Add a second section
|
||||
await rpc("memo.addSection", { companyId: activeCompanyId, title: "Extra" });
|
||||
|
||||
// Delete the section with the review
|
||||
const del = await rpc("memo.deleteSection", {
|
||||
companyId: activeCompanyId,
|
||||
sectionId: memoSections[0].id,
|
||||
});
|
||||
expect(del.ok).toBe(true);
|
||||
|
||||
// Review should be gone
|
||||
const getResult = await rpc("memo.get", { companyId: activeCompanyId });
|
||||
expect(getResult.ok).toBe(true);
|
||||
if (!getResult.ok) return;
|
||||
expect(getResult.data.sectionReviews.find((r) => r.sectionId === memoSections[0].id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Db } from "../db/database.js";
|
||||
import {
|
||||
addMemoAnnotation,
|
||||
addMemoSection,
|
||||
deleteMemoSection,
|
||||
getMemo,
|
||||
resolveMemoAnnotation,
|
||||
resolveCompany,
|
||||
@@ -19,6 +21,8 @@ function errorDetail(operation: string, error: unknown): { operation: string; me
|
||||
|
||||
type MemoMethod =
|
||||
| "memo.get"
|
||||
| "memo.addSection"
|
||||
| "memo.deleteSection"
|
||||
| "memo.updateSection"
|
||||
| "memo.addAnnotation"
|
||||
| "memo.resolveAnnotation"
|
||||
@@ -37,6 +41,23 @@ export function memoHandlers(db: Db): RpcHandlers<MemoMethod> {
|
||||
return fail("INTERNAL_ERROR", "Could not load memo for company.", errorDetail("getMemo", error));
|
||||
}
|
||||
},
|
||||
"memo.addSection": ({ companyId, title, afterSectionId }) => {
|
||||
const company = resolveCompany(db, companyId);
|
||||
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
|
||||
try {
|
||||
const section = addMemoSection(db, company.id, title, afterSectionId);
|
||||
return ok("memo.addSection", { section });
|
||||
} catch (error) {
|
||||
return fail("INTERNAL_ERROR", "Could not add memo section.", errorDetail("addMemoSection", error));
|
||||
}
|
||||
},
|
||||
"memo.deleteSection": ({ companyId, sectionId }) => {
|
||||
const company = resolveCompany(db, companyId);
|
||||
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
|
||||
const deleted = deleteMemoSection(db, company.id, sectionId);
|
||||
if (!deleted) return fail("NOT_FOUND", `Section "${sectionId}" not found.`);
|
||||
return ok("memo.deleteSection", { ok: true });
|
||||
},
|
||||
"memo.updateSection": ({ companyId, sectionId, title, content }) => {
|
||||
const company = resolveCompany(db, companyId);
|
||||
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
|
||||
|
||||
Reference in New Issue
Block a user