Files
task-manager/server/push.ts
T
Paul Nothaft 88cd2a5c88
continuous-integration/drone/push Build is passing
feat: Implement TaskFlow logos and icons throughout the application
- Add favicon files (16x16 to 256x256, .ico)
- Add PWA icons (128, 256, 512, 1024)
- Add apple-touch-icon for iOS
- Add transparent icons for loading screens
- Add horizontal logo with tagline for auth page
- Update HTML head with proper favicon links
- Update PWA manifest with correct icon references
- Update sidebar header with branded icon
- Update mobile header with branded icon
- Update auth/login pages with logo display
- Update loading state with branded splash screen
- Update 404 page with logo header
- Update email template to use proper logo URL
- Update service worker and push notifications to use correct icons
2026-01-19 21:40:09 +01:00

292 lines
8.0 KiB
TypeScript

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<string, unknown>;
}
// VAPID keys management
let vapidConfigured = false;
export async function initializeVapid(): Promise<void> {
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<string | null> {
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<boolean> {
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<boolean> {
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<boolean> {
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<schema.PushSubscription[]> {
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<void> {
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);
}