343 lines
9.4 KiB
TypeScript
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;
|