From 3dfdbe29ad3f6ec8abbd116c3564ed05d58e2b7f Mon Sep 17 00:00:00 2001 From: VictorEzenma Date: Sat, 27 Jun 2026 11:33:01 +0100 Subject: [PATCH] feat(api): implement privacy-safe paginated analytics endpoint --- apps/api/src/routes/public.ts | 14 +++++-- apps/api/src/services/analytics-privacy.ts | 20 +++++++++ apps/api/src/services/query-service.ts | 47 ++++++++++++++++++++++ package-lock.json | 26 ------------ packages/shared/src/types.ts | 32 +++++++++++++++ 5 files changed, 110 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/services/analytics-privacy.ts diff --git a/apps/api/src/routes/public.ts b/apps/api/src/routes/public.ts index a45ac2b..4c46701 100644 --- a/apps/api/src/routes/public.ts +++ b/apps/api/src/routes/public.ts @@ -2,7 +2,7 @@ import { Router } from "express"; import { providers } from "../lib/pricing.js"; import { getAnalyticsSummary, getUsageEvents } from "../lib/persistence.js"; import { config } from "../lib/config.js"; -import { getCatalog } from "../services/query-service.js"; +import { getCatalog, fetchPaginatedAnalytics } from "../services/query-service.js"; export const publicRouter = Router(); @@ -27,6 +27,14 @@ publicRouter.get("/api/usage", (_req, res) => { res.json({ usage: getUsageEvents() }); }); -publicRouter.get("/api/analytics", (_req, res) => { - res.json(getAnalyticsSummary()); +publicRouter.get("/api/analytics", async (req, res, next) => { + try { + const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10; + const cursor = req.query.cursor ? (req.query.cursor as string) : null; + + const result = await fetchPaginatedAnalytics(limit, cursor); + return res.json(result); + } catch (error) { + next(error); + } }); diff --git a/apps/api/src/services/analytics-privacy.ts b/apps/api/src/services/analytics-privacy.ts new file mode 100644 index 0000000..041bd97 --- /dev/null +++ b/apps/api/src/services/analytics-privacy.ts @@ -0,0 +1,20 @@ +import { PrivacySafeAnalyticsRecord } from "@query402/shared"; + +export function formatPrivacySafeAnalytics(rawRecords: any[]): PrivacySafeAnalyticsRecord[] { + return rawRecords.map(record => { + // Note: mapping fields to match UsageEvent structure from persistence.ts + const rawAddress = record.payerAddress || ''; + const redactedAddress = rawAddress.length > 8 + ? `${rawAddress.slice(0, 4)}...${rawAddress.slice(-4)}` + : 'Confidential'; + + return { + id: record.id?.toString() || '', + timestamp: record.timestamp || new Date().toISOString(), + payerAddress: redactedAddress, + volumeType: record.mode === 'demo' ? 'demo' : 'settled', + amount: Number(record.priceUsd || 0), + asset: 'XLM' + }; + }); +} \ No newline at end of file diff --git a/apps/api/src/services/query-service.ts b/apps/api/src/services/query-service.ts index 43264e1..576e6a4 100644 --- a/apps/api/src/services/query-service.ts +++ b/apps/api/src/services/query-service.ts @@ -58,3 +58,50 @@ export function getCatalog() { byCategory }; } + +import { getUsageEvents } from "../lib/persistence.js"; +import { formatPrivacySafeAnalytics } from "./analytics-privacy.js"; +import { PaginatedAnalyticsResponse } from "@query402/shared"; + +/** + * Fetches privacy-safe, cursor-paginated analytics data from the JSON file layer. + */ +export async function fetchPaginatedAnalytics( + limit: number = 10, + cursor: string | null = null +): Promise { + // 1. Read all logs from our local JSON file storage engine + const allEvents = getUsageEvents(); + + let sliceStartIndex = 0; + + // 2. If a cursor is provided, find its index to start our next page chunk + if (cursor) { + const cursorIndex = allEvents.findIndex((event: any) => event.id === cursor); + if (cursorIndex !== -1) { + // Start slicing immediately after the cursor item + sliceStartIndex = cursorIndex + 1; + } + } + + // 3. Extract the chunk + 1 extra item to check if a next page exists + const fetchCount = limit + 1; + const pageChunk = allEvents.slice(sliceStartIndex, sliceStartIndex + fetchCount); + + const hasMore = pageChunk.length > limit; + // Trim down to the requested page limit + const validRecords = hasMore ? pageChunk.slice(0, limit) : pageChunk; + + // 4. Filter and mask the records safely via our privacy module + const cleanData = formatPrivacySafeAnalytics(validRecords); + + // 5. Pick the ID of the last element in our current view as the next cursor token + const nextCursor = hasMore && cleanData.length > 0 ? cleanData[cleanData.length - 1].id : null; + + return { + success: true, + hasMore, + nextCursor, + data: cleanData + }; +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3af2336..ee515ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -403,7 +403,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -421,7 +420,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -439,7 +437,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -457,7 +454,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -475,7 +471,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -493,7 +488,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -511,7 +505,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -529,7 +522,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -547,7 +539,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -565,7 +556,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -583,7 +573,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -601,7 +590,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -619,7 +607,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -637,7 +624,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -655,7 +641,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -673,7 +658,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -691,7 +675,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -709,7 +692,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -727,7 +709,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -745,7 +726,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -763,7 +743,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -781,7 +760,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -799,7 +777,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -817,7 +794,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -835,7 +811,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -853,7 +828,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 99de306..71038d1 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -73,3 +73,35 @@ export interface AnalyticsSummary { recentTransactions: PaymentAttempt[]; recentUsage: UsageEvent[]; } + +export interface PrivacySafeAnalyticsRecord { + id: string; + timestamp: string; + payerAddress: string; + volumeType: 'demo' | 'settled'; + amount: number; + asset: string; +} + +export interface PaginatedAnalyticsResponse { + success: boolean; + hasMore: boolean; + nextCursor: string | null; + data: PrivacySafeAnalyticsRecord[]; +} + +export interface PrivacySafeAnalyticsRecord { + id: string; + timestamp: string; + payerAddress: string; + volumeType: 'demo' | 'settled'; + amount: number; + asset: string; +} + +export interface PaginatedAnalyticsResponse { + success: boolean; + hasMore: boolean; + nextCursor: string | null; + data: PrivacySafeAnalyticsRecord[]; +} \ No newline at end of file