Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/api/documentService.ts
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);
},
};
211 changes: 211 additions & 0 deletions src/components/adoption/AdminDocumentReviewPanel.tsx
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) },
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useApiQuery is enabled whenever adoptionId is truthy. Since hooks execute even when the component later returns null for non-admins, this will still fetch documents for users without admin privileges. Gate the query with enabled: Boolean(adoptionId) && isAdmin (or split into an admin-only inner component so non-admins never instantiate the query/mutation hooks).

Suggested change
{ enabled: Boolean(adoptionId) },
{ enabled: Boolean(adoptionId) && isAdmin },

Copilot uses AI. Check for mistakes.
);

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
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isPending is global for the mutation, but isRowPending only disables the active row. This allows starting another approve/reject while a mutation is already pending, and activeDocumentId will be overwritten (so the UI can show the wrong row as “pending”). Consider disabling all row actions while isPending is true, or track a set/map of pending document IDs to support safe concurrency.

Copilot uses AI. Check for mistakes.
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>
);
}
169 changes: 169 additions & 0 deletions src/components/adoption/__tests__/AdminDocumentReviewPanel.test.tsx
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();
});
});
Loading
Loading