f91978c078
continuous-integration/drone/push Build is failing
- Add PWA manifest and service worker for push notifications
- Implement VAPID key generation and push subscription management
- Add push notification API endpoints (/api/push/*)
- Add push_subscriptions table to database schema
- Update notification settings UI with push support and iOS hints
- Fix translation issue showing "{ task } created" - add missing keys
- Fix mobile sidebar visibility for iOS home screen app
- Change default task filter from "all" to "open" (excludes done tasks)
- Add "Open" filter option to show only todo + inProgress tasks
292 lines
8.0 KiB
TypeScript
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 || '/favicon.png',
|
|
badge: payload.badge || '/favicon.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);
|
|
}
|