From c14f5ab602d644ece15bb7a4d8fb19faa826f86a Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 14 Aug 2026 16:39:00 -0400 Subject: [PATCH] feature: add notification stats --- generated/schema.graphql | 16 +- generated/schema.ts | 36 +++-- generated/types.ts | 30 +++- hasura/metadata/actions.graphql | 19 ++- hasura/metadata/actions.yaml | 10 +- .../preferences/notification-categories.ts | 2 +- .../push/push-notifications.controller.ts | 11 +- .../push/push-notifications.service.ts | 143 ++++++++++-------- 8 files changed, 147 insertions(+), 120 deletions(-) diff --git a/generated/schema.graphql b/generated/schema.graphql index 604f8ea0..20fcbf17 100644 --- a/generated/schema.graphql +++ b/generated/schema.graphql @@ -886,13 +886,20 @@ type WatchDemoOutput { success: Boolean! } -type WebPushKeysOutput { - success: Boolean! +type WebPushPlatformCount { + devices: Int! + platform: String! } type WebPushStatusOutput { + active_7d: Int! configured: Boolean! + last_delivered_at: timestamptz managed_by_environment: Boolean! + never_delivered: Int! + new_7d: Int! + platforms: [WebPushPlatformCount!]! + players: Int! subscriptions: Int! } @@ -38866,11 +38873,6 @@ type mutation_root { denyNameChange(name: String!, steam_id: bigint!): SuccessOutput forfeitMatch(match_id: uuid!, winning_lineup_id: uuid!): SuccessOutput - """ - Generates a fresh self-signed VAPID keypair; invalidates every existing subscription - """ - generateWebPushKeys: WebPushKeysOutput - """ Live pod GSI snapshot — slots, sides, alive/dead. Drives the stream-deck. """ diff --git a/generated/schema.ts b/generated/schema.ts index 262916dc..775dc75b 100644 --- a/generated/schema.ts +++ b/generated/schema.ts @@ -821,14 +821,21 @@ export interface WatchDemoOutput { __typename: 'WatchDemoOutput' } -export interface WebPushKeysOutput { - success: Scalars['Boolean'] - __typename: 'WebPushKeysOutput' +export interface WebPushPlatformCount { + devices: Scalars['Int'] + platform: Scalars['String'] + __typename: 'WebPushPlatformCount' } export interface WebPushStatusOutput { + active_7d: Scalars['Int'] configured: Scalars['Boolean'] + last_delivered_at: (Scalars['timestamptz'] | null) managed_by_environment: Scalars['Boolean'] + never_delivered: Scalars['Int'] + new_7d: Scalars['Int'] + platforms: WebPushPlatformCount[] + players: Scalars['Int'] subscriptions: Scalars['Int'] __typename: 'WebPushStatusOutput' } @@ -12922,8 +12929,6 @@ export interface mutation_root { denyInvite: (SuccessOutput | null) denyNameChange: (SuccessOutput | null) forfeitMatch: (SuccessOutput | null) - /** Generates a fresh self-signed VAPID keypair; invalidates every existing subscription */ - generateWebPushKeys: (WebPushKeysOutput | null) /** Live pod GSI snapshot — slots, sides, alive/dead. Drives the stream-deck. */ getLiveStreamSpecState: (LiveStreamSpecState | null) getTestUploadLink: GetTestUploadResponse @@ -37194,15 +37199,22 @@ export interface WatchDemoOutputGenqlSelection{ __scalar?: boolean | number } -export interface WebPushKeysOutputGenqlSelection{ - success?: boolean | number +export interface WebPushPlatformCountGenqlSelection{ + devices?: boolean | number + platform?: boolean | number __typename?: boolean | number __scalar?: boolean | number } export interface WebPushStatusOutputGenqlSelection{ + active_7d?: boolean | number configured?: boolean | number + last_delivered_at?: boolean | number managed_by_environment?: boolean | number + never_delivered?: boolean | number + new_7d?: boolean | number + platforms?: WebPushPlatformCountGenqlSelection + players?: boolean | number subscriptions?: boolean | number __typename?: boolean | number __scalar?: boolean | number @@ -57623,8 +57635,6 @@ export interface mutation_rootGenqlSelection{ denyInvite?: (SuccessOutputGenqlSelection & { __args: {invite_id: Scalars['uuid'], type: Scalars['String']} }) denyNameChange?: (SuccessOutputGenqlSelection & { __args: {name: Scalars['String'], steam_id: Scalars['bigint']} }) forfeitMatch?: (SuccessOutputGenqlSelection & { __args: {match_id: Scalars['uuid'], winning_lineup_id: Scalars['uuid']} }) - /** Generates a fresh self-signed VAPID keypair; invalidates every existing subscription */ - generateWebPushKeys?: WebPushKeysOutputGenqlSelection /** Live pod GSI snapshot — slots, sides, alive/dead. Drives the stream-deck. */ getLiveStreamSpecState?: (LiveStreamSpecStateGenqlSelection & { __args: {match_id: Scalars['uuid']} }) getTestUploadLink?: GetTestUploadResponseGenqlSelection @@ -101977,10 +101987,10 @@ export type SubscriptionGenqlSelection = subscription_rootGenqlSelection - const WebPushKeysOutput_possibleTypes: string[] = ['WebPushKeysOutput'] - export const isWebPushKeysOutput = (obj?: { __typename?: any } | null): obj is WebPushKeysOutput => { - if (!obj?.__typename) throw new Error('__typename is missing in "isWebPushKeysOutput"') - return WebPushKeysOutput_possibleTypes.includes(obj.__typename) + const WebPushPlatformCount_possibleTypes: string[] = ['WebPushPlatformCount'] + export const isWebPushPlatformCount = (obj?: { __typename?: any } | null): obj is WebPushPlatformCount => { + if (!obj?.__typename) throw new Error('__typename is missing in "isWebPushPlatformCount"') + return WebPushPlatformCount_possibleTypes.includes(obj.__typename) } diff --git a/generated/types.ts b/generated/types.ts index 52a2f401..89cb9b3b 100644 --- a/generated/types.ts +++ b/generated/types.ts @@ -2779,21 +2779,42 @@ export default { 80 ] }, - "WebPushKeysOutput": { - "success": [ - 5 + "WebPushPlatformCount": { + "devices": [ + 40 + ], + "platform": [ + 80 ], "__typename": [ 80 ] }, "WebPushStatusOutput": { + "active_7d": [ + 40 + ], "configured": [ 5 ], + "last_delivered_at": [ + 4584 + ], "managed_by_environment": [ 5 ], + "never_delivered": [ + 40 + ], + "new_7d": [ + 40 + ], + "platforms": [ + 100 + ], + "players": [ + 40 + ], "subscriptions": [ 40 ], @@ -152370,9 +152391,6 @@ export default { ] } ], - "generateWebPushKeys": [ - 100 - ], "getLiveStreamSpecState": [ 45, { diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql index f8518c9b..c2164b58 100644 --- a/hasura/metadata/actions.graphql +++ b/hasura/metadata/actions.graphql @@ -78,10 +78,6 @@ type Query { webPushStatus: WebPushStatusOutput } -type Mutation { - generateWebPushKeys: WebPushKeysOutput -} - type Mutation { bakeShaders( game_server_node_id: uuid! @@ -987,14 +983,21 @@ type SuccessOutput { success: Boolean! } +type WebPushPlatformCount { + platform: String! + devices: Int! +} + type WebPushStatusOutput { configured: Boolean! managed_by_environment: Boolean! subscriptions: Int! -} - -type WebPushKeysOutput { - success: Boolean! + players: Int! + active_7d: Int! + new_7d: Int! + never_delivered: Int! + last_delivered_at: timestamptz + platforms: [WebPushPlatformCount!]! } type DraftGamePreviewPlayer { diff --git a/hasura/metadata/actions.yaml b/hasura/metadata/actions.yaml index 7026a208..3f3879e4 100644 --- a/hasura/metadata/actions.yaml +++ b/hasura/metadata/actions.yaml @@ -157,14 +157,6 @@ actions: permissions: - role: administrator comment: Web push setup status for the application settings page; never returns the private key - - name: generateWebPushKeys - definition: - kind: synchronous - handler: '{{HASURA_GRAPHQL_ACTIONS_HOOK}}' - forward_client_headers: true - permissions: - - role: administrator - comment: Generates a fresh self-signed VAPID keypair; invalidates every existing subscription - name: bakeShaders definition: kind: synchronous @@ -1334,8 +1326,8 @@ custom_types: source: default type: object - name: SuccessOutput + - name: WebPushPlatformCount - name: WebPushStatusOutput - - name: WebPushKeysOutput - name: ScanStartedOutput - name: OrphanObject - name: OrphanScanResultOutput diff --git a/src/notifications/preferences/notification-categories.ts b/src/notifications/preferences/notification-categories.ts index 25af74b6..1d254a46 100644 --- a/src/notifications/preferences/notification-categories.ts +++ b/src/notifications/preferences/notification-categories.ts @@ -90,7 +90,7 @@ export const IN_APP_KEYS: PreferenceKey[] = [ { key: "TeamInvite", defaultEnabled: true }, { key: "TournamentTeamInvite", defaultEnabled: true }, { key: "DraftInvite", defaultEnabled: true }, - { key: "MatchImported", defaultEnabled: false }, + { key: "MatchImported", defaultEnabled: true }, { key: "MatchStatsReady", defaultEnabled: true }, { key: "ClipReady", defaultEnabled: true }, { key: "AwardGranted", defaultEnabled: true }, diff --git a/src/notifications/push/push-notifications.controller.ts b/src/notifications/push/push-notifications.controller.ts index 30d86e9c..e9cc8f79 100644 --- a/src/notifications/push/push-notifications.controller.ts +++ b/src/notifications/push/push-notifications.controller.ts @@ -38,22 +38,13 @@ export class PushNotificationsController { // never returns the private key. @HasuraAction() public async webPushStatus() { - const [subscriptions] = await this.pushNotifications.countSubscriptions(); - return { configured: this.pushNotifications.isConfigured(), managed_by_environment: this.pushNotifications.isManagedByEnvironment(), - subscriptions, + ...(await this.pushNotifications.getSubscriptionStats()), }; } - @HasuraAction() - public async generateWebPushKeys() { - await this.pushNotifications.generateKeys(); - - return { success: true }; - } - @Post("subscribe") @UseGuards(SteamGuard) public async subscribe( diff --git a/src/notifications/push/push-notifications.service.ts b/src/notifications/push/push-notifications.service.ts index fe15349f..eea250be 100644 --- a/src/notifications/push/push-notifications.service.ts +++ b/src/notifications/push/push-notifications.service.ts @@ -37,6 +37,16 @@ type SubscriptionRow = { auth: string; }; +export type SubscriptionStats = { + subscriptions: number; + players: number; + active_7d: number; + new_7d: number; + never_delivered: number; + last_delivered_at: string | null; + platforms: Array<{ platform: string; devices: number }>; +}; + // Which roles can actually see a role-broadcast notification, mirroring the // select_permissions in // hasura/metadata/databases/default/tables/public_notifications.yaml. @@ -64,8 +74,6 @@ const SEND_CHUNK_SIZE = 25; const fanOutClaimKey = (id: string) => `notifications:fan-out:${id}`; -const KEYS_CHANGED_CHANNEL = "web-push-keys-changed"; - // Long enough to outlast Hasura's delivery of the last row in a fan-out, // including its retry_conf (3 retries, 10s apart, 60s timeout each). const FAN_OUT_CLAIM_TTL_SECONDS = 900; @@ -87,22 +95,6 @@ export class PushNotificationsService { this.appConfig = this.configService.get("app"); this.webPushConfig = this.configService.get("webPush"); this.redis = redisManager.getConnection(); - - // A rotation on one pod has to reach the others. They would otherwise keep - // signing with the retired private key, and keep handing the retired public - // key to anyone subscribing, until they happened to restart. - const sub = redisManager.getConnection("sub"); - - void sub.subscribe(KEYS_CHANGED_CHANNEL); - sub.on("message", (channel: string) => { - if (channel !== KEYS_CHANGED_CHANNEL) { - return; - } - - void this.loadKeys().catch((error: unknown) => { - this.logger.warn("unable to reload VAPID keys", error); - }); - }); } // A fan-out writes one notification row per recipient and Hasura's event @@ -130,10 +122,11 @@ export class PushNotificationsService { return (await this.redis.exists(fanOutClaimKey(id))) === 1; } - // Keys live in `settings` so an operator can generate them from the panel -- - // VAPID is a self-signed keypair, not a vendor credential, so there is - // nothing to register and no reason to force an env var. Env still wins when - // set, for anyone who would rather manage secrets outside the database. + // Keys live in `settings` and are generated on first boot -- VAPID is a + // self-signed keypair, not a vendor credential, so there is nothing to + // register and no reason to force an env var or to expose rotation. Env still + // wins when set, for anyone who would rather manage secrets outside the + // database. public async loadKeys(): Promise { const publicKey = this.webPushConfig?.publicKey || @@ -174,33 +167,6 @@ export class PushNotificationsService { this.publicKey = publicKey; } - // Generates a fresh keypair and stores it. - // - // Rotating invalidates every existing subscription -- the browser signed up - // against the old public key and the push service will reject sends signed by - // the new one -- so the stale rows are cleared out rather than left to fail - // one 403 at a time. - public async generateKeys(): Promise<{ publicKey: string }> { - if (this.webPushConfig?.publicKey || this.webPushConfig?.privateKey) { - throw new Error( - "web push keys are set through the environment; unset WEB_PUSH_PUBLIC_KEY/WEB_PUSH_PRIVATE_KEY to manage them here", - ); - } - - const keys = webPush.generateVAPIDKeys(); - - await this.setSetting(SystemSettingName.WebPushPublicKey, keys.publicKey); - await this.setSetting(SystemSettingName.WebPushPrivateKey, keys.privateKey); - await this.postgres.query(`DELETE FROM public.push_subscriptions`); - - await this.loadKeys(); - - // Valid JSON with no payload: the "sub" connection is shared, and the - // sockets subscriber on it parses every message it receives. - await this.redis.publish(KEYS_CHANGED_CHANNEL, "{}"); - - return { publicKey: keys.publicKey }; - } // Which of these players could receive a push at all. Callers use it to // decide whether a recipient who muted the bell still needs a notifications @@ -221,12 +187,65 @@ export class PushNotificationsService { return rows.map((row) => row.steam_id); } - public async countSubscriptions(): Promise<[number]> { - const [row] = await this.postgres.query>( - `SELECT count(*)::text AS count FROM public.push_subscriptions`, + // Everything the admin status page shows, derived from push_subscriptions -- + // no counters to keep in sync, and `last_used_at` is already stamped on every + // successful send, which makes it the only real proof that delivery works + // end to end rather than just that a keypair exists. + public async getSubscriptionStats(): Promise { + const [totals] = await this.postgres.query< + Array<{ + devices: string; + players: string; + active_7d: string; + new_7d: string; + never_delivered: string; + last_delivered_at: Date | null; + }> + >( + `SELECT count(*)::text AS devices, + count(DISTINCT steam_id)::text AS players, + (count(*) FILTER ( + WHERE last_used_at > now() - interval '7 days'))::text AS active_7d, + (count(*) FILTER ( + WHERE created_at > now() - interval '7 days'))::text AS new_7d, + (count(*) FILTER (WHERE last_used_at IS NULL))::text AS never_delivered, + max(last_used_at) AS last_delivered_at + FROM public.push_subscriptions`, ); - return [Number(row?.count ?? 0)]; + // The push service is the only platform signal that can be trusted -- + // user_agent is whatever the browser felt like sending, but the endpoint + // host is enforced by the table's CHECK constraint. + const platforms = await this.postgres.query< + Array<{ platform: string; devices: string }> + >( + `SELECT CASE + WHEN endpoint ~* 'push\\.apple\\.com' THEN 'apple' + WHEN endpoint ~* '(fcm|android)\\.googleapis\\.com' THEN 'google' + WHEN endpoint ~* 'push\\.services\\.mozilla\\.com' THEN 'mozilla' + WHEN endpoint ~* 'notify\\.windows\\.com' THEN 'windows' + ELSE 'other' + END AS platform, + count(*)::text AS devices + FROM public.push_subscriptions + GROUP BY 1 + ORDER BY count(*) DESC`, + ); + + return { + subscriptions: Number(totals?.devices ?? 0), + players: Number(totals?.players ?? 0), + active_7d: Number(totals?.active_7d ?? 0), + new_7d: Number(totals?.new_7d ?? 0), + never_delivered: Number(totals?.never_delivered ?? 0), + last_delivered_at: totals?.last_delivered_at + ? new Date(totals.last_delivered_at).toISOString() + : null, + platforms: platforms.map((row) => ({ + platform: row.platform, + devices: Number(row.devices), + })), + }; } private async getSetting(name: string): Promise { @@ -238,19 +257,11 @@ export class PushNotificationsService { return row?.value || undefined; } - private async setSetting(name: string, value: string): Promise { - await this.postgres.query( - `INSERT INTO public.settings (name, value) VALUES ($1, $2) - ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value`, - [name, value], - ); - } - - // The install-time write, as opposed to the deliberate rotation in - // generateKeys(): the first pod to get here wins and every other pod adopts - // what is already stored. Overwriting instead would leave pods signing with - // different private keys, and every subscription a browser took out against - // the loser's public key rejected with a 403 until that pod restarted. + // The install-time write, and the only one there is -- keys are never + // rotated. The first pod to get here wins and every other pod adopts what is + // already stored. Overwriting instead would leave pods signing with different + // private keys, and every subscription a browser took out against the loser's + // public key rejected with a 403 until that pod restarted. // // Both rows go in one statement so a race cannot leave two halves of // different keypairs behind, and the read is a second statement because a