Replace Webex bot with in-app notification system
Org blocks external Webex bots, so replaced the DM approach with an in-app notification bell. GitLab webhook still fires on issue close, but now writes to a notifications table instead of calling Webex API. - New: notifications table + migration - New: GET/PATCH/POST /api/notifications endpoints - New: NotificationBell component (bell icon + badge + dropdown) - Removed: backend/helpers/webexBot.js (org-blocked) - Removed: WEBEX_BOT_TOKEN from .env
This commit is contained in:
271
frontend/src/components/NotificationBell.js
Normal file
271
frontend/src/components/NotificationBell.js
Normal file
@@ -0,0 +1,271 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Bell } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
const bellButtonStyle = {
|
||||
position: 'relative',
|
||||
background: 'none',
|
||||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
color: '#94A3B8',
|
||||
transition: 'all 0.15s',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
};
|
||||
|
||||
const badgeStyle = {
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
right: '-4px',
|
||||
background: '#EF4444',
|
||||
color: '#fff',
|
||||
fontSize: '0.6rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontWeight: '700',
|
||||
minWidth: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 4px',
|
||||
lineHeight: 1,
|
||||
};
|
||||
|
||||
const dropdownStyle = {
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 8px)',
|
||||
right: 0,
|
||||
width: '360px',
|
||||
maxHeight: '420px',
|
||||
overflowY: 'auto',
|
||||
background: 'linear-gradient(135deg, #0F1A2E 0%, #1E293B 100%)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.7)',
|
||||
zIndex: 100,
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
};
|
||||
|
||||
const dropdownHeaderStyle = {
|
||||
padding: '0.75rem 1rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
const dropdownTitleStyle = {
|
||||
color: '#E2E8F0',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: '600',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
};
|
||||
|
||||
const markAllButtonStyle = {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#0EA5E9',
|
||||
fontSize: '0.7rem',
|
||||
cursor: 'pointer',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '0.25rem',
|
||||
transition: 'all 0.15s',
|
||||
};
|
||||
|
||||
const notificationItemStyle = {
|
||||
padding: '0.75rem 1rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s',
|
||||
};
|
||||
|
||||
const notificationMessageStyle = {
|
||||
color: '#CBD5E1',
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: '1.5',
|
||||
marginBottom: '0.35rem',
|
||||
};
|
||||
|
||||
const notificationTimeStyle = {
|
||||
color: '#64748B',
|
||||
fontSize: '0.65rem',
|
||||
};
|
||||
|
||||
const emptyStyle = {
|
||||
padding: '2rem 1rem',
|
||||
textAlign: 'center',
|
||||
color: '#64748B',
|
||||
fontSize: '0.75rem',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
function NotificationBell() {
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
const fetchCount = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/notifications/count`, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUnreadCount(data.unread);
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently ignore — polling will retry
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/notifications`, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNotifications(data);
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Poll for unread count every 60 seconds
|
||||
useEffect(() => {
|
||||
fetchCount();
|
||||
const interval = setInterval(fetchCount, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchCount]);
|
||||
|
||||
// Fetch full list when dropdown opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchNotifications();
|
||||
}
|
||||
}, [open, fetchNotifications]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
const markAsRead = async (id) => {
|
||||
try {
|
||||
await fetch(`${API_BASE}/notifications/${id}/read`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
});
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
} catch (err) {
|
||||
// Silently ignore
|
||||
}
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await fetch(`${API_BASE}/notifications/read-all`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
} catch (err) {
|
||||
// Silently ignore
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
// Render markdown bold (**text**) as <strong>
|
||||
const renderMessage = (msg) => {
|
||||
const parts = msg.split(/\*\*(.*?)\*\*/g);
|
||||
return parts.map((part, i) =>
|
||||
i % 2 === 1 ? <strong key={i} style={{ color: '#E2E8F0' }}>{part}</strong> : part
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<button
|
||||
style={bellButtonStyle}
|
||||
onClick={() => setOpen(prev => !prev)}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = '#0EA5E9'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.6)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.25)'; }}
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span style={badgeStyle}>{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={dropdownStyle}>
|
||||
<div style={dropdownHeaderStyle}>
|
||||
<span style={dropdownTitleStyle}>Notifications</span>
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
style={markAllButtonStyle}
|
||||
onClick={markAllRead}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.1)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'none'; }}
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<div style={emptyStyle}>No unread notifications</div>
|
||||
) : (
|
||||
notifications.map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
style={notificationItemStyle}
|
||||
onClick={() => markAsRead(n.id)}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.05)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={notificationMessageStyle}>{renderMessage(n.message)}</div>
|
||||
<div style={notificationTimeStyle}>{formatTime(n.created_at)}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationBell;
|
||||
Reference in New Issue
Block a user