diff --git a/.prettierrc.json b/.prettierrc.json index 8a0f27e..93520a5 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -3,4 +3,5 @@ "singleQuote": false, "trailingComma": "none", "printWidth": 100 + } diff --git a/apps/api/src/lib/persistence.ts b/apps/api/src/lib/persistence.ts index f50a0ca..3e264c0 100644 --- a/apps/api/src/lib/persistence.ts +++ b/apps/api/src/lib/persistence.ts @@ -129,6 +129,66 @@ export async function getAnalyticsSummary( return getStorageRepository().getAnalyticsSummary(options); } +export interface AnalyticsExport { + exportedAt: string; + totalQueries: number; + totalSpendUsd: number; + spendByMode: Record; + spendByProvider: Record; + queryCountByStatus: { + demo: number; + paid: number; + failed: number; + }; + paymentEvidenceCount: number; +} + +export async function getAnalyticsExport(): Promise { + const usage = await getUsageEvents(); + const payments = await getPaymentAttempts(); + + const exportedAt = new Date().toISOString(); + const totalQueries = usage.length; + + const spendByMode: Record = { search: 0, news: 0, scrape: 0 }; + const spendByProvider: Record = {}; + const queryCountByStatus = { demo: 0, paid: 0, failed: 0 }; + + for (const event of usage) { + spendByMode[event.mode] += event.priceUsd; + spendByProvider[event.providerId] = + (spendByProvider[event.providerId] ?? 0) + event.priceUsd; + + if (event.paymentStatus === "demo-paid") { + queryCountByStatus.demo++; + } else if (event.paymentStatus === "failed") { + queryCountByStatus.failed++; + } else { + queryCountByStatus.paid++; + } + } + + const totalSpendUsd = Number( + Object.values(spendByMode) + .reduce((sum, v) => sum + v, 0) + .toFixed(6) + ); + + const paymentEvidenceCount = payments.filter( + (p) => p.transactionHash || p.facilitatorResult || p.evidenceKind + ).length; + + return { + exportedAt, + totalQueries, + totalSpendUsd, + spendByMode, + spendByProvider, + queryCountByStatus, + paymentEvidenceCount + }; +} + export async function persistPaidRequest(input: PersistPaidRequestInput): Promise { const payment = buildPaymentAttempt(input); const usage = buildUsageEvent(input, { diff --git a/apps/api/src/routes/public.test.ts b/apps/api/src/routes/public.test.ts index 13d411a..5a8c9f5 100644 --- a/apps/api/src/routes/public.test.ts +++ b/apps/api/src/routes/public.test.ts @@ -1,7 +1,11 @@ import express from "express"; import request from "supertest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +<<<<<<< prime +import { buildTestPaymentAttempt, buildTestUsageEvent } from "../test/storage-test-helpers.js"; +======= import { buildPaidQueryFixture, buildTestUsageEvent } from "../test/storage-test-helpers.js"; +>>>>>>> main import { applyApiTestEnv, resetApiTestStorage } from "../test/api-test-helpers.js"; describe("public routes", () => { @@ -396,4 +400,111 @@ describe("public routes", () => { wallet: 0.02 }); }); + + describe("GET /api/export — sanitized analytics export", () => { + it("returns valid export with zero totals for fresh storage", async () => { + const app = await createPublicApp(); + const response = await request(app).get("/api/export"); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + totalQueries: 0, + totalSpendUsd: 0, + spendByMode: { search: 0, news: 0, scrape: 0 }, + spendByProvider: {}, + queryCountByStatus: { demo: 0, paid: 0, failed: 0 }, + paymentEvidenceCount: 0 + }); + expect(typeof response.body.exportedAt).toBe("string"); + expect(() => new Date(response.body.exportedAt)).not.toThrow(); + }); + + it("reflects saved usage data in the export", async () => { + const app = await createPublicApp(); + const { saveUsageEvent, savePaymentAttempt } = await import("../lib/persistence.js"); + + await saveUsageEvent( + buildTestUsageEvent({ + id: "use_export_1", + mode: "search", + providerId: "search.basic", + paymentStatus: "settled", + priceUsd: 0.05, + createdAt: "2026-06-21T10:00:00.000Z" + }) + ); + await saveUsageEvent( + buildTestUsageEvent({ + id: "use_export_2", + mode: "news", + providerId: "news.fast", + paymentStatus: "demo-paid", + priceUsd: 0.03, + createdAt: "2026-06-21T11:00:00.000Z" + }) + ); + await savePaymentAttempt( + buildTestPaymentAttempt({ + id: "pay_export_1", + transactionHash: "tx_abc123", + evidenceKind: "settled" + }) + ); + await savePaymentAttempt( + buildTestPaymentAttempt({ + id: "pay_export_2", + transactionHash: undefined, + facilitatorResult: undefined, + evidenceKind: undefined + }) + ); + + const response = await request(app).get("/api/export"); + expect(response.status).toBe(200); + + expect(response.body.totalQueries).toBe(2); + expect(response.body.totalSpendUsd).toBe(0.08); + expect(response.body.spendByMode).toEqual({ search: 0.05, news: 0.03, scrape: 0 }); + expect(response.body.spendByProvider).toEqual({ + "search.basic": 0.05, + "news.fast": 0.03 + }); + expect(response.body.queryCountByStatus).toEqual({ demo: 1, paid: 1, failed: 0 }); + expect(response.body.paymentEvidenceCount).toBe(1); + }); + + it("excludes secret-like fields from the response body", async () => { + const app = await createPublicApp(); + const { saveUsageEvent, savePaymentAttempt } = await import("../lib/persistence.js"); + + await saveUsageEvent( + buildTestUsageEvent({ + id: "use_secret_1", + payerPublicKey: "GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890", + facilitatorUrl: "https://channels.openzeppelin.com/x402/testnet" + }) + ); + await savePaymentAttempt( + buildTestPaymentAttempt({ + id: "pay_secret_1", + payerPublicKey: "GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890", + facilitatorResult: { rawHeader: "should-not-leak" } + }) + ); + + const response = await request(app).get("/api/export"); + expect(response.status).toBe(200); + + const body = JSON.stringify(response.body); + expect(body).not.toContain("GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"); + expect(body).not.toContain("rawHeader"); + expect(body).not.toContain("should-not-leak"); + expect(body).not.toContain("facilitatorUrl"); + expect(body).not.toContain("payerPublicKey"); + expect(body).not.toContain("transactionHash"); + expect(body).not.toContain("facilitatorResult"); + expect(body).not.toContain("paymentResponseHeader"); + expect(body).not.toContain("payToAddress"); + }); + }); }); diff --git a/apps/api/src/routes/public.ts b/apps/api/src/routes/public.ts index 84b2a82..18d3a60 100644 --- a/apps/api/src/routes/public.ts +++ b/apps/api/src/routes/public.ts @@ -66,6 +66,15 @@ publicRouter.get("/api/usage", async (req, res, next) => { } }); +publicRouter.get("/api/export", async (_req, res, next) => { + try { + const exportData = await getAnalyticsExport(); + res.json(exportData); + } catch (error) { + next(error); + } +}); + publicRouter.get("/api/analytics", async (req, res, next) => { try { const parsed = analyticsQuerySchema.safeParse(req.query); diff --git a/apps/web/src/pages/ControlDeckPage.tsx b/apps/web/src/pages/ControlDeckPage.tsx index b26988d..ec7c6e2 100644 --- a/apps/web/src/pages/ControlDeckPage.tsx +++ b/apps/web/src/pages/ControlDeckPage.tsx @@ -6,6 +6,7 @@ import { CheckCircle2, CircleDollarSign, Clock4, + Download, Gauge, Home, Radar, @@ -791,6 +792,30 @@ export default function ControlDeckPage() { )} + + diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index b991504..93186fe 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1297,6 +1297,27 @@ body { line-height: 1.5; } +.export-btn { + display: flex; + align-items: center; + gap: 0.4rem; + width: 100%; + margin-top: 0.5rem; + padding: 0.5rem 0.72rem; + border: 1px solid rgba(129, 160, 201, 0.24); + border-radius: 14px; + background: rgba(11, 16, 26, 0.84); + color: #8ca0bc; + font-size: 0.78rem; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.export-btn:hover { + background: rgba(129, 160, 201, 0.12); + color: #e8f2ff; +} + @media (max-width: 1140px) { .landing-hero { grid-template-columns: 1fr; diff --git a/package-lock.json b/package-lock.json index 96cb307..2eda23b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,4 +1,5 @@ { + "name": "query402", "version": "0.1.0", "lockfileVersion": 3,