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:
Jordan Ramos
2026-05-18 17:15:05 -06:00
parent 00bf92a2a1
commit f00a1ce7bb
8 changed files with 454 additions and 81 deletions

View File

@@ -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=

View File

@@ -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 };

View File

@@ -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));
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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());