frontend_nyla/src/hooks/useNotifications.ts
2026-03-29 12:18:56 +02:00

343 lines
9.4 KiB
TypeScript

/**
* 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<string, unknown>): 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<string, unknown>) : {})
);
}
// 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<UserNotification[]>([]);
const [unreadCount, setUnreadCount] = useState<number>(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Polling interval ref
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const prevUnreadCountRef = useRef<number | null>(null);
/**
* Fetch all notifications for the current user
*/
const fetchNotifications = useCallback(async (
options?: { status?: string; type?: string; limit?: number }
): Promise<UserNotification[]> => {
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<number> => {
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<boolean> => {
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<boolean> => {
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<NotificationActionResult | null> => {
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<boolean> => {
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;