diff --git a/src/components/modals/add-legal-form.tsx b/src/components/modals/add-legal-form.tsx index 8878d6f..2a13aeb 100644 --- a/src/components/modals/add-legal-form.tsx +++ b/src/components/modals/add-legal-form.tsx @@ -1,12 +1,20 @@ "use client" -import { useRef, useState } from "react" -import { CheckCircle2, FileText, Link2, Loader2, Upload } from "lucide-react" +import { useCallback, useEffect, useRef, useState } from "react" +import { AlertTriangle, CheckCircle2, FileText, Link2, Loader2, Upload, Zap } 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" +import { useUserStore } from "@/stores/user-store" +import { + addLegalDocument, + addLegalDocumentFile, + checkNodeExists, + checkNodeExistsByHash, + reprocessContent, +} from "@/lib/graph-api" +import { getPrice, payL402 } from "@/lib/sphinx" type Mode = "url" | "file" @@ -21,8 +29,20 @@ function isValidUrl(value: string): boolean { } } +async function computeHash(file: File): Promise { + const buffer = await file.arrayBuffer() + const hashBuffer = await crypto.subtle.digest("SHA-256", buffer) + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + export function AddLegalForm() { const close = useModalStore((s) => s.close) + const openModal = useModalStore((s) => s.open) + const { isAdmin } = useUserStore() + const ownerReferenceId = useUserStore((s) => s.ownerReferenceId) + const refreshBalance = useUserStore((s) => s.refreshBalance) const [mode, setMode] = useState("url") @@ -39,19 +59,55 @@ export function AddLegalForm() { const [success, setSuccess] = useState(false) const [error, setError] = useState("") + // Duplicate detection state + const [existingNode, setExistingNode] = useState<{ ref_id: string; owner_reference_id: string | null } | null>(null) + const [contentHash, setContentHash] = useState(null) + const [price, setPrice] = useState(null) + const urlValid = isValidUrl(urlValue.trim()) - const canSubmit = !submitting && !success && (mode === "url" ? urlValid : !!file && !fileError) + const canSubmit = !submitting && !success && !existingNode && (mode === "url" ? urlValid : !!file && !fileError) + + // Fetch price for content submission + useEffect(() => { + getPrice("v2/content").then(setPrice) + }, []) + + // URL mode — check for existing node when URL becomes valid + useEffect(() => { + if (!urlValid) { + setExistingNode(null) + return + } + const controller = new AbortController() + checkNodeExists("LegalDocument", urlValue.trim(), controller.signal).then((check) => { + setExistingNode( + check.exists && check.ref_id + ? { ref_id: check.ref_id, owner_reference_id: check.owner_reference_id } + : null + ) + }) + return () => controller.abort() + }, [urlValue, urlValid]) + + const currentUserOwns = useCallback( + (nodeRef: string | null) => !!nodeRef && !!ownerReferenceId && nodeRef === ownerReferenceId, + [ownerReferenceId] + ) function handleModeChange(next: Mode) { setMode(next) setError("") setFileError("") + setExistingNode(null) + setContentHash(null) } - function handleFileChange(e: React.ChangeEvent) { + async function handleFileChange(e: React.ChangeEvent) { const selected = e.target.files?.[0] ?? null setFileError("") setError("") + setExistingNode(null) + setContentHash(null) if (!selected) { setFile(null) @@ -71,8 +127,68 @@ export function AddLegalForm() { } setFile(selected) + + // Compute SHA-256 hash and check for existing node + try { + const hash = await computeHash(selected) + setContentHash(hash) + const check = await checkNodeExistsByHash("LegalDocument", hash) + setExistingNode( + check.exists && check.ref_id + ? { ref_id: check.ref_id, owner_reference_id: check.owner_reference_id } + : null + ) + } catch { + // Hash computation failed — allow submission to proceed without dedup + } } + const handleReprocess = useCallback(async () => { + if (!existingNode) return + if (!isAdmin && !currentUserOwns(existingNode.owner_reference_id)) return + setSubmitting(true) + setError("") + try { + const body: Record = + mode === "url" + ? { source_link: urlValue.trim(), content_type: "legal_document" } + : { content_hash: contentHash, content_type: "legal_document" } + await reprocessContent(existingNode.ref_id, body) + setSuccess(true) + refreshBalance() + setTimeout(() => { + setExistingNode(null) + setSuccess(false) + close() + useAppStore.getState().setMyContentOpen(true) + }, 1200) + } catch (err) { + if (err instanceof Response && err.status === 402) { + try { + await payL402(useUserStore.getState().setBudget) + const body: Record = + mode === "url" + ? { source_link: urlValue.trim(), content_type: "legal_document" } + : { content_hash: contentHash, content_type: "legal_document" } + await reprocessContent(existingNode.ref_id, body) + setSuccess(true) + refreshBalance() + setTimeout(() => { + setExistingNode(null) + setSuccess(false) + close() + }, 1200) + } catch { + openModal("budget") + } + } else { + setError("Re-process failed. Try again.") + } + } finally { + setSubmitting(false) + } + }, [existingNode, mode, urlValue, contentHash, refreshBalance, close, openModal]) + async function handleSubmit() { if (!canSubmit) return @@ -192,6 +308,34 @@ export function AddLegalForm() { )} + {/* Already-in-graph yellow callout */} + {existingNode && !submitting && ( +
+ +
+

