/** * useNotifications Hook * * Hook for managing in-app notifications. * Supports fetching, marking as read, and executing actions. */ import { useState, useCallback, useEffect, useRef } from 'react'; import api from '../api'; const _ACCESS_REF_TYPES = new Set(['mandate_access', 'feature_access']); /** API uses PowerOnModel.sysCreatedAt (seconds); legacy clients used createdAt. */ function _coerceToUnixSeconds(value: unknown): number | undefined { if (value == null) return undefined; if (typeof value === 'number' && Number.isFinite(value)) { return value > 1e12 ? value / 1000 : value; } if (typeof value === 'string') { const trimmed = value.trim(); if (!trimmed) return undefined; const asNum = Number(trimmed); if (!Number.isNaN(asNum)) { return asNum > 1e12 ? asNum / 1000 : asNum; } const parsed = Date.parse(trimmed); if (!Number.isNaN(parsed)) return parsed / 1000; } return undefined; } function _normalizeNotificationFromApi(raw: Record): UserNotification { const partial = raw as unknown as UserNotification; const createdAt = _coerceToUnixSeconds(raw.createdAt) ?? _coerceToUnixSeconds(raw.sysCreatedAt) ?? (Number.isFinite(partial.createdAt) ? partial.createdAt : 0) ?? 0; return { ...partial, createdAt, readAt: _coerceToUnixSeconds(raw.readAt) ?? partial.readAt, actionedAt: _coerceToUnixSeconds(raw.actionedAt) ?? partial.actionedAt, expiresAt: _coerceToUnixSeconds(raw.expiresAt) ?? partial.expiresAt, }; } function _normalizeNotificationList(data: unknown): UserNotification[] { if (!Array.isArray(data)) return []; return data.map(item => _normalizeNotificationFromApi(item && typeof item === 'object' ? (item as Record) : {}) ); } // Types export interface NotificationAction { actionId: string; label: string; style: 'primary' | 'danger' | 'default'; } export interface UserNotification { id: string; userId: string; type: 'invitation' | 'system' | 'workflow' | 'mention'; status: 'unread' | 'read' | 'actioned' | 'dismissed'; title: string; message: string; icon?: string; referenceType?: string; referenceId?: string; actions?: NotificationAction[]; actionTaken?: string; actionResult?: string; /** Creation time as Unix seconds (UTC); filled from API sysCreatedAt when needed */ createdAt: number; readAt?: number; actionedAt?: number; expiresAt?: number; } export interface NotificationActionResult { message: string; action: string; notificationId: string; } /** * Hook for managing notifications */ export function useNotifications() { const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Polling interval ref const pollingIntervalRef = useRef(null); const prevUnreadCountRef = useRef(null); /** * Fetch all notifications for the current user */ const fetchNotifications = useCallback(async ( options?: { status?: string; type?: string; limit?: number } ): Promise => { setLoading(true); setError(null); try { const params = new URLSearchParams(); if (options?.status) params.append('status', options.status); if (options?.type) params.append('type', options.type); if (options?.limit) params.append('limit', options.limit.toString()); const queryString = params.toString(); const url = `/api/notifications${queryString ? `?${queryString}` : ''}`; const response = await api.get(url); const data = _normalizeNotificationList(response.data); setNotifications(data); return data; } catch (err: any) { const errorMessage = err.response?.data?.detail || 'Fehler beim Laden der Benachrichtigungen'; setError(errorMessage); return []; } finally { setLoading(false); } }, []); /** * Fetch unread count */ const fetchUnreadCount = useCallback(async (): Promise => { try { const response = await api.get('/api/notifications/unread-count'); const count = response.data.count; const prev = prevUnreadCountRef.current; prevUnreadCountRef.current = count; if (prev !== null && count > prev) { try { const listRes = await api.get('/api/notifications', { params: { status: 'unread', limit: 25 }, }); const list = _normalizeNotificationList(listRes.data); if ( list.length > 0 && list.some(n => n.referenceType && _ACCESS_REF_TYPES.has(n.referenceType)) ) { window.dispatchEvent(new Event('features-changed')); } } catch { /* ignore */ } } setUnreadCount(count); return count; } catch (err: any) { console.error('Failed to fetch unread count:', err); return 0; } }, []); /** * Mark a notification as read */ const markAsRead = useCallback(async (notificationId: string): Promise => { try { await api.put(`/api/notifications/${notificationId}/read`); // Update local state setNotifications(prev => prev.map(n => n.id === notificationId ? { ...n, status: 'read' as const, readAt: Date.now() / 1000 } : n ) ); // Update unread count setUnreadCount(prev => Math.max(0, prev - 1)); return true; } catch (err: any) { console.error('Failed to mark notification as read:', err); return false; } }, []); /** * Mark all notifications as read */ const markAllAsRead = useCallback(async (): Promise => { try { await api.put('/api/notifications/mark-all-read'); // Update local state setNotifications(prev => prev.map(n => n.status === 'unread' ? { ...n, status: 'read' as const, readAt: Date.now() / 1000 } : n ) ); setUnreadCount(0); return true; } catch (err: any) { console.error('Failed to mark all notifications as read:', err); return false; } }, []); /** * Execute an action on a notification */ const executeAction = useCallback(async ( notificationId: string, actionId: string ): Promise => { setLoading(true); setError(null); try { const response = await api.post(`/api/notifications/${notificationId}/action`, { actionId }); const result = response.data as NotificationActionResult; // Update local state setNotifications(prev => prev.map(n => n.id === notificationId ? { ...n, status: 'actioned' as const, actionTaken: actionId, actionResult: result.message, actionedAt: Date.now() / 1000 } : n ) ); // Update unread count if it was unread const notification = notifications.find(n => n.id === notificationId); if (notification?.status === 'unread') { setUnreadCount(prev => Math.max(0, prev - 1)); } return result; } catch (err: any) { const errorMessage = err.response?.data?.detail || 'Fehler bei der Ausführung der Aktion'; setError(errorMessage); return null; } finally { setLoading(false); } }, [notifications]); /** * Dismiss/delete a notification */ const dismissNotification = useCallback(async (notificationId: string): Promise => { try { await api.delete(`/api/notifications/${notificationId}`); // Update local state const notification = notifications.find(n => n.id === notificationId); setNotifications(prev => prev.filter(n => n.id !== notificationId)); // Update unread count if it was unread if (notification?.status === 'unread') { setUnreadCount(prev => Math.max(0, prev - 1)); } return true; } catch (err: any) { console.error('Failed to dismiss notification:', err); return false; } }, [notifications]); /** * Start polling for new notifications */ const startPolling = useCallback((intervalMs: number = 30000) => { // Clear any existing interval if (pollingIntervalRef.current) { clearInterval(pollingIntervalRef.current); } // Fetch immediately fetchUnreadCount(); // Set up polling pollingIntervalRef.current = setInterval(() => { fetchUnreadCount(); }, intervalMs); }, [fetchUnreadCount]); /** * Stop polling */ const stopPolling = useCallback(() => { if (pollingIntervalRef.current) { clearInterval(pollingIntervalRef.current); pollingIntervalRef.current = null; } }, []); // Cleanup on unmount useEffect(() => { return () => { if (pollingIntervalRef.current) { clearInterval(pollingIntervalRef.current); } }; }, []); return { notifications, unreadCount, loading, error, fetchNotifications, fetchUnreadCount, markAsRead, markAllAsRead, executeAction, dismissNotification, startPolling, stopPolling }; } export default useNotifications;