feat: Add Web Push notifications and multiple UX improvements
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:
Paul Nothaft
2026-01-19 20:53:13 +01:00
parent 9bfc9e2f96
commit f91978c078
15 changed files with 1158 additions and 126 deletions
+3
View File
@@ -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
View File
@@ -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);
}
+121
View File
@@ -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;
}