From 9b95fe44c1fce82f98416caa643c2deca595ba99 Mon Sep 17 00:00:00 2001 From: Praxhant97 Date: Sun, 29 Mar 2026 22:08:28 +0530 Subject: [PATCH 1/3] feat(documents): add review document api and mutation hook --- src/api/documentService.ts | 15 ++++++++++ src/hooks/useMutateReviewDocument.ts | 32 +++++++++++++++++++++ src/mocks/handlers/files.ts | 42 ++++++++++++++++++++++++++++ src/types/document.ts | 25 +++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 src/api/documentService.ts create mode 100644 src/hooks/useMutateReviewDocument.ts create mode 100644 src/types/document.ts diff --git a/src/api/documentService.ts b/src/api/documentService.ts new file mode 100644 index 0000000..8a76ac9 --- /dev/null +++ b/src/api/documentService.ts @@ -0,0 +1,15 @@ +import { apiClient } from "../lib/api-client"; +import type { AdoptionDocument, ReviewDocumentPayload } from "../types/document"; + +export const documentService = { + async getAdoptionDocuments(adoptionId: string): Promise { + return apiClient.get(`/adoption/${adoptionId}/documents`); + }, + + async reviewDocument( + documentId: string, + payload: ReviewDocumentPayload, + ): Promise { + return apiClient.patch(`/documents/${documentId}/review`, payload); + }, +}; \ No newline at end of file diff --git a/src/hooks/useMutateReviewDocument.ts b/src/hooks/useMutateReviewDocument.ts new file mode 100644 index 0000000..1b104db --- /dev/null +++ b/src/hooks/useMutateReviewDocument.ts @@ -0,0 +1,32 @@ +import { useApiMutation } from "./useApiMutation"; +import { documentService } from "../api/documentService"; +import type { + AdoptionDocument, + DocumentReviewDecision, +} from "../types/document"; + +interface ReviewDocumentInput { + documentId: string; + decision: DocumentReviewDecision; + reason?: string; +} + +export function useMutateReviewDocument(adoptionId: string) { + const { mutateAsync, isPending, isError, error } = useApiMutation< + AdoptionDocument, + ReviewDocumentInput + >( + ({ documentId, decision, reason }: ReviewDocumentInput) => + documentService.reviewDocument(documentId, { decision, reason }), + { + invalidates: [["adoption", adoptionId, "documents"]], + }, + ); + + return { + mutateReviewDocument: mutateAsync, + isPending, + isError, + error, + }; +} \ No newline at end of file diff --git a/src/mocks/handlers/files.ts b/src/mocks/handlers/files.ts index 9a3bc8a..d2d2736 100644 --- a/src/mocks/handlers/files.ts +++ b/src/mocks/handlers/files.ts @@ -15,6 +15,15 @@ interface Document { adoptionId: string; createdAt: string; updatedAt: string; + onChainVerified: boolean | null; + anchorTxHash: string | null; + reviewStatus: "PENDING" | "APPROVED" | "REJECTED"; + reviewReason: string | null; +} + +interface ReviewDocumentPayload { + decision: "APPROVED" | "REJECTED"; + reason?: string; } interface UploadDocumentsResponse { @@ -36,6 +45,10 @@ const MOCK_DOCUMENTS: Document[] = [ adoptionId: "adoption-001", createdAt: "2026-03-18T08:00:00.000Z", updatedAt: "2026-03-18T08:00:00.000Z", + onChainVerified: true, + anchorTxHash: "4f5f8abf20f6cb9d88a4dd5a13f5e97a9f5a7f72f0f7d9f8cf5a6a4d91aa1142", + reviewStatus: "PENDING", + reviewReason: null, }, { id: "doc-002", @@ -48,6 +61,10 @@ const MOCK_DOCUMENTS: Document[] = [ adoptionId: "adoption-001", createdAt: "2026-03-18T08:30:00.000Z", updatedAt: "2026-03-18T08:30:00.000Z", + onChainVerified: null, + anchorTxHash: null, + reviewStatus: "PENDING", + reviewReason: null, }, ]; @@ -81,6 +98,10 @@ export const filesHandlers = [ adoptionId: params.id as string, createdAt: now, updatedAt: now, + onChainVerified: null, + anchorTxHash: null, + reviewStatus: "PENDING", + reviewReason: null, }; return HttpResponse.json({ @@ -97,4 +118,25 @@ export const filesHandlers = [ ); return HttpResponse.json(docs); }), + + // PATCH /api/documents/:id/review — review a single document (admin) + http.patch("/api/documents/:id/review", async ({ request, params }) => { + await delay(getDelay(request)); + + const body = (await request.json()) as ReviewDocumentPayload; + const document = MOCK_DOCUMENTS.find((doc) => doc.id === params.id); + + if (!document) { + return HttpResponse.json( + { message: "Document not found" }, + { status: 404 }, + ); + } + + document.reviewStatus = body.decision; + document.reviewReason = body.decision === "REJECTED" ? (body.reason ?? "") : null; + document.updatedAt = new Date().toISOString(); + + return HttpResponse.json(document); + }), ]; diff --git a/src/types/document.ts b/src/types/document.ts new file mode 100644 index 0000000..9683aa7 --- /dev/null +++ b/src/types/document.ts @@ -0,0 +1,25 @@ +export type DocumentReviewStatus = "PENDING" | "APPROVED" | "REJECTED"; + +export type DocumentReviewDecision = "APPROVED" | "REJECTED"; + +export interface AdoptionDocument { + id: string; + fileName: string; + fileUrl: string; + publicId: string; + mimeType: string; + size: number; + uploadedById: string; + adoptionId: string; + createdAt: string; + updatedAt: string; + onChainVerified: boolean | null; + anchorTxHash: string | null; + reviewStatus: DocumentReviewStatus; + reviewReason: string | null; +} + +export interface ReviewDocumentPayload { + decision: DocumentReviewDecision; + reason?: string; +} \ No newline at end of file From 148a4394a6dcb37057fc1a171ecf005e57dc8d88 Mon Sep 17 00:00:00 2001 From: Praxhant97 Date: Sun, 29 Mar 2026 22:08:34 +0530 Subject: [PATCH 2/3] feat(ui): add admin document verification panel --- .../adoption/AdminDocumentReviewPanel.tsx | 211 ++++++++++++++++++ src/pages/SettlementSummaryPage.tsx | 12 +- 2 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 src/components/adoption/AdminDocumentReviewPanel.tsx diff --git a/src/components/adoption/AdminDocumentReviewPanel.tsx b/src/components/adoption/AdminDocumentReviewPanel.tsx new file mode 100644 index 0000000..9af7d5e --- /dev/null +++ b/src/components/adoption/AdminDocumentReviewPanel.tsx @@ -0,0 +1,211 @@ +import { useEffect, useMemo, useState } from "react"; +import { useRoleGuard } from "../../hooks/useRoleGuard"; +import { useApiQuery } from "../../hooks/useApiQuery"; +import { useMutateReviewDocument } from "../../hooks/useMutateReviewDocument"; +import { documentService } from "../../api/documentService"; +import { DocumentIntegrityBadge } from "../ui/DocumentIntegrityBadge"; +import type { AdoptionDocument } from "../../types/document"; + +interface AdminDocumentReviewPanelProps { + adoptionId: string; +} + +const statusClasses: Record = { + PENDING: "bg-amber-100 text-amber-800", + APPROVED: "bg-green-100 text-green-800", + REJECTED: "bg-red-100 text-red-800", +}; + +export function AdminDocumentReviewPanel({ adoptionId }: AdminDocumentReviewPanelProps) { + const { isAdmin } = useRoleGuard(); + const { mutateReviewDocument, isPending } = useMutateReviewDocument(adoptionId); + const [documents, setDocuments] = useState([]); + const [rejectingDocumentId, setRejectingDocumentId] = useState(null); + const [rejectionReason, setRejectionReason] = useState(""); + const [activeDocumentId, setActiveDocumentId] = useState(null); + + const { data, isLoading, isError } = useApiQuery( + ["adoption", adoptionId, "documents"], + () => documentService.getAdoptionDocuments(adoptionId), + { enabled: Boolean(adoptionId) }, + ); + + useEffect(() => { + if (data) { + setDocuments(data); + } + }, [data]); + + const approvedCount = useMemo( + () => documents.filter((doc) => doc.reviewStatus === "APPROVED").length, + [documents], + ); + const totalCount = documents.length; + const allApproved = totalCount > 0 && approvedCount === totalCount; + + if (!isAdmin) { + return null; + } + + const updateDocument = (updated: AdoptionDocument) => { + setDocuments((current) => + current.map((doc) => (doc.id === updated.id ? updated : doc)), + ); + }; + + const handleApprove = async (documentId: string) => { + setActiveDocumentId(documentId); + try { + const updated = await mutateReviewDocument({ + documentId, + decision: "APPROVED", + }); + updateDocument(updated); + } finally { + setActiveDocumentId(null); + } + }; + + const handleRejectSubmit = async (documentId: string) => { + const reason = rejectionReason.trim(); + if (!reason) { + return; + } + + setActiveDocumentId(documentId); + try { + const updated = await mutateReviewDocument({ + documentId, + decision: "REJECTED", + reason, + }); + updateDocument(updated); + setRejectingDocumentId(null); + setRejectionReason(""); + } finally { + setActiveDocumentId(null); + } + }; + + return ( +
+
+

Document verification

+ + {approvedCount} of {totalCount} documents approved + +
+ + {allApproved ? ( +

+ All documents verified — adoption can proceed to escrow +

+ ) : null} + + {isLoading ? ( +

Loading documents...

+ ) : null} + + {isError ? ( +

Failed to load documents. Please refresh and try again.

+ ) : null} + + {!isLoading && !isError && documents.length === 0 ? ( +

No documents uploaded yet.

+ ) : null} + +
+ {documents.map((document) => { + const isRowPending = isPending && activeDocumentId === document.id; + const isRejecting = rejectingDocumentId === document.id; + + return ( +
+
+
+

{document.fileName}

+
+ + + {document.reviewStatus} + +
+
+ +
+ + +
+
+ + {isRejecting ? ( +
+ + setRejectionReason(event.target.value)} + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm" + placeholder="Enter rejection reason" + /> + + +
+ ) : null} +
+ ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/SettlementSummaryPage.tsx b/src/pages/SettlementSummaryPage.tsx index 6d6d96f..aac1966 100644 --- a/src/pages/SettlementSummaryPage.tsx +++ b/src/pages/SettlementSummaryPage.tsx @@ -10,6 +10,8 @@ import { EmptyState } from "../components/ui/emptyState"; import type { EscrowStatus } from "../components/escrow/types"; import type { EscrowOnChainStatus } from "../types/escrow"; import type { SettlementSummary as UISettlementSummary } from "../components/escrow/types"; +import { useRoleGuard } from "../hooks/useRoleGuard"; +import { AdminDocumentReviewPanel } from "../components/adoption/AdminDocumentReviewPanel"; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -76,7 +78,9 @@ export function SettlementSummaryPage({ summary: propSummary, onComplete, }: SettlementSummaryPageProps) { + const { isAdmin: isAdminFromRole } = useRoleGuard(); const { adoptionId: paramAdoptionId } = useParams<{ adoptionId: string }>(); + const canAdminReview = isAdmin || isAdminFromRole; // If we have a prop-driven summary, we follow its status/data. // Otherwise, we fetch on-chain settlement details for the adoption. @@ -121,13 +125,15 @@ export function SettlementSummaryPage({ /> )} - {isAdmin && escrowStatus === "FUNDED" && ( + {canAdminReview && escrowStatus === "FUNDED" && ( {})} /> )} + {adoptionId ? : null} + {/* ── Status + confirmation depth ── */}
{isLoading ? ( @@ -160,7 +166,7 @@ export function SettlementSummaryPage({

- {isAdmin && adoptionId && ( + {canAdminReview && adoptionId && ( )} From a8f8413a0703c3b90ccf7947027120a9628ac5ea Mon Sep 17 00:00:00 2001 From: Praxhant97 Date: Sun, 29 Mar 2026 22:08:38 +0530 Subject: [PATCH 3/3] test(ui): cover admin document review interactions --- .../AdminDocumentReviewPanel.test.tsx | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 src/components/adoption/__tests__/AdminDocumentReviewPanel.test.tsx diff --git a/src/components/adoption/__tests__/AdminDocumentReviewPanel.test.tsx b/src/components/adoption/__tests__/AdminDocumentReviewPanel.test.tsx new file mode 100644 index 0000000..b5a92d8 --- /dev/null +++ b/src/components/adoption/__tests__/AdminDocumentReviewPanel.test.tsx @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { AdminDocumentReviewPanel } from "../AdminDocumentReviewPanel"; +import type { AdoptionDocument } from "../../../types/document"; + +vi.mock("../../../hooks/useRoleGuard", () => ({ + useRoleGuard: vi.fn(), +})); + +vi.mock("../../../hooks/useApiQuery", () => ({ + useApiQuery: vi.fn(), +})); + +vi.mock("../../../hooks/useMutateReviewDocument", () => ({ + useMutateReviewDocument: vi.fn(), +})); + +import { useRoleGuard } from "../../../hooks/useRoleGuard"; +import { useApiQuery } from "../../../hooks/useApiQuery"; +import { useMutateReviewDocument } from "../../../hooks/useMutateReviewDocument"; + +const mockUseRoleGuard = vi.mocked(useRoleGuard); +const mockUseApiQuery = vi.mocked(useApiQuery); +const mockUseMutateReviewDocument = vi.mocked(useMutateReviewDocument); + +const seedDocuments: AdoptionDocument[] = [ + { + id: "doc-1", + fileName: "identity-proof.pdf", + fileUrl: "https://example.com/identity-proof.pdf", + publicId: "adoptions/adoption-001/identity-proof", + mimeType: "application/pdf", + size: 1024, + uploadedById: "user-1", + adoptionId: "adoption-001", + createdAt: "2026-03-28T10:00:00.000Z", + updatedAt: "2026-03-28T10:00:00.000Z", + onChainVerified: true, + anchorTxHash: "tx-hash-1", + reviewStatus: "APPROVED", + reviewReason: null, + }, + { + id: "doc-2", + fileName: "home-checklist.pdf", + fileUrl: "https://example.com/home-checklist.pdf", + publicId: "adoptions/adoption-001/home-checklist", + mimeType: "application/pdf", + size: 2048, + uploadedById: "user-1", + adoptionId: "adoption-001", + createdAt: "2026-03-28T11:00:00.000Z", + updatedAt: "2026-03-28T11:00:00.000Z", + onChainVerified: null, + anchorTxHash: null, + reviewStatus: "PENDING", + reviewReason: null, + }, +]; + +describe("AdminDocumentReviewPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockUseRoleGuard.mockReturnValue({ + role: "admin", + isAdmin: true, + isUser: false, + }); + + mockUseApiQuery.mockReturnValue({ + data: seedDocuments, + isLoading: false, + isError: false, + isForbidden: false, + isNotFound: false, + error: null, + }); + + mockUseMutateReviewDocument.mockReturnValue({ + mutateReviewDocument: vi.fn(async ({ documentId, decision, reason }) => { + const existing = seedDocuments.find((doc) => doc.id === documentId); + if (!existing) { + throw new Error("Document not found in test seed"); + } + + return { + ...existing, + reviewStatus: decision, + reviewReason: decision === "REJECTED" ? (reason ?? "") : null, + updatedAt: "2026-03-29T00:00:00.000Z", + }; + }), + isPending: false, + isError: false, + error: null, + }); + }); + + it("is hidden for non-admin users", () => { + mockUseRoleGuard.mockReturnValue({ + role: "user", + isAdmin: false, + isUser: true, + }); + + render(); + + expect(screen.queryByLabelText("Document verification panel")).not.toBeInTheDocument(); + }); + + it("shows inline reject reason input before rejection submission", async () => { + const mutateReviewDocument = vi.fn(async ({ documentId, decision, reason }) => { + const existing = seedDocuments.find((doc) => doc.id === documentId); + if (!existing) { + throw new Error("Document not found in test seed"); + } + + return { + ...existing, + reviewStatus: decision, + reviewReason: reason ?? null, + }; + }); + + mockUseMutateReviewDocument.mockReturnValue({ + mutateReviewDocument, + isPending: false, + isError: false, + error: null, + }); + + render(); + + const targetRow = screen.getByTestId("document-row-doc-2"); + fireEvent.click(within(targetRow).getByRole("button", { name: "Reject with reason" })); + + const reasonInput = within(targetRow).getByPlaceholderText("Enter rejection reason"); + fireEvent.change(reasonInput, { target: { value: "File is blurry" } }); + fireEvent.click(within(targetRow).getByRole("button", { name: "Submit rejection" })); + + await waitFor(() => { + expect(mutateReviewDocument).toHaveBeenCalledWith({ + documentId: "doc-2", + decision: "REJECTED", + reason: "File is blurry", + }); + }); + }); + + it("updates progress badge after approval", async () => { + render(); + + expect(screen.getByTestId("review-progress-badge").textContent).toContain( + "1 of 2 documents approved", + ); + + const pendingRow = screen.getByTestId("document-row-doc-2"); + fireEvent.click(within(pendingRow).getByRole("button", { name: "Approve" })); + + await waitFor(() => { + expect(screen.getByTestId("review-progress-badge").textContent).toContain( + "2 of 2 documents approved", + ); + }); + + expect(screen.getByTestId("all-documents-approved-message")).toBeInTheDocument(); + }); +});