diff --git a/system/backend/notification-choice.mjs b/system/backend/notification-choice.mjs new file mode 100644 index 0000000000..52b56bf6f0 --- /dev/null +++ b/system/backend/notification-choice.mjs @@ -0,0 +1,53 @@ +const CHOICES = new Set([ + "denied", + "dismissed", + "disabled", + "enabled", + "error", +]); + +const SOURCES = new Set(["auto", "bell", "observed", "register"]); + +const COUNTERS = { + denied: "deniedCount", + dismissed: "dismissedCount", + disabled: "disabledCount", + enabled: "enabledCount", + error: "errorCount", +}; + +export function notificationChoiceUpdate(userSub, body = {}, at = new Date()) { + const choice = typeof body.choice === "string" ? body.choice : ""; + const source = typeof body.source === "string" ? body.source : ""; + const deviceId = typeof body.deviceId === "string" ? body.deviceId : ""; + + if (!CHOICES.has(choice)) throw new Error("Invalid notification choice"); + if (!SOURCES.has(source)) throw new Error("Invalid notification source"); + if (deviceId.length < 8 || deviceId.length > 128) { + throw new Error("Invalid notification device ID"); + } + + const label = + typeof body.label === "string" ? body.label.slice(0, 64) : ""; + const platform = body.platform === "ios" ? "ios" : "web"; + const user = userSub.startsWith("sotce-") + ? userSub + : "sotce-" + userSub; + + const update = { + $set: { choice, source, label, platform, updatedAt: at }, + $setOnInsert: { createdAt: at }, + }; + + // An observed denial records the browser's current state without counting + // every subsequent page load as another rejection. + if (source !== "observed") { + update.$inc = { [COUNTERS[choice]]: 1 }; + } + + return { + filter: { user, deviceId }, + update, + options: { upsert: true }, + }; +} diff --git a/system/netlify/functions/SOTCE-NET-AGENTS.md b/system/netlify/functions/SOTCE-NET-AGENTS.md index 6140588461..9e101f6b45 100644 --- a/system/netlify/functions/SOTCE-NET-AGENTS.md +++ b/system/netlify/functions/SOTCE-NET-AGENTS.md @@ -67,8 +67,9 @@ Key: `cookie.png`, `cookie-open.png`, `thumbnail.png`, `helvetica.woff`, `helvet | Method | Path | Auth | What | |--------|------|------|------| -| GET | `/subscribers` | — | Subscriber count | +| GET | `/subscribers` | — | Active subscriber count | | POST | `/subscribe` | — | Stripe Checkout session | +| POST | `/notification-choice` | Bearer | Record minimized push permission/toggle state | | POST | `/subscribed` | Bearer | Check sub + fetch pages/questions | | POST | `/cancel` | Bearer | Cancel subscription | | POST | `/write-a-page` | 👑 Admin | Draft CRUD + publish | @@ -132,6 +133,15 @@ Key: `cookie.png`, `cookie-open.png`, `thumbnail.png`, `helvetica.woff`, `helvet // Unique index on (user, page) ``` +### `sotce-notification-choices` +```js +{ user: "sotce-auth0|sub", deviceId, label, platform, + choice: "denied"|"dismissed"|"disabled"|"enabled"|"error", + source: "auto"|"bell"|"observed"|"register", + createdAt: Date, updatedAt: Date, + deniedCount?, dismissedCount?, disabledCount?, enabledCount?, errorCount? } +``` + ### `chat-sotce` ```js { text, user, from: "@handle", when: Date, count: N } diff --git a/system/netlify/functions/sotce-net.mjs b/system/netlify/functions/sotce-net.mjs index ff1bcc3f57..afefa7269d 100644 --- a/system/netlify/functions/sotce-net.mjs +++ b/system/netlify/functions/sotce-net.mjs @@ -97,6 +97,7 @@ import { broadcastToTopic, sendToUser } from "../../../shared/push.mjs"; import Stripe from "stripe"; import crypto from "node:crypto"; +import { notificationChoiceUpdate } from "../../backend/notification-choice.mjs"; // The HTML shell is identical for every request to a given path (auth and all // dynamic content happen client-side), so render it once per title and serve @@ -341,45 +342,6 @@ export const handler = async (event, context) => { } } - // Get the cumulative count of all subscriptions ever created for the given - // productId (any status — active, canceled, etc.). - // TODO: Put this behind a redis cache... 24.10.14.01.23 - async function getCumulativeSubscriptionCount(productId) { - try { - const stripe = new Stripe(key); - - // Fetch all subscriptions regardless of status. - let hasMore = true; - let totalSubscriptions = 0; - let startingAfter = undefined; - - while (hasMore) { - const subscriptions = await stripe.subscriptions.list({ - status: "all", - limit: 100, // Maximum allowed per request - starting_after: startingAfter, - }); - - // Filter subscriptions by the productId - const matchingSubscriptions = subscriptions.data.filter((sub) => - sub.items.data.some((item) => item.price.product === productId), - ); - - totalSubscriptions += matchingSubscriptions.length; - - // Check if more pages of subscriptions exist - 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 @@ -4916,8 +4878,10 @@ export const handler = async (event, context) => { // browser's ask right away (or a silent re-register when // permission is already granted). Safari holds prompts // for a tap on the bell itself. - if (!on && !declined && Notification.permission !== "denied") { - enableNotifications() + if (!on && Notification.permission === "denied") { + recordNotificationChoice("denied", "observed"); + } else if (!on && !declined) { + enableNotifications("auto") .then(function (ok) { ringing = ok; bell.classList.toggle("on", ok); @@ -4932,7 +4896,7 @@ export const handler = async (event, context) => { bell.classList.toggle("on", want); // Snap now, settle after. try { if (want) { - const ok = await enableNotifications(); + const ok = await enableNotifications("bell"); ringing = ok; bell.classList.toggle("on", ok); if (ok) { @@ -4949,7 +4913,7 @@ export const handler = async (event, context) => { ); } } else { - await disableNotifications(); + await disableNotifications("bell"); ringing = false; declined = "1"; try { @@ -10215,13 +10179,42 @@ export const handler = async (event, context) => { } } - async function enableNotifications() { + async function recordNotificationChoice(choice, source) { + try { + const token = + window.sotceTOKEN || (await auth0Client.getTokenSilently()); + await fetch("/api/sotce-net/notification-choice", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + token, + }, + body: JSON.stringify({ + choice, + source, + deviceId: pushDeviceId(), + label: pushDeviceLabel(), + platform: "web", + }), + }); + } catch (err) { + console.warn("Notification choice could not be recorded."); + } + } + + async function enableNotifications(source = "bell") { if (!pushSupported()) { alert("This browser does not support notifications."); return false; } const permission = await Notification.requestPermission(); - if (permission !== "granted") return false; + if (permission !== "granted") { + await recordNotificationChoice( + permission === "denied" ? "denied" : "dismissed", + source, + ); + return false; + } const reg = await navigator.serviceWorker.register("/sw.js"); await navigator.serviceWorker.ready; const keyBytes = Uint8Array.from( @@ -10257,11 +10250,15 @@ export const handler = async (event, context) => { tenant: "sotce", }), }); + await recordNotificationChoice( + res.ok ? "enabled" : "error", + res.ok ? source : "register", + ); if (res.ok) console.log("🔔 Notifications enabled."); return res.ok; } - async function disableNotifications() { + async function disableNotifications(source = "bell") { const sub = await pushSubscriptionNow(); if (!sub) return; fetch("/api/register-push-token", { @@ -10274,6 +10271,7 @@ export const handler = async (event, context) => { }), }).catch(function () {}); await sub.unsubscribe(); + await recordNotificationChoice("disabled", source); console.log("🔕 Notifications disabled."); } @@ -10752,16 +10750,16 @@ export const handler = async (event, context) => { { "Content-Type": "application/manifest+json; charset=utf-8" }, ); } else if (path === "/subscribers" && method === "get") { - // Counting means paginating every Stripe subscription ever (seconds of - // API calls), and the logged-out gate blocks on this response — so cache - // the count and refresh it in the background once it goes stale. + // 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. const COUNT_TTL = 15 * 60 * 1000; - const countCacheKey = "sotce-subscriber-count"; + const countCacheKey = "sotce-active-subscriber-count"; let cached; try { await KeyValue.connect(); - const raw = await KeyValue.get(countCacheKey, "cumulative"); + const raw = await KeyValue.get(countCacheKey, "active"); if (raw) cached = JSON.parse(raw); } catch (err) { shell.error("Subscriber count cache read failed:", err); @@ -10771,7 +10769,7 @@ export const handler = async (event, context) => { try { await KeyValue.set( countCacheKey, - "cumulative", + "active", JSON.stringify({ count, at: Date.now() }), ); } catch (err) { @@ -10782,7 +10780,7 @@ export const handler = async (event, context) => { if (cached !== undefined) { if (Date.now() - cached.at > COUNT_TTL) { // Stale: serve it now, refresh after responding. - getCumulativeSubscriptionCount(productId) + getActiveSubscriptionCount(productId) .then((count) => { if (count !== undefined && count !== null) return storeCount(count); }) @@ -10791,7 +10789,7 @@ export const handler = async (event, context) => { return respond(200, { subscribers: cached.count }); } - const subscribers = await getCumulativeSubscriptionCount(productId); + const subscribers = await getActiveSubscriptionCount(productId); if (subscribers !== undefined && subscribers !== null) { await storeCount(subscribers); @@ -10799,6 +10797,33 @@ export const handler = async (event, context) => { } else { return respond(500, { message: "Could not get subscriber count." }); } + } else if (path === "/notification-choice" && method === "post") { + const user = await authorize(event.headers, "sotce"); + if (!user) return respond(401, { message: "Unauthorized." }); + + let operation; + try { + operation = notificationChoiceUpdate( + user.sub, + JSON.parse(event.body || "{}"), + ); + } catch (err) { + return respond(400, { message: err.message }); + } + + const database = await connect(); + try { + const choices = database.db.collection("sotce-notification-choices"); + await choices.createIndex({ user: 1, deviceId: 1 }, { unique: true }); + await choices.updateOne( + operation.filter, + operation.update, + operation.options, + ); + return respond(200, { status: "recorded" }); + } finally { + await database.disconnect(); + } } else if (path === "/subscribe" && method === "post") { try { const stripe = new Stripe(key); diff --git a/system/tests/notification-choice.test.mjs b/system/tests/notification-choice.test.mjs new file mode 100644 index 0000000000..fb79c5266b --- /dev/null +++ b/system/tests/notification-choice.test.mjs @@ -0,0 +1,77 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { notificationChoiceUpdate } from "../backend/notification-choice.mjs"; + +test("records a notification rejection without content or endpoint data", () => { + const at = new Date("2026-09-13T00:00:00.000Z"); + const operation = notificationChoiceUpdate( + "auth0|reader", + { + choice: "denied", + source: "bell", + deviceId: "device-123", + label: "Chrome on macOS", + platform: "web", + endpoint: "must-not-be-stored", + text: "must-not-be-stored", + }, + at, + ); + + assert.deepEqual(operation, { + filter: { user: "sotce-auth0|reader", deviceId: "device-123" }, + update: { + $set: { + choice: "denied", + source: "bell", + label: "Chrome on macOS", + platform: "web", + updatedAt: at, + }, + $setOnInsert: { createdAt: at }, + $inc: { deniedCount: 1 }, + }, + options: { upsert: true }, + }); +}); + +test("observing an existing denial does not inflate the rejection count", () => { + const operation = notificationChoiceUpdate("sotce-auth0|reader", { + choice: "denied", + source: "observed", + deviceId: "device-123", + }); + + assert.equal(operation.filter.user, "sotce-auth0|reader"); + assert.equal(operation.update.$inc, undefined); +}); + +test("rejects unknown choices, sources, and malformed device IDs", () => { + assert.throws( + () => + notificationChoiceUpdate("auth0|reader", { + choice: "maybe", + source: "bell", + deviceId: "device-123", + }), + /Invalid notification choice/, + ); + assert.throws( + () => + notificationChoiceUpdate("auth0|reader", { + choice: "denied", + source: "unknown", + deviceId: "device-123", + }), + /Invalid notification source/, + ); + assert.throws( + () => + notificationChoiceUpdate("auth0|reader", { + choice: "denied", + source: "bell", + deviceId: "short", + }), + /Invalid notification device ID/, + ); +});