/ frontend / src / services / notification.ts
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  }