From 96bb08a349ed202762daa3949e30279a1ca4a165 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 13 Sep 2026 15:47:42 -0400 Subject: [PATCH] Separate public and operational subscriber counts --- system/netlify/functions/SOTCE-NET-AGENTS.md | 3 +- system/netlify/functions/metrics.mjs | 12 ++- system/netlify/functions/sotce-net.mjs | 91 +++++++++++++++++-- .../tests/subscriber-count-routing.test.mjs | 31 +++++++ 4 files changed, 124 insertions(+), 13 deletions(-) create mode 100644 system/tests/subscriber-count-routing.test.mjs diff --git a/system/netlify/functions/SOTCE-NET-AGENTS.md b/system/netlify/functions/SOTCE-NET-AGENTS.md index 9e101f6b45..f22967a4d2 100644 --- a/system/netlify/functions/SOTCE-NET-AGENTS.md +++ b/system/netlify/functions/SOTCE-NET-AGENTS.md @@ -67,7 +67,8 @@ Key: `cookie.png`, `cookie-open.png`, `thumbnail.png`, `helvetica.woff`, `helvet | Method | Path | Auth | What | |--------|------|------|------| -| GET | `/subscribers` | — | Active subscriber count | +| GET | `/subscribers` | — | Overall subscriber count shown on the gate | +| GET | `/active-subscribers` | — | Active subscriber count for internal metrics | | POST | `/subscribe` | — | Stripe Checkout session | | POST | `/notification-choice` | Bearer | Record minimized push permission/toggle state | | POST | `/subscribed` | Bearer | Check sub + fetch pages/questions | diff --git a/system/netlify/functions/metrics.mjs b/system/netlify/functions/metrics.mjs index 972e962beb..0fd7b76c00 100644 --- a/system/netlify/functions/metrics.mjs +++ b/system/netlify/functions/metrics.mjs @@ -84,11 +84,13 @@ async function computeMetrics() { await database.disconnect(); // Get sotce subscriptions. - let sotceSubscribers = null; - try { - sotceSubscribers = ( - await (await fetch("https://sotce.net/sotce-net/subscribers")).json() - ).subscribers; + let sotceSubscribers = null; + try { + sotceSubscribers = ( + await ( + await fetch("https://sotce.net/sotce-net/active-subscribers") + ).json() + ).subscribers; } catch (err) { shell.error("🔴 Could not retrieve `sotce-net` subscriber count:", err); } diff --git a/system/netlify/functions/sotce-net.mjs b/system/netlify/functions/sotce-net.mjs index afefa7269d..06874c66af 100644 --- a/system/netlify/functions/sotce-net.mjs +++ b/system/netlify/functions/sotce-net.mjs @@ -342,6 +342,39 @@ export const handler = async (event, context) => { } } + // Get the overall count of subscriptions ever created for the product, + // including active and ended subscriptions. This is the lifetime readership + // number shown on the public gate, not an operational active-user metric. + async function getCumulativeSubscriptionCount(productId) { + try { + const stripe = new Stripe(key); + let hasMore = true; + let totalSubscriptions = 0; + let startingAfter; + + while (hasMore) { + const subscriptions = await stripe.subscriptions.list({ + status: "all", + limit: 100, + starting_after: startingAfter, + }); + + totalSubscriptions += subscriptions.data.filter((sub) => + sub.items.data.some((item) => item.price.product === productId), + ).length; + + hasMore = subscriptions.has_more; + if (hasMore) { + startingAfter = subscriptions.data[subscriptions.data.length - 1].id; + } + } + + return totalSubscriptions; + } catch (err) { + shell.error("Error fetching cumulative subscription count:", err); + } + } + const MAX_LINES = 19; // 🏠 Home, Chat, Page Routes @@ -10749,10 +10782,7 @@ export const handler = async (event, context) => { }, { "Content-Type": "application/manifest+json; charset=utf-8" }, ); - } else if (path === "/subscribers" && method === "get") { - // Counting active subscriptions means paginating Stripe, and the logged-out - // gate blocks on this response — cache the count and refresh it in the - // background once it goes stale. + } else if (path === "/active-subscribers" && method === "get") { const COUNT_TTL = 15 * 60 * 1000; const countCacheKey = "sotce-active-subscriber-count"; @@ -10762,7 +10792,7 @@ export const handler = async (event, context) => { const raw = await KeyValue.get(countCacheKey, "active"); if (raw) cached = JSON.parse(raw); } catch (err) { - shell.error("Subscriber count cache read failed:", err); + shell.error("Active subscriber count cache read failed:", err); } const storeCount = async (count) => { @@ -10772,6 +10802,53 @@ export const handler = async (event, context) => { "active", JSON.stringify({ count, at: Date.now() }), ); + } catch (err) { + shell.error("Active subscriber count cache write failed:", err); + } + }; + + if (cached !== undefined) { + if (Date.now() - cached.at > COUNT_TTL) { + getActiveSubscriptionCount(productId) + .then((count) => { + if (count !== undefined && count !== null) return storeCount(count); + }) + .catch((err) => + shell.error("Active subscriber count refresh failed:", err), + ); + } + return respond(200, { subscribers: cached.count }); + } + + const subscribers = await getActiveSubscriptionCount(productId); + if (subscribers !== undefined && subscribers !== null) { + await storeCount(subscribers); + return respond(200, { subscribers }); + } + return respond(500, { message: "Could not get active subscriber count." }); + } else if (path === "/subscribers" && method === "get") { + // The public gate shows overall readership. Counting it means paginating + // every Stripe subscription, so cache the result and refresh it in the + // background once it goes stale. + const COUNT_TTL = 15 * 60 * 1000; + const countCacheKey = "sotce-subscriber-count"; + + let cached; + try { + await KeyValue.connect(); + const raw = await KeyValue.get(countCacheKey, "cumulative"); + if (raw) cached = JSON.parse(raw); + } catch (err) { + shell.error("Subscriber count cache read failed:", err); + } + + const storeCount = async (count) => { + try { + await KeyValue.set( + countCacheKey, + "cumulative", + JSON.stringify({ count, at: Date.now() }), + ); } catch (err) { shell.error("Subscriber count cache write failed:", err); } @@ -10780,7 +10857,7 @@ export const handler = async (event, context) => { if (cached !== undefined) { if (Date.now() - cached.at > COUNT_TTL) { // Stale: serve it now, refresh after responding. - getActiveSubscriptionCount(productId) + getCumulativeSubscriptionCount(productId) .then((count) => { if (count !== undefined && count !== null) return storeCount(count); }) @@ -10789,7 +10866,7 @@ export const handler = async (event, context) => { return respond(200, { subscribers: cached.count }); } - const subscribers = await getActiveSubscriptionCount(productId); + const subscribers = await getCumulativeSubscriptionCount(productId); if (subscribers !== undefined && subscribers !== null) { await storeCount(subscribers); diff --git a/system/tests/subscriber-count-routing.test.mjs b/system/tests/subscriber-count-routing.test.mjs new file mode 100644 index 0000000000..0ede23bb7a --- /dev/null +++ b/system/tests/subscriber-count-routing.test.mjs @@ -0,0 +1,31 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const appSource = await readFile( + new URL("../netlify/functions/sotce-net.mjs", import.meta.url), + "utf8", +); +const metricsSource = await readFile( + new URL("../netlify/functions/metrics.mjs", import.meta.url), + "utf8", +); + +test("the public gate requests the overall subscriber count", () => { + assert.match( + appSource, + /fetch\("\/sotce-net\/subscribers"\)/, + ); + assert.match( + appSource, + /path === "\/subscribers"[\s\S]*getCumulativeSubscriptionCount/, + ); +}); + +test("internal metrics request the active subscriber count", () => { + assert.match(metricsSource, /\/active-subscribers/); + assert.match( + appSource, + /path === "\/active-subscribers"[\s\S]*getActiveSubscriptionCount/, + ); +}); -- 2.51.2