email-activity agent: propose -> review -> approve grid notes (v0.1.0:64)

When a sent/received email is matched to an investor, a local-model agent drafts a
one-line dated note and queues it as a PENDING proposal (it never writes the grid
itself). On the Email Capture page a partner sees "Proposed grid notes", can edit the
text, and Approve (appends to that investor's grid notes cell, newest at bottom,
stamped with the approver) or Dismiss. Going-forward only: a cutoff (app_settings
email_activity_since, set on first run) means email dated before the feature was
enabled is never summarized, so the historical backfill makes no noise. Sovereign:
summaries run entirely on the local model (no redaction needed). Gmail sync interval
tightened 180 -> 15 min so outgoing email surfaces quickly.

Backend: migration 0002 (email_activity_proposals); propose_email_activity_notes()
runs via a new scheduler post_sync hook; list/decide functions + routes
GET /api/activity/proposals, POST .../{id}/approve|dismiss. Grid append stamps the
approving user (fundraising_state.updated_by has a FK to users). Test
test_email_activity.py (propose cutoff/idempotency, approve appends + edited note,
dismiss, already-decided guard) under FK enforcement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Keysat
2026-06-06 15:55:26 -05:00
parent 3893a4fb9f
commit 069e60053b
9 changed files with 462 additions and 7 deletions
+56
View File
@@ -9956,6 +9956,9 @@
const [error, setError] = useState('');
const [busy, setBusy] = useState('');
const [oneEmail, setOneEmail] = useState(() => user?.email || '');
const [proposals, setProposals] = useState([]);
const [edits, setEdits] = useState({});
const [deciding, setDeciding] = useState(null);
const load = useCallback(async () => {
let s;
@@ -9972,8 +9975,11 @@
}
let a = { accounts: [] };
try { a = await api('/api/email/accounts', {}, token); } catch (_) { /* none / disabled */ }
let pr = { proposals: [] };
try { pr = await api('/api/activity/proposals', {}, token); } catch (_) { /* admin-only / none */ }
setStatus(s);
setAccounts(Array.isArray(a?.accounts) ? a.accounts : []);
setProposals(Array.isArray(pr?.proposals) ? pr.proposals : []);
}, [token]);
useEffect(() => {
@@ -10019,6 +10025,23 @@
}
};
const decide = async (p, decision) => {
if (deciding) return;
try {
setDeciding(p.id);
const body = decision === 'approve'
? JSON.stringify({ note: (edits[p.id] != null ? edits[p.id] : p.proposed_note) })
: undefined;
await api(`/api/activity/proposals/${p.id}/${decision}`, { method: 'POST', body }, token);
setProposals((prev) => prev.filter((x) => x.id !== p.id));
onShowToast(decision === 'approve' ? 'Note added to the grid' : 'Proposal dismissed', 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Action failed'), 'error');
} finally {
setDeciding(null);
}
};
if (loading) return <div style={{ padding: '20px' }}><SkeletonBlock lines={8} /></div>;
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
if (!status) return <div className="empty-state">No data</div>;
@@ -10076,6 +10099,39 @@
</div>
</div>
{isAdmin && proposals.length > 0 && (
<div className="section">
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
Proposed grid notes
<span className="approval-pill">{proposals.length} to review</span>
</div>
<div className="index-action-hint" style={{ marginTop: 0, marginBottom: '12px' }}>
The agent drafted these from matched emails on your local model. Edit if needed, then Approve to append the note to that investor's grid notes, or Dismiss.
</div>
{proposals.map((p) => (
<div key={p.id} className="merge-candidate-card" style={{ marginBottom: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '10px', marginBottom: '6px' }}>
<div style={{ fontWeight: 600 }}>{p.investor_name || 'Unknown investor'}</div>
<div className="kpi-subtitle">{p.direction === 'sent' ? 'Sent' : 'Received'}{p.email_date ? ` · ${formatDate(p.email_date)}` : ''}</div>
</div>
{p.email_subject ? <div className="kpi-subtitle" style={{ marginBottom: '8px' }}>Subject: {p.email_subject}</div> : null}
<textarea
className="text-input"
style={{ width: '100%', minHeight: '54px', marginBottom: '8px' }}
value={edits[p.id] != null ? edits[p.id] : (p.proposed_note || '')}
onChange={(e) => setEdits((m) => ({ ...m, [p.id]: e.target.value }))}
/>
<div className="index-action-buttons">
<button onClick={() => decide(p, 'approve')} disabled={deciding === p.id}>
{deciding === p.id ? 'Adding…' : 'Approve & add to grid'}
</button>
<button onClick={() => decide(p, 'dismiss')} disabled={deciding === p.id}>Dismiss</button>
</div>
</div>
))}
</div>
)}
{isAdmin && (
<div className="section">
<div className="section-title">Actions</div>