feat: Add Web Push notifications and multiple UX improvements
continuous-integration/drone/push Build is failing
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
This commit is contained in:
@@ -2,6 +2,7 @@ import express, { type Request, Response, NextFunction } from "express";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
import { initializeDatabase, closeDatabase } from "./db.js";
|
||||
import { storage } from "./storage";
|
||||
import { initializeVapid } from "./push.js";
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", true);
|
||||
@@ -79,6 +80,8 @@ app.use((req, res, next) => {
|
||||
if (process.env.NODE_ENV === 'production' || process.env.USE_DB === 'true') {
|
||||
try {
|
||||
await initializeDatabase();
|
||||
// Initialize Web Push VAPID keys after database is ready
|
||||
await initializeVapid();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize database:', error);
|
||||
process.exit(1);
|
||||
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
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);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { mcpServer } from "./mcp";
|
||||
import { AiService, DEFAULT_SYSTEM_PROMPT } from "./ai.js";
|
||||
import { RecurrenceService } from "./services/recurrence.js";
|
||||
import { GamificationService } from "./gamification.js";
|
||||
import { getVapidPublicKey, savePushSubscription, removePushSubscription, removeUserSubscriptions, sendPushNotification } from "./push.js";
|
||||
|
||||
const emailService = new EmailService(storage);
|
||||
const aiService = new AiService(storage);
|
||||
@@ -2219,6 +2220,126 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// PUSH NOTIFICATION ROUTES
|
||||
// ============================================
|
||||
|
||||
// Get VAPID public key (needed by client to subscribe)
|
||||
app.get("/api/push/vapid-public-key", async (req, res) => {
|
||||
try {
|
||||
const publicKey = await getVapidPublicKey();
|
||||
if (!publicKey) {
|
||||
return res.status(503).json({ error: "Push notifications not configured" });
|
||||
}
|
||||
res.json({ publicKey });
|
||||
} catch (e) {
|
||||
console.error("Get VAPID key failed:", e);
|
||||
res.status(500).json({ error: "Failed to get VAPID key" });
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to push notifications
|
||||
app.post("/api/push/subscribe", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
const { subscription } = req.body;
|
||||
if (!subscription || !subscription.endpoint || !subscription.keys) {
|
||||
return res.status(400).json({ error: "Invalid subscription data" });
|
||||
}
|
||||
|
||||
const userAgent = req.headers['user-agent'];
|
||||
const success = await savePushSubscription(userId, subscription, userAgent);
|
||||
|
||||
if (success) {
|
||||
// Send a welcome notification to confirm it works
|
||||
await sendPushNotification(userId, {
|
||||
title: "Notifications Enabled",
|
||||
body: "You'll now receive task reminders even when the app is closed!",
|
||||
tag: "welcome"
|
||||
});
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(500).json({ error: "Failed to save subscription" });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Subscribe failed:", e);
|
||||
res.status(500).json({ error: "Failed to subscribe" });
|
||||
}
|
||||
});
|
||||
|
||||
// Unsubscribe from push notifications
|
||||
app.post("/api/push/unsubscribe", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
try {
|
||||
const { endpoint } = req.body;
|
||||
if (!endpoint) {
|
||||
return res.status(400).json({ error: "Endpoint required" });
|
||||
}
|
||||
|
||||
const success = await removePushSubscription(endpoint);
|
||||
res.json({ success });
|
||||
} catch (e) {
|
||||
console.error("Unsubscribe failed:", e);
|
||||
res.status(500).json({ error: "Failed to unsubscribe" });
|
||||
}
|
||||
});
|
||||
|
||||
// Unsubscribe all devices for current user
|
||||
app.delete("/api/push/subscriptions", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
const success = await removeUserSubscriptions(userId);
|
||||
res.json({ success });
|
||||
} catch (e) {
|
||||
console.error("Remove subscriptions failed:", e);
|
||||
res.status(500).json({ error: "Failed to remove subscriptions" });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle subscription changes (from service worker)
|
||||
app.post("/api/push/resubscribe", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
const { oldEndpoint, newSubscription } = req.body;
|
||||
|
||||
// Remove old subscription
|
||||
if (oldEndpoint) {
|
||||
await removePushSubscription(oldEndpoint);
|
||||
}
|
||||
|
||||
// Save new subscription
|
||||
if (newSubscription) {
|
||||
const userAgent = req.headers['user-agent'];
|
||||
await savePushSubscription(userId, newSubscription, userAgent);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error("Resubscribe failed:", e);
|
||||
res.status(500).json({ error: "Failed to resubscribe" });
|
||||
}
|
||||
});
|
||||
|
||||
// Test notification (useful for debugging)
|
||||
app.post("/api/push/test", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
const result = await sendPushNotification(userId, {
|
||||
title: "Test Notification",
|
||||
body: "Push notifications are working!",
|
||||
tag: "test"
|
||||
});
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
console.error("Test notification failed:", e);
|
||||
res.status(500).json({ error: "Failed to send test notification" });
|
||||
}
|
||||
});
|
||||
|
||||
// End of routes
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user