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,5 +1,5 @@
import React, { useState } from 'react';
import { X, Bug, Lightbulb, Send, Loader, CheckCircle, AlertCircle } from 'lucide-react';
import React, { useState, useRef } from 'react';
import { X, Bug, Lightbulb, Send, Loader, CheckCircle, AlertCircle, Image, Trash2 } from 'lucide-react';
// ⚠️ CONVENTION: Use relative API path from REACT_APP_API_BASE only — the hardcoded fallback 'http://localhost:3001/api' is an absolute URL. Other components use just the env var without an absolute fallback.
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
@@ -124,19 +124,102 @@ function TypeSelector({ value, onChange }) {
);
}
// ---------------------------------------------------------------------------
// Screenshot thumbnails
// ---------------------------------------------------------------------------
function ScreenshotPreviews({ files, onRemove }) {
if (files.length === 0) return null;
return (
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.5rem' }}>
{files.map((file, idx) => (
<div
key={idx}
style={{
position: 'relative',
width: '64px', height: '64px',
borderRadius: '0.375rem',
overflow: 'hidden',
border: '1px solid rgba(14,165,233,0.2)',
background: 'rgba(14,165,233,0.06)',
}}
>
<img
src={URL.createObjectURL(file)}
alt={file.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
<button
type="button"
onClick={() => onRemove(idx)}
style={{
position: 'absolute', top: '2px', right: '2px',
width: '18px', height: '18px',
borderRadius: '50%',
background: 'rgba(239,68,68,0.9)',
border: 'none',
cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 0,
}}
title="Remove screenshot"
>
<Trash2 style={{ width: 10, height: 10, color: '#fff' }} />
</button>
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Main modal component
// ---------------------------------------------------------------------------
const MAX_FILES = 3;
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_TYPES = 'image/png,image/jpeg,image/gif,image/webp';
export default function FeedbackModal({ isOpen, onClose, defaultType, currentPage }) {
const [type, setType] = useState(defaultType || 'bug');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [screenshots, setScreenshots] = useState([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
const fileInputRef = useRef(null);
if (!isOpen) return null;
const handleFileChange = (e) => {
const newFiles = Array.from(e.target.files || []);
setError(null);
// Validate file count
const totalFiles = screenshots.length + newFiles.length;
if (totalFiles > MAX_FILES) {
setError(`Maximum ${MAX_FILES} screenshots allowed`);
e.target.value = '';
return;
}
// Validate file sizes
for (const file of newFiles) {
if (file.size > MAX_FILE_SIZE) {
setError(`"${file.name}" exceeds 5MB limit`);
e.target.value = '';
return;
}
}
setScreenshots(prev => [...prev, ...newFiles]);
e.target.value = '';
};
const handleRemoveScreenshot = (idx) => {
setScreenshots(prev => prev.filter((_, i) => i !== idx));
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!title.trim() || !description.trim()) {
@@ -147,16 +230,21 @@ export default function FeedbackModal({ isOpen, onClose, defaultType, currentPag
setSubmitting(true);
setError(null);
try {
const formData = new FormData();
formData.append('type', type);
formData.append('title', title.trim());
formData.append('description', description.trim());
if (currentPage) {
formData.append('page', currentPage);
}
for (const file of screenshots) {
formData.append('screenshots', file);
}
const res = await fetch(`${API_BASE}/feedback`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type,
title: title.trim(),
description: description.trim(),
page: currentPage || null,
}),
body: formData,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Submission failed (${res.status})`);
@@ -164,6 +252,7 @@ export default function FeedbackModal({ isOpen, onClose, defaultType, currentPag
setSuccess(data.issue);
setTitle('');
setDescription('');
setScreenshots([]);
} catch (err) {
setError(err.message);
} finally {
@@ -176,6 +265,7 @@ export default function FeedbackModal({ isOpen, onClose, defaultType, currentPag
setSuccess(null);
setTitle('');
setDescription('');
setScreenshots([]);
onClose();
};
@@ -293,6 +383,43 @@ export default function FeedbackModal({ isOpen, onClose, defaultType, currentPag
/>
</div>
{/* Screenshots */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Screenshots (optional, max 3)</label>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_TYPES}
multiple
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={screenshots.length >= MAX_FILES}
style={{
...btnStyle,
background: 'rgba(14,165,233,0.06)',
border: '1px solid rgba(14,165,233,0.2)',
color: screenshots.length >= MAX_FILES ? '#475569' : '#94A3B8',
cursor: screenshots.length >= MAX_FILES ? 'not-allowed' : 'pointer',
padding: '0.5rem 0.75rem',
fontSize: '0.72rem',
}}
>
<Image style={{ width: 14, height: 14 }} />
{screenshots.length >= MAX_FILES ? 'Max reached' : 'Attach images'}
</button>
<span style={{
fontSize: '0.65rem', color: '#475569', marginLeft: '0.5rem',
fontFamily: "'JetBrains Mono', monospace",
}}>
PNG, JPG, GIF, WebP 5MB each
</span>
<ScreenshotPreviews files={screenshots} onRemove={handleRemoveScreenshot} />
</div>
{/* Current page indicator */}
{currentPage && (
<div style={{