337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
/**
|
|
* Notification Store
|
|
* Manages notifications for AI task completions and system events
|
|
*
|
|
* Features:
|
|
* - 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
|
|
// ============================================================================
|
|
|
|
export type NotificationType = 'success' | 'error' | 'warning' | 'info';
|
|
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;
|
|
message: string;
|
|
timestamp: Date;
|
|
read: boolean;
|
|
actionLabel?: string;
|
|
actionHref?: string;
|
|
metadata?: {
|
|
taskId?: string;
|
|
functionName?: string;
|
|
count?: number;
|
|
credits?: number;
|
|
};
|
|
}
|
|
|
|
interface NotificationStore {
|
|
notifications: Notification[];
|
|
unreadCount: number;
|
|
isLoading: boolean;
|
|
lastFetched: Date | null;
|
|
|
|
// Actions
|
|
addNotification: (notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) => void;
|
|
markAsRead: (id: string) => void;
|
|
markAllAsRead: () => void;
|
|
removeNotification: (id: string) => void;
|
|
clearAll: () => void;
|
|
|
|
// AI Task specific
|
|
addAITaskNotification: (
|
|
functionName: string,
|
|
success: boolean,
|
|
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
|
|
// All ai_* types map to 'ai_task', everything else to appropriate category
|
|
const getCategory = (type: string): NotificationCategory => {
|
|
if (type.startsWith('ai_')) return 'ai_task';
|
|
if (type.startsWith('content_') || type === 'keywords_imported') return 'ai_task';
|
|
if (type.startsWith('wordpress_') || type.startsWith('credits_') || type.startsWith('site_')) return 'system';
|
|
if (type === 'system_info' || type === 'system') return 'system';
|
|
// Legacy mappings
|
|
const legacyMap: Record<string, NotificationCategory> = {
|
|
'ai_task': 'ai_task',
|
|
'system': 'system',
|
|
'credit': 'system',
|
|
'billing': 'system',
|
|
'integration': 'system',
|
|
'content': 'ai_task',
|
|
'info': 'info',
|
|
};
|
|
return legacyMap[type] || 'info';
|
|
};
|
|
|
|
return {
|
|
id: `api_${api.id}`,
|
|
apiId: api.id,
|
|
type: api.severity as NotificationType,
|
|
category: getCategory(api.notification_type),
|
|
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
|
|
// ============================================================================
|
|
|
|
export const useNotificationStore = create<NotificationStore>((set, get) => ({
|
|
notifications: [],
|
|
unreadCount: 0,
|
|
isLoading: false,
|
|
lastFetched: null,
|
|
|
|
addNotification: (notification) => {
|
|
const newNotification: Notification = {
|
|
...notification,
|
|
id: generateId(),
|
|
timestamp: new Date(),
|
|
read: false,
|
|
};
|
|
|
|
set((state) => ({
|
|
notifications: [newNotification, ...state.notifications].slice(0, 50), // Keep last 50
|
|
unreadCount: state.unreadCount + 1,
|
|
}));
|
|
},
|
|
|
|
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: 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: 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: () => {
|
|
set({ notifications: [], unreadCount: 0 });
|
|
},
|
|
|
|
addAITaskNotification: (functionName, success, message, metadata) => {
|
|
const displayNames: Record<string, string> = {
|
|
'auto_cluster': 'Keyword Clustering',
|
|
'generate_ideas': 'Idea Generation',
|
|
'generate_content': 'Content Generation',
|
|
'generate_images': 'Image Generation',
|
|
'generate_image_prompts': 'Image Prompts',
|
|
'optimize_content': 'Content Optimization',
|
|
};
|
|
|
|
const actionHrefs: Record<string, string> = {
|
|
'auto_cluster': '/planner/clusters',
|
|
'generate_ideas': '/planner/ideas',
|
|
'generate_content': '/writer/content',
|
|
'generate_images': '/writer/images',
|
|
'generate_image_prompts': '/writer/images',
|
|
'optimize_content': '/writer/content',
|
|
};
|
|
|
|
const title = displayNames[functionName] || functionName.replace(/_/g, ' ');
|
|
|
|
get().addNotification({
|
|
type: success ? 'success' : 'error',
|
|
category: 'ai_task',
|
|
title: success ? `${title} Complete` : `${title} Failed`,
|
|
message,
|
|
actionLabel: success ? 'View Results' : 'Retry',
|
|
actionHref: actionHrefs[functionName] || '/dashboard',
|
|
metadata: {
|
|
...metadata,
|
|
functionName,
|
|
},
|
|
});
|
|
},
|
|
|
|
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);
|
|
}
|
|
},
|
|
}));
|
|
|
|
// ============================================================================
|
|
// HELPER FUNCTIONS
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Format notification timestamp as relative time
|
|
*/
|
|
export function formatNotificationTime(timestamp: Date): string {
|
|
const now = new Date();
|
|
const diff = now.getTime() - timestamp.getTime();
|
|
|
|
const minutes = Math.floor(diff / 60000);
|
|
const hours = Math.floor(diff / 3600000);
|
|
const days = Math.floor(diff / 86400000);
|
|
|
|
if (minutes < 1) return 'Just now';
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
if (hours < 24) return `${hours}h ago`;
|
|
if (days < 7) return `${days}d ago`;
|
|
|
|
return timestamp.toLocaleDateString();
|
|
}
|
|
|
|
/**
|
|
* Get icon color classes for notification type
|
|
*/
|
|
export function getNotificationColors(type: NotificationType): {
|
|
bg: string;
|
|
icon: string;
|
|
border: string;
|
|
} {
|
|
const colors = {
|
|
success: {
|
|
bg: 'bg-success-50 dark:bg-success-900/20',
|
|
icon: 'text-success-500',
|
|
border: 'border-success-200 dark:border-success-800',
|
|
},
|
|
error: {
|
|
bg: 'bg-error-50 dark:bg-error-900/20',
|
|
icon: 'text-error-500',
|
|
border: 'border-error-200 dark:border-error-800',
|
|
},
|
|
warning: {
|
|
bg: 'bg-warning-50 dark:bg-warning-900/20',
|
|
icon: 'text-warning-500',
|
|
border: 'border-warning-200 dark:border-warning-800',
|
|
},
|
|
info: {
|
|
bg: 'bg-brand-50 dark:bg-brand-900/20',
|
|
icon: 'text-brand-500',
|
|
border: 'border-brand-200 dark:border-brand-800',
|
|
},
|
|
};
|
|
|
|
return colors[type];
|
|
}
|