Add push notification functionality with tests and digest scheduler
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m14s

- Implemented push subscription API with tests for subscribing and unsubscribing users.
- Created web push notification tests triggered by child actions.
- Added digest scheduler to send email digests to users at 9 PM local time.
- Developed utility functions for creating and validating digest action tokens.
- Integrated web push sender to handle sending notifications to users.
- Added service worker for handling push notifications in the frontend.
- Created a push opt-in component for user notification preferences.
- Implemented tests for the push opt-in component to ensure correct behavior.
- Updated frontend services to manage push subscriptions and permissions.
This commit is contained in:
2026-04-15 21:56:10 -04:00
parent 0d50a324a3
commit ad2bdf4c4f
47 changed files with 3177 additions and 197 deletions

View File

@@ -0,0 +1,103 @@
/**
* Push subscription manager.
* Handles requesting notification permission, subscribing to push,
* and syncing the subscription with the backend.
*/
async function fetchVapidPublicKey(): Promise<string | null> {
try {
const res = await fetch('/api/push-vapid-key')
if (!res.ok) return null
const data = await res.json()
return data.public_key || null
} catch {
return null
}
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)))
}
/**
* Request notification permission and subscribe to push notifications.
* Posts the subscription to the backend, including the user's timezone.
* Returns true if subscription was successfully registered.
*/
export async function subscribeToPush(): Promise<boolean> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return false
}
try {
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
return false
}
const vapidKey = await fetchVapidPublicKey()
if (!vapidKey) {
console.warn('[Push] VAPID public key not available; skipping subscription')
return false
}
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey),
})
const subJson = subscription.toJSON()
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const res = await fetch('/api/push-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subJson.endpoint,
keys: subJson.keys,
timezone,
}),
})
return res.ok
} catch (err) {
console.warn('[Push] Failed to subscribe:', err)
return false
}
}
/**
* Unsubscribe from push notifications and remove the subscription from the backend.
*/
export async function unsubscribeFromPush(): Promise<void> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (!subscription) return
const endpoint = subscription.endpoint
await subscription.unsubscribe()
await fetch('/api/push-subscription', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint }),
})
} catch (err) {
console.warn('[Push] Failed to unsubscribe:', err)
}
}
/**
* Check the current push permission state without prompting.
*/
export function getPushPermissionState(): NotificationPermission | 'unsupported' {
if (!('Notification' in window)) return 'unsupported'
return Notification.permission
}