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:
@@ -7,8 +7,19 @@
|
|||||||
<meta name="description"
|
<meta name="description"
|
||||||
content="Personal task management app with calendar scheduling, kanban boards, and time tracking" />
|
content="Personal task management app with calendar scheduling, kanban boards, and time tracking" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no" />
|
||||||
|
|
||||||
|
<!-- PWA Meta Tags -->
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#7c3aed" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="TaskFlow" />
|
||||||
|
|
||||||
|
<!-- Icons -->
|
||||||
<link rel="icon" type="image/png" href="/favicon.png?v=4" />
|
<link rel="icon" type="image/png" href="/favicon.png?v=4" />
|
||||||
<link rel="apple-touch-icon" href="/favicon.png?v=4" />
|
<link rel="apple-touch-icon" href="/favicon.png?v=4" />
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link
|
<link
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "TaskFlow",
|
||||||
|
"short_name": "TaskFlow",
|
||||||
|
"description": "Gamified task management with AI assistance",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0f0f23",
|
||||||
|
"theme_color": "#7c3aed",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/favicon.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/favicon.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["productivity", "utilities"],
|
||||||
|
"prefer_related_applications": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// TaskFlow Service Worker for Push Notifications
|
||||||
|
const CACHE_NAME = 'taskflow-v1';
|
||||||
|
|
||||||
|
// Install event - cache essential assets
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
console.log('[SW] Installing service worker...');
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate event - clean up old caches
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
console.log('[SW] Activating service worker...');
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((cacheNames) => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames
|
||||||
|
.filter((name) => name !== CACHE_NAME)
|
||||||
|
.map((name) => caches.delete(name))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push event - handle incoming push notifications
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
console.log('[SW] Push received:', event);
|
||||||
|
|
||||||
|
let data = {
|
||||||
|
title: 'TaskFlow',
|
||||||
|
body: 'You have a new notification',
|
||||||
|
icon: '/favicon.png',
|
||||||
|
badge: '/favicon.png',
|
||||||
|
tag: 'taskflow-notification',
|
||||||
|
data: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.data) {
|
||||||
|
try {
|
||||||
|
const payload = event.data.json();
|
||||||
|
data = {
|
||||||
|
title: payload.title || data.title,
|
||||||
|
body: payload.body || data.body,
|
||||||
|
icon: payload.icon || data.icon,
|
||||||
|
badge: payload.badge || data.badge,
|
||||||
|
tag: payload.tag || data.tag,
|
||||||
|
data: payload.data || {}
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// If not JSON, use text
|
||||||
|
data.body = event.data.text();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
body: data.body,
|
||||||
|
icon: data.icon,
|
||||||
|
badge: data.badge,
|
||||||
|
tag: data.tag,
|
||||||
|
data: data.data,
|
||||||
|
vibrate: [100, 50, 100],
|
||||||
|
requireInteraction: false,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
action: 'open',
|
||||||
|
title: 'Open TaskFlow'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'dismiss',
|
||||||
|
title: 'Dismiss'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title, options)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notification click event - handle user interaction
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
console.log('[SW] Notification clicked:', event);
|
||||||
|
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
if (event.action === 'dismiss') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default action or 'open' action - open the app
|
||||||
|
const urlToOpen = event.notification.data?.url || '/';
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||||
|
// Check if app is already open
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||||
|
client.focus();
|
||||||
|
if (event.notification.data?.url) {
|
||||||
|
client.navigate(event.notification.data.url);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Open new window if not already open
|
||||||
|
if (clients.openWindow) {
|
||||||
|
return clients.openWindow(urlToOpen);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notification close event
|
||||||
|
self.addEventListener('notificationclose', (event) => {
|
||||||
|
console.log('[SW] Notification closed:', event);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle push subscription change
|
||||||
|
self.addEventListener('pushsubscriptionchange', (event) => {
|
||||||
|
console.log('[SW] Push subscription changed:', event);
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: event.oldSubscription?.options?.applicationServerKey
|
||||||
|
}).then((subscription) => {
|
||||||
|
// Send new subscription to server
|
||||||
|
return fetch('/api/push/resubscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
oldEndpoint: event.oldSubscription?.endpoint,
|
||||||
|
newSubscription: subscription.toJSON()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -337,6 +337,14 @@ function AppContent() {
|
|||||||
<AppSidebar user={user} />
|
<AppSidebar user={user} />
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<div className={`min-h-screen bg-background flex flex-col ${adhdEnabled ? 'adhd-mode' : ''} ${adhdEnabled && adhdSettings.reducedAnimations ? 'reduced-motion' : ''} ${adhdEnabled && adhdSettings.largerTargets ? 'larger-targets' : ''}`}>
|
<div className={`min-h-screen bg-background flex flex-col ${adhdEnabled ? 'adhd-mode' : ''} ${adhdEnabled && adhdSettings.reducedAnimations ? 'reduced-motion' : ''} ${adhdEnabled && adhdSettings.largerTargets ? 'larger-targets' : ''}`}>
|
||||||
|
{/* Mobile Header with Sidebar Toggle */}
|
||||||
|
<header className="sticky top-0 z-50 flex items-center gap-3 px-4 py-3 bg-background/80 backdrop-blur-xl border-b border-border/50 md:hidden">
|
||||||
|
<SidebarTrigger className="h-9 w-9" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<img src="/favicon.png" alt="Logo" className="w-7 h-7 rounded-lg" />
|
||||||
|
<span className="font-bold text-lg">{t('app.title')}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
<main className="flex-1 p-4 md:p-6 max-w-screen-2xl mx-auto w-full relative">
|
<main className="flex-1 p-4 md:p-6 max-w-screen-2xl mx-auto w-full relative">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/">
|
<Route path="/">
|
||||||
|
|||||||
@@ -35,14 +35,14 @@ interface TasksWithCalendarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SortOption = 'dueDate' | 'priority' | 'title' | 'status';
|
type SortOption = 'dueDate' | 'priority' | 'title' | 'status';
|
||||||
type FilterOption = 'all' | 'todo' | 'inProgress' | 'done' | 'overdue' | 'planned' | 'unplanned';
|
type FilterOption = 'all' | 'open' | 'todo' | 'inProgress' | 'done' | 'overdue' | 'planned' | 'unplanned';
|
||||||
|
|
||||||
export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onTaskDelete, onStartTimer, onStopTimer }: TasksWithCalendarProps) {
|
export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onTaskDelete, onStartTimer, onStopTimer }: TasksWithCalendarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dateLocale = useDateLocale();
|
const dateLocale = useDateLocale();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [sortBy, setSortBy] = useState<SortOption>('dueDate');
|
const [sortBy, setSortBy] = useState<SortOption>('dueDate');
|
||||||
const [filterBy, setFilterBy] = useState<FilterOption>('all');
|
const [filterBy, setFilterBy] = useState<FilterOption>('open');
|
||||||
const [calendarStartDate, setCalendarStartDate] = useState(startOfToday());
|
const [calendarStartDate, setCalendarStartDate] = useState(startOfToday());
|
||||||
const [draggedTask, setDraggedTask] = useState<string | null>(null);
|
const [draggedTask, setDraggedTask] = useState<string | null>(null);
|
||||||
const [hoveredDate, setHoveredDate] = useState<Date | null>(null);
|
const [hoveredDate, setHoveredDate] = useState<Date | null>(null);
|
||||||
@@ -106,6 +106,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
|||||||
return !task.dueDate && !task.startDate;
|
return !task.dueDate && !task.startDate;
|
||||||
case 'all':
|
case 'all':
|
||||||
return true;
|
return true;
|
||||||
|
case 'open':
|
||||||
|
return task.status !== 'done';
|
||||||
default:
|
default:
|
||||||
return task.status === filterBy;
|
return task.status === filterBy;
|
||||||
}
|
}
|
||||||
@@ -173,6 +175,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
|||||||
return tasks.filter(t => !t.dueDate && !t.startDate).length;
|
return tasks.filter(t => !t.dueDate && !t.startDate).length;
|
||||||
case 'all':
|
case 'all':
|
||||||
return tasks.length;
|
return tasks.length;
|
||||||
|
case 'open':
|
||||||
|
return tasks.filter(t => t.status !== 'done').length;
|
||||||
default:
|
default:
|
||||||
return tasks.filter(task => task.status === filter).length;
|
return tasks.filter(task => task.status === filter).length;
|
||||||
}
|
}
|
||||||
@@ -286,6 +290,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
|||||||
</div>
|
</div>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="open">{t('taskList.filter.open')} ({getFilterCount('open')})</SelectItem>
|
||||||
<SelectItem value="all">{t('taskList.filter.all')} ({getFilterCount('all')})</SelectItem>
|
<SelectItem value="all">{t('taskList.filter.all')} ({getFilterCount('all')})</SelectItem>
|
||||||
<SelectItem value="todo">{t('taskList.filter.todo')} ({getFilterCount('todo')})</SelectItem>
|
<SelectItem value="todo">{t('taskList.filter.todo')} ({getFilterCount('todo')})</SelectItem>
|
||||||
<SelectItem value="inProgress">{t('taskList.filter.inProgress')} ({getFilterCount('inProgress')})</SelectItem>
|
<SelectItem value="inProgress">{t('taskList.filter.inProgress')} ({getFilterCount('inProgress')})</SelectItem>
|
||||||
@@ -322,7 +327,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
|||||||
{unscheduledTasks.length === 0 ? (
|
{unscheduledTasks.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<p className="text-muted-foreground" data-testid="text-no-tasks">
|
<p className="text-muted-foreground" data-testid="text-no-tasks">
|
||||||
{searchQuery || filterBy !== 'all'
|
{searchQuery || (filterBy !== 'all' && filterBy !== 'open')
|
||||||
? t('taskList.noMatchingTasks')
|
? t('taskList.noMatchingTasks')
|
||||||
: t('taskList.noUnscheduledTasks')
|
: t('taskList.noUnscheduledTasks')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,230 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
import { Task } from '@shared/schema';
|
import { Task } from '@shared/schema';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { de, enUS } from 'date-fns/locale';
|
import { de, enUS } from 'date-fns/locale';
|
||||||
|
|
||||||
|
// Convert VAPID public key from base64 to Uint8Array (required for subscription)
|
||||||
|
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||||
|
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||||
|
const base64 = (base64String + padding)
|
||||||
|
.replace(/-/g, '+')
|
||||||
|
.replace(/_/g, '/');
|
||||||
|
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if push notifications are supported
|
||||||
|
function isPushSupported(): boolean {
|
||||||
|
return 'serviceWorker' in navigator && 'PushManager' in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if running as installed PWA (standalone)
|
||||||
|
function isStandalone(): boolean {
|
||||||
|
return window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
|
(window.navigator as any).standalone === true;
|
||||||
|
}
|
||||||
|
|
||||||
export function useNotifications({ poll = true }: { poll?: boolean } = {}) {
|
export function useNotifications({ poll = true }: { poll?: boolean } = {}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [permission, setPermission] = useState<NotificationPermission>('default');
|
const [permission, setPermission] = useState<NotificationPermission>('default');
|
||||||
|
const [pushSupported, setPushSupported] = useState(false);
|
||||||
|
const [pushSubscription, setPushSubscription] = useState<PushSubscription | null>(null);
|
||||||
|
const [serviceWorkerReady, setServiceWorkerReady] = useState(false);
|
||||||
const [enabled, setEnabled] = useState(false);
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
|
||||||
// Track notified tasks to prevent duplicate alerts in same session
|
// Track notified tasks to prevent duplicate alerts in same session
|
||||||
const notifiedTasksRef = useRef<Set<string>>(new Set());
|
const notifiedTasksRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Check support and register service worker on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const initializePush = async () => {
|
||||||
|
if (!isPushSupported()) {
|
||||||
|
console.log('[Notifications] Push not supported');
|
||||||
|
setPushSupported(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPushSupported(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Register service worker
|
||||||
|
const registration = await navigator.serviceWorker.register('/sw.js', {
|
||||||
|
scope: '/'
|
||||||
|
});
|
||||||
|
console.log('[Notifications] Service worker registered:', registration.scope);
|
||||||
|
|
||||||
|
// Wait for service worker to be ready
|
||||||
|
await navigator.serviceWorker.ready;
|
||||||
|
setServiceWorkerReady(true);
|
||||||
|
|
||||||
|
// Check current subscription
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
setPushSubscription(subscription);
|
||||||
|
|
||||||
|
// Update permission state
|
||||||
if ('Notification' in window) {
|
if ('Notification' in window) {
|
||||||
setPermission(Notification.permission);
|
setPermission(Notification.permission);
|
||||||
const isEnabled = localStorage.getItem('taskflow-notifications-enabled') === 'true';
|
const isEnabled = localStorage.getItem('taskflow-push-enabled') === 'true';
|
||||||
setEnabled(isEnabled && Notification.permission === 'granted');
|
setEnabled(isEnabled && Notification.permission === 'granted' && subscription !== null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Notifications] Service worker registration failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initializePush();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch VAPID public key
|
||||||
|
const { data: vapidData } = useQuery<{ publicKey: string }>({
|
||||||
|
queryKey: ['/api/push/vapid-public-key'],
|
||||||
|
enabled: serviceWorkerReady && pushSupported,
|
||||||
|
retry: false,
|
||||||
|
staleTime: Infinity, // VAPID key doesn't change
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe mutation
|
||||||
|
const subscribeMutation = useMutation({
|
||||||
|
mutationFn: async (subscription: PushSubscription) => {
|
||||||
|
const response = await fetch('/api/push/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ subscription: subscription.toJSON() }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to subscribe');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
localStorage.setItem('taskflow-push-enabled', 'true');
|
||||||
|
setEnabled(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unsubscribe mutation
|
||||||
|
const unsubscribeMutation = useMutation({
|
||||||
|
mutationFn: async (endpoint: string) => {
|
||||||
|
const response = await fetch('/api/push/unsubscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ endpoint }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to unsubscribe');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
localStorage.setItem('taskflow-push-enabled', 'false');
|
||||||
|
setEnabled(false);
|
||||||
|
setPushSubscription(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request permission and subscribe to push
|
||||||
|
const requestPermission = useCallback(async (): Promise<boolean> => {
|
||||||
|
if (!pushSupported || !serviceWorkerReady || !vapidData?.publicKey) {
|
||||||
|
console.log('[Notifications] Cannot request permission - prerequisites not met');
|
||||||
|
|
||||||
|
// Fallback to basic notification permission
|
||||||
|
if ('Notification' in window) {
|
||||||
|
const result = await Notification.requestPermission();
|
||||||
|
setPermission(result);
|
||||||
|
return result === 'granted';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request notification permission
|
||||||
|
const permissionResult = await Notification.requestPermission();
|
||||||
|
setPermission(permissionResult);
|
||||||
|
|
||||||
|
if (permissionResult !== 'granted') {
|
||||||
|
console.log('[Notifications] Permission denied');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get service worker registration
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
|
||||||
|
// Check for existing subscription
|
||||||
|
let subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
// Create new subscription
|
||||||
|
const applicationServerKey = urlBase64ToUint8Array(vapidData.publicKey);
|
||||||
|
subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey,
|
||||||
|
});
|
||||||
|
console.log('[Notifications] New push subscription created');
|
||||||
|
}
|
||||||
|
|
||||||
|
setPushSubscription(subscription);
|
||||||
|
|
||||||
|
// Send subscription to server
|
||||||
|
await subscribeMutation.mutateAsync(subscription);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Notifications] Failed to subscribe:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [pushSupported, serviceWorkerReady, vapidData?.publicKey, subscribeMutation]);
|
||||||
|
|
||||||
|
// Toggle notifications on/off
|
||||||
|
const toggleEnabled = useCallback(async (value: boolean) => {
|
||||||
|
if (value) {
|
||||||
|
// Enable notifications
|
||||||
|
if (permission !== 'granted' || !pushSubscription) {
|
||||||
|
await requestPermission();
|
||||||
|
} else {
|
||||||
|
setEnabled(true);
|
||||||
|
localStorage.setItem('taskflow-push-enabled', 'true');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Disable notifications
|
||||||
|
if (pushSubscription) {
|
||||||
|
try {
|
||||||
|
await pushSubscription.unsubscribe();
|
||||||
|
await unsubscribeMutation.mutateAsync(pushSubscription.endpoint);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Notifications] Failed to unsubscribe:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setEnabled(false);
|
||||||
|
localStorage.setItem('taskflow-push-enabled', 'false');
|
||||||
|
}
|
||||||
|
}, [permission, pushSubscription, requestPermission, unsubscribeMutation]);
|
||||||
|
|
||||||
|
// Send test notification
|
||||||
|
const sendTestNotification = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/push/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const requestPermission = useCallback(async () => {
|
// Query tasks for fallback polling (when push isn't available or app is open)
|
||||||
if (!('Notification' in window)) return false;
|
|
||||||
|
|
||||||
const result = await Notification.requestPermission();
|
|
||||||
setPermission(result);
|
|
||||||
|
|
||||||
if (result === 'granted') {
|
|
||||||
setEnabled(true);
|
|
||||||
localStorage.setItem('taskflow-notifications-enabled', 'true');
|
|
||||||
new Notification(t('notifications.enabledTitle'), {
|
|
||||||
body: t('notifications.enabledBody'),
|
|
||||||
// icon: '/favicon.ico' // Chrome sometimes blocks if icon 404
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
const toggleEnabled = useCallback((value: boolean) => {
|
|
||||||
if (value && permission !== 'granted') {
|
|
||||||
requestPermission();
|
|
||||||
} else {
|
|
||||||
setEnabled(value);
|
|
||||||
localStorage.setItem('taskflow-notifications-enabled', String(value));
|
|
||||||
}
|
|
||||||
}, [permission, requestPermission]);
|
|
||||||
|
|
||||||
// Query tasks for polling
|
|
||||||
const { data: tasks } = useQuery<Task[]>({
|
const { data: tasks } = useQuery<Task[]>({
|
||||||
queryKey: ['/api/tasks'],
|
queryKey: ['/api/tasks'],
|
||||||
enabled: enabled && poll // Only poll if enabled AND polling is active
|
enabled: enabled && poll && !pushSupported // Only poll if push isn't supported
|
||||||
});
|
});
|
||||||
|
|
||||||
// Polling Logic
|
// Fallback Polling Logic (for when push isn't available)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!poll || !enabled || !tasks || permission !== 'granted') return;
|
if (!poll || !enabled || !tasks || permission !== 'granted' || pushSupported) return;
|
||||||
|
|
||||||
const checkTasks = () => {
|
const checkTasks = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -74,9 +242,7 @@ export function useNotifications({ poll = true }: { poll?: boolean } = {}) {
|
|||||||
notifiedTasksRef.current.add(task.id);
|
notifiedTasksRef.current.add(task.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alert: Just Overdue (within last 1 min to catch it once)
|
// Alert: Just Overdue
|
||||||
// or purely check if overdue and not notified?
|
|
||||||
// Let's stick to "Just became overdue" or "Is overdue" but protect with Set
|
|
||||||
if (diffMinutes < 0) {
|
if (diffMinutes < 0) {
|
||||||
sendNotification(task, 'overdue');
|
sendNotification(task, 'overdue');
|
||||||
notifiedTasksRef.current.add(task.id);
|
notifiedTasksRef.current.add(task.id);
|
||||||
@@ -95,22 +261,26 @@ export function useNotifications({ poll = true }: { poll?: boolean } = {}) {
|
|||||||
|
|
||||||
new Notification(title, {
|
new Notification(title, {
|
||||||
body,
|
body,
|
||||||
// icon: '/favicon.ico',
|
tag: `task-${task.id}-${type}`
|
||||||
tag: `task-${task.id}-${type}` // prevent duplicate native notifications
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check immediately and then interval
|
|
||||||
checkTasks();
|
checkTasks();
|
||||||
const interval = setInterval(checkTasks, 60000); // Check every minute
|
const interval = setInterval(checkTasks, 60000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [enabled, tasks, permission, t, i18n.language]);
|
}, [enabled, tasks, permission, t, i18n.language, poll, pushSupported]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
permission,
|
permission,
|
||||||
enabled,
|
enabled,
|
||||||
|
pushSupported,
|
||||||
|
pushSubscription,
|
||||||
|
isStandalone: isStandalone(),
|
||||||
|
serviceWorkerReady,
|
||||||
requestPermission,
|
requestPermission,
|
||||||
toggleEnabled
|
toggleEnabled,
|
||||||
|
sendTestNotification,
|
||||||
|
isLoading: subscribeMutation.isPending || unsubscribeMutation.isPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "Benachrichtigungen",
|
"title": "Benachrichtigungen",
|
||||||
"enableBrowser": "Browser-Benachrichtigungen aktivieren",
|
"enableBrowser": "Browser-Benachrichtigungen aktivieren",
|
||||||
|
"enablePush": "Push-Benachrichtigungen",
|
||||||
"description": "Erhalten Sie Warnungen für bevorstehende und überfällige Aufgaben.",
|
"description": "Erhalten Sie Warnungen für bevorstehende und überfällige Aufgaben.",
|
||||||
|
"pushDescription": "Erhalten Sie Benachrichtigungen auch wenn die App geschlossen ist.",
|
||||||
"enabledTitle": "Benachrichtigungen aktiviert",
|
"enabledTitle": "Benachrichtigungen aktiviert",
|
||||||
"enabledBody": "Sie erhalten nun Warnungen für Ihre Aufgaben.",
|
"enabledBody": "Sie erhalten nun Warnungen für Ihre Aufgaben.",
|
||||||
"upcomingTitle": "Demnächst: {{task}}",
|
"upcomingTitle": "Demnächst: {{task}}",
|
||||||
@@ -20,7 +22,16 @@
|
|||||||
"request": "Zugriff anfordern"
|
"request": "Zugriff anfordern"
|
||||||
},
|
},
|
||||||
"recent": "Aktuelle Warnungen",
|
"recent": "Aktuelle Warnungen",
|
||||||
"empty": "Alles erledigt! Keine urgierten Warnungen."
|
"empty": "Alles erledigt! Keine urgierten Warnungen.",
|
||||||
|
"pushSupported": "Push unterstützt",
|
||||||
|
"installedApp": "Installierte App",
|
||||||
|
"permissionDenied": "Berechtigung vom Browser verweigert. Bitte setzen Sie die Website-Berechtigungen zurück.",
|
||||||
|
"iosHintTitle": "Zum Home-Bildschirm hinzufügen",
|
||||||
|
"iosHintDesc": "Um Push-Benachrichtigungen auf iOS zu erhalten, tippen Sie auf die Teilen-Schaltfläche und wählen Sie \"Zum Home-Bildschirm\", dann öffnen Sie die App von dort.",
|
||||||
|
"sendTest": "Test-Benachrichtigung senden",
|
||||||
|
"testSent": "Test gesendet",
|
||||||
|
"testSentDesc": "Überprüfen Sie Ihr Gerät auf eine Benachrichtigung.",
|
||||||
|
"testFailed": "Test-Benachrichtigung konnte nicht gesendet werden."
|
||||||
},
|
},
|
||||||
"unscheduled": {
|
"unscheduled": {
|
||||||
"title": "Ungeplante Aufgaben",
|
"title": "Ungeplante Aufgaben",
|
||||||
@@ -51,11 +62,14 @@
|
|||||||
"listHeader": "Aufgabenliste",
|
"listHeader": "Aufgabenliste",
|
||||||
"calendarTitle": "Nächste {{count}} Tage",
|
"calendarTitle": "Nächste {{count}} Tage",
|
||||||
"filter": {
|
"filter": {
|
||||||
|
"open": "Offen",
|
||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
"todo": "Zu erledigen",
|
"todo": "Zu erledigen",
|
||||||
"inProgress": "In Bearbeitung",
|
"inProgress": "In Bearbeitung",
|
||||||
"done": "Erledigt",
|
"done": "Erledigt",
|
||||||
"overdue": "Überfällig"
|
"overdue": "Überfällig",
|
||||||
|
"planned": "Geplant",
|
||||||
|
"unplanned": "Ungeplant"
|
||||||
},
|
},
|
||||||
"sort": {
|
"sort": {
|
||||||
"dueDate": "Fälligkeitsdatum",
|
"dueDate": "Fälligkeitsdatum",
|
||||||
@@ -626,6 +640,14 @@
|
|||||||
"loading": "Laden...",
|
"loading": "Laden...",
|
||||||
"error": "Ein Fehler ist aufgetreten"
|
"error": "Ein Fehler ist aufgetreten"
|
||||||
},
|
},
|
||||||
|
"task": {
|
||||||
|
"created": "Aufgabe erstellt",
|
||||||
|
"createdDescription": "\"{{title}}\" wurde hinzugefügt."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"createTask": "Fehler",
|
||||||
|
"createTaskDescription": "Aufgabe konnte nicht erstellt werden. Bitte versuchen Sie es erneut."
|
||||||
|
},
|
||||||
"deleteConfirmation": {
|
"deleteConfirmation": {
|
||||||
"title": "Aufgabe löschen",
|
"title": "Aufgabe löschen",
|
||||||
"description": "Möchten Sie diese Aufgabe wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
"description": "Möchten Sie diese Aufgabe wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "Notifications",
|
"title": "Notifications",
|
||||||
"enableBrowser": "Enable Browser Notifications",
|
"enableBrowser": "Enable Browser Notifications",
|
||||||
|
"enablePush": "Push Notifications",
|
||||||
"description": "Get alerted about upcoming and overdue tasks.",
|
"description": "Get alerted about upcoming and overdue tasks.",
|
||||||
|
"pushDescription": "Receive notifications even when the app is closed.",
|
||||||
"enabledTitle": "Notifications Enabled",
|
"enabledTitle": "Notifications Enabled",
|
||||||
"enabledBody": "You will now receive alerts for your tasks.",
|
"enabledBody": "You will now receive alerts for your tasks.",
|
||||||
"upcomingTitle": "Upcoming: {{task}}",
|
"upcomingTitle": "Upcoming: {{task}}",
|
||||||
@@ -20,7 +22,16 @@
|
|||||||
"request": "Request Permission"
|
"request": "Request Permission"
|
||||||
},
|
},
|
||||||
"recent": "Recent Alerts",
|
"recent": "Recent Alerts",
|
||||||
"empty": "All caught up! No urgent alerts."
|
"empty": "All caught up! No urgent alerts.",
|
||||||
|
"pushSupported": "Push Supported",
|
||||||
|
"installedApp": "Installed App",
|
||||||
|
"permissionDenied": "Permission denied by browser. Please reset site permissions.",
|
||||||
|
"iosHintTitle": "Add to Home Screen",
|
||||||
|
"iosHintDesc": "To receive push notifications on iOS, tap the Share button and select \"Add to Home Screen\", then open the app from there.",
|
||||||
|
"sendTest": "Send Test Notification",
|
||||||
|
"testSent": "Test Sent",
|
||||||
|
"testSentDesc": "Check for a notification on your device.",
|
||||||
|
"testFailed": "Failed to send test notification."
|
||||||
},
|
},
|
||||||
"unscheduled": {
|
"unscheduled": {
|
||||||
"title": "Unscheduled Tasks",
|
"title": "Unscheduled Tasks",
|
||||||
@@ -51,6 +62,7 @@
|
|||||||
"listHeader": "Task List",
|
"listHeader": "Task List",
|
||||||
"calendarTitle": "Next {{count}} Days",
|
"calendarTitle": "Next {{count}} Days",
|
||||||
"filter": {
|
"filter": {
|
||||||
|
"open": "Open",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"todo": "To Do",
|
"todo": "To Do",
|
||||||
"inProgress": "In Progress",
|
"inProgress": "In Progress",
|
||||||
@@ -607,6 +619,14 @@
|
|||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"error": "An error occurred"
|
"error": "An error occurred"
|
||||||
},
|
},
|
||||||
|
"task": {
|
||||||
|
"created": "Task Created",
|
||||||
|
"createdDescription": "\"{{title}}\" has been added."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"createTask": "Error",
|
||||||
|
"createTaskDescription": "Failed to create task. Please try again."
|
||||||
|
},
|
||||||
"deleteConfirmation": {
|
"deleteConfirmation": {
|
||||||
"title": "Delete Task",
|
"title": "Delete Task",
|
||||||
"description": "Are you sure you want to delete this task? This action cannot be undone.",
|
"description": "Are you sure you want to delete this task? This action cannot be undone.",
|
||||||
|
|||||||
@@ -22,33 +22,114 @@ import { DataExportCard } from '@/components/user/DataExportCard';
|
|||||||
|
|
||||||
const NotificationSettings = () => {
|
const NotificationSettings = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { enabled, toggleEnabled, permission, requestPermission } = useNotifications({ poll: false });
|
const { toast } = useToast();
|
||||||
|
const {
|
||||||
|
enabled,
|
||||||
|
toggleEnabled,
|
||||||
|
permission,
|
||||||
|
requestPermission,
|
||||||
|
pushSupported,
|
||||||
|
isStandalone,
|
||||||
|
sendTestNotification,
|
||||||
|
isLoading
|
||||||
|
} = useNotifications({ poll: false });
|
||||||
|
|
||||||
const handleToggle = (checked: boolean) => {
|
const handleToggle = async (checked: boolean) => {
|
||||||
if (checked && permission !== 'granted') {
|
if (checked && permission !== 'granted') {
|
||||||
requestPermission();
|
const success = await requestPermission();
|
||||||
|
if (success) {
|
||||||
|
toast({
|
||||||
|
title: t('notifications.enabledTitle'),
|
||||||
|
description: t('notifications.enabledBody'),
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toggleEnabled(checked);
|
await toggleEnabled(checked);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestNotification = async () => {
|
||||||
|
const success = await sendTestNotification();
|
||||||
|
if (success) {
|
||||||
|
toast({
|
||||||
|
title: t('notifications.testSent', 'Test Sent'),
|
||||||
|
description: t('notifications.testSentDesc', 'Check for a notification on your device.'),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: t('common.error'),
|
||||||
|
description: t('notifications.testFailed', 'Failed to send test notification.'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Push Support Status */}
|
||||||
|
{pushSupported && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||||
|
{t('notifications.pushSupported', 'Push Supported')}
|
||||||
|
</span>
|
||||||
|
{isStandalone && (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-400">
|
||||||
|
{t('notifications.installedApp', 'Installed App')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* iOS PWA Hint */}
|
||||||
|
{!pushSupported && /iPhone|iPad|iPod/.test(navigator.userAgent) && !isStandalone && (
|
||||||
|
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
|
||||||
|
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||||
|
<strong>{t('notifications.iosHintTitle', 'Add to Home Screen')}</strong><br />
|
||||||
|
{t('notifications.iosHintDesc', 'To receive push notifications on iOS, tap the Share button and select "Add to Home Screen", then open the app from there.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<p className="font-medium">{t('notifications.enableBrowser')}</p>
|
<p className="font-medium">
|
||||||
|
{pushSupported
|
||||||
|
? t('notifications.enablePush', 'Push Notifications')
|
||||||
|
: t('notifications.enableBrowser')}
|
||||||
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{permission === 'denied' ?
|
{permission === 'denied' ? (
|
||||||
<span className="text-destructive">Permission denied by browser. Please reset site permissions.</span> :
|
<span className="text-destructive">
|
||||||
|
{t('notifications.permissionDenied', 'Permission denied by browser. Please reset site permissions.')}
|
||||||
|
</span>
|
||||||
|
) : pushSupported ? (
|
||||||
|
t('notifications.pushDescription', 'Receive notifications even when the app is closed.')
|
||||||
|
) : (
|
||||||
t('notifications.description')
|
t('notifications.description')
|
||||||
}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
onCheckedChange={handleToggle}
|
onCheckedChange={handleToggle}
|
||||||
disabled={permission === 'denied'}
|
disabled={permission === 'denied' || isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Test Button */}
|
||||||
|
{enabled && pushSupported && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleTestNotification}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{t('notifications.sendTest', 'Send Test Notification')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Generated
+122
-15
@@ -84,6 +84,7 @@
|
|||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"wouter": "^3.3.5",
|
"wouter": "^3.3.5",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.24.2",
|
"zod": "^3.24.2",
|
||||||
@@ -104,6 +105,7 @@
|
|||||||
"@types/passport-local": "^1.0.38",
|
"@types/passport-local": "^1.0.38",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"@types/ws": "^8.5.13",
|
"@types/ws": "^8.5.13",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
@@ -7002,6 +7004,16 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/web-push": {
|
||||||
|
"version": "3.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||||
|
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -7194,6 +7206,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/async": {
|
"node_modules/async": {
|
||||||
"version": "3.2.6",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
@@ -7383,6 +7407,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||||
|
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.4",
|
"version": "1.20.4",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||||
@@ -7484,27 +7514,18 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/bufferutil": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"node-gyp-build": "^4.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.14.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -8855,6 +8876,15 @@
|
|||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -9999,6 +10029,15 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http_ece": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-cookie-agent": {
|
"node_modules/http-cookie-agent": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-7.0.3.tgz",
|
||||||
@@ -10043,6 +10082,19 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "25.7.4",
|
"version": "25.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.4.tgz",
|
||||||
@@ -10748,6 +10800,27 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/leven": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||||
@@ -12056,6 +12129,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.1.1",
|
"version": "10.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
||||||
@@ -12071,6 +12150,15 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minipass": {
|
"node_modules/minipass": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||||
@@ -16094,6 +16182,25 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-push": {
|
||||||
|
"version": "3.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
|
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1.js": "^5.3.0",
|
||||||
|
"http_ece": "1.2.0",
|
||||||
|
"https-proxy-agent": "^7.0.0",
|
||||||
|
"jws": "^4.0.0",
|
||||||
|
"minimist": "^1.2.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"web-push": "src/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"wouter": "^3.3.5",
|
"wouter": "^3.3.5",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.24.2",
|
"zod": "^3.24.2",
|
||||||
@@ -106,6 +107,7 @@
|
|||||||
"@types/passport-local": "^1.0.38",
|
"@types/passport-local": "^1.0.38",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"@types/ws": "^8.5.13",
|
"@types/ws": "^8.5.13",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import express, { type Request, Response, NextFunction } from "express";
|
|||||||
import { registerRoutes } from "./routes.js";
|
import { registerRoutes } from "./routes.js";
|
||||||
import { initializeDatabase, closeDatabase } from "./db.js";
|
import { initializeDatabase, closeDatabase } from "./db.js";
|
||||||
import { storage } from "./storage";
|
import { storage } from "./storage";
|
||||||
|
import { initializeVapid } from "./push.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.set("trust proxy", true);
|
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') {
|
if (process.env.NODE_ENV === 'production' || process.env.USE_DB === 'true') {
|
||||||
try {
|
try {
|
||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
|
// Initialize Web Push VAPID keys after database is ready
|
||||||
|
await initializeVapid();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize database:', error);
|
console.error('Failed to initialize database:', error);
|
||||||
process.exit(1);
|
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 { AiService, DEFAULT_SYSTEM_PROMPT } from "./ai.js";
|
||||||
import { RecurrenceService } from "./services/recurrence.js";
|
import { RecurrenceService } from "./services/recurrence.js";
|
||||||
import { GamificationService } from "./gamification.js";
|
import { GamificationService } from "./gamification.js";
|
||||||
|
import { getVapidPublicKey, savePushSubscription, removePushSubscription, removeUserSubscriptions, sendPushNotification } from "./push.js";
|
||||||
|
|
||||||
const emailService = new EmailService(storage);
|
const emailService = new EmailService(storage);
|
||||||
const aiService = new AiService(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
|
// End of routes
|
||||||
return httpServer;
|
return httpServer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -524,3 +524,28 @@ export const encouragementLogs = pgTable("encouragement_logs", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type EncouragementLog = typeof encouragementLogs.$inferSelect;
|
export type EncouragementLog = typeof encouragementLogs.$inferSelect;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PUSH NOTIFICATIONS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Push Subscriptions - Store Web Push subscriptions for each user/device
|
||||||
|
export const pushSubscriptions = pgTable("push_subscriptions", {
|
||||||
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
userId: varchar("user_id").references(() => users.id).notNull(),
|
||||||
|
endpoint: text("endpoint").notNull().unique(),
|
||||||
|
p256dh: text("p256dh").notNull(), // Public key
|
||||||
|
auth: text("auth").notNull(), // Auth secret
|
||||||
|
userAgent: text("user_agent"), // To identify device
|
||||||
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
|
lastUsedAt: timestamp("last_used_at").defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const insertPushSubscriptionSchema = createInsertSchema(pushSubscriptions).omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastUsedAt: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InsertPushSubscription = z.infer<typeof insertPushSubscriptionSchema>;
|
||||||
|
export type PushSubscription = typeof pushSubscriptions.$inferSelect;
|
||||||
|
|||||||
Reference in New Issue
Block a user