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

@@ -1914,6 +1914,8 @@ function Memo({
>({});
const [lastSavedAt, setLastSavedAt] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [addingSection, setAddingSection] = useState(false);
const [deletingSectionId, setDeletingSectionId] = useState<string | null>(null);
const saveTimers = useRef<Record<string, number>>({});
const sections = sectionsDraft;
const activeIndex = Math.max(
@@ -2123,6 +2125,64 @@ function Memo({
else setSaveError(result.error.message);
}
async function addSection(afterSectionId?: string) {
if (!company) return;
setAddingSection(true);
try {
const index = afterSectionId
? sections.findIndex((s) => s.id === afterSectionId)
: sections.length - 1;
const title = `New Section`;
const result = await rpc.call("memo.addSection", {
companyId: company.id,
title,
afterSectionId,
});
if (result.ok) {
const newSection = result.data.section;
setSectionsDraft((prev) => {
const next = [...prev];
next.splice(index + 1, 0, newSection);
return next;
});
setActiveSectionId(newSection.id);
setSaveStateBySection((prev) => ({ ...prev, [newSection.id]: "saved" }));
addToast({ type: "success", title: "Section added", desc: `"${title}" added to memo` });
} else {
addToast({ type: "error", title: "Could not add section", desc: result.error.message });
}
} finally {
setAddingSection(false);
}
}
async function deleteSection(sectionId: string) {
if (!company) return;
if (sections.length <= 1) {
addToast({ type: "warning", title: "Cannot delete", desc: "A memo must have at least one section." });
return;
}
setDeletingSectionId(sectionId);
try {
const result = await rpc.call("memo.deleteSection", {
companyId: company.id,
sectionId,
});
if (result.ok) {
setSectionsDraft((prev) => prev.filter((s) => s.id !== sectionId));
if (activeSectionId === sectionId) {
const remaining = sections.filter((s) => s.id !== sectionId);
setActiveSectionId(remaining[0]?.id ?? "");
}
addToast({ type: "success", title: "Section deleted" });
} else {
addToast({ type: "error", title: "Could not delete section", desc: result.error.message });
}
} finally {
setDeletingSectionId(null);
}
}
const activeCitations = memo.citations.filter(
(c) => c.sectionId === activeSectionId,
);
@@ -2176,22 +2236,43 @@ function Memo({
</div>
{!outlineCollapsed &&
sections.map((s, i) => (
<button
className={navButton(s.id === activeSectionId)}
<div
className={navItem(s.id === activeSectionId)}
key={s.id}
onClick={() => activateSection(s.id, true)}
>
<span className="mr-2 font-[var(--font-mono)] text-[10px] text-[var(--muted)]">
{String(i + 1).padStart(2, "0")}
</span>
{s.title}
{s.primaryAgent && (
<span className="ml-auto font-[var(--font-mono)] text-[9px] text-[var(--muted)]">
[{s.primaryAgent}]
<button
className="flex-1 text-left bg-transparent border-0 p-0 text-inherit cursor-pointer"
onClick={() => activateSection(s.id, true)}
>
<span className="mr-2 font-[var(--font-mono)] text-[10px] text-[var(--muted)]">
{String(i + 1).padStart(2, "0")}
</span>
)}
</button>
{s.title}
{s.primaryAgent && (
<span className="ml-auto font-[var(--font-mono)] text-[9px] text-[var(--muted)]">
[{s.primaryAgent}]
</span>
)}
</button>
<button
className={cx(ui.iconBtn, "shrink-0 text-[var(--muted)] hover:text-[var(--red)]", deletingSectionId === s.id && "opacity-50")}
aria-label={`Delete section ${s.title}`}
disabled={deletingSectionId === s.id || sections.length <= 1}
onClick={() => void deleteSection(s.id)}
>
×
</button>
</div>
))}
{!outlineCollapsed && (
<button
className={cx(ui.btn, ui.btnSm, "mt-2 w-full", addingSection && "opacity-50")}
disabled={addingSection}
onClick={() => void addSection()}
>
{addingSection ? "Adding..." : "+ Add Section"}
</button>
)}
</aside>
<article className="mx-auto min-w-0 max-w-[720px] overflow-y-auto px-8 py-8">
<div className="sticky top-0 z-10 mb-5 flex items-center gap-2 bg-[var(--bg)] py-2">
@@ -2309,6 +2390,13 @@ function Memo({
)}
</section>
))}
<button
className={cx(ui.btn, ui.btnSm, "mt-4 mb-8 w-full", addingSection && "opacity-50")}
disabled={addingSection}
onClick={() => void addSection(sections[sections.length - 1]?.id)}
>
{addingSection ? "Adding..." : "+ Add Section"}
</button>
</article>
{rightPanelCollapsed ? (
<button
@@ -4062,6 +4150,14 @@ function navButton(active: boolean) {
);
}
function navItem(active: boolean) {
return cx(
"relative flex items-center gap-1 px-3 py-1.5 text-[12.5px] text-[var(--muted)] hover:text-[var(--fg)]",
active &&
"font-semibold text-[var(--fg)] before:absolute before:left-0 before:top-1 before:bottom-1 before:w-[3px] before:bg-[var(--accent)]",
);
}
function tagClass(tone?: string) {
return cx(
ui.tag,

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