From f00a1ce7bbdec8546735670b423a63f7932f6103 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Mon, 18 May 2026 17:15:05 -0600 Subject: [PATCH] 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 --- backend/.env.example | 3 - backend/helpers/webexBot.js | 64 ----- backend/migrations/add_notifications_table.js | 45 +++ backend/routes/notifications.js | 98 +++++++ backend/routes/webhooks.js | 48 +++- backend/server.js | 4 + frontend/src/App.js | 2 + frontend/src/components/NotificationBell.js | 271 ++++++++++++++++++ 8 files changed, 454 insertions(+), 81 deletions(-) delete mode 100644 backend/helpers/webexBot.js create mode 100644 backend/migrations/add_notifications_table.js create mode 100644 backend/routes/notifications.js create mode 100644 frontend/src/components/NotificationBell.js diff --git a/backend/.env.example b/backend/.env.example index a0dfc1e..3a8f7b6 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -80,6 +80,3 @@ GITLAB_PAT= # Generate with: openssl rand -hex 20 GITLAB_WEBHOOK_SECRET=changeme_generate_a_random_secret -# Webex Bot — used to DM users when their feedback issues are resolved. -# Create a bot at https://developer.webex.com/my-apps and use its access token. -WEBEX_BOT_TOKEN= diff --git a/backend/helpers/webexBot.js b/backend/helpers/webexBot.js deleted file mode 100644 index f695a19..0000000 --- a/backend/helpers/webexBot.js +++ /dev/null @@ -1,64 +0,0 @@ -// Webex Bot DM Helper -// Fire-and-forget pattern — logs success/failure but never throws. -// Used to notify users when their feedback issues are resolved. - -const https = require('https'); - -const WEBEX_API_URL = 'https://webexapis.com/v1/messages'; -const WEBEX_BOT_TOKEN = process.env.WEBEX_BOT_TOKEN || ''; - -/** - * Send a direct message to a user via Webex Teams bot. - * @param {string} email - Recipient's email address - * @param {string} markdownMessage - Message body (Webex markdown supported) - */ -function sendDirectMessage(email, markdownMessage) { - if (!WEBEX_BOT_TOKEN) { - console.warn('[WebexBot] WEBEX_BOT_TOKEN not configured — skipping DM'); - return; - } - - if (!email || !markdownMessage) { - console.warn('[WebexBot] Missing email or message — skipping DM'); - return; - } - - const postData = JSON.stringify({ - toPersonEmail: email, - markdown: markdownMessage, - }); - - const parsed = new URL(WEBEX_API_URL); - - const reqOpts = { - method: 'POST', - hostname: parsed.hostname, - path: parsed.pathname, - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${WEBEX_BOT_TOKEN}`, - 'Content-Length': Buffer.byteLength(postData), - }, - }; - - const req = https.request(reqOpts, (res) => { - let data = ''; - res.on('data', chunk => { data += chunk; }); - res.on('end', () => { - if (res.statusCode >= 200 && res.statusCode < 300) { - console.log(`[WebexBot] DM sent to ${email} (${res.statusCode})`); - } else { - console.error(`[WebexBot] Failed to DM ${email} — ${res.statusCode}: ${data}`); - } - }); - }); - - req.on('error', (err) => { - console.error(`[WebexBot] Request error sending DM to ${email}:`, err.message); - }); - - req.write(postData); - req.end(); -} - -module.exports = { sendDirectMessage }; diff --git a/backend/migrations/add_notifications_table.js b/backend/migrations/add_notifications_table.js new file mode 100644 index 0000000..ff10b1a --- /dev/null +++ b/backend/migrations/add_notifications_table.js @@ -0,0 +1,45 @@ +const pool = require('../db'); + +async function run() { + console.log('Starting notifications table migration...'); + try { + await pool.query(` + CREATE TABLE IF NOT EXISTS notifications ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + username TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'issue_resolved', + title TEXT NOT NULL, + message TEXT NOT NULL, + issue_number INTEGER, + read BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() + ) + `); + console.log('✓ notifications table created (or already exists)'); + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_notifications_username + ON notifications(username) + `); + console.log('✓ username index created'); + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_notifications_read + ON notifications(username, read) + `); + console.log('✓ username/read index created'); + + console.log('Migration complete.'); + } catch (err) { + console.error('Migration failed:', err.message); + throw err; + } +} + +module.exports = { run }; + +// Self-execute when run directly +if (require.main === module) { + run().then(() => process.exit(0)).catch(() => process.exit(1)); +} diff --git a/backend/routes/notifications.js b/backend/routes/notifications.js new file mode 100644 index 0000000..e558bf0 --- /dev/null +++ b/backend/routes/notifications.js @@ -0,0 +1,98 @@ +// Notifications route — in-app notification management for users +// Provides unread notifications, counts, and mark-as-read operations. + +const express = require('express'); +const pool = require('../db'); +const { requireAuth } = require('../middleware/auth'); + +function createNotificationsRouter() { + const router = express.Router(); + + // All routes require authentication + router.use(requireAuth()); + + /** + * GET /api/notifications + * Returns unread notifications for the current user, ordered by newest first. + * Limited to 50 results. + */ + router.get('/', async (req, res) => { + try { + const { rows } = await pool.query( + `SELECT id, type, title, message, issue_number, read, created_at + FROM notifications + WHERE username = $1 AND read = FALSE + ORDER BY created_at DESC + LIMIT 50`, + [req.user.username] + ); + res.json(rows); + } catch (err) { + console.error('[Notifications] Error fetching notifications:', err.message); + res.status(500).json({ error: 'Failed to fetch notifications' }); + } + }); + + /** + * GET /api/notifications/count + * Returns the unread notification count for the current user (for badge display). + */ + router.get('/count', async (req, res) => { + try { + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS unread + FROM notifications + WHERE username = $1 AND read = FALSE`, + [req.user.username] + ); + res.json({ unread: rows[0].unread }); + } catch (err) { + console.error('[Notifications] Error fetching count:', err.message); + res.status(500).json({ error: 'Failed to fetch notification count' }); + } + }); + + /** + * PATCH /api/notifications/:id/read + * Marks a single notification as read. Only the owning user can mark their own. + */ + router.patch('/:id/read', async (req, res) => { + const { id } = req.params; + try { + const result = await pool.query( + `UPDATE notifications SET read = TRUE + WHERE id = $1 AND username = $2`, + [id, req.user.username] + ); + if (result.rowCount === 0) { + return res.status(404).json({ error: 'Notification not found' }); + } + res.json({ status: 'ok' }); + } catch (err) { + console.error('[Notifications] Error marking read:', err.message); + res.status(500).json({ error: 'Failed to mark notification as read' }); + } + }); + + /** + * POST /api/notifications/read-all + * Marks all notifications as read for the current user. + */ + router.post('/read-all', async (req, res) => { + try { + const result = await pool.query( + `UPDATE notifications SET read = TRUE + WHERE username = $1 AND read = FALSE`, + [req.user.username] + ); + res.json({ status: 'ok', marked: result.rowCount }); + } catch (err) { + console.error('[Notifications] Error marking all read:', err.message); + res.status(500).json({ error: 'Failed to mark notifications as read' }); + } + }); + + return router; +} + +module.exports = createNotificationsRouter; diff --git a/backend/routes/webhooks.js b/backend/routes/webhooks.js index 11270b8..d4e56a4 100644 --- a/backend/routes/webhooks.js +++ b/backend/routes/webhooks.js @@ -1,16 +1,32 @@ // GitLab Webhook Routes — receives issue lifecycle events from GitLab -// Used to notify users via Webex when their feedback issues are closed. +// Used to create in-app notifications when feedback issues are closed. const express = require('express'); const pool = require('../db'); -const { sendDirectMessage } = require('../helpers/webexBot'); const GITLAB_WEBHOOK_SECRET = process.env.GITLAB_WEBHOOK_SECRET || ''; function createWebhooksRouter() { const router = express.Router(); - // POST /api/webhooks/gitlab — GitLab issue webhook receiver + /** + * POST /api/webhooks/gitlab + * + * Receives GitLab issue webhook events. When an issue is closed, parses the + * submitter username from the issue description and creates an in-app notification. + * + * Always returns HTTP 200 to prevent GitLab from retrying on app-level failures. + * + * @header {string} x-gitlab-token - Webhook secret token (must match GITLAB_WEBHOOK_SECRET env var) + * @body {object} object_attributes - GitLab issue event payload + * @body {string} object_attributes.action - The issue action (only 'close' is processed) + * @body {string} object_attributes.title - The issue title + * @body {number} object_attributes.iid - The issue number + * @body {string} object_attributes.description - The issue description (parsed for "**Submitted by:** username") + * @returns {object} 200 - { status: 'ok', notified: username } + * @returns {object} 200 - { status: 'ignored', reason: 'invalid token' | 'not a close event' | 'no submitter in description' | 'user not found' } + * @returns {object} 200 - { status: 'error', message: string } + */ router.post('/gitlab', express.json(), async (req, res) => { // Always return 200 — webhooks should not retry on app-level failures try { @@ -36,30 +52,34 @@ function createWebhooksRouter() { // Format: **Submitted by:** username const submitterMatch = description.match(/\*\*Submitted by:\*\*\s*(\S+)/); if (!submitterMatch) { - console.log('[Webhook] No submitter found in issue description — skipping DM'); + console.log('[Webhook] No submitter found in issue description — skipping notification'); return res.status(200).json({ status: 'ignored', reason: 'no submitter in description' }); } const username = submitterMatch[1]; - // Look up user email in database + // Verify user exists in database const { rows } = await pool.query( - 'SELECT email FROM users WHERE username = $1', + 'SELECT id FROM users WHERE username = $1', [username] ); - if (!rows || rows.length === 0 || !rows[0].email) { - console.log(`[Webhook] No email found for user "${username}" — skipping DM`); - return res.status(200).json({ status: 'ignored', reason: 'user email not found' }); + if (!rows || rows.length === 0) { + console.log(`[Webhook] No user found for "${username}" — skipping notification`); + return res.status(200).json({ status: 'ignored', reason: 'user not found' }); } - const email = rows[0].email; + const userId = rows[0].id; - // Send Webex DM notification - const message = `Hey! Your bug report **${issueTitle}** (Issue #${issueNumber}) has been resolved and deployed. — Patches O'Houlihan`; - sendDirectMessage(email, message); + // Insert in-app notification + const message = `Your bug report **${issueTitle}** (Issue #${issueNumber}) has been resolved and deployed.`; + await pool.query( + `INSERT INTO notifications (user_id, username, type, title, message, issue_number) + VALUES ($1, $2, 'issue_resolved', $3, $4, $5)`, + [userId, username, issueTitle, message, issueNumber] + ); - console.log(`[Webhook] Issue #${issueNumber} closed — notified ${username} (${email})`); + console.log(`[Webhook] Issue #${issueNumber} closed — notification created for ${username}`); return res.status(200).json({ status: 'ok', notified: username }); } catch (err) { console.error('[Webhook] Error processing GitLab webhook:', err.message); diff --git a/backend/server.js b/backend/server.js index 94613c9..a251bbc 100644 --- a/backend/server.js +++ b/backend/server.js @@ -34,6 +34,7 @@ const createJiraTicketsRouter = require('./routes/jiraTickets'); const createCardApiRouter = require('./routes/cardApi'); const createFeedbackRouter = require('./routes/feedback'); const createWebhooksRouter = require('./routes/webhooks'); +const createNotificationsRouter = require('./routes/notifications'); const app = express(); const PORT = process.env.PORT || 3001; @@ -231,6 +232,9 @@ app.use('/api/card', createCardApiRouter()); // Feedback routes — bug reports and feature requests to GitLab app.use('/api/feedback', createFeedbackRouter()); +// In-app notifications routes (authenticated users) +app.use('/api/notifications', createNotificationsRouter()); + // GitLab webhook routes — receives issue lifecycle events (no auth required) app.use('/api/webhooks', createWebhooksRouter()); diff --git a/frontend/src/App.js b/frontend/src/App.js index 181a5d6..22a3165 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -19,6 +19,7 @@ import JiraPage from './components/pages/JiraPage'; import AdminPage from './components/pages/AdminPage'; import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar'; import FeedbackModal from './components/FeedbackModal'; +import NotificationBell from './components/NotificationBell'; import './App.css'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; @@ -1051,6 +1052,7 @@ export default function App() { Bug + setShowUserManagement(true)} onAuditLog={() => setShowAuditLog(true)} onFeatureRequest={() => { setFeedbackType('feature'); setShowFeedback(true); }} /> diff --git a/frontend/src/components/NotificationBell.js b/frontend/src/components/NotificationBell.js new file mode 100644 index 0000000..63a6b4b --- /dev/null +++ b/frontend/src/components/NotificationBell.js @@ -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 + const renderMessage = (msg) => { + const parts = msg.split(/\*\*(.*?)\*\*/g); + return parts.map((part, i) => + i % 2 === 1 ? {part} : part + ); + }; + + return ( +
+ + + {open && ( +
+
+ Notifications + {notifications.length > 0 && ( + + )} +
+ + {notifications.length === 0 ? ( +
No unread notifications
+ ) : ( + notifications.map(n => ( +
markAsRead(n.id)} + onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.05)'; }} + onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }} + > +
{renderMessage(n.message)}
+
{formatTime(n.created_at)}
+
+ )) + )} +
+ )} +
+ ); +} + +export default NotificationBell;