Migrated from design-system-swarm with fresh git history.
Old project history preserved in /home/overbits/apps/design-system-swarm
Core components:
- MCP Server (Python FastAPI with mcp 1.23.1)
- Claude Plugin (agents, commands, skills, strategies, hooks, core)
- DSS Backend (dss-mvp1 - token translation, Figma sync)
- Admin UI (Node.js/React)
- Server (Node.js/Express)
- Storybook integration (dss-mvp1/.storybook)
Self-contained configuration:
- All paths relative or use DSS_BASE_PATH=/home/overbits/dss
- PYTHONPATH configured for dss-mvp1 and dss-claude-plugin
- .env file with all configuration
- Claude plugin uses ${CLAUDE_PLUGIN_ROOT} for portability
Migration completed: $(date)
🤖 Clean migration with full functionality preserved
299 lines
8.3 KiB
JavaScript
299 lines
8.3 KiB
JavaScript
/**
|
|
* @fileoverview Manages application-wide notifications.
|
|
* Handles persistence via IndexedDB, real-time updates via SSE, and state management.
|
|
*/
|
|
|
|
import dssDB from '../db/indexed-db.js';
|
|
|
|
const NOTIFICATION_STORE = 'notifications';
|
|
|
|
class NotificationService extends EventTarget {
|
|
constructor() {
|
|
super();
|
|
this.notifications = [];
|
|
this.unreadCount = 0;
|
|
this._eventSource = null;
|
|
this._initialized = false;
|
|
this._broadcastChannel = null;
|
|
}
|
|
|
|
async init() {
|
|
if (this._initialized) return;
|
|
this._initialized = true;
|
|
|
|
await this._loadFromStorage();
|
|
this._connectToEvents();
|
|
this._setupCrossTabSync();
|
|
}
|
|
|
|
/**
|
|
* Setup cross-tab synchronization via BroadcastChannel API
|
|
* When notifications are modified in another tab, reload them
|
|
*/
|
|
_setupCrossTabSync() {
|
|
try {
|
|
this._broadcastChannel = new BroadcastChannel('dss-notifications');
|
|
|
|
this._broadcastChannel.onmessage = (event) => {
|
|
if (event.data?.type === 'notifications-updated') {
|
|
console.log('[NotificationService] Notifications updated in another tab, reloading...');
|
|
this._loadFromStorage();
|
|
}
|
|
};
|
|
} catch (error) {
|
|
console.warn('[NotificationService] BroadcastChannel not available:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Notify other tabs about notification changes
|
|
* @private
|
|
*/
|
|
_notifyOtherTabs() {
|
|
if (this._broadcastChannel) {
|
|
try {
|
|
this._broadcastChannel.postMessage({ type: 'notifications-updated' });
|
|
} catch (error) {
|
|
console.warn('[NotificationService] Failed to broadcast update:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
async _loadFromStorage() {
|
|
try {
|
|
this.notifications = await dssDB.getAll(NOTIFICATION_STORE) || [];
|
|
this._sortNotifications();
|
|
this._updateUnreadCount();
|
|
this.dispatchEvent(new CustomEvent('notifications-updated', {
|
|
detail: { notifications: this.notifications }
|
|
}));
|
|
} catch (error) {
|
|
console.error('Failed to load notifications from storage:', error);
|
|
this.notifications = [];
|
|
}
|
|
}
|
|
|
|
_connectToEvents() {
|
|
// Only connect if SSE endpoint exists and we have an auth token
|
|
try {
|
|
// Get access token from localStorage
|
|
const authTokens = localStorage.getItem('auth_tokens');
|
|
if (!authTokens) {
|
|
console.log('[NotificationService] No auth token available, skipping SSE connection');
|
|
return;
|
|
}
|
|
|
|
const { accessToken } = JSON.parse(authTokens);
|
|
if (!accessToken) {
|
|
console.log('[NotificationService] No access token found, skipping SSE connection');
|
|
return;
|
|
}
|
|
|
|
// Construct SSE URL with token parameter (EventSource can't send custom headers)
|
|
const sseUrl = `/api/notifications/events?token=${encodeURIComponent(accessToken)}`;
|
|
console.log('[NotificationService] Connecting to SSE with authentication...');
|
|
|
|
this._eventSource = new EventSource(sseUrl);
|
|
|
|
this._eventSource.onmessage = (event) => {
|
|
try {
|
|
const notificationData = JSON.parse(event.data);
|
|
console.log('[NotificationService] SSE notification received:', notificationData.type);
|
|
// From SSE, persist to local storage
|
|
this.create(notificationData, true);
|
|
} catch (error) {
|
|
console.error('[NotificationService] Error parsing SSE notification:', error);
|
|
}
|
|
};
|
|
|
|
this._eventSource.onerror = (err) => {
|
|
console.warn('[NotificationService] SSE connection error, using local-only mode');
|
|
this._eventSource.close();
|
|
this._eventSource = null;
|
|
};
|
|
|
|
this._eventSource.onopen = () => {
|
|
console.log('[NotificationService] ✅ SSE connection established successfully');
|
|
};
|
|
} catch (error) {
|
|
console.warn('[NotificationService] SSE setup error:', error);
|
|
}
|
|
}
|
|
|
|
_sortNotifications() {
|
|
this.notifications.sort((a, b) => b.timestamp - a.timestamp);
|
|
}
|
|
|
|
_updateUnreadCount() {
|
|
const newCount = this.notifications.filter(n => !n.read).length;
|
|
if (newCount !== this.unreadCount) {
|
|
this.unreadCount = newCount;
|
|
this.dispatchEvent(new CustomEvent('unread-count-changed', {
|
|
detail: { count: this.unreadCount }
|
|
}));
|
|
}
|
|
}
|
|
|
|
_dispatchUpdate() {
|
|
this._sortNotifications();
|
|
this._updateUnreadCount();
|
|
this.dispatchEvent(new CustomEvent('notifications-updated', {
|
|
detail: { notifications: this.notifications }
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Creates a new notification.
|
|
* @param {object} notificationData - The notification data.
|
|
* @param {string} notificationData.title - Notification title
|
|
* @param {string} [notificationData.message] - Notification message
|
|
* @param {string} [notificationData.type='info'] - 'info', 'success', 'warning', 'error'
|
|
* @param {string} [notificationData.source] - Source service name
|
|
* @param {Array} [notificationData.actions] - Action buttons
|
|
* @param {boolean} [persist=true] - Whether to save to IndexedDB.
|
|
* @returns {Promise<object>} The created notification.
|
|
*/
|
|
async create(notificationData, persist = true) {
|
|
const newNotification = {
|
|
id: crypto.randomUUID(),
|
|
timestamp: Date.now(),
|
|
read: false,
|
|
type: 'info',
|
|
...notificationData,
|
|
};
|
|
|
|
this.notifications.unshift(newNotification);
|
|
|
|
if (persist) {
|
|
try {
|
|
await dssDB.put(NOTIFICATION_STORE, newNotification);
|
|
} catch (error) {
|
|
console.error('Failed to persist notification:', error);
|
|
}
|
|
}
|
|
|
|
this._dispatchUpdate();
|
|
this._notifyOtherTabs();
|
|
|
|
// Also show as toast for immediate visibility
|
|
if (typeof window.showToast === 'function') {
|
|
window.showToast({
|
|
message: `<strong>${newNotification.title}</strong>${newNotification.message ? `<br>${newNotification.message}` : ''}`,
|
|
type: newNotification.type,
|
|
duration: 5000,
|
|
dismissible: true
|
|
});
|
|
}
|
|
|
|
return newNotification;
|
|
}
|
|
|
|
/**
|
|
* Retrieves all notifications.
|
|
* @returns {object[]}
|
|
*/
|
|
getAll() {
|
|
return [...this.notifications];
|
|
}
|
|
|
|
/**
|
|
* Get unread count
|
|
* @returns {number}
|
|
*/
|
|
getUnreadCount() {
|
|
return this.unreadCount;
|
|
}
|
|
|
|
/**
|
|
* Marks a specific notification as read.
|
|
* @param {string} id - The ID of the notification to update.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async markAsRead(id) {
|
|
const notification = this.notifications.find(n => n.id === id);
|
|
if (notification && !notification.read) {
|
|
notification.read = true;
|
|
try {
|
|
await dssDB.put(NOTIFICATION_STORE, notification);
|
|
} catch (error) {
|
|
console.error('Failed to update notification:', error);
|
|
}
|
|
this._dispatchUpdate();
|
|
this._notifyOtherTabs();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Marks all unread notifications as read.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async markAllAsRead() {
|
|
const updates = [];
|
|
this.notifications.forEach(n => {
|
|
if (!n.read) {
|
|
n.read = true;
|
|
updates.push(dssDB.put(NOTIFICATION_STORE, n));
|
|
}
|
|
});
|
|
|
|
if (updates.length > 0) {
|
|
try {
|
|
await Promise.all(updates);
|
|
} catch (error) {
|
|
console.error('Failed to mark all as read:', error);
|
|
}
|
|
this._dispatchUpdate();
|
|
this._notifyOtherTabs();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deletes a notification by its ID.
|
|
* @param {string} id
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async delete(id) {
|
|
this.notifications = this.notifications.filter(n => n.id !== id);
|
|
try {
|
|
await dssDB.delete(NOTIFICATION_STORE, id);
|
|
} catch (error) {
|
|
console.error('Failed to delete notification:', error);
|
|
}
|
|
this._dispatchUpdate();
|
|
this._notifyOtherTabs();
|
|
}
|
|
|
|
/**
|
|
* Clears all notifications.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async clearAll() {
|
|
this.notifications = [];
|
|
try {
|
|
await dssDB.clear(NOTIFICATION_STORE);
|
|
} catch (error) {
|
|
console.error('Failed to clear notifications:', error);
|
|
}
|
|
this._dispatchUpdate();
|
|
this._notifyOtherTabs();
|
|
}
|
|
|
|
/**
|
|
* Cleanup on app shutdown
|
|
*/
|
|
destroy() {
|
|
if (this._eventSource) {
|
|
this._eventSource.close();
|
|
this._eventSource = null;
|
|
}
|
|
if (this._broadcastChannel) {
|
|
this._broadcastChannel.close();
|
|
this._broadcastChannel = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export a singleton instance
|
|
const notificationService = new NotificationService();
|
|
export default notificationService;
|