notification.ts
1 /** 2 * Notification service - handles in-app and push notifications 3 */ 4 5 import type { 6 Notification, 7 NotificationType, 8 NotificationPriority, 9 NotificationPreferences, 10 NotificationSummary, 11 NotificationGroup, 12 PushSubscription, 13 } from '../types/notification' 14 15 const STORAGE_KEY_PREFERENCES = 'forge_notification_prefs' 16 const STORAGE_KEY_READ = 'forge_notification_read' 17 18 /** 19 * Get notifications for a user 20 */ 21 export async function getNotifications( 22 address: string, 23 options?: { 24 unreadOnly?: boolean 25 limit?: number 26 offset?: number 27 types?: NotificationType[] 28 } 29 ): Promise<Notification[]> { 30 try { 31 const params = new URLSearchParams({ address }) 32 if (options?.unreadOnly) params.set('unread', 'true') 33 if (options?.limit) params.set('limit', String(options.limit)) 34 if (options?.offset) params.set('offset', String(options.offset)) 35 if (options?.types) params.set('types', options.types.join(',')) 36 37 const res = await fetch(`/api/notifications?${params}`) 38 if (!res.ok) return [] 39 40 const notifications = await res.json() 41 42 // Apply local read state 43 const readIds = getLocalReadState() 44 return notifications.map((n: Notification) => ({ 45 ...n, 46 read: n.read || readIds.has(n.id), 47 })) 48 } catch { 49 return [] 50 } 51 } 52 53 /** 54 * Get notification summary (counts) 55 */ 56 export async function getNotificationSummary( 57 address: string 58 ): Promise<NotificationSummary> { 59 try { 60 const res = await fetch(`/api/notifications/summary?address=${address}`) 61 if (!res.ok) { 62 return { unreadCount: 0, urgentCount: 0, latestAt: null } 63 } 64 return res.json() 65 } catch { 66 return { unreadCount: 0, urgentCount: 0, latestAt: null } 67 } 68 } 69 70 /** 71 * Mark notification as read 72 */ 73 export async function markAsRead(notificationId: string): Promise<void> { 74 // Update local state immediately 75 const readIds = getLocalReadState() 76 readIds.add(notificationId) 77 saveLocalReadState(readIds) 78 79 // Sync to server 80 try { 81 await fetch(`/api/notifications/${notificationId}/read`, { 82 method: 'POST', 83 }) 84 } catch { 85 // Local state is already updated 86 } 87 } 88 89 /** 90 * Mark all notifications as read 91 */ 92 export async function markAllAsRead(address: string): Promise<void> { 93 try { 94 await fetch(`/api/notifications/read-all`, { 95 method: 'POST', 96 headers: { 'Content-Type': 'application/json' }, 97 body: JSON.stringify({ address }), 98 }) 99 100 // Clear local read state since all are now read on server 101 saveLocalReadState(new Set()) 102 } catch { 103 // Silent fail 104 } 105 } 106 107 /** 108 * Get notification preferences 109 */ 110 export function getPreferences(): NotificationPreferences { 111 try { 112 const stored = localStorage.getItem(STORAGE_KEY_PREFERENCES) 113 if (stored) { 114 return JSON.parse(stored) 115 } 116 } catch { 117 // Use defaults 118 } 119 120 return { 121 enabledTypes: [ 122 'vote_started', 123 'vote_ending', 124 'vote_passed', 125 'vote_failed', 126 'pr_sponsored', 127 'pr_merged', 128 'comment_reply', 129 'mention', 130 'emergency_action', 131 'stake_warning', 132 'missed_vote', 133 ], 134 inApp: true, 135 email: false, 136 push: true, 137 quietHoursEnabled: false, 138 quietHoursStart: '22:00', 139 quietHoursEnd: '08:00', 140 chains: ['alpha', 'delta'], 141 } 142 } 143 144 /** 145 * Save notification preferences 146 */ 147 export function savePreferences(prefs: NotificationPreferences): void { 148 localStorage.setItem(STORAGE_KEY_PREFERENCES, JSON.stringify(prefs)) 149 150 // Sync to server for cross-device consistency 151 syncPreferencesToServer(prefs).catch(() => { 152 // Silent fail - local storage is primary 153 }) 154 } 155 156 /** 157 * Sync preferences to server 158 */ 159 async function syncPreferencesToServer( 160 prefs: NotificationPreferences 161 ): Promise<void> { 162 await fetch('/api/notifications/preferences', { 163 method: 'PUT', 164 headers: { 'Content-Type': 'application/json' }, 165 body: JSON.stringify(prefs), 166 }) 167 } 168 169 /** 170 * Register push subscription 171 */ 172 export async function registerPushSubscription( 173 subscription: PushSubscription 174 ): Promise<boolean> { 175 try { 176 const res = await fetch('/api/notifications/push/register', { 177 method: 'POST', 178 headers: { 'Content-Type': 'application/json' }, 179 body: JSON.stringify(subscription), 180 }) 181 return res.ok 182 } catch { 183 return false 184 } 185 } 186 187 /** 188 * Unregister push subscription 189 */ 190 export async function unregisterPushSubscription( 191 deviceToken: string 192 ): Promise<void> { 193 try { 194 await fetch('/api/notifications/push/unregister', { 195 method: 'POST', 196 headers: { 'Content-Type': 'application/json' }, 197 body: JSON.stringify({ deviceToken }), 198 }) 199 } catch { 200 // Silent fail 201 } 202 } 203 204 /** 205 * Group notifications by date 206 */ 207 export function groupNotificationsByDate( 208 notifications: Notification[] 209 ): NotificationGroup[] { 210 const groups = new Map<string, Notification[]>() 211 const today = new Date() 212 const yesterday = new Date(today) 213 yesterday.setDate(yesterday.getDate() - 1) 214 215 for (const notification of notifications) { 216 const date = new Date(notification.createdAt * 1000) 217 let label: string 218 219 if (isSameDay(date, today)) { 220 label = 'Today' 221 } else if (isSameDay(date, yesterday)) { 222 label = 'Yesterday' 223 } else { 224 label = date.toLocaleDateString() 225 } 226 227 const existing = groups.get(label) || [] 228 existing.push(notification) 229 groups.set(label, existing) 230 } 231 232 return Array.from(groups.entries()).map(([date, notifications]) => ({ 233 date, 234 notifications, 235 })) 236 } 237 238 /** 239 * Get notification icon based on type 240 */ 241 export function getNotificationIcon(type: NotificationType): string { 242 const icons: Record<NotificationType, string> = { 243 vote_started: 'vote', 244 vote_ending: 'clock', 245 vote_passed: 'check', 246 vote_failed: 'x', 247 pr_sponsored: 'star', 248 pr_merged: 'merge', 249 comment_reply: 'message', 250 mention: 'at', 251 emergency_action: 'alert', 252 governor_registered: 'user-plus', 253 stake_warning: 'alert-triangle', 254 missed_vote: 'alert-circle', 255 } 256 return icons[type] 257 } 258 259 /** 260 * Get notification color based on priority 261 */ 262 export function getNotificationColor(priority: NotificationPriority): string { 263 const colors: Record<NotificationPriority, string> = { 264 low: 'gray', 265 medium: 'blue', 266 high: 'orange', 267 urgent: 'red', 268 } 269 return colors[priority] 270 } 271 272 /** 273 * Format notification time 274 */ 275 export function formatNotificationTime(timestamp: number): string { 276 const now = Date.now() / 1000 277 const diff = now - timestamp 278 279 if (diff < 60) return 'Just now' 280 if (diff < 3600) return `${Math.floor(diff / 60)}m ago` 281 if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` 282 if (diff < 604800) return `${Math.floor(diff / 86400)}d ago` 283 284 return new Date(timestamp * 1000).toLocaleDateString() 285 } 286 287 /** 288 * Check if notification should be shown (respects quiet hours) 289 */ 290 export function shouldShowNotification( 291 notification: Notification, 292 prefs: NotificationPreferences 293 ): boolean { 294 // Check if type is enabled 295 if (!prefs.enabledTypes.includes(notification.type)) { 296 return false 297 } 298 299 // Check chain filter 300 if (!prefs.chains.includes(notification.chain)) { 301 return false 302 } 303 304 // Check quiet hours 305 if (prefs.quietHoursEnabled) { 306 const now = new Date() 307 const hours = now.getHours() 308 const minutes = now.getMinutes() 309 const currentTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}` 310 311 const start = prefs.quietHoursStart 312 const end = prefs.quietHoursEnd 313 314 // Handle overnight quiet hours (e.g., 22:00 - 08:00) 315 if (start > end) { 316 if (currentTime >= start || currentTime < end) { 317 // Urgent notifications bypass quiet hours 318 return notification.priority === 'urgent' 319 } 320 } else { 321 if (currentTime >= start && currentTime < end) { 322 return notification.priority === 'urgent' 323 } 324 } 325 } 326 327 return true 328 } 329 330 // Helper functions 331 function isSameDay(d1: Date, d2: Date): boolean { 332 return ( 333 d1.getFullYear() === d2.getFullYear() && 334 d1.getMonth() === d2.getMonth() && 335 d1.getDate() === d2.getDate() 336 ) 337 } 338 339 function getLocalReadState(): Set<string> { 340 try { 341 const stored = localStorage.getItem(STORAGE_KEY_READ) 342 if (stored) { 343 return new Set(JSON.parse(stored)) 344 } 345 } catch { 346 // Return empty set 347 } 348 return new Set() 349 } 350 351 function saveLocalReadState(readIds: Set<string>): void { 352 // Keep only last 1000 IDs to prevent storage bloat 353 const ids = Array.from(readIds).slice(-1000) 354 localStorage.setItem(STORAGE_KEY_READ, JSON.stringify(ids)) 355 } 356 357 /** 358 * Create a WebSocket connection for real-time notifications 359 */ 360 export function createNotificationSocket( 361 address: string, 362 onNotification: (notification: Notification) => void 363 ): WebSocket | null { 364 try { 365 const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' 366 const ws = new WebSocket(`${protocol}//${window.location.host}/api/notifications/ws?address=${address}`) 367 368 ws.onmessage = (event) => { 369 try { 370 const notification = JSON.parse(event.data) as Notification 371 onNotification(notification) 372 } catch { 373 // Invalid message 374 } 375 } 376 377 ws.onerror = () => { 378 // Silent fail - will reconnect 379 } 380 381 return ws 382 } catch { 383 return null 384 } 385 }