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:
2026-05-15 00:27:09 -04:00
parent 0624026af3
commit 0e5a31866f
6 changed files with 346 additions and 12 deletions

View File

@@ -211,6 +211,15 @@ export type RpcRequestMap = {
"model.deleteRow": { companyId: string; tab: string; row: number };
"model.runScenario": { companyId: string; scenario: string; overrides: Record<string, string> };
"memo.get": { companyId: string };
"memo.addSection": {
companyId: string;
title: string;
afterSectionId?: string;
};
"memo.deleteSection": {
companyId: string;
sectionId: string;
};
"memo.updateSection": {
companyId: string;
sectionId: string;
@@ -282,6 +291,12 @@ export type RpcResponseMap = {
annotations: MemoAnnotation[];
sectionReviews: MemoSectionReview[];
};
"memo.addSection": {
section: MemoSection;
};
"memo.deleteSection": {
ok: boolean;
};
"memo.updateSection": {
section: MemoSection;
status: "draft" | "review" | "final";

View File

@@ -93,6 +93,15 @@ export const RpcRequestSchemas = {
overrides: z.record(z.string()),
}),
"memo.get": z.object({ companyId: idString }),
"memo.addSection": z.object({
companyId: idString,
title: nonEmptyString,
afterSectionId: idString.optional(),
}),
"memo.deleteSection": z.object({
companyId: idString,
sectionId: idString,
}),
"memo.updateSection": z.object({
companyId: idString,
sectionId: idString,
@@ -178,6 +187,12 @@ export const RpcResponseSchemas = {
annotations: z.array(MemoAnnotationSchema),
sectionReviews: z.array(MemoSectionReviewSchema),
}),
"memo.addSection": z.object({
section: MemoSectionSchema,
}),
"memo.deleteSection": z.object({
ok: z.boolean(),
}),
"memo.updateSection": z.object({
section: MemoSectionSchema,
status: z.enum(["draft", "review", "final"]),

View File

@@ -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,

View File

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

View File

@@ -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.`);