Repurpose Communications tab as admin-only email-activity panel (v0.1.0:80)

The Communications tab is now an admin-only search over captured Gmail
(email_* tables), part of consolidating on the fundraising grid + email
capture as the canonical system of record.

- New GET /api/email/activity (admin-enforced server-side): filter by
  investor / mailbox / direction with free-text search over subject,
  snippet, and sender. Query logic in db.query_email_activity.
  - Soft-delete honored on the per-mailbox sighting (emails carry no
    deleted_at; deletion lives on email_account_messages).
  - Direction decided at the email level (outbound if the sender is one of
    our mailboxes), mirroring digest_builder.
  - Graveyard investors are hidden from the filter dropdown (CRM-wide
    graveyard=0 convention) but their email stays visible in the list and
    findable by free-text search — this is an audit surface.
- Communications page rewritten to render the panel; the classic manual
  "Log Communication" form is retired (the grid context menu remains the
  manual-log path). Nav item + page are admin-only.
- Tests: email_integration/test_email_activity_panel.py (filters,
  per-sighting soft-delete, roll-ups, graveyard handling, route 401/403);
  full suite 22/22. Frontend render verified via a jsdom mount smoke test
  plus the pinned classic-runtime Babel transform.

Code-only, no schema migration (version migrations are no-ops).
This commit is contained in:
Keysat
2026-06-16 14:49:59 -05:00
parent f9705d2216
commit 42d2b4b245
8 changed files with 494 additions and 291 deletions
+120 -282
View File
@@ -4253,318 +4253,154 @@
);
};
const CommunicationsPage = ({ token, onShowToast }) => {
const [communications, setCommunications] = useState([]);
const [contacts, setContacts] = useState([]);
const [investorNames, setInvestorNames] = useState([]);
const CommunicationsPage = ({ token, user, onShowToast }) => {
// Repurposed (v0.1.0:80): the Communications tab is now the admin-only
// email-activity panel over the captured email_* tables. The classic
// manual "Log Communication" surface was retired (grid is canonical).
const isAdmin = user?.role === 'admin';
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [type, setType] = useState('');
const [disabled, setDisabled] = useState(false);
const [error, setError] = useState('');
const [investorId, setInvestorId] = useState('');
const [accountId, setAccountId] = useState('');
const [direction, setDirection] = useState('');
const [search, setSearch] = useState('');
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState({ type: 'email', contact_id: '', investor_selection: '', investor_name_new: '', append_note: true });
const [quickMapSearch, setQuickMapSearch] = useState('');
const [formError, setFormError] = useState('');
const [confirmDelete, setConfirmDelete] = useState(null);
const NEW_INVESTOR_VALUE = '__new_investor__';
const [debouncedSearch, setDebouncedSearch] = useState('');
const rankedContacts = useMemo(() => {
const q = String(quickMapSearch || '').trim();
if (!q) return contacts;
return contacts
.map((c, idx) => {
const label = `${contactName(c)} ${c.organization_name || c.organization || ''}`.trim();
const score = fuzzyScore(q, label);
return { c, idx, score };
})
.filter((x) => x.score >= 0.45)
.sort((a, b) => (b.score - a.score) || (a.idx - b.idx))
.map((x) => x.c);
}, [contacts, quickMapSearch]);
const rankedInvestors = useMemo(() => {
const q = String(quickMapSearch || '').trim();
if (!q) return investorNames;
return investorNames
.map((name, idx) => ({ name, idx, score: fuzzyScore(q, name) }))
.filter((x) => x.score >= 0.45)
.sort((a, b) => (b.score - a.score) || (a.idx - b.idx))
.map((x) => x.name);
}, [investorNames, quickMapSearch]);
// Debounce the free-text box so each keystroke doesn't hit the server.
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => clearTimeout(t);
}, [search]);
useEffect(() => {
const fetchComms = async () => {
if (!isAdmin) { setLoading(false); return undefined; }
let cancelled = false;
(async () => {
try {
setLoading(true);
const [commResult, contactResult, fundraisingResult] = await Promise.all([
api(`/api/communications?type=${type}&search=${search}&limit=200`, {}, token),
api('/api/contacts?limit=1000', {}, token),
api('/api/fundraising/state', {}, token)
]);
setCommunications(commResult.data || []);
setContacts(contactResult.data || []);
const names = Array.from(new Set(
((fundraisingResult?.data?.grid?.rows || [])
.map((r) => String(r?.investor_name || '').trim())
.filter(Boolean))
)).sort((a, b) => a.localeCompare(b));
setInvestorNames(names);
const params = new URLSearchParams();
if (investorId) params.set('investor_id', investorId);
if (accountId) params.set('account_id', accountId);
if (direction) params.set('direction', direction);
if (debouncedSearch) params.set('q', debouncedSearch);
params.set('limit', '200');
const res = await api(`/api/email/activity?${params.toString()}`, {}, token);
if (cancelled) return;
setData(res);
setDisabled(false);
setError('');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to load communications'), 'error');
if (cancelled) return;
// Integration off on the server -> show the disabled state, not an error.
if (err?.status === 503 || /disabl/i.test(err?.payload?.error || '')) {
setDisabled(true);
setData(null);
} else {
setError(getErrorMessage(err, 'Failed to load email activity'));
}
} finally {
setLoading(false);
if (!cancelled) setLoading(false);
}
};
fetchComms();
}, [token, type, search, onShowToast]);
})();
return () => { cancelled = true; };
}, [token, isAdmin, investorId, accountId, direction, debouncedSearch]);
const handleAddComm = async (e) => {
e.preventDefault();
setFormError('');
try {
const selected = contacts.find((c) => c.id === formData.contact_id);
if (!selected) {
setFormError('Contact is required');
return;
}
const selectedInvestor = String(formData.investor_selection || '').trim();
const newInvestorInput = String(formData.investor_name_new || '').trim();
const investorName = selectedInvestor === NEW_INVESTOR_VALUE ? newInvestorInput : selectedInvestor;
if (!investorName) {
setFormError('Investor mapping is required. Select an investor or create a new one.');
return;
}
const fullName = contactName(selected || {});
await api('/api/fundraising/log-communication', {
method: 'POST',
body: JSON.stringify({
investor_name: investorName,
create_investor_if_missing: true,
contact: {
name: fullName,
email: selected?.email || '',
title: selected?.title || ''
},
type: formData.type || 'note',
subject: formData.subject || '',
body: formData.body || '',
outcome: formData.outcome || '',
next_action: formData.next_action || '',
next_action_date: formData.next_action_date || '',
append_note: !!formData.append_note
})
}, token);
setShowForm(false);
setFormData({ type: 'email', contact_id: '', investor_selection: '', investor_name_new: '', append_note: true });
setQuickMapSearch('');
const [result, fundraisingResult] = await Promise.all([
api(`/api/communications?type=${type}&search=${search}&limit=200`, {}, token),
api('/api/fundraising/state', {}, token)
]);
setCommunications(result.data || []);
const names = Array.from(new Set(
((fundraisingResult?.data?.grid?.rows || [])
.map((r) => String(r?.investor_name || '').trim())
.filter(Boolean))
)).sort((a, b) => a.localeCompare(b));
setInvestorNames(names);
onShowToast('Communication logged', 'success');
} catch (err) {
setFormError(getErrorMessage(err, 'Failed to log communication'));
}
};
if (!isAdmin) {
return (
<div className="page-container">
<h2 className="section-title" style={{ marginBottom: '20px' }}>Communications</h2>
<div className="empty-state">Communications is an admin-only view (captured email activity).</div>
</div>
);
}
const handleDeleteComm = async (id) => {
try {
await api(`/api/communications/${id}`, { method: 'DELETE' }, token);
setCommunications((prev) => prev.filter((c) => c.id !== id));
setConfirmDelete(null);
onShowToast('Communication deleted', 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to delete communication'), 'error');
}
};
const typeLabel = (v) => {
if (v === 'email') return 'Email';
if (v === 'call') return 'Call';
if (v === 'meeting') return 'Meeting';
if (v === 'text') return 'Text';
return 'Note';
};
const emails = data?.emails || [];
const accounts = data?.accounts || [];
const investors = data?.investors || [];
const hasFilter = !!(investorId || accountId || direction || debouncedSearch);
return (
<div className="page-container">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2 className="section-title">Communications</h2>
<button onClick={() => {
setFormError('');
setFormData({ type: 'email', contact_id: '', investor_selection: '', investor_name_new: '', append_note: true });
setQuickMapSearch('');
setShowForm(true);
}}>+ Log Communication</button>
<span className="form-help">Captured email activity. Logging &amp; drafts live in the Fundraising Grid and Outreach.</span>
</div>
<div className="section">
<div className="controls">
<input
type="text"
className="search-input"
placeholder="Search subject/body..."
placeholder="Search subject, sender, snippet..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select className="select-input" value={type} onChange={(e) => setType(e.target.value)}>
<option value="">All Types</option>
<option value="email">Email</option>
<option value="call">Call</option>
<option value="meeting">Meeting</option>
<option value="note">Note</option>
<option value="text">Text</option>
<select className="select-input" value={investorId} onChange={(e) => setInvestorId(e.target.value)}>
<option value="">All investors</option>
{investors.map((iv) => <option key={iv.id} value={iv.id}>{iv.name}</option>)}
</select>
<select className="select-input" value={accountId} onChange={(e) => setAccountId(e.target.value)}>
<option value="">All mailboxes</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email_address}</option>)}
</select>
<select className="select-input" value={direction} onChange={(e) => setDirection(e.target.value)}>
<option value="">In &amp; out</option>
<option value="inbound">Received</option>
<option value="outbound">Sent</option>
</select>
</div>
{loading ? (
<SkeletonBlock lines={8} />
) : communications.length === 0 ? (
<div className="empty-state">No communications</div>
) : error ? (
<div className="toast error" style={{ position: 'static' }}>{error}</div>
) : disabled ? (
<span className="index-job-pill idle"><span className="index-job-dot" /> Email integration is disabled — no Gmail service-account key on the server.</span>
) : emails.length === 0 ? (
<div className="empty-state">{hasFilter ? 'No email activity matches these filters.' : 'No captured email yet. Enroll mailboxes in Email Capture.'}</div>
) : (
<div className="timeline">
{communications.map((comm) => (
<div key={comm.id} className="timeline-item">
<div className="timeline-marker"></div>
<div className="timeline-content">
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px' }}>
<div className="timeline-header">{typeLabel(comm.type)} · {contactName(comm)}</div>
<button className="button-danger" style={{ padding: '4px 8px', fontSize: '11px' }} onClick={() => setConfirmDelete(comm.id)}>Delete</button>
<>
<div className="form-help" style={{ marginBottom: '10px' }}>
Showing {emails.length}{data?.truncated ? '+ (refine filters to see more)' : ''} email{emails.length === 1 ? '' : 's'}.
</div>
<div className="timeline">
{emails.map((em) => {
const sent = em.direction === 'outbound';
const who = em.from_name ? `${em.from_name} <${em.from_email}>` : (em.from_email || 'Unknown sender');
const tags = [...(em.investors || []).map((iv) => iv.name), ...(em.investor_labels || [])];
return (
<div key={em.id} className="timeline-item">
<div className="timeline-marker"></div>
<div className="timeline-content">
<div className="timeline-header">
<span style={{ color: sent ? '#7fd1a8' : '#7fb0e0' }}>{sent ? '↗ Sent' : '↘ Received'}</span>
{' · '}{who}{em.has_attachments ? ' 📎' : ''}
</div>
<div className="timeline-meta">
{formatDate(em.sent_at)}
{(em.mailboxes || []).length > 0 && <span> · {em.mailboxes.join(', ')}</span>}
</div>
{em.subject && <div className="timeline-body" style={{ fontWeight: 600 }}>{em.subject}</div>}
{em.snippet && <div className="timeline-body" style={{ marginTop: '4px', color: '#9fb0c2' }}>{em.snippet}</div>}
{tags.length > 0 ? (
<div style={{ marginTop: '6px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{tags.map((t) => (
<span key={t} style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '10px', border: '1px solid #263548', background: '#0d1622', color: em.is_matched ? '#cfe0f2' : '#9fb0c2' }}>{t}</span>
))}
</div>
) : (
<div style={{ marginTop: '6px' }}>
<span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '10px', border: '1px dashed #4a3a2a', color: '#c0a070' }}>Unmatched</span>
</div>
)}
</div>
</div>
<div className="timeline-meta">{formatDate(comm.communication_date)}</div>
{comm.subject && <div className="timeline-body">{comm.subject}</div>}
{comm.body && <div className="timeline-body" style={{ marginTop: '4px', color: '#9fb0c2' }}>{comm.body}</div>}
</div>
</div>
))}
</div>
);
})}
</div>
</>
)}
</div>
{showForm && (
<div className="modal-overlay">
<div className="modal">
<div className="modal-header">Log Communication</div>
{formError && <div className="toast error" style={{ position: 'static', marginBottom: '16px' }}>{formError}</div>}
<div className="form-help" style={{ marginBottom: '14px', padding: '10px', border: '1px solid #263548', borderRadius: '8px', background: '#0d1622' }}>
This writes a communication record to the shared timeline, updates <strong>Last Communication Date</strong> on the fundraising row, and can append a one-line summary into <strong>Notes / Communication / Outreach</strong>.
</div>
<form onSubmit={handleAddComm}>
<div className="form-group">
<label className="form-label">Quick Find</label>
<input
type="text"
className="text-input"
placeholder="Search investor or contact (fuzzy)"
value={quickMapSearch}
onChange={(e) => setQuickMapSearch(e.target.value)}
/>
<div className="form-help">Use one search box to narrow both lists below. Selecting a contact auto-populates investor mapping when available.</div>
</div>
<div className="form-group">
<label className="form-label">Contact *</label>
<select className="select-input" value={formData.contact_id || ''} onChange={(e) => {
const contactId = e.target.value;
const selected = contacts.find((c) => c.id === contactId);
const orgName = String(selected?.organization_name || selected?.organization || '').trim();
setFormData((f) => {
if (!orgName) return { ...f, contact_id: contactId };
if (investorNames.includes(orgName)) {
return { ...f, contact_id: contactId, investor_selection: orgName, investor_name_new: '' };
}
return { ...f, contact_id: contactId, investor_selection: NEW_INVESTOR_VALUE, investor_name_new: orgName };
});
}} required>
<option value="">Select contact</option>
{rankedContacts.map((c) => (
<option key={c.id} value={c.id}>
{contactName(c)}{(c.organization_name || c.organization) ? ` · ${c.organization_name || c.organization}` : ''}
</option>
))}
</select>
<div className="form-help">Person this communication is tied to.</div>
</div>
<div className="form-group">
<label className="form-label">Investor Mapping *</label>
<select className="select-input" value={formData.investor_selection || ''} onChange={(e) => setFormData((f) => ({ ...f, investor_selection: e.target.value }))} required>
<option value="">Select investor</option>
{rankedInvestors.map((name) => <option key={name} value={name}>{name}</option>)}
<option value={NEW_INVESTOR_VALUE}>+ Create new investor...</option>
</select>
{formData.investor_selection === NEW_INVESTOR_VALUE && (
<input
type="text"
className="text-input"
placeholder="New investor name"
value={formData.investor_name_new || ''}
onChange={(e) => setFormData((f) => ({ ...f, investor_name_new: e.target.value }))}
required
/>
)}
<div className="form-help">Ensures this communication lands on the right fundraising row. New investor names are auto-created in Fundraising Grid.</div>
</div>
<div className="form-group">
<label className="form-label">Type</label>
<select className="select-input" value={formData.type || 'note'} onChange={(e) => setFormData((f) => ({ ...f, type: e.target.value }))}>
<option value="email">Email</option>
<option value="call">Call</option>
<option value="meeting">Meeting</option>
<option value="note">Note</option>
<option value="text">Text</option>
</select>
<div className="form-help">Communication category for reporting and filtering.</div>
</div>
<div className="form-group">
<label className="form-label">Summary</label>
<input type="text" className="text-input" placeholder="Short summary (used in timeline and notes append)" value={formData.subject || ''} onChange={(e) => setFormData((f) => ({ ...f, subject: e.target.value }))} />
<div className="form-help">This is not an email subject line. It is the headline summary shown in Communications.</div>
</div>
<div className="form-group">
<label className="form-label">Details</label>
<textarea className="text-input" rows="4" placeholder="Full detail, context, and key points" value={formData.body || ''} onChange={(e) => setFormData((f) => ({ ...f, body: e.target.value }))} />
<div className="form-help">Saved in Communications history only (not appended to investor notes unless your summary references it).</div>
</div>
<div className="form-group">
<label className="form-label">Outcome</label>
<input type="text" className="text-input" value={formData.outcome || ''} onChange={(e) => setFormData((f) => ({ ...f, outcome: e.target.value }))} />
<div className="form-help">Result of this touchpoint (for quick review later).</div>
</div>
<div className="form-group">
<label className="form-label">Next Action</label>
<input type="text" className="text-input" value={formData.next_action || ''} onChange={(e) => setFormData((f) => ({ ...f, next_action: e.target.value }))} />
<div className="form-help">Explicit follow-up task (what should happen next).</div>
</div>
<div className="form-group">
<label className="form-label">Next Action Date</label>
<input type="date" className="text-input" value={formData.next_action_date || ''} onChange={(e) => setFormData((f) => ({ ...f, next_action_date: e.target.value }))} />
<div className="form-help">Target date for the next action.</div>
</div>
<div className="form-group">
<label className="form-label">
<input type="checkbox" checked={!!formData.append_note} onChange={(e) => setFormData((f) => ({ ...f, append_note: e.target.checked }))} />
{' '}Append summary to Fundraising Grid notes
</label>
<div className="form-help">Adds one line to <strong>Notes / Communication / Outreach</strong>: date + type + contact + summary.</div>
</div>
<div className="form-actions">
<button type="button" className="button-secondary" onClick={() => setShowForm(false)}>Cancel</button>
<button type="submit">Log</button>
</div>
</form>
</div>
</div>
)}
{confirmDelete && (
<ConfirmDialog
title="Delete Communication"
message="Are you sure?"
onConfirm={() => handleDeleteComm(confirmDelete)}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div>
);
};
@@ -10785,9 +10621,11 @@
<button className={`nav-item ${page === 'pipeline' ? 'active' : ''}`} onClick={() => setPage('pipeline')}>
<span className="nav-item-icon"></span> Pipeline
</button>
<button className={`nav-item ${page === 'communications' ? 'active' : ''}`} onClick={() => setPage('communications')}>
<span className="nav-item-icon"></span> Communications
</button>
{user?.role === 'admin' && (
<button className={`nav-item ${page === 'communications' ? 'active' : ''}`} onClick={() => setPage('communications')}>
<span className="nav-item-icon"></span> Communications
</button>
)}
<button className={`nav-item ${page === 'thesis' ? 'active' : ''}`} onClick={() => setPage('thesis')}>
<span className="nav-item-icon">§</span> Thesis
</button>
@@ -10862,7 +10700,7 @@
{page === 'dashboard' && <DashboardPage token={token} />}
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} />}
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} />}
{page === 'communications' && <CommunicationsPage token={token} onShowToast={showToast} />}
{page === 'communications' && <CommunicationsPage token={token} user={user} onShowToast={showToast} />}
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
{page === 'thesis-workshop' && <ThesisWorkshopPage token={token} user={user} onShowToast={showToast} />}
{page === 'outreach' && <OutreachPage token={token} user={user} onShowToast={showToast} />}