Compare commits
3 Commits
t3code/61c
...
v2/rewrite
| Author | SHA1 | Date | |
|---|---|---|---|
| 27f00005f5 | |||
| 50d166cb7c | |||
| 0e5a31866f |
8
.vite/deps/_metadata.json
Normal file
8
.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"hash": "051e2474",
|
||||||
|
"configHash": "e4c88079",
|
||||||
|
"lockfileHash": "d1d1ccd5",
|
||||||
|
"browserHash": "5d55767b",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
3
.vite/deps/package.json
Normal file
3
.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -603,6 +603,7 @@ export function App() {
|
|||||||
onSelectCompany={selectCompany}
|
onSelectCompany={selectCompany}
|
||||||
onRemoveHolding={removeHolding}
|
onRemoveHolding={removeHolding}
|
||||||
pendingTicker={settingActiveTicker ?? removingTicker ?? addingTicker}
|
pendingTicker={settingActiveTicker ?? removingTicker ?? addingTicker}
|
||||||
|
profile={profile}
|
||||||
onRunResearch={() => runPipeline("research")}
|
onRunResearch={() => runPipeline("research")}
|
||||||
runningResearch={runningPipeline === "research"}
|
runningResearch={runningPipeline === "research"}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
@@ -1157,17 +1158,28 @@ function Home(props: {
|
|||||||
onSelectCompany: (tickerOrId: string, screen?: Screen) => void;
|
onSelectCompany: (tickerOrId: string, screen?: Screen) => void;
|
||||||
onRemoveHolding: (ticker: string) => void;
|
onRemoveHolding: (ticker: string) => void;
|
||||||
pendingTicker: string | null;
|
pendingTicker: string | null;
|
||||||
|
profile: Partial<UserProfile>;
|
||||||
onRunResearch: () => void | Promise<void>;
|
onRunResearch: () => void | Promise<void>;
|
||||||
runningResearch: boolean;
|
runningResearch: boolean;
|
||||||
addToast: (t: Omit<Toast, "id">) => void;
|
addToast: (t: Omit<Toast, "id">) => void;
|
||||||
}) {
|
}) {
|
||||||
const holdings = props.holdings;
|
const holdings = props.holdings;
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
const timeGreeting = hour < 12 ? "Good morning" : hour < 17 ? "Good afternoon" : "Good evening";
|
||||||
|
const initials = props.profile.name
|
||||||
|
? props.profile.name
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<section className={ui.screen}>
|
<section className={ui.screen}>
|
||||||
<div className="mb-5 flex items-start justify-between gap-4">
|
<div className="mb-5 flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className={ui.eyebrow}>Morning Briefing</p>
|
<p className={ui.eyebrow}>{timeGreeting.replace("Good ", "")} Briefing</p>
|
||||||
<h1 className={ui.h1}>Good morning, JD</h1>
|
<h1 className={ui.h1}>{timeGreeting}{initials ? `, ${initials}` : ""}</h1>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className={cx(ui.btn, ui.btnPrimary)}
|
className={cx(ui.btn, ui.btnPrimary)}
|
||||||
@@ -1933,6 +1945,8 @@ function Memo({
|
|||||||
>({});
|
>({});
|
||||||
const [lastSavedAt, setLastSavedAt] = useState<string | null>(null);
|
const [lastSavedAt, setLastSavedAt] = useState<string | null>(null);
|
||||||
const [saveError, setSaveError] = 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 saveTimers = useRef<Record<string, number>>({});
|
||||||
const sections = sectionsDraft;
|
const sections = sectionsDraft;
|
||||||
const activeIndex = Math.max(
|
const activeIndex = Math.max(
|
||||||
@@ -2142,6 +2156,64 @@ function Memo({
|
|||||||
else setSaveError(result.error.message);
|
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(
|
const activeCitations = memo.citations.filter(
|
||||||
(c) => c.sectionId === activeSectionId,
|
(c) => c.sectionId === activeSectionId,
|
||||||
);
|
);
|
||||||
@@ -2195,9 +2267,12 @@ function Memo({
|
|||||||
</div>
|
</div>
|
||||||
{!outlineCollapsed &&
|
{!outlineCollapsed &&
|
||||||
sections.map((s, i) => (
|
sections.map((s, i) => (
|
||||||
<button
|
<div
|
||||||
className={navButton(s.id === activeSectionId)}
|
className={navItem(s.id === activeSectionId)}
|
||||||
key={s.id}
|
key={s.id}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="flex-1 text-left bg-transparent border-0 p-0 text-inherit cursor-pointer"
|
||||||
onClick={() => activateSection(s.id, true)}
|
onClick={() => activateSection(s.id, true)}
|
||||||
>
|
>
|
||||||
<span className="mr-2 font-[var(--font-mono)] text-[10px] text-[var(--muted)]">
|
<span className="mr-2 font-[var(--font-mono)] text-[10px] text-[var(--muted)]">
|
||||||
@@ -2210,7 +2285,25 @@ function Memo({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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>
|
</aside>
|
||||||
<article className="mx-auto min-w-0 max-w-[720px] overflow-y-auto px-8 py-8">
|
<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">
|
<div className="sticky top-0 z-10 mb-5 flex items-center gap-2 bg-[var(--bg)] py-2">
|
||||||
@@ -2328,6 +2421,13 @@ function Memo({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</article>
|
||||||
{rightPanelCollapsed ? (
|
{rightPanelCollapsed ? (
|
||||||
<button
|
<button
|
||||||
@@ -4165,6 +4265,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) {
|
function tagClass(tone?: string) {
|
||||||
return cx(
|
return cx(
|
||||||
ui.tag,
|
ui.tag,
|
||||||
|
|||||||
@@ -213,6 +213,15 @@ export type RpcRequestMap = {
|
|||||||
"model.deleteRow": { companyId: string; tab: string; row: number };
|
"model.deleteRow": { companyId: string; tab: string; row: number };
|
||||||
"model.runScenario": { companyId: string; scenario: string; overrides: Record<string, string> };
|
"model.runScenario": { companyId: string; scenario: string; overrides: Record<string, string> };
|
||||||
"memo.get": { companyId: string };
|
"memo.get": { companyId: string };
|
||||||
|
"memo.addSection": {
|
||||||
|
companyId: string;
|
||||||
|
title: string;
|
||||||
|
afterSectionId?: string;
|
||||||
|
};
|
||||||
|
"memo.deleteSection": {
|
||||||
|
companyId: string;
|
||||||
|
sectionId: string;
|
||||||
|
};
|
||||||
"memo.updateSection": {
|
"memo.updateSection": {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
sectionId: string;
|
sectionId: string;
|
||||||
@@ -284,6 +293,12 @@ export type RpcResponseMap = {
|
|||||||
annotations: MemoAnnotation[];
|
annotations: MemoAnnotation[];
|
||||||
sectionReviews: MemoSectionReview[];
|
sectionReviews: MemoSectionReview[];
|
||||||
};
|
};
|
||||||
|
"memo.addSection": {
|
||||||
|
section: MemoSection;
|
||||||
|
};
|
||||||
|
"memo.deleteSection": {
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
"memo.updateSection": {
|
"memo.updateSection": {
|
||||||
section: MemoSection;
|
section: MemoSection;
|
||||||
status: "draft" | "review" | "final";
|
status: "draft" | "review" | "final";
|
||||||
|
|||||||
@@ -101,6 +101,15 @@ export const RpcRequestSchemas = {
|
|||||||
overrides: z.record(z.string()),
|
overrides: z.record(z.string()),
|
||||||
}),
|
}),
|
||||||
"memo.get": z.object({ companyId: idString }),
|
"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({
|
"memo.updateSection": z.object({
|
||||||
companyId: idString,
|
companyId: idString,
|
||||||
sectionId: idString,
|
sectionId: idString,
|
||||||
@@ -186,6 +195,12 @@ export const RpcResponseSchemas = {
|
|||||||
annotations: z.array(MemoAnnotationSchema),
|
annotations: z.array(MemoAnnotationSchema),
|
||||||
sectionReviews: z.array(MemoSectionReviewSchema),
|
sectionReviews: z.array(MemoSectionReviewSchema),
|
||||||
}),
|
}),
|
||||||
|
"memo.addSection": z.object({
|
||||||
|
section: MemoSectionSchema,
|
||||||
|
}),
|
||||||
|
"memo.deleteSection": z.object({
|
||||||
|
ok: z.boolean(),
|
||||||
|
}),
|
||||||
"memo.updateSection": z.object({
|
"memo.updateSection": z.object({
|
||||||
section: MemoSectionSchema,
|
section: MemoSectionSchema,
|
||||||
status: z.enum(["draft", "review", "final"]),
|
status: z.enum(["draft", "review", "final"]),
|
||||||
|
|||||||
@@ -667,6 +667,48 @@ export function createDefaultMemo(db: Db, companyId: string): void {
|
|||||||
`).run(companyId);
|
`).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(
|
export function updateMemoSection(
|
||||||
db: Db,
|
db: Db,
|
||||||
companyId: string,
|
companyId: string,
|
||||||
|
|||||||
@@ -121,4 +121,149 @@ describe("memo RPC", () => {
|
|||||||
});
|
});
|
||||||
expect(result.data.review).not.toHaveProperty("section_id");
|
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 type { Db } from "../db/database.js";
|
||||||
import {
|
import {
|
||||||
addMemoAnnotation,
|
addMemoAnnotation,
|
||||||
|
addMemoSection,
|
||||||
|
deleteMemoSection,
|
||||||
getMemo,
|
getMemo,
|
||||||
resolveMemoAnnotation,
|
resolveMemoAnnotation,
|
||||||
resolveCompany,
|
resolveCompany,
|
||||||
@@ -19,6 +21,8 @@ function errorDetail(operation: string, error: unknown): { operation: string; me
|
|||||||
|
|
||||||
type MemoMethod =
|
type MemoMethod =
|
||||||
| "memo.get"
|
| "memo.get"
|
||||||
|
| "memo.addSection"
|
||||||
|
| "memo.deleteSection"
|
||||||
| "memo.updateSection"
|
| "memo.updateSection"
|
||||||
| "memo.addAnnotation"
|
| "memo.addAnnotation"
|
||||||
| "memo.resolveAnnotation"
|
| "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));
|
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 }) => {
|
"memo.updateSection": ({ companyId, sectionId, title, content }) => {
|
||||||
const company = resolveCompany(db, companyId);
|
const company = resolveCompany(db, companyId);
|
||||||
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
|
if (!company) return fail("NOT_FOUND", `Company "${companyId}" not found.`);
|
||||||
|
|||||||
Reference in New Issue
Block a user