-
Notifications
You must be signed in to change notification settings - Fork 86
Feat/admin file verification UI #294
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AdoptionDocument[]> { | ||
| return apiClient.get(`/adoption/${adoptionId}/documents`); | ||
| }, | ||
|
|
||
| async reviewDocument( | ||
| documentId: string, | ||
| payload: ReviewDocumentPayload, | ||
| ): Promise<AdoptionDocument> { | ||
| return apiClient.patch(`/documents/${documentId}/review`, payload); | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AdoptionDocument["reviewStatus"], string> = { | ||
| 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<AdoptionDocument[]>([]); | ||
| const [rejectingDocumentId, setRejectingDocumentId] = useState<string | null>(null); | ||
| const [rejectionReason, setRejectionReason] = useState(""); | ||
| const [activeDocumentId, setActiveDocumentId] = useState<string | null>(null); | ||
|
|
||
| const { data, isLoading, isError } = useApiQuery<AdoptionDocument[]>( | ||
| ["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 ( | ||
| <section className="rounded-xl border border-gray-200 bg-white p-5" aria-label="Document verification panel"> | ||
| <div className="flex flex-wrap items-center justify-between gap-3"> | ||
| <h2 className="text-lg font-semibold text-gray-900">Document verification</h2> | ||
| <span | ||
| data-testid="review-progress-badge" | ||
| className="rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-700" | ||
| > | ||
| {approvedCount} of {totalCount} documents approved | ||
| </span> | ||
| </div> | ||
|
|
||
| {allApproved ? ( | ||
| <p | ||
| data-testid="all-documents-approved-message" | ||
| className="mt-3 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm font-medium text-green-800" | ||
| > | ||
| All documents verified — adoption can proceed to escrow | ||
| </p> | ||
| ) : null} | ||
|
|
||
| {isLoading ? ( | ||
| <p className="mt-4 text-sm text-gray-500">Loading documents...</p> | ||
| ) : null} | ||
|
|
||
| {isError ? ( | ||
| <p className="mt-4 text-sm text-red-700">Failed to load documents. Please refresh and try again.</p> | ||
| ) : null} | ||
|
|
||
| {!isLoading && !isError && documents.length === 0 ? ( | ||
| <p className="mt-4 text-sm text-gray-500">No documents uploaded yet.</p> | ||
| ) : null} | ||
|
|
||
| <div className="mt-4 space-y-3"> | ||
| {documents.map((document) => { | ||
| const isRowPending = isPending && activeDocumentId === document.id; | ||
| const isRejecting = rejectingDocumentId === document.id; | ||
|
|
||
|
Comment on lines
+125
to
+127
|
||
| return ( | ||
| <article | ||
| key={document.id} | ||
| className="rounded-lg border border-gray-200 p-3" | ||
| data-testid={`document-row-${document.id}`} | ||
| > | ||
| <div className="flex flex-wrap items-start justify-between gap-3"> | ||
| <div className="space-y-2"> | ||
| <p className="text-sm font-semibold text-gray-900">{document.fileName}</p> | ||
| <div className="flex flex-wrap items-center gap-2"> | ||
| <DocumentIntegrityBadge | ||
| onChainVerified={document.onChainVerified} | ||
| anchorTxHash={document.anchorTxHash} | ||
| /> | ||
| <span | ||
| className={`rounded-full px-2 py-1 text-xs font-semibold ${statusClasses[document.reviewStatus]}`} | ||
| > | ||
| {document.reviewStatus} | ||
| </span> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="flex flex-wrap items-center gap-2"> | ||
| <button | ||
| type="button" | ||
| onClick={() => void handleApprove(document.id)} | ||
| disabled={isRowPending || document.reviewStatus === "APPROVED"} | ||
| className="rounded-md border border-green-600 px-3 py-1.5 text-sm font-semibold text-green-700 hover:bg-green-50 disabled:cursor-not-allowed disabled:opacity-50" | ||
| > | ||
| Approve | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={() => { | ||
| setRejectingDocumentId(document.id); | ||
| setRejectionReason(document.reviewReason ?? ""); | ||
| }} | ||
| disabled={isRowPending} | ||
| className="rounded-md border border-red-600 px-3 py-1.5 text-sm font-semibold text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50" | ||
| > | ||
| Reject with reason | ||
| </button> | ||
| </div> | ||
| </div> | ||
|
|
||
| {isRejecting ? ( | ||
| <div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center"> | ||
| <label htmlFor={`reject-reason-${document.id}`} className="sr-only"> | ||
| Rejection reason | ||
| </label> | ||
| <input | ||
| id={`reject-reason-${document.id}`} | ||
| value={rejectionReason} | ||
| onChange={(event) => setRejectionReason(event.target.value)} | ||
| className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm" | ||
| placeholder="Enter rejection reason" | ||
| /> | ||
| <button | ||
| type="button" | ||
| onClick={() => void handleRejectSubmit(document.id)} | ||
| disabled={isRowPending || rejectionReason.trim().length === 0} | ||
| className="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50" | ||
| > | ||
| Submit rejection | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={() => { | ||
| setRejectingDocumentId(null); | ||
| setRejectionReason(""); | ||
| }} | ||
| className="rounded-md border border-gray-300 px-3 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50" | ||
| > | ||
| Cancel | ||
| </button> | ||
| </div> | ||
| ) : null} | ||
| </article> | ||
| ); | ||
| })} | ||
| </div> | ||
| </section> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(<AdminDocumentReviewPanel adoptionId="adoption-001" />); | ||
|
|
||
| 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(<AdminDocumentReviewPanel adoptionId="adoption-001" />); | ||
|
|
||
| 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(<AdminDocumentReviewPanel adoptionId="adoption-001" />); | ||
|
|
||
| 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(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
useApiQueryis enabled wheneveradoptionIdis truthy. Since hooks execute even when the component later returnsnullfor non-admins, this will still fetch documents for users without admin privileges. Gate the query withenabled: Boolean(adoptionId) && isAdmin(or split into an admin-only inner component so non-admins never instantiate the query/mutation hooks).