From ff7de47f03db3e9e419cc71f739f1ad9705f223b Mon Sep 17 00:00:00 2001 From: edehvictor Date: Wed, 22 Apr 2026 17:41:16 +0100 Subject: [PATCH 1/2] fix: use SELECT 1 for health checks --- backend/src/app.js | 20 +++++-------- backend/src/index.js | 53 +++++++++++++--------------------- backend/src/lib/health.js | 20 +++++++++++++ backend/src/lib/health.test.js | 45 +++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 45 deletions(-) create mode 100644 backend/src/lib/health.js create mode 100644 backend/src/lib/health.test.js diff --git a/backend/src/app.js b/backend/src/app.js index 542caf8a..69fb5be0 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -19,9 +19,8 @@ import x402Router from "./routes/x402.js"; import authRouter from "./routes/auth.js"; import { requireApiKeyAuth } from "./lib/auth.js"; -import { isHorizonReachable } from "./lib/stellar.js"; -import { supabase } from "./lib/supabase.js"; import { pool } from "./lib/db.js"; +import { probeHealth } from "./lib/health.js"; import { x402Middleware } from "./middleware/x402.js"; import { idempotencyMiddleware } from "./lib/idempotency.js"; @@ -203,18 +202,15 @@ export async function createApp({ redisClient }) { * type: string * horizon_reachable: * type: boolean - */ + */ app.get("/health", async (req, res) => { - const [dbResult, horizonReachable] = await Promise.allSettled([ - supabase.from("merchants").select("id").limit(1), - isHorizonReachable(), - ]); - - const dbOk = dbResult.status === "fulfilled" && !dbResult.value?.error; - if (!dbOk) { - console.error("Health DB error:", JSON.stringify(dbResult.reason || dbResult.value?.error)); + const { database, horizon } = await probeHealth(); + + if (!database.ok) { + console.error("Health DB error:", database.error); } - const horizonOk = horizonReachable.status === "fulfilled" && horizonReachable.value === true; + const dbOk = database.ok; + const horizonOk = horizon.ok; const status = dbOk && horizonOk ? 200 : 503; diff --git a/backend/src/index.js b/backend/src/index.js index 5a817cbb..b8bd009c 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -13,12 +13,11 @@ import metricsRouter from "./routes/metrics.js"; import authRouter from "./routes/auth.js"; import auditRouter from "./routes/audit.js"; import { requireApiKeyAuth } from "./lib/auth.js"; -import { isHorizonReachable } from "./lib/stellar.js"; -import { supabase } from "./lib/supabase.js"; import { pool, closePool } from "./lib/db.js"; import { validateEnvironmentVariables } from "./lib/env-validation.js"; import { formatZodError } from "./lib/request-schemas.js"; import { idempotencyMiddleware } from "./lib/idempotency.js"; +import { probeHealth } from "./lib/health.js"; import { closeRedisClient, connectRedisClient } from "./lib/redis.js"; import { createRedisRateLimitStore, @@ -82,45 +81,33 @@ app.use(express.json({ limit: "1mb" })); app.use(morgan(":request-id :method :url :status :response-time ms")); app.get("/health", async (req, res) => { - try { - const [dbResult, horizonReachable] = await Promise.all([ - supabase.from("merchants").select("id").limit(1), - isHorizonReachable(), - ]); - - const { error } = dbResult; - - if (error) { - return res.status(503).json({ - ok: false, - service: "stellar-payment-api", - error: "Database unavailable", - horizon_reachable: horizonReachable, - }); - } - - if (!horizonReachable) { - return res.status(503).json({ - ok: false, - service: "stellar-payment-api", - error: "Horizon unavailable", - horizon_reachable: false, - }); - } - - res.json({ - ok: true, + const { database, horizon } = await probeHealth(); + + if (!database.ok) { + res.status(503).json({ + ok: false, service: "stellar-payment-api", - horizon_reachable: true, + error: "Database unavailable", + horizon_reachable: horizon.ok, }); - } catch { + return; + } + + if (!horizon.ok) { res.status(503).json({ ok: false, service: "stellar-payment-api", - error: "Health check failed", + error: "Horizon unavailable", horizon_reachable: false, }); + return; } + + res.json({ + ok: true, + service: "stellar-payment-api", + horizon_reachable: true, + }); }); app.use("/api/create-payment", requireApiKeyAuth()); diff --git a/backend/src/lib/health.js b/backend/src/lib/health.js new file mode 100644 index 00000000..c36a1afb --- /dev/null +++ b/backend/src/lib/health.js @@ -0,0 +1,20 @@ +import { pool } from "./db.js"; +import { isHorizonReachable } from "./stellar.js"; + +export async function probeHealth({ db = pool, horizonProbe = isHorizonReachable } = {}) { + const [dbResult, horizonResult] = await Promise.allSettled([ + db.query("SELECT 1"), + horizonProbe(), + ]); + + return { + database: { + ok: dbResult.status === "fulfilled", + error: dbResult.status === "rejected" ? dbResult.reason : null, + }, + horizon: { + ok: horizonResult.status === "fulfilled" && horizonResult.value === true, + error: horizonResult.status === "rejected" ? horizonResult.reason : null, + }, + }; +} diff --git a/backend/src/lib/health.test.js b/backend/src/lib/health.test.js new file mode 100644 index 00000000..84be7bb9 --- /dev/null +++ b/backend/src/lib/health.test.js @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const query = vi.fn(); +const isHorizonReachable = vi.fn(); + +vi.mock("./db.js", () => ({ + pool: { query }, +})); + +vi.mock("./stellar.js", () => ({ + isHorizonReachable, +})); + +describe("probeHealth", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('runs a "SELECT 1" database probe and reports both services healthy', async () => { + query.mockResolvedValue({ rows: [{ "?column?": 1 }] }); + isHorizonReachable.mockResolvedValue(true); + + const { probeHealth } = await import("./health.js"); + const result = await probeHealth(); + + expect(query).toHaveBeenCalledWith("SELECT 1"); + expect(isHorizonReachable).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + database: { ok: true, error: null }, + horizon: { ok: true, error: null }, + }); + }); + + it("reports the database as unavailable when the SELECT 1 query fails", async () => { + const dbError = new Error("db down"); + query.mockRejectedValue(dbError); + isHorizonReachable.mockResolvedValue(true); + + const { probeHealth } = await import("./health.js"); + const result = await probeHealth(); + + expect(result.database).toEqual({ ok: false, error: dbError }); + expect(result.horizon).toEqual({ ok: true, error: null }); + }); +}); From 37fec31de3a366ac5ff417ccd32e6105a8ded394 Mon Sep 17 00:00:00 2001 From: edehvictor Date: Thu, 23 Apr 2026 00:29:12 +0100 Subject: [PATCH 2/2] fix: repair issue 18 CI failures --- backend/tests/integration/health.test.js | 7 + .../app/(authenticated)/dashboard/page.tsx | 198 ++++++++++-------- frontend/src/components/PaymentMetrics.tsx | 35 +++- frontend/src/components/WalletSelector.tsx | 109 ++++++---- 4 files changed, 216 insertions(+), 133 deletions(-) diff --git a/backend/tests/integration/health.test.js b/backend/tests/integration/health.test.js index 1a1c50f7..4a99d723 100644 --- a/backend/tests/integration/health.test.js +++ b/backend/tests/integration/health.test.js @@ -18,6 +18,13 @@ vi.mock("../../src/lib/stellar.js", () => ({ isHorizonReachable: vi.fn(async () => true), })); +vi.mock("../../src/lib/health.js", () => ({ + probeHealth: vi.fn(async () => ({ + database: { ok: true, error: null }, + horizon: { ok: true, error: null }, + })), +})); + vi.mock("../../src/lib/supabase.js", () => { return { supabase: { diff --git a/frontend/src/app/(authenticated)/dashboard/page.tsx b/frontend/src/app/(authenticated)/dashboard/page.tsx index ba29dcb4..c74cda2d 100644 --- a/frontend/src/app/(authenticated)/dashboard/page.tsx +++ b/frontend/src/app/(authenticated)/dashboard/page.tsx @@ -13,14 +13,11 @@ import { } from "@/lib/merchant-store"; import { useTranslations } from "next-intl"; import FirstApiKeyModal from "@/components/FirstApiKeyModal"; -import PaymentMetrics from "@/components/PaymentMetrics"; -import RecentPayments from "@/components/RecentPayments"; -import WithdrawModal from "@/components/WithdrawModal"; +import FirstPaymentCelebration from "@/components/FirstPaymentCelebration"; export default function DashboardPage() { const t = useTranslations("dashboardPage"); const [isFirstKeyModalOpen, setIsFirstKeyModalOpen] = useState(false); - const [isWithdrawOpen, setIsWithdrawOpen] = useState(false); const hydrated = useMerchantHydrated(); const apiKey = useMerchantApiKey(); const merchant = useMerchantMetadata(); @@ -44,100 +41,127 @@ export default function DashboardPage() { if (!hydrated || loading) return ; return ( -
- {/* ── Welcome & Quick Actions ────────────────────────────────────────── */} -
-
-

- Merchant Hub -

-

- Overview of your Stellar payment ecosystem and performance. -

-
+
+
+

Overview

+

+ {merchant?.business_name ?? t("title")} +

+

{t("description")}

+
-
- - - - - Create Link -
- +
+
+
+

Business Overview

+ +
- - - - - View Docs - +
+
+

Recent Activity

+ + {t("viewAllPayments")} -> + +
+ +
+
- - - - - - Settings - +
-
+
+

{t("development")}

+
+
+
+

{t("apiKeysTip")}

+
+
+
+

{t("webhookLogsTip")}

+
+
+
- {/* ── Main Dashboard Content ────────────────────────────────────────── */} -
- {/* Performance Metrics Section */} -
-
-

Performance

-
- - Live monitoring active +
+

API Endpoint

+
+
+ https://pluto-api.up.railway.app/api + +
+

Use this base URL for all subscription and x402 API requests.

-
- -
+ - {/* Activity Table Section */} -
-
-

Recent Activity

- - View all payments - - - - -
-
- -
-
+
+

x402 Integration

+

+ Build pay-per-request flows with the production x402 setup guide. +

+
+ + Open x402 Integration Guide + Docs + +
+
+
setIsFirstKeyModalOpen(false)} /> - setIsWithdrawOpen(false)} />
); } diff --git a/frontend/src/components/PaymentMetrics.tsx b/frontend/src/components/PaymentMetrics.tsx index de227d1f..fe37be64 100644 --- a/frontend/src/components/PaymentMetrics.tsx +++ b/frontend/src/components/PaymentMetrics.tsx @@ -343,17 +343,30 @@ export default function PaymentMetrics({ }); }; - if (loading || !hydrated) { - return ( -
-
-
-
-
-
-
-
- ); + const handleExport = async ( + format: ExportFormat, + containerRef: RefObject + ) => { + setExporting(true); + + try { + await exportChart( + containerRef, + format, + `multi-asset-volume-${range.toLowerCase()}` + ); + toast.success(t("exportSuccess", { format: format.toUpperCase() })); + } catch (exportError) { + const message = + exportError instanceof Error ? exportError.message : t("exportFailed"); + toast.error(message); + } finally { + setExporting(false); + } + }; + + if (showSkeleton || loading || !hydrated) { + return ; } if (error) { diff --git a/frontend/src/components/WalletSelector.tsx b/frontend/src/components/WalletSelector.tsx index b419c83c..fbe395ac 100644 --- a/frontend/src/components/WalletSelector.tsx +++ b/frontend/src/components/WalletSelector.tsx @@ -12,7 +12,6 @@ interface WalletSelectorProps { onConnected: () => void; } -// Freighter icon SVG function FreighterIcon() { return ( @@ -45,7 +44,6 @@ export default function WalletSelector({ networkPassphrase, onConnected }: Walle const t = useTranslations("walletSelector"); const { providers, activeProvider, selectProvider } = useWallet(); - // Track which wallets are installed (not just allowed) const [installed, setInstalled] = useState>({}); const [connecting, setConnecting] = useState(null); const [wcUri, setWcUri] = useState(null); @@ -55,25 +53,71 @@ export default function WalletSelector({ networkPassphrase, onConnected }: Walle useEffect(() => { let cancelled = false; Promise.all( - providers.map(async (p) => { - const ok = await p.isAvailable(); - return [p.id, ok] as const; + providers.map(async (provider) => { + const ok = await provider.isAvailable(); + return [provider.id, ok] as const; }), ).then((entries) => { if (!cancelled) setInstalled(Object.fromEntries(entries)); }); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [providers]); if (activeProvider) return null; + function getFriendlyErrorMessage(id: string, err: unknown) { + const message = err instanceof Error ? err.message : String(err ?? ""); + const normalized = message.toLowerCase(); + + if ( + normalized.includes("4001") || + normalized.includes("reject") || + normalized.includes("declin") || + normalized.includes("denied") || + normalized.includes("canceled") || + normalized.includes("cancelled") + ) { + return t("userRejected"); + } + + if ( + normalized.includes("project_id") || + normalized.includes("project id") || + normalized.includes("walletconnect is disabled") || + normalized.includes("pairing uri") + ) { + return t("walletConnectUnavailable"); + } + + if ( + normalized.includes("not installed") || + normalized.includes("not found") || + normalized.includes("extension") + ) { + return t("extensionNotFound"); + } + + if ( + normalized.includes("no stellar accounts") || + normalized.includes("public key") || + normalized.includes("session not established") + ) { + return t("noAccountFound"); + } + + return id === "walletconnect" ? t("walletConnectFailed") : t("connectionFailed"); + } + async function handleSelect(id: string) { setConnectError(null); + setWcError(null); + setWcUri(null); setConnecting(id); try { if (id === "walletconnect") { - setWcError(null); const { uri, approval } = await connectWalletConnect(networkPassphrase); setWcUri(uri); await approval; @@ -83,11 +127,9 @@ export default function WalletSelector({ networkPassphrase, onConnected }: Walle return; } - // For Freighter — call getPublicKey directly which triggers requestAccess popup if (id === "freighter") { - const provider = providers.find(p => p.id === "freighter"); + const provider = providers.find((entry) => entry.id === "freighter"); if (!provider) throw new Error("Freighter provider not found"); - // This triggers the Freighter popup await provider.getPublicKey(); selectProvider(id); onConnected(); @@ -97,7 +139,7 @@ export default function WalletSelector({ networkPassphrase, onConnected }: Walle selectProvider(id); onConnected(); } catch (err) { - const msg = err instanceof Error ? err.message : "Connection failed"; + const msg = getFriendlyErrorMessage(id, err); if (id === "walletconnect") { setWcError(msg); setWcUri(null); @@ -113,54 +155,53 @@ export default function WalletSelector({ networkPassphrase, onConnected }: Walle

{t("chooseWallet")}

-

Connect your Stellar wallet to complete this payment.

+

{t("description")}

- {providers.map((p) => { - const isWc = p.id === "walletconnect"; - const isConnecting = connecting === p.id; - const isInstalled = installed[p.id] ?? false; - // WalletConnect needs a project ID; Freighter just needs to be installed - const isDisabled = isWc ? !isInstalled : false; + {providers.map((provider) => { + const isWc = provider.id === "walletconnect"; + const isConnecting = connecting === provider.id; + const isInstalled = installed[provider.id] ?? false; + const isDisabled = !isInstalled; return (
- {/* WalletConnect QR */} {wcUri && ( -
+

{t("scanTitle")}

-

Scan with Freighter mobile or any WalletConnect-compatible wallet

+

{t("scanDescription")}

)} {(wcError || connectError) && ( -
+
{wcError || connectError}
)} - {/* Freighter install prompt */} - {!installed["freighter"] && ( + {!installed.freighter && ( - Don't have Freighter? Install it → + Don't have Freighter? Install it -> )}