⚠️ This content is already in the graph

+ {(isAdmin || currentUserOwns(existingNode.owner_reference_id)) ? ( + + ) : ( +

Contact an admin to re-process this content

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

{error}

@@ -202,10 +346,14 @@ export function AddLegalForm() { {mode === "url" ? urlValid - ? "URL looks good — ready to submit" + ? existingNode + ? "Already in graph — use Re-process above" + : "URL looks good — ready to submit" : "Enter a valid PDF URL to continue" : file - ? "File ready — click Add Document" + ? existingNode + ? "Already in graph — use Re-process above" + : "File ready — click Add Document" : "Select a PDF file to continue"} diff --git a/src/components/modals/add-source-form.tsx b/src/components/modals/add-source-form.tsx index f5b9033..e043aa2 100644 --- a/src/components/modals/add-source-form.tsx +++ b/src/components/modals/add-source-form.tsx @@ -1,7 +1,7 @@ "use client" import { useCallback, useEffect, useState } from "react" -import { Loader2, CheckCircle2, LinkIcon, Zap, X, RefreshCw } from "lucide-react" +import { Loader2, CheckCircle2, LinkIcon, Zap, X, RefreshCw, AlertTriangle } from "lucide-react" import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { MAX_LENGTHS } from "@/lib/input-limits" @@ -18,7 +18,7 @@ import { isSubscriptionSource, type SourceType, } from "@/lib/source-detection" -import { checkNodeExists, type GraphNode } from "@/lib/graph-api" +import { checkNodeExists, reprocessContent, CONTENT_TYPE_TO_NODE_TYPE, type GraphNode } from "@/lib/graph-api" import { unlockNode } from "@/lib/unlock-node" const CONTENT_TYPE_BY_SOURCE: Partial> = { @@ -41,6 +41,7 @@ export function AddSourceForm() { const { close, open: openModal } = useModalStore() const { budget, setBudget, pubKey, routeHint, isAdmin } = useUserStore() const refreshBalance = useUserStore((s) => s.refreshBalance) + const ownerReferenceId = useUserStore((s) => s.ownerReferenceId) const activeSkin = useAppStore((s) => s.activeSkin) const [sourceUrl, setSourceUrl] = useState("") const [detectedType, setDetectedType] = useState(null) @@ -57,6 +58,7 @@ export function AddSourceForm() { const [cachedRefId, setCachedRefId] = useState(null) const [previewState, setPreviewState] = useState(null) const [, setPreviewedNode] = useState(null) + const [existingNode, setExistingNode] = useState<{ ref_id: string; owner_reference_id: string | null } | null>(null) // Fetch price based on detected type useEffect(() => { @@ -66,6 +68,11 @@ export function AddSourceForm() { } }, [detectedType]) + const currentUserOwns = useCallback( + (nodeRef: string | null) => !!nodeRef && !!ownerReferenceId && nodeRef === ownerReferenceId, + [ownerReferenceId] + ) + const handleDetect = useCallback(async (value: string) => { setSourceUrl(value) setDetectedType(null) @@ -75,6 +82,7 @@ export function AddSourceForm() { setCachedRefId(null) setPreviewState(null) setPreviewedNode(null) + setExistingNode(null) const trimmed = value.trim() if (!trimmed || trimmed.length < 5) return @@ -157,6 +165,15 @@ export function AddSourceForm() { setCacheStatus("miss") setCachedRefId(null) } + } else if (type && !isSubscriptionSource(type)) { + const contentType = CONTENT_TYPE_BY_SOURCE[type] + const nodeType = contentType ? CONTENT_TYPE_TO_NODE_TYPE[contentType] : null + if (nodeType && nodeType !== "Episode") { + const check = await checkNodeExists(nodeType, trimmed) + if (check.exists && check.ref_id) { + setExistingNode({ ref_id: check.ref_id, owner_reference_id: check.owner_reference_id }) + } + } } } catch { setDetectedType(null) @@ -165,6 +182,46 @@ export function AddSourceForm() { } }, [close]) + const handleReprocess = useCallback(async () => { + if (!existingNode || !detectedType) return + if (!isAdmin && !currentUserOwns(existingNode.owner_reference_id)) return + const contentType = CONTENT_TYPE_BY_SOURCE[detectedType] + if (!contentType) return + setSubmitting(true) + setError("") + try { + await reprocessContent(existingNode.ref_id, { source_link: sourceUrl.trim(), content_type: contentType }) + setSuccess(true) + refreshBalance() + setTimeout(() => { + setExistingNode(null) + setSuccess(false) + close() + useAppStore.getState().bumpMyContentRefresh() + }, 1200) + } catch (err) { + if (err instanceof Response && err.status === 402) { + try { + await payL402(setBudget) + await reprocessContent(existingNode.ref_id, { source_link: sourceUrl.trim(), content_type: contentType }) + setSuccess(true) + refreshBalance() + setTimeout(() => { + setExistingNode(null) + setSuccess(false) + close() + }, 1200) + } catch { + openModal("budget") + } + } else { + setError("Re-process failed. Try again.") + } + } finally { + setSubmitting(false) + } + }, [existingNode, detectedType, sourceUrl, setBudget, refreshBalance, close, openModal]) + const submitWithAuth = useCallback( async (source: string, sourceType: SourceType) => { const l402 = await getL402() @@ -482,7 +539,35 @@ export function AddSourceForm() { )} - {error && ( + {/* Already-in-graph yellow callout — shown for non-Episode duplicate URLs */} + {existingNode && !cacheStatus && !detecting && ( +
+ +
+

⚠️ This content is already in the graph

+ {(isAdmin || currentUserOwns(existingNode.owner_reference_id)) ? ( + + ) : ( +

Contact an admin to re-process this content

+ )} +
+
+ )} + + {error && !existingNode && (

{error}

)} @@ -534,7 +619,8 @@ export function AddSourceForm() { !detectedType || !sourceUrl.trim() || isSubscriptionBlocked || - isInProgress + isInProgress || + !!existingNode } className="text-xs bg-primary text-primary-foreground hover:bg-primary/90" > diff --git a/src/lib/__tests__/add-content-modal.test.tsx b/src/lib/__tests__/add-content-modal.test.tsx index 7e8ff5a..e057f4f 100644 --- a/src/lib/__tests__/add-content-modal.test.tsx +++ b/src/lib/__tests__/add-content-modal.test.tsx @@ -19,6 +19,7 @@ vi.mock("@/stores/modal-store", () => ({ const mockSetBudget = vi.fn() const mockRefreshBalance = vi.fn().mockResolvedValue(undefined) let mockIsAdmin = false +let mockOwnerReferenceId = "" vi.mock("@/stores/user-store", () => ({ useUserStore: (sel?: (s: unknown) => unknown) => { @@ -29,6 +30,7 @@ vi.mock("@/stores/user-store", () => ({ routeHint: "", isAdmin: mockIsAdmin, refreshBalance: mockRefreshBalance, + ownerReferenceId: mockOwnerReferenceId, } return sel ? sel(state) : state }, @@ -131,8 +133,19 @@ vi.mock("@/lib/source-detection", () => ({ // --- Graph API mock --- const mockCheckNodeExists = vi.fn() +const mockReprocessContent = vi.fn() + vi.mock("@/lib/graph-api", () => ({ checkNodeExists: (...args: unknown[]) => mockCheckNodeExists(...args), + reprocessContent: (...args: unknown[]) => mockReprocessContent(...args), + CONTENT_TYPE_TO_NODE_TYPE: { + audio_video: "Episode", + document: "Document", + webpage: "Document", + arxiv_paper: "ArxivPaper", + tweet: "Tweet", + legal_document: "LegalDocument", + }, })) // --- Input limits mock --- @@ -287,7 +300,8 @@ describe("AddContentModal — preview probe", () => { it("scope guard: probe NOT fired for non-cacheable source type (web_page)", async () => { mockDetectSourceType.mockResolvedValue("web_page") - // checkNodeExists would not be called either, so apiGet definitely won't + // web_page now triggers checkNodeExists for Document dedup, but NOT the preview probe (api.get) + mockCheckNodeExists.mockResolvedValue({ exists: false, ref_id: null, status: null, owner_reference_id: null }) render() @@ -298,7 +312,7 @@ describe("AddContentModal — preview probe", () => { expect(mockDetectSourceType).toHaveBeenCalled() }) - expect(mockCheckNodeExists).not.toHaveBeenCalled() + // Preview probe (api.get) must NOT be called — only checkNodeExists is called for dedup expect(mockApiGet).not.toHaveBeenCalled() }) }) @@ -571,3 +585,197 @@ describe("AddSourceForm — content_type overridden by active skin", () => { vi.useRealTimers() }) }) + +// --------------------------------------------------------------------------- +// AddSourceForm — "Already in graph" yellow callout + Re-process flow +// --------------------------------------------------------------------------- + +describe("AddSourceForm — already-in-graph callout", () => { + beforeEach(() => { + vi.clearAllMocks() + mockActiveModal = "addContent" + mockIsAdmin = false + mockOwnerReferenceId = "" + mockGetL402.mockResolvedValue(null) + mockPayL402.mockResolvedValue(undefined) + mockGetPrice.mockResolvedValue(10) + mockApiPost.mockResolvedValue({}) + mockRefreshBalance.mockResolvedValue(undefined) + mockIsSubscriptionSource.mockReturnValue(false) + mockCheckNodeExists.mockResolvedValue({ exists: false, ref_id: null, status: null, owner_reference_id: null }) + mockReprocessContent.mockResolvedValue({}) + }) + + it("shows yellow callout for a Document-type duplicate", async () => { + mockDetectSourceType.mockResolvedValue("document") + mockCheckNodeExists.mockResolvedValue({ + exists: true, + ref_id: "doc-ref-1", + status: "completed", + owner_reference_id: "lsat:other-user", + }) + + render() + const input = screen.getByPlaceholderText(/Paste URL/) + await userEvent.type(input, "https://example.com/doc.pdf") + + await waitFor(() => { + expect(screen.getByText(/This content is already in the graph/i)).toBeInTheDocument() + }) + }) + + it("shows no Re-process button for non-admin, non-owner", async () => { + mockDetectSourceType.mockResolvedValue("document") + mockIsAdmin = false + mockOwnerReferenceId = "lsat:me" + mockCheckNodeExists.mockResolvedValue({ + exists: true, + ref_id: "doc-ref-1", + status: "completed", + owner_reference_id: "lsat:other-user", + }) + + render() + const input = screen.getByPlaceholderText(/Paste URL/) + await userEvent.type(input, "https://example.com/doc.pdf") + + await waitFor(() => { + expect(screen.getByText(/Contact an admin to re-process/i)).toBeInTheDocument() + }) + expect(screen.queryByRole("button", { name: /re-process/i })).not.toBeInTheDocument() + }) + + it("shows Re-process button when isAdmin is true", async () => { + mockDetectSourceType.mockResolvedValue("document") + mockIsAdmin = true + mockCheckNodeExists.mockResolvedValue({ + exists: true, + ref_id: "doc-ref-1", + status: "completed", + owner_reference_id: "lsat:other-user", + }) + + render() + const input = screen.getByPlaceholderText(/Paste URL/) + await userEvent.type(input, "https://example.com/doc.pdf") + + await waitFor(() => { + expect(screen.getByRole("button", { name: /re-process/i })).toBeInTheDocument() + }) + }) + + it("shows Re-process button when ownerReferenceId matches node's owner_reference_id", async () => { + mockDetectSourceType.mockResolvedValue("document") + mockIsAdmin = false + mockOwnerReferenceId = "lsat:owner-uuid" + mockCheckNodeExists.mockResolvedValue({ + exists: true, + ref_id: "doc-ref-1", + status: "completed", + owner_reference_id: "lsat:owner-uuid", + }) + + render() + const input = screen.getByPlaceholderText(/Paste URL/) + await userEvent.type(input, "https://example.com/doc.pdf") + + await waitFor(() => { + expect(screen.getByRole("button", { name: /re-process/i })).toBeInTheDocument() + }) + }) + + it("checkNodeExists is NOT called for subscription source types", async () => { + mockDetectSourceType.mockResolvedValue("youtube_channel") + mockIsSubscriptionSource.mockReturnValue(true) + + render() + const input = screen.getByPlaceholderText(/Paste URL/) + await userEvent.type(input, "https://youtube.com/c/testchannel") + + await waitFor(() => { + expect(mockDetectSourceType).toHaveBeenCalled() + }) + + expect(mockCheckNodeExists).not.toHaveBeenCalled() + }) + + it("calls reprocessContent (not api.post) on Re-process click", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) }) + + mockDetectSourceType.mockResolvedValue("document") + mockIsAdmin = true + mockCheckNodeExists.mockResolvedValue({ + exists: true, + ref_id: "doc-ref-1", + status: "completed", + owner_reference_id: "lsat:other-user", + }) + mockReprocessContent.mockResolvedValue({}) + + render() + const input = screen.getByPlaceholderText(/Paste URL/) + await user.type(input, "https://example.com/doc.pdf") + + await waitFor(() => { + expect(screen.getByRole("button", { name: /re-process/i })).toBeInTheDocument() + }) + + await user.click(screen.getByRole("button", { name: /re-process/i })) + + await waitFor(() => { + expect(mockReprocessContent).toHaveBeenCalledWith( + "doc-ref-1", + expect.objectContaining({ source_link: "https://example.com/doc.pdf", content_type: "document" }) + ) + }) + expect(mockApiPost).not.toHaveBeenCalledWith("/v2/content", expect.anything(), expect.anything()) + + vi.useRealTimers() + }) + + it("primary submit button is disabled when existingNode is set", async () => { + mockDetectSourceType.mockResolvedValue("document") + mockCheckNodeExists.mockResolvedValue({ + exists: true, + ref_id: "doc-ref-1", + status: "completed", + owner_reference_id: "lsat:other-user", + }) + + render() + const input = screen.getByPlaceholderText(/Paste URL/) + await userEvent.type(input, "https://example.com/doc.pdf") + + await waitFor(() => { + expect(screen.getByText(/This content is already in the graph/i)).toBeInTheDocument() + }) + + // The primary "Add Source" / "Pay & Add" button should be disabled + const submitBtn = screen.getByRole("button", { name: /add source|pay & add/i }) + expect(submitBtn).toBeDisabled() + }) + + it("existing Episode cacheStatus (green Cached badge) flow is unchanged", async () => { + mockDetectSourceType.mockResolvedValue("youtube_video") + mockCheckNodeExists.mockResolvedValue({ + exists: true, + ref_id: "ep-ref-1", + status: "completed", + owner_reference_id: null, + }) + // Preview probe returns 402 (pay-required) + mockApiGet.mockRejectedValue(Object.assign(new Response(null, { status: 402 }))) + + render() + const input = screen.getByPlaceholderText(/Paste URL/) + await userEvent.type(input, "https://youtube.com/watch?v=abc123") + + await waitFor(() => { + // Multiple elements may say "Cached — instant unlock" (badge + footer); check at least one exists + expect(screen.getAllByText(/Cached — instant unlock/i).length).toBeGreaterThan(0) + }) + // Yellow callout should NOT appear for Episode types + expect(screen.queryByText(/This content is already in the graph/i)).not.toBeInTheDocument() + }) +}) diff --git a/src/lib/__tests__/add-legal-form-reprocess.test.tsx b/src/lib/__tests__/add-legal-form-reprocess.test.tsx new file mode 100644 index 0000000..362ebab --- /dev/null +++ b/src/lib/__tests__/add-legal-form-reprocess.test.tsx @@ -0,0 +1,415 @@ +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() +const mockOpenModal = vi.fn() + +vi.mock("@/stores/modal-store", () => ({ + useModalStore: (sel?: (s: unknown) => unknown) => { + const state = { close: mockClose, open: mockOpenModal } + return sel ? sel(state) : state + }, +})) + +// --- App store mock --- +const mockSetMyContentOpen = vi.fn() + +vi.mock("@/stores/app-store", () => { + const getState = () => ({ setMyContentOpen: mockSetMyContentOpen }) + return { + useAppStore: Object.assign( + (sel?: (s: unknown) => unknown) => { + const state = { setMyContentOpen: mockSetMyContentOpen } + return sel ? sel(state) : state + }, + { getState } + ), + } +}) + +// --- User store mock --- +const mockSetBudget = vi.fn() +const mockRefreshBalance = vi.fn().mockResolvedValue(undefined) +let mockIsAdmin = false +let mockOwnerReferenceId = "" + +vi.mock("@/stores/user-store", () => ({ + useUserStore: Object.assign( + (sel?: (s: unknown) => unknown) => { + const state = { + isAdmin: mockIsAdmin, + ownerReferenceId: mockOwnerReferenceId, + refreshBalance: mockRefreshBalance, + setBudget: mockSetBudget, + } + return sel ? sel(state) : state + }, + { + getState: () => ({ setBudget: mockSetBudget }), + } + ), +})) + +// --- graph-api mocks --- +const mockCheckNodeExists = vi.fn() +const mockCheckNodeExistsByHash = vi.fn() +const mockReprocessContent = vi.fn() +const mockAddLegalDocument = vi.fn() +const mockAddLegalDocumentFile = vi.fn() + +vi.mock("@/lib/graph-api", () => ({ + checkNodeExists: (...args: unknown[]) => mockCheckNodeExists(...args), + checkNodeExistsByHash: (...args: unknown[]) => mockCheckNodeExistsByHash(...args), + reprocessContent: (...args: unknown[]) => mockReprocessContent(...args), + addLegalDocument: (...args: unknown[]) => mockAddLegalDocument(...args), + addLegalDocumentFile: (...args: unknown[]) => mockAddLegalDocumentFile(...args), +})) + +// --- sphinx mocks --- +const mockGetPrice = vi.fn().mockResolvedValue(10) +const mockPayL402 = vi.fn().mockResolvedValue(undefined) + +vi.mock("@/lib/sphinx", () => ({ + getPrice: (...args: unknown[]) => mockGetPrice(...args), + payL402: (...args: unknown[]) => mockPayL402(...args), + getL402: vi.fn().mockResolvedValue(null), +})) + +// --- crypto.subtle mock --- +const mockDigest = vi.fn() + +Object.defineProperty(globalThis, "crypto", { + value: { + subtle: { + digest: (...args: unknown[]) => mockDigest(...args), + }, + }, + writable: true, +}) + +import { AddLegalForm } from "@/components/modals/add-legal-form" + +const EXISTING_NODE = { + exists: true, + ref_id: "legal-ref-1", + status: "completed", + owner_reference_id: "lsat:owner-uuid", +} + +const NO_EXISTING_NODE = { + exists: false, + ref_id: null, + status: null, + owner_reference_id: null, +} + +describe("AddLegalForm — URL mode duplicate detection", () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsAdmin = false + mockOwnerReferenceId = "" + mockGetPrice.mockResolvedValue(10) + mockCheckNodeExists.mockResolvedValue(NO_EXISTING_NODE) + mockCheckNodeExistsByHash.mockResolvedValue(NO_EXISTING_NODE) + mockReprocessContent.mockResolvedValue({}) + mockAddLegalDocument.mockResolvedValue({ status: "ok", nodes: [], status_messages: [] }) + mockAddLegalDocumentFile.mockResolvedValue({ status: "ok", nodes: [], status_messages: [] }) + }) + + it("calls checkNodeExists('LegalDocument', url) when URL becomes valid", async () => { + render() + + const input = screen.getByPlaceholderText(/https:\/\/example.com\/document.pdf/i) + await userEvent.type(input, "https://example.com/contract.pdf") + + await waitFor(() => { + expect(mockCheckNodeExists).toHaveBeenCalledWith( + "LegalDocument", + "https://example.com/contract.pdf", + expect.any(AbortSignal) + ) + }) + }) + + it("shows yellow callout when URL matches existing LegalDocument", async () => { + mockCheckNodeExists.mockResolvedValue(EXISTING_NODE) + + render() + const input = screen.getByPlaceholderText(/https:\/\/example.com\/document.pdf/i) + await userEvent.type(input, "https://example.com/contract.pdf") + + await waitFor(() => { + expect(screen.getByText(/This content is already in the graph/i)).toBeInTheDocument() + }) + }) + + it("shows no callout when URL has no match", async () => { + mockCheckNodeExists.mockResolvedValue(NO_EXISTING_NODE) + + render() + const input = screen.getByPlaceholderText(/https:\/\/example.com\/document.pdf/i) + await userEvent.type(input, "https://example.com/newdoc.pdf") + + await waitFor(() => { + expect(mockCheckNodeExists).toHaveBeenCalled() + }) + + expect(screen.queryByText(/This content is already in the graph/i)).not.toBeInTheDocument() + }) + + it("URL callout: non-admin, non-owner sees contact admin hint, no button", async () => { + mockCheckNodeExists.mockResolvedValue({ + ...EXISTING_NODE, + owner_reference_id: "lsat:someone-else", + }) + mockIsAdmin = false + mockOwnerReferenceId = "lsat:me" + + render() + const input = screen.getByPlaceholderText(/https:\/\/example.com\/document.pdf/i) + await userEvent.type(input, "https://example.com/contract.pdf") + + await waitFor(() => { + expect(screen.getByText(/Contact an admin to re-process/i)).toBeInTheDocument() + }) + expect(screen.queryByRole("button", { name: /re-process/i })).not.toBeInTheDocument() + }) + + it("URL callout: admin sees Re-process button", async () => { + mockCheckNodeExists.mockResolvedValue(EXISTING_NODE) + mockIsAdmin = true + + render() + const input = screen.getByPlaceholderText(/https:\/\/example.com\/document.pdf/i) + await userEvent.type(input, "https://example.com/contract.pdf") + + await waitFor(() => { + expect(screen.getByRole("button", { name: /re-process/i })).toBeInTheDocument() + }) + }) + + it("URL callout: owner sees Re-process button", async () => { + mockCheckNodeExists.mockResolvedValue({ + ...EXISTING_NODE, + owner_reference_id: "lsat:owner-uuid", + }) + mockIsAdmin = false + mockOwnerReferenceId = "lsat:owner-uuid" + + render() + const input = screen.getByPlaceholderText(/https:\/\/example.com\/document.pdf/i) + await userEvent.type(input, "https://example.com/contract.pdf") + + await waitFor(() => { + expect(screen.getByRole("button", { name: /re-process/i })).toBeInTheDocument() + }) + }) + + it("calls reprocessContent with source_link and legal_document on Re-process click", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) }) + + mockCheckNodeExists.mockResolvedValue(EXISTING_NODE) + mockIsAdmin = true + mockReprocessContent.mockResolvedValue({}) + + render() + const input = screen.getByPlaceholderText(/https:\/\/example.com\/document.pdf/i) + await user.type(input, "https://example.com/contract.pdf") + + await waitFor(() => { + expect(screen.getByRole("button", { name: /re-process/i })).toBeInTheDocument() + }) + + await user.click(screen.getByRole("button", { name: /re-process/i })) + + await waitFor(() => { + expect(mockReprocessContent).toHaveBeenCalledWith( + "legal-ref-1", + expect.objectContaining({ + source_link: "https://example.com/contract.pdf", + content_type: "legal_document", + }) + ) + }) + + vi.useRealTimers() + }) +}) + +describe("AddLegalForm — file mode hash-based dedup", () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsAdmin = false + mockOwnerReferenceId = "" + mockGetPrice.mockResolvedValue(10) + mockCheckNodeExists.mockResolvedValue(NO_EXISTING_NODE) + mockCheckNodeExistsByHash.mockResolvedValue(NO_EXISTING_NODE) + mockReprocessContent.mockResolvedValue({}) + mockAddLegalDocumentFile.mockResolvedValue({ status: "ok", nodes: [], status_messages: [] }) + + // Mock crypto.subtle.digest to return a fixed hash buffer + const hashBytes = new Uint8Array(32).fill(0xab) + mockDigest.mockResolvedValue(hashBytes.buffer) + }) + + const makePdfFile = (name = "test.pdf", sizeKB = 10) => { + const content = new Uint8Array(sizeKB * 1024).fill(0x25) // 0x25 = '%' + return new File([content], name, { type: "application/pdf" }) + } + + it("invokes crypto.subtle.digest on file selection", async () => { + render() + + // Switch to file mode + await userEvent.click(screen.getByRole("button", { name: /file/i })) + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + const file = makePdfFile() + await userEvent.upload(fileInput, file) + + await waitFor(() => { + expect(mockDigest).toHaveBeenCalledWith("SHA-256", expect.any(ArrayBuffer)) + }) + }) + + it("calls checkNodeExistsByHash('LegalDocument', hash) after file selection", async () => { + render() + await userEvent.click(screen.getByRole("button", { name: /file/i })) + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + const file = makePdfFile() + await userEvent.upload(fileInput, file) + + await waitFor(() => { + expect(mockCheckNodeExistsByHash).toHaveBeenCalledWith( + "LegalDocument", + expect.any(String) + ) + }) + }) + + it("shows yellow callout when file hash matches existing node", async () => { + mockCheckNodeExistsByHash.mockResolvedValue(EXISTING_NODE) + + render() + await userEvent.click(screen.getByRole("button", { name: /file/i })) + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + await userEvent.upload(fileInput, makePdfFile()) + + await waitFor(() => { + expect(screen.getByText(/This content is already in the graph/i)).toBeInTheDocument() + }) + }) + + it("no callout when hash has no match", async () => { + mockCheckNodeExistsByHash.mockResolvedValue(NO_EXISTING_NODE) + + render() + await userEvent.click(screen.getByRole("button", { name: /file/i })) + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + await userEvent.upload(fileInput, makePdfFile()) + + await waitFor(() => { + expect(mockCheckNodeExistsByHash).toHaveBeenCalled() + }) + expect(screen.queryByText(/This content is already in the graph/i)).not.toBeInTheDocument() + }) + + it("file callout: non-admin, non-owner sees contact admin hint", async () => { + mockCheckNodeExistsByHash.mockResolvedValue({ + ...EXISTING_NODE, + owner_reference_id: "lsat:someone-else", + }) + mockIsAdmin = false + mockOwnerReferenceId = "lsat:me" + + render() + await userEvent.click(screen.getByRole("button", { name: /file/i })) + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + await userEvent.upload(fileInput, makePdfFile()) + + await waitFor(() => { + expect(screen.getByText(/Contact an admin to re-process/i)).toBeInTheDocument() + }) + expect(screen.queryByRole("button", { name: /re-process/i })).not.toBeInTheDocument() + }) + + it("file callout: admin sees Re-process button", async () => { + mockCheckNodeExistsByHash.mockResolvedValue(EXISTING_NODE) + mockIsAdmin = true + + render() + await userEvent.click(screen.getByRole("button", { name: /file/i })) + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + await userEvent.upload(fileInput, makePdfFile()) + + await waitFor(() => { + expect(screen.getByRole("button", { name: /re-process/i })).toBeInTheDocument() + }) + }) + + it("file callout: owner sees Re-process button", async () => { + mockCheckNodeExistsByHash.mockResolvedValue({ + ...EXISTING_NODE, + owner_reference_id: "lsat:owner-uuid", + }) + mockIsAdmin = false + mockOwnerReferenceId = "lsat:owner-uuid" + + render() + await userEvent.click(screen.getByRole("button", { name: /file/i })) + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + await userEvent.upload(fileInput, makePdfFile()) + + await waitFor(() => { + expect(screen.getByRole("button", { name: /re-process/i })).toBeInTheDocument() + }) + }) + + it("calls reprocessContent with content_hash and legal_document on Re-process click", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) }) + + mockCheckNodeExistsByHash.mockResolvedValue(EXISTING_NODE) + mockIsAdmin = true + mockReprocessContent.mockResolvedValue({}) + + render() + await user.click(screen.getByRole("button", { name: /file/i })) + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + await user.upload(fileInput, makePdfFile()) + + await waitFor(() => { + expect(screen.getByRole("button", { name: /re-process/i })).toBeInTheDocument() + }) + + await user.click(screen.getByRole("button", { name: /re-process/i })) + + await waitFor(() => { + expect(mockReprocessContent).toHaveBeenCalledWith( + "legal-ref-1", + expect.objectContaining({ + content_type: "legal_document", + content_hash: expect.any(String), + }) + ) + }) + // Should NOT have source_link for file mode + expect(mockReprocessContent).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ source_link: expect.anything() }) + ) + + vi.useRealTimers() + }) +}) diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index d28653b..036a03b 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -590,19 +590,59 @@ export async function checkNodeExists( nodeType: string, key: string, signal?: AbortSignal -): Promise<{ exists: boolean; ref_id: string | null; status: string | null }> { +): Promise<{ exists: boolean; ref_id: string | null; status: string | null; owner_reference_id: string | null }> { const params = new URLSearchParams({ node_type: nodeType, key }) try { - return await api.get<{ exists: boolean; ref_id: string | null; status: string | null }>( + return await api.get<{ exists: boolean; ref_id: string | null; status: string | null; owner_reference_id: string | null }>( `/v2/nodes/check?${params}`, undefined, signal ) } catch { - return { exists: false, ref_id: null, status: null } + return { exists: false, ref_id: null, status: null, owner_reference_id: null } } } +// Hash-based duplicate check for file-upload dedup via content_hash +export async function checkNodeExistsByHash( + nodeType: string, + hash: string, + signal?: AbortSignal +): Promise<{ exists: boolean; ref_id: string | null; status: string | null; owner_reference_id: string | null }> { + const params = new URLSearchParams({ node_type: nodeType, hash }) + try { + return await api.get<{ exists: boolean; ref_id: string | null; status: string | null; owner_reference_id: string | null }>( + `/v2/nodes/check?${params}`, + undefined, + signal + ) + } catch { + return { exists: false, ref_id: null, status: null, owner_reference_id: null } + } +} + +// Maps content_type values to their corresponding node type names +export const CONTENT_TYPE_TO_NODE_TYPE: Record = { + audio_video: "Episode", + document: "Document", + webpage: "Document", + arxiv_paper: "ArxivPaper", + tweet: "Tweet", + legal_document: "LegalDocument", +} + +// Re-process an existing node via force_update — follows L402 payment flow +export async function reprocessContent( + refId: string, + body: Record, + signal?: AbortSignal +): Promise { + const l402 = await getL402() + const headers: Record = {} + if (l402) headers["Authorization"] = l402 + return api.post("/v2/content", { ...body, ref_id: refId, force_update: true }, headers, signal) +} + diff --git a/src/stores/user-store.ts b/src/stores/user-store.ts index 946f795..e5c193a 100644 --- a/src/stores/user-store.ts +++ b/src/stores/user-store.ts @@ -12,6 +12,7 @@ interface UserState { routeHint: string budget: number | null nodeCount: number + ownerReferenceId: string } interface UserActions { @@ -34,6 +35,7 @@ export const useUserStore = create((set) => ({ routeHint: "", budget: 0, nodeCount: 0, + ownerReferenceId: "", setIsAdmin: (isAdmin) => set({ isAdmin }), setIsAuthenticated: (isAuthenticated) => set({ isAuthenticated }), setPubKey: (pubKey) => set({ pubKey }), @@ -46,8 +48,8 @@ export const useUserStore = create((set) => ({ const l402 = await getL402() if (!l402) { set({ budget: 0 }); return } try { - const bal = await api.get<{ balance: number }>("/balance", { Authorization: l402 }) - set({ budget: bal.balance }) + const bal = await api.get<{ balance: number; owner_reference_id?: string }>("/balance", { Authorization: l402 }) + set({ budget: bal.balance, ownerReferenceId: bal.owner_reference_id ?? "" }) } catch { cookieStorage.removeItem("l402") set({ budget: 0 })