import webpush from 'web-push'; import { getDatabase } from './db'; import * as schema from '@shared/schema'; import { eq, and } from 'drizzle-orm'; // Types export interface PushSubscriptionData { endpoint: string; keys: { p256dh: string; auth: string; }; } export interface PushNotificationPayload { title: string; body: string; icon?: string; badge?: string; tag?: string; data?: Record; } // VAPID keys management let vapidConfigured = false; export async function initializeVapid(): Promise { const db = getDatabase(); if (!db) { console.log('[Push] Database not available, skipping VAPID initialization'); return; } try { // Try to get existing VAPID keys from system settings const publicKeyResult = await db.select() .from(schema.systemSettings) .where(eq(schema.systemSettings.key, 'vapid_public_key')); const privateKeyResult = await db.select() .from(schema.systemSettings) .where(eq(schema.systemSettings.key, 'vapid_private_key')); let publicKey = publicKeyResult[0]?.value; let privateKey = privateKeyResult[0]?.value; // Generate new keys if they don't exist if (!publicKey || !privateKey) { console.log('[Push] Generating new VAPID keys...'); const keys = webpush.generateVAPIDKeys(); publicKey = keys.publicKey; privateKey = keys.privateKey; // Store keys in system settings await db.insert(schema.systemSettings) .values({ key: 'vapid_public_key', value: publicKey }) .onConflictDoUpdate({ target: schema.systemSettings.key, set: { value: publicKey } }); await db.insert(schema.systemSettings) .values({ key: 'vapid_private_key', value: privateKey }) .onConflictDoUpdate({ target: schema.systemSettings.key, set: { value: privateKey } }); console.log('[Push] VAPID keys generated and stored'); } // Configure web-push with VAPID keys const vapidEmail = process.env.VAPID_EMAIL || 'mailto:admin@taskflow.app'; webpush.setVapidDetails(vapidEmail, publicKey, privateKey); vapidConfigured = true; console.log('[Push] VAPID configured successfully'); } catch (error) { console.error('[Push] Failed to initialize VAPID:', error); } } export async function getVapidPublicKey(): Promise { const db = getDatabase(); if (!db) return null; try { const result = await db.select() .from(schema.systemSettings) .where(eq(schema.systemSettings.key, 'vapid_public_key')); return result[0]?.value || null; } catch (error) { console.error('[Push] Failed to get VAPID public key:', error); return null; } } // Subscription management export async function savePushSubscription( userId: string, subscription: PushSubscriptionData, userAgent?: string ): Promise { const db = getDatabase(); if (!db) return false; try { // Check if subscription already exists const existing = await db.select() .from(schema.pushSubscriptions) .where(eq(schema.pushSubscriptions.endpoint, subscription.endpoint)); if (existing[0]) { // Update existing subscription await db.update(schema.pushSubscriptions) .set({ userId, p256dh: subscription.keys.p256dh, auth: subscription.keys.auth, userAgent, lastUsedAt: new Date() }) .where(eq(schema.pushSubscriptions.endpoint, subscription.endpoint)); } else { // Create new subscription await db.insert(schema.pushSubscriptions) .values({ userId, endpoint: subscription.endpoint, p256dh: subscription.keys.p256dh, auth: subscription.keys.auth, userAgent }); } console.log(`[Push] Saved subscription for user ${userId}`); return true; } catch (error) { console.error('[Push] Failed to save subscription:', error); return false; } } export async function removePushSubscription(endpoint: string): Promise { const db = getDatabase(); if (!db) return false; try { await db.delete(schema.pushSubscriptions) .where(eq(schema.pushSubscriptions.endpoint, endpoint)); console.log('[Push] Removed subscription'); return true; } catch (error) { console.error('[Push] Failed to remove subscription:', error); return false; } } export async function removeUserSubscriptions(userId: string): Promise { const db = getDatabase(); if (!db) return false; try { await db.delete(schema.pushSubscriptions) .where(eq(schema.pushSubscriptions.userId, userId)); console.log(`[Push] Removed all subscriptions for user ${userId}`); return true; } catch (error) { console.error('[Push] Failed to remove user subscriptions:', error); return false; } } export async function getUserSubscriptions(userId: string): Promise { const db = getDatabase(); if (!db) return []; try { return await db.select() .from(schema.pushSubscriptions) .where(eq(schema.pushSubscriptions.userId, userId)); } catch (error) { console.error('[Push] Failed to get user subscriptions:', error); return []; } } // Send push notification export async function sendPushNotification( userId: string, payload: PushNotificationPayload ): Promise<{ sent: number; failed: number }> { if (!vapidConfigured) { console.log('[Push] VAPID not configured, skipping notification'); return { sent: 0, failed: 0 }; } const subscriptions = await getUserSubscriptions(userId); if (subscriptions.length === 0) { console.log(`[Push] No subscriptions for user ${userId}`); return { sent: 0, failed: 0 }; } const notificationPayload = JSON.stringify({ title: payload.title, body: payload.body, icon: payload.icon || '/icon-256.png', badge: payload.badge || '/icon-128.png', tag: payload.tag || `taskflow-${Date.now()}`, data: payload.data || {} }); let sent = 0; let failed = 0; for (const subscription of subscriptions) { try { await webpush.sendNotification( { endpoint: subscription.endpoint, keys: { p256dh: subscription.p256dh, auth: subscription.auth } }, notificationPayload ); // Update last used timestamp const db = getDatabase(); if (db) { await db.update(schema.pushSubscriptions) .set({ lastUsedAt: new Date() }) .where(eq(schema.pushSubscriptions.id, subscription.id)); } sent++; } catch (error: any) { console.error(`[Push] Failed to send to subscription ${subscription.id}:`, error.message); // Remove invalid subscriptions (410 Gone or 404 Not Found) if (error.statusCode === 410 || error.statusCode === 404) { await removePushSubscription(subscription.endpoint); console.log('[Push] Removed invalid subscription'); } failed++; } } console.log(`[Push] Sent ${sent} notifications, ${failed} failed for user ${userId}`); return { sent, failed }; } // Send notification to multiple users export async function sendPushNotificationToUsers( userIds: string[], payload: PushNotificationPayload ): Promise<{ sent: number; failed: number }> { let totalSent = 0; let totalFailed = 0; for (const userId of userIds) { const result = await sendPushNotification(userId, payload); totalSent += result.sent; totalFailed += result.failed; } return { sent: totalSent, failed: totalFailed }; } // Task notification helpers export async function sendTaskReminderNotification( userId: string, taskTitle: string, type: 'upcoming' | 'overdue' ): Promise { const payload: PushNotificationPayload = { title: type === 'upcoming' ? `Upcoming: ${taskTitle}` : `Overdue: ${taskTitle}`, body: type === 'upcoming' ? 'This task is due soon!' : 'This task is now overdue!', tag: `task-${type}-${Date.now()}`, data: { type, taskTitle } }; await sendPushNotification(userId, payload); }