From b419a0102725e333f43e808555717be6f199f5ef Mon Sep 17 00:00:00 2001 From: Timi16 Date: Sat, 30 May 2026 17:35:01 -0700 Subject: [PATCH] feat: async transaction db persistence, double-confirmation dialogs, skeleton loading enhancements, copy-to-clipboard utility buttons --- .claude/settings.local.json | 4 +- .../app/admin/api-keys/loading.tsx | 26 +- admin-dashboard/app/admin/signers/loading.tsx | 26 +- .../app/admin/webhooks/loading.tsx | 26 +- .../components/dashboard/ApiKeysTable.tsx | 105 +++++--- .../components/dashboard/CopyButton.test.tsx | 99 ++++++++ .../components/dashboard/CopyButton.tsx | 72 +++++- .../components/dashboard/SARTable.tsx | 17 +- .../dashboard/WebhookSettingsManager.tsx | 35 ++- .../components/signers/SignerPoolManager.tsx | 26 +- .../skeletons/ApiKeysTableSkeleton.tsx | 106 ++++++++ .../skeletons/SignerPoolSkeleton.tsx | 139 +++++++++++ .../components/skeletons/WebhookSkeleton.tsx | 97 ++++++++ admin-dashboard/components/skeletons/index.ts | 3 + .../components/ui/ConfirmDialog.test.tsx | 101 ++++++++ .../components/ui/ConfirmDialog.tsx | 78 ++++++ server/src/handlers/feeBump.ts | 28 ++- server/src/utils/asyncDbPersist.test.ts | 232 ++++++++++++++++++ server/src/utils/asyncDbPersist.ts | 91 +++++++ 19 files changed, 1231 insertions(+), 80 deletions(-) create mode 100644 admin-dashboard/components/dashboard/CopyButton.test.tsx create mode 100644 admin-dashboard/components/skeletons/ApiKeysTableSkeleton.tsx create mode 100644 admin-dashboard/components/skeletons/SignerPoolSkeleton.tsx create mode 100644 admin-dashboard/components/skeletons/WebhookSkeleton.tsx create mode 100644 admin-dashboard/components/ui/ConfirmDialog.test.tsx create mode 100644 admin-dashboard/components/ui/ConfirmDialog.tsx create mode 100644 server/src/utils/asyncDbPersist.test.ts create mode 100644 server/src/utils/asyncDbPersist.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b288449e..bac8a9cb 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,9 @@ { "permissions": { "allow": [ - "Bash(npx vitest *)" + "Bash(npx vitest *)", + "Bash(ls /Users/ik/Documents/fluid/admin-dashboard/*.config.*)", + "Bash(ls /Users/ik/Documents/fluid/admin-dashboard/*.config.js)" ] } } diff --git a/admin-dashboard/app/admin/api-keys/loading.tsx b/admin-dashboard/app/admin/api-keys/loading.tsx index 70546b9e..fa64a795 100644 --- a/admin-dashboard/app/admin/api-keys/loading.tsx +++ b/admin-dashboard/app/admin/api-keys/loading.tsx @@ -1,13 +1,23 @@ -import { AdminPageSkeleton } from "@/components/skeletons"; +import { ApiKeysTableSkeleton } from "@/components/skeletons"; export default function Loading() { return ( - +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
); } diff --git a/admin-dashboard/app/admin/signers/loading.tsx b/admin-dashboard/app/admin/signers/loading.tsx index 60067a19..3c4dcaaf 100644 --- a/admin-dashboard/app/admin/signers/loading.tsx +++ b/admin-dashboard/app/admin/signers/loading.tsx @@ -1,13 +1,23 @@ -import { AdminPageSkeleton } from "@/components/skeletons"; +import { SignerPoolSkeleton } from "@/components/skeletons"; export default function Loading() { return ( - +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
); } diff --git a/admin-dashboard/app/admin/webhooks/loading.tsx b/admin-dashboard/app/admin/webhooks/loading.tsx index a3f6c4e5..66f6a1d9 100644 --- a/admin-dashboard/app/admin/webhooks/loading.tsx +++ b/admin-dashboard/app/admin/webhooks/loading.tsx @@ -1,13 +1,23 @@ -import { AdminPageSkeleton } from "@/components/skeletons"; +import { WebhookSkeleton } from "@/components/skeletons"; export default function Loading() { return ( - +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
); } diff --git a/admin-dashboard/components/dashboard/ApiKeysTable.tsx b/admin-dashboard/components/dashboard/ApiKeysTable.tsx index f57b8154..74269f34 100644 --- a/admin-dashboard/components/dashboard/ApiKeysTable.tsx +++ b/admin-dashboard/components/dashboard/ApiKeysTable.tsx @@ -3,7 +3,8 @@ import { useState } from "react"; import { ShieldOff } from "lucide-react"; import type { ApiKey, ChainId } from "@/components/dashboard/types"; -import { RevokeKeyDialog } from "@/components/dashboard/RevokeKeyDialog"; +import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; +import { CopyButton } from "@/components/dashboard/CopyButton"; interface ApiKeysTableProps { initialKeys: ApiKey[]; @@ -120,24 +121,43 @@ export function ApiKeysTable({ }: ApiKeysTableProps) { const [keys, setKeys] = useState(initialKeys); const [pendingRevoke, setPendingRevoke] = useState(null); + const [revoking, setRevoking] = useState(false); + const [revokeError, setRevokeError] = useState(null); - async function handleRevoke(keyId: string) { - const res = await fetch(`${serverUrl}/admin/api-keys/${keyId}/revoke`, { - method: "PATCH", - headers: { - "x-admin-token": adminToken, - "Content-Type": "application/json", - }, - }); + async function handleRevoke() { + if (!pendingRevoke) return; + setRevoking(true); + setRevokeError(null); + try { + const res = await fetch(`${serverUrl}/admin/api-keys/${pendingRevoke.id}/revoke`, { + method: "PATCH", + headers: { + "x-admin-token": adminToken, + "Content-Type": "application/json", + }, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error ?? `Request failed (${res.status})`); + } - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body?.error ?? `Request failed (${res.status})`); + setKeys((prev) => + prev.map((k: ApiKey) => (k.id === pendingRevoke.id ? { ...k, active: false } : k)), + ); + setPendingRevoke(null); + } catch (err: unknown) { + setRevokeError(err instanceof Error ? err.message : "Failed to revoke key. Please try again."); + } finally { + setRevoking(false); } + } - setKeys((prev) => - prev.map((k: ApiKey) => (k.id === keyId ? { ...k, active: false } : k)), - ); + function closeRevokeDialog() { + if (!revoking) { + setPendingRevoke(null); + setRevokeError(null); + } } function handleChainUpdate(keyId: string, chains: ChainId[]) { @@ -201,15 +221,25 @@ export function ApiKeysTable({ } > - - {apiKey.key} - +
+ + {apiKey.key} + + {apiKey.active && ( + + )} +
@@ -263,13 +293,26 @@ export function ApiKeysTable({ - {pendingRevoke && ( - setPendingRevoke(null)} - /> + { if (!open) closeRevokeDialog(); }} + title="Revoke API Key" + description={ + pendingRevoke + ? `This will immediately deactivate ${pendingRevoke.key}. Any dApp or service using this key will lose access instantly. This action cannot be undone.` + : "" + } + confirmLabel={revoking ? "Revoking…" : "Revoke Key"} + cancelLabel="Cancel" + onConfirm={() => void handleRevoke()} + onCancel={closeRevokeDialog} + variant="destructive" + isLoading={revoking} + /> + {revokeError && ( +
+ {revokeError} +
)} ); diff --git a/admin-dashboard/components/dashboard/CopyButton.test.tsx b/admin-dashboard/components/dashboard/CopyButton.test.tsx new file mode 100644 index 00000000..ce99c8ca --- /dev/null +++ b/admin-dashboard/components/dashboard/CopyButton.test.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { CopyButton } from "./CopyButton"; + +describe("CopyButton", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("navigator", { + clipboard: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("renders with default label", () => { + render(); + expect(screen.getByText("Copy")).toBeInTheDocument(); + }); + + it("renders with a custom label", () => { + render(); + expect(screen.getByText("Copy hash")).toBeInTheDocument(); + }); + + it("calls navigator.clipboard.writeText with the correct value on click", async () => { + render(); + await act(async () => { + fireEvent.click(screen.getByRole("button")); + }); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("abc123"); + }); + + it("shows 'Copied' state immediately after click", async () => { + render(); + await act(async () => { + fireEvent.click(screen.getByRole("button")); + }); + expect(screen.getByText("Copied")).toBeInTheDocument(); + }); + + it("reverts to copy label after 2 seconds", async () => { + render(); + await act(async () => { + fireEvent.click(screen.getByRole("button")); + }); + expect(screen.getByText("Copied")).toBeInTheDocument(); + await act(async () => { + vi.advanceTimersByTime(2000); + }); + expect(screen.getByText("Copy key")).toBeInTheDocument(); + }); + + it("renders in iconOnly mode without a text label", () => { + render(); + // The text label should not be in the document + expect(screen.queryByText("Copy")).not.toBeInTheDocument(); + // But the button itself is still present + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("shows no text label in iconOnly Copied state", async () => { + render(); + await act(async () => { + fireEvent.click(screen.getByRole("button")); + }); + expect(screen.queryByText("Copied")).not.toBeInTheDocument(); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("handles clipboard failure gracefully without staying in copied state", async () => { + vi.stubGlobal("navigator", { + clipboard: { + writeText: vi.fn().mockRejectedValue(new Error("Permission denied")), + }, + }); + render(); + await act(async () => { + fireEvent.click(screen.getByRole("button")); + }); + expect(screen.queryByText("Copied")).not.toBeInTheDocument(); + expect(screen.getByText("Copy")).toBeInTheDocument(); + }); + + it("renders in sm size without errors", () => { + render(); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("renders in lg size without errors", () => { + render(); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); +}); diff --git a/admin-dashboard/components/dashboard/CopyButton.tsx b/admin-dashboard/components/dashboard/CopyButton.tsx index c122704e..cfc0b0aa 100644 --- a/admin-dashboard/components/dashboard/CopyButton.tsx +++ b/admin-dashboard/components/dashboard/CopyButton.tsx @@ -1,33 +1,91 @@ "use client"; import { useState } from "react"; +import { Copy, Check } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; + +type CopyButtonSize = "sm" | "md" | "lg"; interface CopyButtonProps { value: string; label?: string; + size?: CopyButtonSize; + iconOnly?: boolean; } -export function CopyButton({ value, label = "Copy" }: CopyButtonProps) { +const SIZE_CLASSES: Record = { + sm: "min-h-7 px-2 py-0.5 text-[11px] gap-1", + md: "min-h-9 px-3 py-1 text-xs gap-1.5", + lg: "min-h-10 px-4 py-1.5 text-sm gap-2", +}; + +const ICON_SIZES: Record = { + sm: "h-3 w-3", + md: "h-3.5 w-3.5", + lg: "h-4 w-4", +}; + +export function CopyButton({ + value, + label = "Copy", + size = "md", + iconOnly = false, +}: CopyButtonProps) { const [copied, setCopied] = useState(false); async function handleCopy() { try { await navigator.clipboard.writeText(value); setCopied(true); - window.setTimeout(() => setCopied(false), 1500); + window.setTimeout(() => setCopied(false), 2000); } catch { setCopied(false); } } + const iconClass = ICON_SIZES[size]; + return ( - + + {copied ? ( + + + ) : ( + + + )} + + ); } diff --git a/admin-dashboard/components/dashboard/SARTable.tsx b/admin-dashboard/components/dashboard/SARTable.tsx index b5c54054..83aa5f5e 100644 --- a/admin-dashboard/components/dashboard/SARTable.tsx +++ b/admin-dashboard/components/dashboard/SARTable.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import type { SARPageData, SARReport, SARStatus } from "@/lib/sar-data"; +import { CopyButton } from "@/components/dashboard/CopyButton"; function formatDate(value: string) { return new Intl.DateTimeFormat("en-US", { @@ -268,8 +269,20 @@ export function SARTable({ data }: SARTableProps) { {formatDate(report.createdAt)} - - {shortenHash(report.txHash)} + +
+ + {shortenHash(report.txHash)} + + {report.txHash && ( + + )} +
{report.tenantName} diff --git a/admin-dashboard/components/dashboard/WebhookSettingsManager.tsx b/admin-dashboard/components/dashboard/WebhookSettingsManager.tsx index 41233508..63aa539a 100644 --- a/admin-dashboard/components/dashboard/WebhookSettingsManager.tsx +++ b/admin-dashboard/components/dashboard/WebhookSettingsManager.tsx @@ -6,6 +6,7 @@ import type { WebhookTenantSettings, } from "@/components/dashboard/types"; import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; import { Card, CardContent, @@ -57,6 +58,7 @@ export function WebhookSettingsManager({ }) { const [rows, setRows] = useState(initialRows); const [saveStateByTenant, setSaveStateByTenant] = useState>({}); + const [pendingSave, setPendingSave] = useState(null); const updateRow = (tenantId: string, updater: (row: WebhookTenantSettings) => WebhookTenantSettings) => { setRows((current) => @@ -125,6 +127,7 @@ export function WebhookSettingsManager({ }; return ( + <>
{rows.map((row) => { const state = saveStateByTenant[row.tenantId] ?? { @@ -198,7 +201,13 @@ export function WebhookSettingsManager({
+ + { if (!open) setPendingSave(null); }} + title="Disable Webhook Delivery" + description={ + pendingSave + ? `Saving with an empty URL will disable all webhook delivery for tenant "${pendingSave.tenantName ?? pendingSave.tenantId}". Are you sure you want to continue?` + : "" + } + confirmLabel="Yes, disable webhooks" + cancelLabel="Cancel" + onConfirm={() => { + if (pendingSave) { + const rowToSave = pendingSave; + setPendingSave(null); + void saveTenantSettings(rowToSave); + } + }} + onCancel={() => setPendingSave(null)} + variant="destructive" + isLoading={pendingSave !== null && (saveStateByTenant[pendingSave.tenantId]?.saving ?? false)} + /> + ); } diff --git a/admin-dashboard/components/signers/SignerPoolManager.tsx b/admin-dashboard/components/signers/SignerPoolManager.tsx index 479ba973..e96529e4 100644 --- a/admin-dashboard/components/signers/SignerPoolManager.tsx +++ b/admin-dashboard/components/signers/SignerPoolManager.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { CopyButton } from "@/components/dashboard/CopyButton"; import type { ManagedSigner } from "@/lib/signer-management"; import { SignerBalanceRingChart } from "@/components/signers/SignerBalanceRingChart"; +import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; function formatHash(value: string) { if (value.length <= 18) { @@ -50,6 +51,7 @@ export function SignerPoolManager({ const [error, setError] = useState(""); const [isPending, startTransition] = useTransition(); const [removingKey, setRemovingKey] = useState(null); + const [pendingRemoveKey, setPendingRemoveKey] = useState(null); const [walletAddress, setWalletAddress] = useState(null); const [walletBalance, setWalletBalance] = useState("0.00"); @@ -303,7 +305,7 @@ export function SignerPoolManager({ {signer.canRemove ? (
) : null} + { if (!open && !removingKey) setPendingRemoveKey(null); }} + title="Remove Signer" + description={ + pendingRemoveKey + ? `Remove signer ${formatHash(pendingRemoveKey)} from the pool? This signer will no longer sponsor transactions.` + : "" + } + confirmLabel="Remove Signer" + cancelLabel="Cancel" + onConfirm={() => { + if (pendingRemoveKey) { + setPendingRemoveKey(null); + void handleRemoveSigner(pendingRemoveKey); + } + }} + onCancel={() => setPendingRemoveKey(null)} + variant="destructive" + isLoading={pendingRemoveKey !== null && removingKey === pendingRemoveKey} + /> + {txSuccessHash ? (
diff --git a/admin-dashboard/components/skeletons/ApiKeysTableSkeleton.tsx b/admin-dashboard/components/skeletons/ApiKeysTableSkeleton.tsx new file mode 100644 index 00000000..2a153449 --- /dev/null +++ b/admin-dashboard/components/skeletons/ApiKeysTableSkeleton.tsx @@ -0,0 +1,106 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export interface ApiKeysTableSkeletonProps { + /** Number of skeleton rows to render. Defaults to 6. */ + rows?: number; +} + +/** + * Geometry-accurate loading placeholder for . + * + * Mirrors the six-column layout exactly: + * Key | Tenant | Chains | Created | Status | Action + * + * Column proportions and row heights are kept in sync with the real table + * so swapping in live data causes zero layout shift. + */ +export function ApiKeysTableSkeleton({ rows = 6 }: ApiKeysTableSkeletonProps) { + const safeRows = Math.max(1, Math.min(50, Math.trunc(rows))); + + return ( +
+ {/* Card header */} +
+ + +
+ +
+ + {/* Column header row */} + + + {/* Key */} + + {/* Tenant (hidden on mobile) */} + + {/* Chains */} + + {/* Created (hidden on mobile) */} + + {/* Status */} + + {/* Action */} + + + + + {/* Data rows */} + + {Array.from({ length: safeRows }).map((_, rowIndex) => ( + + {/* Key — monospace pill */} + + {/* Tenant */} + + {/* Chains — four badge-like pills */} + + {/* Created */} + + {/* Status badge */} + + {/* Action button */} + + + ))} + + +
+
+ ); +} diff --git a/admin-dashboard/components/skeletons/SignerPoolSkeleton.tsx b/admin-dashboard/components/skeletons/SignerPoolSkeleton.tsx new file mode 100644 index 00000000..08f5e26b --- /dev/null +++ b/admin-dashboard/components/skeletons/SignerPoolSkeleton.tsx @@ -0,0 +1,139 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export interface SignerPoolSkeletonProps { + /** Number of skeleton signer rows to render. Defaults to 5. */ + rows?: number; +} + +/** + * Geometry-accurate loading placeholder for . + * + * Mirrors the full layout: + * - Three summary stat cards (Active / Low Balance / Sequence Errors) + * - Signer pool table with columns: + * Signer | Status | Balance | Source | In Flight | Sequence | Actions + * + * All padding, heights, and border-radii are kept in sync with the real + * component so swapping to live data causes zero layout shift. + */ +export function SignerPoolSkeleton({ rows = 5 }: SignerPoolSkeletonProps) { + const safeRows = Math.max(1, Math.min(50, Math.trunc(rows))); + + return ( +
+ {/* Stat cards — Active / Low Balance / Sequence Errors */} +
+ {(["emerald", "amber", "rose"] as const).map((color) => ( +
+ + + +
+ ))} +
+ + {/* Signer pool table */} +
+ {/* Table header bar */} +
+
+ + +
+
+ + +
+
+ +
+ + + + {/* Signer */} + + {/* Status */} + + {/* Balance */} + + {/* Source */} + + {/* In Flight (hidden < lg) */} + + {/* Sequence (hidden < xl) */} + + {/* Actions */} + + + + + + {Array.from({ length: safeRows }).map((_, rowIndex) => ( + + {/* Signer — truncated hash + full key beneath */} + + {/* Status badge */} + + {/* Balance */} + + {/* Source badge */} + + {/* In Flight */} + + {/* Sequence */} + + {/* Actions */} + + + ))} + + +
+
+
+ ); +} diff --git a/admin-dashboard/components/skeletons/WebhookSkeleton.tsx b/admin-dashboard/components/skeletons/WebhookSkeleton.tsx new file mode 100644 index 00000000..0fe71691 --- /dev/null +++ b/admin-dashboard/components/skeletons/WebhookSkeleton.tsx @@ -0,0 +1,97 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export interface WebhookSkeletonProps { + /** Number of tenant webhook cards to render. Defaults to 3. */ + cards?: number; + /** Number of event-type toggle rows per card. Defaults to 3. */ + eventRows?: number; +} + +/** + * Geometry-accurate loading placeholder for . + * + * Mirrors the per-tenant card layout: + * - Card header: tenant name + ID + last-updated timestamp + * - Webhook URL input field + * - Event-type section heading + * - One toggle row per event type (title + description + switch) + * - Save button + * + * Card geometry (padding, border-radius, spacing) matches the real + * shadcn/ui shell so the swap-in causes zero layout shift. + */ +export function WebhookSkeleton({ cards = 3, eventRows = 3 }: WebhookSkeletonProps) { + const safeCards = Math.max(1, Math.min(20, Math.trunc(cards))); + const safeEventRows = Math.max(1, Math.min(10, Math.trunc(eventRows))); + + return ( +
+ {Array.from({ length: safeCards }).map((_, cardIndex) => ( +
+ {/* Card header */} +
+
+ {/* CardTitle — tenant name */} + + {/* CardDescription — tenant ID */} + +
+ {/* Last-updated timestamp */} + +
+ + {/* Card content */} +
+ {/* Webhook URL field */} +
+ + + +
+ + {/* Event Types section */} +
+
+ + +
+ + {/* Toggle rows */} +
+ {Array.from({ length: safeEventRows }).map((_, rowIndex) => ( +
+
+ {/* Event type title */} + + {/* Event type description */} + +
+ {/* Toggle switch */} + +
+ ))} +
+
+ + {/* Save button */} + +
+
+ ))} +
+ ); +} diff --git a/admin-dashboard/components/skeletons/index.ts b/admin-dashboard/components/skeletons/index.ts index 02a8cbbb..de3a8066 100644 --- a/admin-dashboard/components/skeletons/index.ts +++ b/admin-dashboard/components/skeletons/index.ts @@ -1,5 +1,8 @@ export { AdminPageSkeleton, type AdminPageSkeletonProps } from "./AdminPageSkeleton"; +export { ApiKeysTableSkeleton, type ApiKeysTableSkeletonProps } from "./ApiKeysTableSkeleton"; export { ChartSkeleton, type ChartSkeletonProps } from "./ChartSkeleton"; export { DashboardSkeleton } from "./DashboardSkeleton"; +export { SignerPoolSkeleton, type SignerPoolSkeletonProps } from "./SignerPoolSkeleton"; export { StatCardSkeleton } from "./StatCardSkeleton"; export { TableSkeleton, type TableSkeletonProps } from "./TableSkeleton"; +export { WebhookSkeleton, type WebhookSkeletonProps } from "./WebhookSkeleton"; diff --git a/admin-dashboard/components/ui/ConfirmDialog.test.tsx b/admin-dashboard/components/ui/ConfirmDialog.test.tsx new file mode 100644 index 00000000..2bac5f3d --- /dev/null +++ b/admin-dashboard/components/ui/ConfirmDialog.test.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; + +function renderDialog(overrides: Partial> = {}) { + const props: React.ComponentProps = { + open: true, + onOpenChange: vi.fn(), + title: "Delete item", + description: "This action cannot be undone. Are you sure?", + onConfirm: vi.fn(), + ...overrides, + }; + return { ...render(React.createElement(ConfirmDialog, props)), props }; +} + +describe("ConfirmDialog", () => { + it("renders the title and description", () => { + renderDialog(); + + expect(screen.getByRole("heading", { name: "Delete item" })).toBeInTheDocument(); + expect(screen.getByText("This action cannot be undone. Are you sure?")).toBeInTheDocument(); + }); + + it("renders default confirm and cancel labels", () => { + renderDialog(); + + expect(screen.getByRole("button", { name: "Confirm" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + }); + + it("renders custom confirm and cancel labels", () => { + renderDialog({ confirmLabel: "Yes, delete", cancelLabel: "No, keep it" }); + + expect(screen.getByRole("button", { name: "Yes, delete" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "No, keep it" })).toBeInTheDocument(); + }); + + it("calls onConfirm when confirm button is clicked", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + renderDialog({ onConfirm }); + + await user.click(screen.getByRole("button", { name: "Confirm" })); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it("calls onCancel when cancel button is clicked and onCancel is provided", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + const onOpenChange = vi.fn(); + renderDialog({ onCancel, onOpenChange }); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it("calls onOpenChange(false) when cancel button is clicked and no onCancel provided", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + renderDialog({ onOpenChange }); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("disables both buttons and shows spinner when isLoading is true", () => { + renderDialog({ isLoading: true }); + + const confirmBtn = screen.getByRole("button", { name: /confirm/i }); + const cancelBtn = screen.getByRole("button", { name: "Cancel" }); + + expect(confirmBtn).toBeDisabled(); + expect(cancelBtn).toBeDisabled(); + // Loader icon is rendered as a sibling to the label text + expect(confirmBtn.querySelector("svg")).toBeInTheDocument(); + }); + + it("does not render when open is false", () => { + renderDialog({ open: false }); + + expect(screen.queryByRole("heading", { name: "Delete item" })).not.toBeInTheDocument(); + }); + + it("calls onOpenChange when the dialog requests to close (Escape / overlay click)", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + renderDialog({ onOpenChange }); + + await user.keyboard("{Escape}"); + + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/admin-dashboard/components/ui/ConfirmDialog.tsx b/admin-dashboard/components/ui/ConfirmDialog.tsx new file mode 100644 index 00000000..c747b978 --- /dev/null +++ b/admin-dashboard/components/ui/ConfirmDialog.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +export interface ConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + confirmLabel?: string; + cancelLabel?: string; + onConfirm: () => void; + onCancel?: () => void; + variant?: "destructive" | "default"; + isLoading?: boolean; +} + +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = "Confirm", + cancelLabel = "Cancel", + onConfirm, + onCancel, + variant = "destructive", + isLoading = false, +}: ConfirmDialogProps) { + function handleCancel() { + if (onCancel) { + onCancel(); + } else { + onOpenChange(false); + } + } + + return ( + + + + {title} + {description} + + + + + + + + ); +} diff --git a/server/src/handlers/feeBump.ts b/server/src/handlers/feeBump.ts index 3e568c06..bf4bedc0 100644 --- a/server/src/handlers/feeBump.ts +++ b/server/src/handlers/feeBump.ts @@ -1,5 +1,5 @@ import StellarSdk, { Transaction } from "@stellar/stellar-sdk"; -import { createHash } from "crypto"; +import { createHash, randomUUID } from "crypto"; import { Config, FeePayerAccount, pickFeePayerAccount } from "../config"; import { NextFunction, Request, Response } from "express"; import { AppError } from "../errors/AppError"; @@ -37,6 +37,7 @@ import { FeeBumpJobData, } from "../queues/feeBumpQueue"; import { getFcmNotifier } from "../services/fcmNotifier"; +import { persistTransactionAsync } from "../utils/asyncDbPersist"; const FEEBUMP_JOB_TIMEOUT_MS = parseInt( process.env.FEEBUMP_JOB_TIMEOUT_MS ?? "30000", @@ -138,19 +139,20 @@ function fingerprintSponsorshipRequest(value: unknown): string { return createHash("sha256").update(serialized).digest("hex"); } -async function createPendingTransactionRecord( +function createPendingTransactionRecord( tenantId: string, prepared: PreparedFeeBump, -): Promise<{ id: string }> { - return prisma.transaction.create({ - data: { - innerTxHash: prepared.innerTxHash, - tenantId, - status: "PENDING", - costStroops: prepared.feeAmount, - category: prepared.category, - }, +): { id: string } { + const id = randomUUID(); + persistTransactionAsync({ + id, + innerTxHash: prepared.innerTxHash, + tenantId, + status: "PENDING", + costStroops: prepared.feeAmount, + category: prepared.category, }); + return { id }; } async function executePreparedFeeBump( @@ -315,7 +317,7 @@ export async function processFeeBump( "QUOTA_EXCEEDED", ); } - const transactionRecord = await createPendingTransactionRecord( + const transactionRecord = createPendingTransactionRecord( tenant.id, prepared, ); @@ -526,7 +528,7 @@ export async function feeBumpHandler( ); } - const transactionRecord = await createPendingTransactionRecord( + const transactionRecord = createPendingTransactionRecord( tenant.id, prepared, ); diff --git a/server/src/utils/asyncDbPersist.test.ts b/server/src/utils/asyncDbPersist.test.ts new file mode 100644 index 00000000..cb0c27ad --- /dev/null +++ b/server/src/utils/asyncDbPersist.test.ts @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ── Prisma mock ─────────────────────────────────────────────────────────────── +vi.mock("./db", () => { + const mockPrisma = { + transaction: { + create: vi.fn(), + }, + }; + return { + default: mockPrisma, + prisma: mockPrisma, + }; +}); + +// ── Logger mock ─────────────────────────────────────────────────────────────── +vi.mock("./logger", () => ({ + createLogger: () => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }), +})); + +import { prisma } from "./db"; +import { + persistTransactionAsync, + persistTransactionWithRetry, + type TransactionPersistData, +} from "./asyncDbPersist"; + +const mockCreate = vi.mocked(prisma.transaction.create); + +function sampleData(overrides: Partial = {}): TransactionPersistData { + return { + innerTxHash: "abc123def456", + tenantId: "tenant-1", + status: "PENDING", + costStroops: 1000, + category: "Payment", + ...overrides, + }; +} + +describe("persistTransactionAsync", () => { + beforeEach(() => { + mockCreate.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("does not block the caller — returns before the DB write resolves", async () => { + // The DB write takes 50 ms; the caller should return immediately. + const DB_WRITE_DELAY = 50; + mockCreate.mockImplementation( + () => + new Promise((resolve) => + setTimeout(() => resolve({ id: "new-id" }), DB_WRITE_DELAY), + ), + ); + + const start = Date.now(); + persistTransactionAsync(sampleData()); + const elapsed = Date.now() - start; + + // Should return well before the simulated DB delay + expect(elapsed).toBeLessThan(DB_WRITE_DELAY); + }); + + it("calls prisma.transaction.create with the correct data", async () => { + mockCreate.mockResolvedValue({ id: "created-id" } as any); + + persistTransactionAsync(sampleData({ id: "preset-uuid" })); + + // Drain the microtask queue so the promise chain runs + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockCreate).toHaveBeenCalledOnce(); + const callArg = mockCreate.mock.calls[0][0]; + expect(callArg.data).toMatchObject({ + id: "preset-uuid", + innerTxHash: "abc123def456", + tenantId: "tenant-1", + status: "PENDING", + costStroops: BigInt(1000), + category: "Payment", + }); + }); + + it("omits id field when not provided", async () => { + mockCreate.mockResolvedValue({ id: "auto-id" } as any); + + persistTransactionAsync(sampleData()); + await new Promise((resolve) => setImmediate(resolve)); + + const callArg = mockCreate.mock.calls[0][0]; + expect(callArg.data).not.toHaveProperty("id"); + }); + + it("handles a null tenantId by omitting tenantId from the create payload", async () => { + mockCreate.mockResolvedValue({ id: "new-id" } as any); + + persistTransactionAsync(sampleData({ tenantId: null })); + await new Promise((resolve) => setImmediate(resolve)); + + const callArg = mockCreate.mock.calls[0][0]; + // tenantId: null → undefined → omitted from Prisma data object + expect(callArg.data.tenantId).toBeUndefined(); + }); + + it("logs an error but does NOT throw when the DB write fails", async () => { + mockCreate.mockRejectedValue(new Error("DB connection lost")); + + // Must not throw + expect(() => persistTransactionAsync(sampleData())).not.toThrow(); + + // Allow the rejection handler to run + await new Promise((resolve) => setImmediate(resolve)); + // If we reach here without an unhandled rejection, the error was caught + }); + + it("includes the optional chain field when provided", async () => { + mockCreate.mockResolvedValue({ id: "new-id" } as any); + + persistTransactionAsync(sampleData({ chain: "evm" })); + await new Promise((resolve) => setImmediate(resolve)); + + const callArg = mockCreate.mock.calls[0][0]; + expect(callArg.data.chain).toBe("evm"); + }); + + it("omits chain field when not provided", async () => { + mockCreate.mockResolvedValue({ id: "new-id" } as any); + + const data = sampleData(); + delete data.chain; + persistTransactionAsync(data); + await new Promise((resolve) => setImmediate(resolve)); + + const callArg = mockCreate.mock.calls[0][0]; + expect(callArg.data).not.toHaveProperty("chain"); + }); +}); + +describe("persistTransactionWithRetry", () => { + beforeEach(() => { + mockCreate.mockReset(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("resolves immediately when the first attempt succeeds", async () => { + mockCreate.mockResolvedValue({ id: "ok-id" } as any); + + await persistTransactionWithRetry(sampleData()); + + expect(mockCreate).toHaveBeenCalledOnce(); + }); + + it("retries on failure and succeeds on the second attempt", async () => { + mockCreate + .mockRejectedValueOnce(new Error("transient error")) + .mockResolvedValue({ id: "ok-id" } as any); + + const promise = persistTransactionWithRetry(sampleData(), 3); + + // Advance time past the first retry delay (100 ms) + await vi.advanceTimersByTimeAsync(200); + await promise; + + expect(mockCreate).toHaveBeenCalledTimes(2); + }); + + it("exhausts all retries and resolves (does not throw) when every attempt fails", async () => { + mockCreate.mockRejectedValue(new Error("persistent error")); + + const promise = persistTransactionWithRetry(sampleData(), 2); + + // 3 attempts: delays of 100 ms, 200 ms + await vi.advanceTimersByTimeAsync(1000); + await promise; // should resolve, not reject + + // attempt 0, attempt 1, attempt 2 → 3 total + expect(mockCreate).toHaveBeenCalledTimes(3); + }); + + it("uses exponential backoff between attempts", async () => { + mockCreate + .mockRejectedValueOnce(new Error("err")) + .mockRejectedValueOnce(new Error("err")) + .mockResolvedValue({ id: "ok-id" } as any); + + const promise = persistTransactionWithRetry(sampleData(), 3); + + // First retry delay: 100 ms (2^0 * 100) + await vi.advanceTimersByTimeAsync(100); + // Second retry delay: 200 ms (2^1 * 100) + await vi.advanceTimersByTimeAsync(200); + + await promise; + expect(mockCreate).toHaveBeenCalledTimes(3); + }); + + it("uses the default maxRetries of 3 when not specified", async () => { + mockCreate.mockRejectedValue(new Error("always fails")); + + const promise = persistTransactionWithRetry(sampleData()); + + // Advance through 3 retry delays: 100 + 200 + 400 ms + await vi.advanceTimersByTimeAsync(1000); + await promise; + + // 1 initial + 3 retries = 4 total calls + expect(mockCreate).toHaveBeenCalledTimes(4); + }); + + it("handles null tenantId correctly", async () => { + mockCreate.mockResolvedValue({ id: "ok-id" } as any); + + await persistTransactionWithRetry(sampleData({ tenantId: null })); + + const callArg = mockCreate.mock.calls[0][0]; + expect(callArg.data.tenantId).toBeUndefined(); + }); +}); diff --git a/server/src/utils/asyncDbPersist.ts b/server/src/utils/asyncDbPersist.ts new file mode 100644 index 00000000..a6b90e03 --- /dev/null +++ b/server/src/utils/asyncDbPersist.ts @@ -0,0 +1,91 @@ +import { prisma } from "./db"; +import { createLogger } from "./logger"; + +const logger = createLogger({ component: "asyncDbPersist" }); + +export interface TransactionPersistData { + id?: string; + innerTxHash: string; + tenantId: string | null; + status: string; + costStroops: number; + category: string; + chain?: string; +} + +/** + * Fire-and-forget DB write. Logs errors but never throws. + * The caller is NOT blocked by the database insert. + */ +export function persistTransactionAsync(data: TransactionPersistData): void { + void prisma.transaction + .create({ + data: { + ...(data.id !== undefined ? { id: data.id } : {}), + innerTxHash: data.innerTxHash, + tenantId: data.tenantId ?? undefined, + status: data.status, + costStroops: BigInt(data.costStroops), + category: data.category, + ...(data.chain !== undefined ? { chain: data.chain } : {}), + }, + }) + .catch((err: unknown) => { + logger.error( + { err, innerTxHash: data.innerTxHash, tenantId: data.tenantId }, + "asyncDbPersist: failed to persist transaction record", + ); + }); +} + +/** + * Async DB write with exponential-backoff retry logic. + * Suitable for callers who need a reliability guarantee but can still + * await off the critical response path. + */ +export async function persistTransactionWithRetry( + data: TransactionPersistData, + maxRetries: number = 3, +): Promise { + let lastErr: unknown; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + await prisma.transaction.create({ + data: { + ...(data.id !== undefined ? { id: data.id } : {}), + innerTxHash: data.innerTxHash, + tenantId: data.tenantId ?? undefined, + status: data.status, + costStroops: BigInt(data.costStroops), + category: data.category, + ...(data.chain !== undefined ? { chain: data.chain } : {}), + }, + }); + return; + } catch (err: unknown) { + lastErr = err; + + if (attempt < maxRetries) { + const delayMs = 100 * Math.pow(2, attempt); + logger.warn( + { + err, + attempt, + maxRetries, + delayMs, + innerTxHash: data.innerTxHash, + tenantId: data.tenantId, + }, + "asyncDbPersist: retrying transaction persist after error", + ); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + } + + logger.error( + { err: lastErr, innerTxHash: data.innerTxHash, tenantId: data.tenantId, maxRetries }, + "asyncDbPersist: all retry attempts exhausted for transaction persist", + ); +}