All files / web/src/lib/notifications web-push.ts

0% Statements 0/123
0% Branches 0/1
0% Functions 0/1
0% Lines 0/123

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124                                                                                                                                                                                                                                                       
import webpush from 'web-push'
import { eq } from 'drizzle-orm'
import { db } from '@/db'
import { userPushSubscriptions } from '@/db/schema'
import type { WebPushSubscriptionJson } from '@/db/schema'

let configured = false

/**
 * Lazily configure VAPID credentials — avoids crashes at build time
 * when env vars are absent.
 */
function ensureConfigured(): void {
  if (configured) return

  const publicKey = process.env.VAPID_PUBLIC_KEY
  const privateKey = process.env.VAPID_PRIVATE_KEY
  if (!publicKey || !privateKey) {
    throw new Error('[web-push] VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be set')
  }

  const subject = process.env.VAPID_SUBJECT ?? 'mailto:hallock@gmail.com'
  webpush.setVapidDetails(subject, publicKey, privateKey)
  configured = true
}

export interface WebPushResult {
  success: boolean
  statusCode?: number
}

/**
 * Send a web push notification to a single subscription endpoint.
 *
 * Catches 410 Gone and 404 (endpoint expired/invalid) and returns
 * a failure result instead of throwing — the caller can use
 * `shouldDisable` logic based on statusCode.
 *
 * Re-throws other errors for the channel to handle.
 */
export async function sendWebPush(
  subscription: WebPushSubscriptionJson,
  payload: object
): Promise<WebPushResult> {
  ensureConfigured()

  try {
    await webpush.sendNotification(
      {
        endpoint: subscription.endpoint,
        keys: subscription.keys,
      },
      JSON.stringify(payload)
    )
    return { success: true, statusCode: 201 }
  } catch (err: unknown) {
    const statusCode =
      err && typeof err === 'object' && 'statusCode' in err
        ? (err as { statusCode: number }).statusCode
        : undefined

    // 410 Gone or 404 = endpoint expired / unsubscribed
    if (statusCode === 410 || statusCode === 404) {
      return { success: false, statusCode }
    }

    throw err
  }
}

/**
 * Send a web push notification to ALL registered push endpoints for a user.
 *
 * Looks up endpoints from user_push_subscriptions table.
 * Removes expired endpoints (410/404) automatically.
 * Returns count of total endpoints and successful sends.
 */
export async function sendWebPushToUser(
  userId: string,
  payload: object
): Promise<{ total: number; sent: number }> {
  const subs = await db
    .select()
    .from(userPushSubscriptions)
    .where(eq(userPushSubscriptions.userId, userId))

  if (subs.length === 0) return { total: 0, sent: 0 }

  let sent = 0
  for (const sub of subs) {
    const pushSub: WebPushSubscriptionJson = {
      endpoint: sub.endpoint,
      keys: sub.keys,
    }
    try {
      const result = await sendWebPush(pushSub, payload)
      if (result.success) {
        sent++
        // Update lastUsedAt
        db.update(userPushSubscriptions)
          .set({ lastUsedAt: new Date() })
          .where(eq(userPushSubscriptions.id, sub.id))
          .catch(() => {})
      } else if (result.statusCode === 410 || result.statusCode === 404) {
        // Endpoint gone — remove it
        db.delete(userPushSubscriptions)
          .where(eq(userPushSubscriptions.id, sub.id))
          .catch((err) => console.error('[web-push] Failed to remove expired endpoint:', err))
      }
    } catch (err) {
      console.error('[web-push] Error sending to endpoint:', err)
    }
  }

  return { total: subs.length, sent }
}

/**
 * Reset configured state (for testing).
 */
export function _resetWebPushConfig(): void {
  configured = false
}