diff --git a/src/components/modals/add-legal-form.tsx b/src/components/modals/add-legal-form.tsx new file mode 100644 index 0000000..8878d6f --- /dev/null +++ b/src/components/modals/add-legal-form.tsx @@ -0,0 +1,237 @@ +"use client" + +import { useRef, useState } from "react" +import { CheckCircle2, FileText, Link2, Loader2, Upload } from "lucide-react" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { useModalStore } from "@/stores/modal-store" +import { useAppStore } from "@/stores/app-store" +import { addLegalDocument, addLegalDocumentFile } from "@/lib/graph-api" + +type Mode = "url" | "file" + +const MAX_PDF_BYTES = 20 * 1024 * 1024 // 20 MB + +function isValidUrl(value: string): boolean { + try { + new URL(value) + return true + } catch { + return false + } +} + +export function AddLegalForm() { + const close = useModalStore((s) => s.close) + + const [mode, setMode] = useState("url") + + // URL mode state + const [urlValue, setUrlValue] = useState("") + + // File mode state + const [file, setFile] = useState(null) + const [fileError, setFileError] = useState("") + const fileInputRef = useRef(null) + + // Shared state + const [submitting, setSubmitting] = useState(false) + const [success, setSuccess] = useState(false) + const [error, setError] = useState("") + + const urlValid = isValidUrl(urlValue.trim()) + const canSubmit = !submitting && !success && (mode === "url" ? urlValid : !!file && !fileError) + + function handleModeChange(next: Mode) { + setMode(next) + setError("") + setFileError("") + } + + function handleFileChange(e: React.ChangeEvent) { + const selected = e.target.files?.[0] ?? null + setFileError("") + setError("") + + if (!selected) { + setFile(null) + return + } + + if (selected.type !== "application/pdf") { + setFile(null) + setFileError("Only PDF files are accepted.") + return + } + + if (selected.size > MAX_PDF_BYTES) { + setFile(null) + setFileError("File exceeds the 20 MB limit.") + return + } + + setFile(selected) + } + + async function handleSubmit() { + if (!canSubmit) return + + setSubmitting(true) + setError("") + + try { + if (mode === "url") { + await addLegalDocument(urlValue.trim()) + } else { + await addLegalDocumentFile(file!) + } + + setSuccess(true) + setTimeout(() => { + close() + useAppStore.getState().setMyContentOpen(true) + }, 1200) + } catch (err: unknown) { + if (err instanceof Response) { + try { + const body = await err.json() + setError(body?.message || "Failed to submit document. Try again.") + } catch { + setError("Failed to submit document. Try again.") + } + } else { + setError("Failed to submit document. Try again.") + } + } finally { + setSubmitting(false) + } + } + + return ( +
+ {/* Mode segmented control */} +
+ {(["url", "file"] as Mode[]).map((m) => { + const Icon = m === "url" ? Link2 : Upload + const label = m === "url" ? "URL" : "File" + return ( + + ) + })} +
+ + {/* URL mode */} + {mode === "url" && ( +
+ +
+ + { + setUrlValue(e.target.value) + setError("") + }} + placeholder="https://example.com/document.pdf" + className="h-10 w-full rounded-md border border-border/50 bg-muted/50 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none" + /> +
+
+ )} + + {/* File mode */} + {mode === "file" && ( +
+ + + + {fileError && ( +

{fileError}

+ )} +
+ )} + + {/* API error */} + {error && ( +

{error}

+ )} + + {/* Footer */} +
+ + {mode === "url" + ? urlValid + ? "URL looks good — ready to submit" + : "Enter a valid PDF URL to continue" + : file + ? "File ready — click Add Document" + : "Select a PDF file to continue"} + + + + +
+
+ ) +} diff --git a/src/components/modals/add-modal.tsx b/src/components/modals/add-modal.tsx index ac8a14d..d850da9 100644 --- a/src/components/modals/add-modal.tsx +++ b/src/components/modals/add-modal.tsx @@ -1,6 +1,6 @@ "use client" -import { Sparkles, Workflow, GitMerge } from "lucide-react" +import { Sparkles, Workflow, GitMerge, Scale } from "lucide-react" import { Dialog, DialogContent, @@ -10,28 +10,39 @@ import { } from "@/components/ui/dialog" import { cn } from "@/lib/utils" import { useModalStore, type AddTab } from "@/stores/modal-store" +import { useAppStore } from "@/stores/app-store" import { AddSourceForm } from "@/components/modals/add-source-form" import { AddNodeForm } from "@/components/modals/add-node-form" import { AddEdgeForm } from "@/components/modals/add-edge-form" - -// All three modes are open to everyone — like Add Node, edge creation is a -// paid action gated by sats (handled in the form), not by role. -const TABS: { id: AddTab; label: string; icon: React.ComponentType<{ className?: string }> }[] = [ - { id: "source", label: "Smart", icon: Sparkles }, - { id: "node", label: "Node", icon: Workflow }, - { id: "edge", label: "Edge", icon: GitMerge }, -] +import { AddLegalForm } from "@/components/modals/add-legal-form" export function AddModal() { const activeModal = useModalStore((s) => s.activeModal) const tab = useModalStore((s) => s.addTab) const setTab = useModalStore((s) => s.setAddTab) const close = useModalStore((s) => s.close) + const activeSkin = useAppStore((s) => s.activeSkin) const isOpen = activeModal === "add" + const isLegalSkin = activeSkin === "legal" + + // Build tabs dynamically based on active skin. + const TABS: { id: AddTab; label: string; icon: React.ComponentType<{ className?: string }> }[] = + isLegalSkin + ? [ + { id: "legal", label: "Legal", icon: Scale }, + { id: "source", label: "Other", icon: Sparkles }, + { id: "node", label: "Node", icon: Workflow }, + { id: "edge", label: "Edge", icon: GitMerge }, + ] + : [ + { id: "source", label: "Smart", icon: Sparkles }, + { id: "node", label: "Node", icon: Workflow }, + { id: "edge", label: "Edge", icon: GitMerge }, + ] - // Guard against a stale/invalid tab id. - const activeTab = TABS.some((t) => t.id === tab) ? tab : "source" + // Guard against a stale/invalid tab id — falls back to the first available tab. + const activeTab = TABS.some((t) => t.id === tab) ? tab : TABS[0].id return ( !open && close()}> @@ -71,6 +82,7 @@ export function AddModal() { {/* Only the active tab is mounted — gives each form fresh state and re-runs its on-mount fetches when selected. */} + {activeTab === "legal" && } {activeTab === "source" && } {activeTab === "node" && } {activeTab === "edge" && } diff --git a/src/lib/__tests__/add-legal-form.test.tsx b/src/lib/__tests__/add-legal-form.test.tsx new file mode 100644 index 0000000..937d91b --- /dev/null +++ b/src/lib/__tests__/add-legal-form.test.tsx @@ -0,0 +1,326 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import React from "react" + +// --- Modal store mock --- +const mockClose = vi.fn() +let mockAddTab = "legal" + +vi.mock("@/stores/modal-store", () => ({ + useModalStore: (sel?: (s: unknown) => unknown) => { + const state = { + activeModal: "add", + addTab: mockAddTab, + close: mockClose, + setAddTab: vi.fn(), + open: vi.fn(), + openAdd: vi.fn(), + } + return sel ? sel(state) : state + }, +})) + +// --- App store mock --- +const mockSetMyContentOpen = vi.fn() +let mockActiveSkin = "legal" + +vi.mock("@/stores/app-store", () => { + const getState = () => ({ + setMyContentOpen: mockSetMyContentOpen, + activeSkin: mockActiveSkin, + }) + return { + useAppStore: Object.assign( + (sel?: (s: unknown) => unknown) => { + const state = { activeSkin: mockActiveSkin, setMyContentOpen: mockSetMyContentOpen } + return sel ? sel(state) : state + }, + { getState } + ), + } +}) + +// --- Graph API mocks --- +const mockAddLegalDocument = vi.fn() +const mockAddLegalDocumentFile = vi.fn() + +vi.mock("@/lib/graph-api", () => ({ + addLegalDocument: (...args: unknown[]) => mockAddLegalDocument(...args), + addLegalDocumentFile: (...args: unknown[]) => mockAddLegalDocumentFile(...args), +})) + +// --- UI component mocks --- +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + onClick, + disabled, + className, + variant, + }: { + children: React.ReactNode + onClick?: () => void + disabled?: boolean + className?: string + variant?: string + }) => ( + + ), +})) + +vi.mock("@/lib/utils", () => ({ + cn: (...args: string[]) => args.filter(Boolean).join(" "), +})) + +// Import the component under test +import { AddLegalForm } from "@/components/modals/add-legal-form" +import { AddModal } from "@/components/modals/add-modal" + +// --- Additional mocks needed for AddModal --- +vi.mock("@/components/modals/add-source-form", () => ({ + AddSourceForm: () =>
, +})) + +vi.mock("@/components/modals/add-node-form", () => ({ + AddNodeForm: () =>
, +})) + +vi.mock("@/components/modals/add-edge-form", () => ({ + AddEdgeForm: () =>
, +})) + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, + DialogDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, +})) + +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks() + mockActiveSkin = "legal" + mockAddTab = "legal" + mockAddLegalDocument.mockResolvedValue({ status: "Success", nodes: [], status_messages: [] }) + mockAddLegalDocumentFile.mockResolvedValue({ status: "Success", nodes: [], status_messages: [] }) +}) + +afterEach(() => { + // Ensure fake timers never leak between tests + vi.useRealTimers() +}) + +// --------------------------------------------------------------------------- +// AddLegalForm — URL mode +// --------------------------------------------------------------------------- + +describe("AddLegalForm — URL mode", () => { + it("renders URL input by default", () => { + render() + expect(screen.getByPlaceholderText(/https:\/\/example\.com\/document\.pdf/i)).toBeInTheDocument() + }) + + it("disables submit when URL is empty", () => { + render() + const btn = screen.getByRole("button", { name: /add document/i }) + expect(btn).toBeDisabled() + }) + + it("disables submit for malformed URL", async () => { + render() + const input = screen.getByPlaceholderText(/https:\/\/example\.com\/document\.pdf/i) + await userEvent.type(input, "not-a-url") + const btn = screen.getByRole("button", { name: /add document/i }) + expect(btn).toBeDisabled() + }) + + it("enables submit for valid URL", async () => { + render() + const input = screen.getByPlaceholderText(/https:\/\/example\.com\/document\.pdf/i) + await userEvent.type(input, "https://example.com/doc.pdf") + const btn = screen.getByRole("button", { name: /add document/i }) + expect(btn).not.toBeDisabled() + }) + + it("calls addLegalDocument with the entered URL on submit", async () => { + render() + const input = screen.getByPlaceholderText(/https:\/\/example\.com\/document\.pdf/i) + await userEvent.type(input, "https://example.com/doc.pdf") + await userEvent.click(screen.getByRole("button", { name: /add document/i })) + await waitFor(() => { + expect(mockAddLegalDocument).toHaveBeenCalledWith("https://example.com/doc.pdf") + }) + }) +}) + +// --------------------------------------------------------------------------- +// AddLegalForm — File mode +// --------------------------------------------------------------------------- + +describe("AddLegalForm — File mode", () => { + it("switches to file mode", async () => { + render() + await userEvent.click(screen.getByRole("button", { name: /file/i })) + expect(screen.getByText(/click to select a pdf file/i)).toBeInTheDocument() + }) + + it("shows error for non-PDF file", async () => { + // applyAccept: false lets the non-PDF through to our component's own validation + const user = userEvent.setup({ applyAccept: false }) + render() + await user.click(screen.getByRole("button", { name: /file/i })) + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + const docxFile = new File(["content"], "resume.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }) + await user.upload(fileInput, docxFile) + + expect(await screen.findByText(/only pdf files are accepted/i)).toBeInTheDocument() + }) + + it("shows error for oversized PDF", async () => { + render() + await userEvent.click(screen.getByRole("button", { name: /file/i })) + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + // 21 MB PDF + const bigFile = new File([new ArrayBuffer(21 * 1024 * 1024)], "big.pdf", { + type: "application/pdf", + }) + await userEvent.upload(fileInput, bigFile) + + expect(await screen.findByText(/exceeds the 20 mb limit/i)).toBeInTheDocument() + }) + + it("enables submit for valid PDF ≤ 20 MB", async () => { + render() + await userEvent.click(screen.getByRole("button", { name: /file/i })) + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + const pdfFile = new File(["pdf content"], "doc.pdf", { type: "application/pdf" }) + await userEvent.upload(fileInput, pdfFile) + + const btn = screen.getByRole("button", { name: /add document/i }) + expect(btn).not.toBeDisabled() + }) + + it("calls addLegalDocumentFile on submit with a valid PDF", async () => { + render() + await userEvent.click(screen.getByRole("button", { name: /file/i })) + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + const pdfFile = new File(["pdf content"], "doc.pdf", { type: "application/pdf" }) + await userEvent.upload(fileInput, pdfFile) + + await userEvent.click(screen.getByRole("button", { name: /add document/i })) + + await waitFor(() => { + expect(mockAddLegalDocumentFile).toHaveBeenCalledWith(pdfFile) + }) + }) +}) + +// --------------------------------------------------------------------------- +// AddLegalForm — Success path +// --------------------------------------------------------------------------- + +describe("AddLegalForm — success path", () => { + it("calls close() and setMyContentOpen(true) after 1200 ms on success", async () => { + // shouldAdvanceTime: true lets real time still pass so waitFor/promises work + vi.useFakeTimers({ shouldAdvanceTime: true }) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) }) + + render() + const input = screen.getByPlaceholderText(/https:\/\/example\.com\/document\.pdf/i) + await user.type(input, "https://example.com/doc.pdf") + await user.click(screen.getByRole("button", { name: /add document/i })) + + await waitFor(() => { + expect(mockAddLegalDocument).toHaveBeenCalled() + }) + + // Fast-forward past the 1200 ms delay + await vi.advanceTimersByTimeAsync(1200) + + await waitFor(() => { + expect(mockClose).toHaveBeenCalled() + expect(mockSetMyContentOpen).toHaveBeenCalledWith(true) + }) + }) +}) + +// --------------------------------------------------------------------------- +// AddLegalForm — Error path +// --------------------------------------------------------------------------- + +describe("AddLegalForm — error path", () => { + it("shows error message and keeps modal open on API failure", async () => { + mockAddLegalDocument.mockRejectedValue( + Object.assign(new Response(JSON.stringify({ message: "Server error" }), { status: 500 }), {}) + ) + + render() + const input = screen.getByPlaceholderText(/https:\/\/example\.com\/document\.pdf/i) + await userEvent.type(input, "https://example.com/doc.pdf") + await userEvent.click(screen.getByRole("button", { name: /add document/i })) + + await waitFor(() => { + expect(screen.getByText(/server error/i)).toBeInTheDocument() + }) + expect(mockClose).not.toHaveBeenCalled() + }) + + it("shows fallback error message for generic errors", async () => { + mockAddLegalDocument.mockRejectedValue(new Error("Network failure")) + + render() + const input = screen.getByPlaceholderText(/https:\/\/example\.com\/document\.pdf/i) + await userEvent.type(input, "https://example.com/doc.pdf") + await userEvent.click(screen.getByRole("button", { name: /add document/i })) + + await waitFor(() => { + expect(screen.getByText(/failed to submit document/i)).toBeInTheDocument() + }) + }) +}) + +// --------------------------------------------------------------------------- +// AddModal — Skin guard integration +// --------------------------------------------------------------------------- + +describe("AddModal — skin guard", () => { + it("shows Legal tab first when activeSkin === 'legal'", () => { + mockActiveSkin = "legal" + mockAddTab = "legal" + render() + expect(screen.getByRole("button", { name: /legal/i })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /other/i })).toBeInTheDocument() + expect(screen.queryByRole("button", { name: /^smart$/i })).not.toBeInTheDocument() + }) + + it("shows Smart tab (no Legal) when activeSkin !== 'legal'", () => { + mockActiveSkin = "default" + mockAddTab = "source" + render() + expect(screen.getByRole("button", { name: /^smart$/i })).toBeInTheDocument() + expect(screen.queryByRole("button", { name: /^legal$/i })).not.toBeInTheDocument() + }) + + it("stale-tab guard: falls back to 'source' when legal tab active but skin switches to default", () => { + // Tab is "legal" but skin is now "default" — should render Smart tab as active + mockActiveSkin = "default" + mockAddTab = "legal" // stale value + render() + // The AddSourceForm should be rendered (source tab is the fallback first tab) + expect(screen.getByTestId("add-source-form")).toBeInTheDocument() + }) +}) diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index 2217f48..3183516 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -832,3 +832,47 @@ export async function dismissReview( export async function getSchemaAudit(): Promise { return api.get("/schema/audit") } + +// ── Legal document ingestion ───────────────────────────────────────────────── + +// URL path — submits a publicly accessible PDF URL to the legal workflow. +export async function addLegalDocument( + pdfUrl: string, + signal?: AbortSignal +): Promise<{ status: string; nodes: Array>; status_messages: string[] }> { + const l402 = await getL402() + const headers: Record = {} + if (l402) headers["Authorization"] = l402 + return api.post("/v2/legal", { pdf_url: pdfUrl }, headers, signal) +} + +// File path — multipart POST, mirrors addImageContent pattern. +export async function addLegalDocumentFile( + file: File, + signal?: AbortSignal +): Promise<{ status: string; nodes: Array>; status_messages: string[] }> { + const url = new URL(`${API_URL}/v2/legal/upload`) + + const signed = await getSignedMessage() + if (signed.signature) { + url.searchParams.append("sig", signed.signature) + url.searchParams.append("msg", signed.message) + } + + const l402 = await getL402() + const headers: Record = {} + if (l402) headers["Authorization"] = l402 + + const form = new FormData() + form.append("file", file) + + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: form, + signal: signal ?? new AbortController().signal, + }) + + if (!response.ok) throw response + return response.json() +} diff --git a/src/stores/modal-store.ts b/src/stores/modal-store.ts index f41cb66..2d4352d 100644 --- a/src/stores/modal-store.ts +++ b/src/stores/modal-store.ts @@ -4,7 +4,7 @@ import { create } from "zustand" import type { GraphNode } from "@/lib/graph-api" type ModalId = "add" | "budget" | "editNode" | null -export type AddTab = "source" | "node" | "edge" +export type AddTab = "source" | "node" | "edge" | "legal" interface ModalState { activeModal: ModalId