final all done 2nd last plan before goign live
This commit is contained in:
@@ -3,13 +3,22 @@
|
||||
* Manages notifications for AI task completions and system events
|
||||
*
|
||||
* Features:
|
||||
* - In-memory notification queue
|
||||
* - In-memory notification queue for optimistic UI
|
||||
* - API sync for persistent notifications
|
||||
* - Auto-dismissal with configurable timeout
|
||||
* - Read/unread state tracking
|
||||
* - Category-based filtering (ai_task, system, info)
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
fetchNotifications,
|
||||
fetchUnreadCount,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
deleteNotification as deleteNotificationAPI,
|
||||
type NotificationAPI,
|
||||
} from '../services/notifications.api';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
@@ -20,6 +29,7 @@ export type NotificationCategory = 'ai_task' | 'system' | 'info';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
apiId?: number; // Server ID for synced notifications
|
||||
type: NotificationType;
|
||||
category: NotificationCategory;
|
||||
title: string;
|
||||
@@ -39,6 +49,8 @@ export interface Notification {
|
||||
interface NotificationStore {
|
||||
notifications: Notification[];
|
||||
unreadCount: number;
|
||||
isLoading: boolean;
|
||||
lastFetched: Date | null;
|
||||
|
||||
// Actions
|
||||
addNotification: (notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) => void;
|
||||
@@ -54,17 +66,61 @@ interface NotificationStore {
|
||||
message: string,
|
||||
metadata?: Notification['metadata']
|
||||
) => void;
|
||||
|
||||
// API sync
|
||||
fetchNotifications: () => Promise<void>;
|
||||
syncUnreadCount: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
|
||||
const generateId = () => `notif_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
/**
|
||||
* Convert API notification to store format
|
||||
*/
|
||||
function apiToStoreNotification(api: NotificationAPI): Notification {
|
||||
// Map API notification_type to store category
|
||||
const categoryMap: Record<string, NotificationCategory> = {
|
||||
'ai_task': 'ai_task',
|
||||
'system': 'system',
|
||||
'credit': 'system',
|
||||
'billing': 'system',
|
||||
'integration': 'system',
|
||||
'content': 'ai_task',
|
||||
'info': 'info',
|
||||
};
|
||||
|
||||
return {
|
||||
id: `api_${api.id}`,
|
||||
apiId: api.id,
|
||||
type: api.severity as NotificationType,
|
||||
category: categoryMap[api.notification_type] || 'info',
|
||||
title: api.title,
|
||||
message: api.message,
|
||||
timestamp: new Date(api.created_at),
|
||||
read: api.is_read,
|
||||
actionLabel: api.action_label || undefined,
|
||||
actionHref: api.action_url || undefined,
|
||||
metadata: api.metadata ? {
|
||||
functionName: api.metadata.function_name as string | undefined,
|
||||
count: api.metadata.count as number | undefined,
|
||||
credits: api.metadata.credits as number | undefined,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STORE IMPLEMENTATION
|
||||
// ============================================================================
|
||||
|
||||
const generateId = () => `notif_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
export const useNotificationStore = create<NotificationStore>((set, get) => ({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
isLoading: false,
|
||||
lastFetched: null,
|
||||
|
||||
addNotification: (notification) => {
|
||||
const newNotification: Notification = {
|
||||
@@ -80,31 +136,60 @@ export const useNotificationStore = create<NotificationStore>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
|
||||
markAsRead: (id) => {
|
||||
markAsRead: async (id) => {
|
||||
const notification = get().notifications.find(n => n.id === id);
|
||||
|
||||
// Optimistic update
|
||||
set((state) => ({
|
||||
notifications: state.notifications.map((n) =>
|
||||
n.id === id ? { ...n, read: true } : n
|
||||
),
|
||||
unreadCount: Math.max(0, state.notifications.filter(n => !n.read && n.id !== id).length),
|
||||
}));
|
||||
|
||||
// Sync with API if this is a server notification
|
||||
if (notification?.apiId) {
|
||||
try {
|
||||
await markNotificationRead(notification.apiId);
|
||||
} catch (error) {
|
||||
console.error('Failed to mark notification as read:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
markAllAsRead: () => {
|
||||
markAllAsRead: async () => {
|
||||
// Optimistic update
|
||||
set((state) => ({
|
||||
notifications: state.notifications.map((n) => ({ ...n, read: true })),
|
||||
unreadCount: 0,
|
||||
}));
|
||||
|
||||
// Sync with API
|
||||
try {
|
||||
await markAllNotificationsRead();
|
||||
} catch (error) {
|
||||
console.error('Failed to mark all notifications as read:', error);
|
||||
}
|
||||
},
|
||||
|
||||
removeNotification: (id) => {
|
||||
set((state) => {
|
||||
const notification = state.notifications.find(n => n.id === id);
|
||||
const wasUnread = notification && !notification.read;
|
||||
return {
|
||||
notifications: state.notifications.filter((n) => n.id !== id),
|
||||
unreadCount: wasUnread ? Math.max(0, state.unreadCount - 1) : state.unreadCount,
|
||||
};
|
||||
});
|
||||
removeNotification: async (id) => {
|
||||
const notification = get().notifications.find(n => n.id === id);
|
||||
const wasUnread = notification && !notification.read;
|
||||
|
||||
// Optimistic update
|
||||
set((state) => ({
|
||||
notifications: state.notifications.filter((n) => n.id !== id),
|
||||
unreadCount: wasUnread ? Math.max(0, state.unreadCount - 1) : state.unreadCount,
|
||||
}));
|
||||
|
||||
// Sync with API if this is a server notification
|
||||
if (notification?.apiId) {
|
||||
try {
|
||||
await deleteNotificationAPI(notification.apiId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete notification:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
@@ -145,6 +230,43 @@ export const useNotificationStore = create<NotificationStore>((set, get) => ({
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
fetchNotifications: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const response = await fetchNotifications({ page_size: 50 });
|
||||
const apiNotifications = response.results.map(apiToStoreNotification);
|
||||
|
||||
// Merge API notifications with local (non-synced) notifications
|
||||
const localNotifications = get().notifications.filter(n => !n.apiId);
|
||||
const merged = [...localNotifications, ...apiNotifications]
|
||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
||||
.slice(0, 50);
|
||||
|
||||
const unread = merged.filter(n => !n.read).length;
|
||||
|
||||
set({
|
||||
notifications: merged,
|
||||
unreadCount: unread,
|
||||
lastFetched: new Date(),
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch notifications:', error);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
syncUnreadCount: async () => {
|
||||
try {
|
||||
const response = await fetchUnreadCount();
|
||||
// Only update if we have API notifications
|
||||
const localUnread = get().notifications.filter(n => !n.apiId && !n.read).length;
|
||||
set({ unreadCount: response.unread_count + localUnread });
|
||||
} catch (error) {
|
||||
console.error('Failed to sync unread count:', error);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user