Add screenshot uploads to feedback modal, Webex bot DM on issue close

- Feedback modal now supports up to 3 image attachments (PNG/JPG/GIF/WebP, 5MB
  each) with thumbnail previews. Images are uploaded to GitLab project uploads
  and embedded as markdown in the issue description.
- New webhook endpoint (POST /api/webhooks/gitlab) receives issue close events,
  parses the submitter from the description, looks up their email, and sends a
  Webex DM via the Patches O'Houlihan bot.
- New helper: backend/helpers/webexBot.js (fire-and-forget DM sender).
- Requires WEBEX_BOT_TOKEN and GITLAB_WEBHOOK_SECRET in backend/.env.
This commit is contained in:
Jordan Ramos
2026-05-18 16:54:00 -06:00
parent 520f50fbbf
commit 00bf92a2a1
6 changed files with 470 additions and 37 deletions

View File

@@ -1,11 +1,54 @@
// Feedback route — proxies bug reports and feature requests to GitLab Issues API
// Supports optional screenshot uploads (up to 3 images, 5MB each).
// Keeps the GitLab PAT server-side so it's never exposed to the browser.
const express = require('express');
const https = require('https');
const http = require('http');
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const { requireAuth } = require('../middleware/auth');
// Multer setup for screenshot uploads — same temp directory pattern as compliance
const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp');
const screenshotStorage = multer.diskStorage({
destination: (req, file, cb) => {
if (!fs.existsSync(TEMP_DIR)) {
fs.mkdirSync(TEMP_DIR, { recursive: true });
}
cb(null, TEMP_DIR);
},
filename: (req, file, cb) => {
const timestamp = Date.now();
const safeName = file.originalname
.replace(/\0/g, '')
.replace(/\.\./g, '')
.replace(/[\/\\]/g, '')
.replace(/[^a-zA-Z0-9._-]/g, '_')
.trim();
cb(null, `${timestamp}-${safeName}`);
},
});
const ALLOWED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const screenshotUpload = multer({
storage: screenshotStorage,
fileFilter: (req, file, cb) => {
if (!ALLOWED_IMAGE_TYPES.has(file.mimetype)) {
return cb(new Error(`File type '${file.mimetype}' not allowed. Only PNG, JPG, GIF, and WebP images are accepted.`));
}
cb(null, true);
},
limits: {
fileSize: MAX_FILE_SIZE,
files: 3,
},
});
function createFeedbackRouter() {
const router = express.Router();
@@ -13,44 +56,154 @@ function createFeedbackRouter() {
const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID || '';
const GITLAB_PAT = process.env.GITLAB_PAT || '';
router.post('/', requireAuth(), async (req, res) => {
if (!GITLAB_URL || !GITLAB_PROJECT_ID || !GITLAB_PAT) {
return res.status(503).json({ error: 'Feedback integration not configured' });
}
/**
* Upload a single file to GitLab's project uploads API.
* Returns { markdown, url } on success, null on failure.
*/
function uploadFileToGitlab(filePath, fileName) {
return new Promise((resolve) => {
const apiUrl = `${GITLAB_URL.replace(/\/$/, '')}/api/v4/projects/${encodeURIComponent(GITLAB_PROJECT_ID)}/uploads`;
const { type, title, description, page } = req.body;
try {
const parsed = new URL(apiUrl);
const transport = parsed.protocol === 'https:' ? https : http;
if (!type || !title || !description) {
return res.status(400).json({ error: 'type, title, and description are required' });
}
const boundary = `----FormBoundary${Date.now()}${Math.random().toString(36).slice(2)}`;
const fileContent = fs.readFileSync(filePath);
if (!['bug', 'feature'].includes(type)) {
return res.status(400).json({ error: 'type must be "bug" or "feature"' });
}
const header = Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` +
`Content-Type: application/octet-stream\r\n\r\n`
);
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
const body = Buffer.concat([header, fileContent, footer]);
const labels = type === 'bug' ? 'bug' : 'enhancement';
const prefix = type === 'bug' ? '🐛 Bug' : '✨ Feature Request';
const username = req.user?.username || 'unknown';
const reqOpts = {
method: 'POST',
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname + parsed.search,
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'PRIVATE-TOKEN': GITLAB_PAT,
'Content-Length': body.length,
},
rejectAuthorized: false,
};
const body = [
`**Submitted by:** ${username}`,
page ? `**Page:** ${page}` : null,
`**Type:** ${prefix}`,
'',
'---',
'',
description,
].filter(Boolean).join('\n');
const apiReq = transport.request(reqOpts, (apiRes) => {
let data = '';
apiRes.on('data', chunk => data += chunk);
apiRes.on('end', () => {
try {
const result = JSON.parse(data);
if (apiRes.statusCode === 201 && result.markdown) {
resolve(result);
} else {
console.error(`[Feedback] GitLab upload returned ${apiRes.statusCode}:`, data);
resolve(null);
}
} catch {
console.error('[Feedback] GitLab upload returned invalid JSON:', data);
resolve(null);
}
});
});
const postData = JSON.stringify({
title: `[${prefix}] ${title}`,
description: body,
labels,
apiReq.on('error', (err) => {
console.error('[Feedback] GitLab upload request error:', err.message);
resolve(null);
});
apiReq.write(body);
apiReq.end();
} catch (err) {
console.error('[Feedback] GitLab upload error:', err.message);
resolve(null);
}
});
}
const apiUrl = `${GITLAB_URL.replace(/\/$/, '')}/api/v4/projects/${encodeURIComponent(GITLAB_PROJECT_ID)}/issues`;
/**
* Clean up temp files after processing.
*/
function cleanupFiles(files) {
for (const file of files) {
try {
if (fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
} catch (err) {
console.error(`[Feedback] Failed to clean up temp file ${file.path}:`, err.message);
}
}
}
router.post('/', requireAuth(), screenshotUpload.array('screenshots', 3), async (req, res) => {
const uploadedFiles = req.files || [];
try {
if (!GITLAB_URL || !GITLAB_PROJECT_ID || !GITLAB_PAT) {
return res.status(503).json({ error: 'Feedback integration not configured' });
}
const { type, title, description, page } = req.body;
if (!type || !title || !description) {
return res.status(400).json({ error: 'type, title, and description are required' });
}
if (!['bug', 'feature'].includes(type)) {
return res.status(400).json({ error: 'type must be "bug" or "feature"' });
}
const labels = type === 'bug' ? 'bug' : 'enhancement';
const prefix = type === 'bug' ? '🐛 Bug' : '✨ Feature Request';
const username = req.user?.username || 'unknown';
// Upload screenshots to GitLab and collect markdown links
const imageMarkdowns = [];
for (const file of uploadedFiles) {
const result = await uploadFileToGitlab(file.path, file.originalname);
if (result && result.markdown) {
imageMarkdowns.push(result.markdown);
}
}
const bodyParts = [
`**Submitted by:** ${username}`,
page ? `**Page:** ${page}` : null,
`**Type:** ${prefix}`,
'',
'---',
'',
description,
].filter(Boolean);
// Append screenshot markdown at the bottom
if (imageMarkdowns.length > 0) {
bodyParts.push('');
bodyParts.push('---');
bodyParts.push('');
bodyParts.push('**Screenshots:**');
bodyParts.push('');
for (const md of imageMarkdowns) {
bodyParts.push(md);
bodyParts.push('');
}
}
const body = bodyParts.join('\n');
const postData = JSON.stringify({
title: `[${prefix}] ${title}`,
description: body,
labels,
});
const apiUrl = `${GITLAB_URL.replace(/\/$/, '')}/api/v4/projects/${encodeURIComponent(GITLAB_PROJECT_ID)}/issues`;
const result = await new Promise((resolve, reject) => {
const parsed = new URL(apiUrl);
const transport = parsed.protocol === 'https:' ? https : http;
@@ -102,6 +255,9 @@ function createFeedbackRouter() {
} catch (err) {
console.error('[Feedback] Request failed:', err.message);
res.status(502).json({ error: 'Failed to connect to GitLab' });
} finally {
// Always clean up temp files
cleanupFiles(uploadedFiles);
}
});