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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user