Add daily activity digest — Phase B (v0.1.0:77)

Sends a once-a-day internal email to all active admins summarizing each team
member's email activity per investor, plus a team-wide by-investor view
(inbound + outbound, deduped). Narratives are generated on the LOCAL Spark
model, never Claude — the digest is intentionally un-anonymized, so substance
stays on Ten31 infra. This is an internal ops email, exempt from the
'agents draft, humans send' rule (which governs outward LP contact).

- backend/digest_builder.py: per-user + per-investor activity queries
  (soft-delete filtered), per-user Spark narrative with a deterministic
  fallback, two-section plain-text body, and the DB-backed policy resolver.
- backend/email_integration/digest_scheduler.py: always-on daily thread that
  re-reads the policy each cycle and sends once/day; window cursor in
  app_settings so a missed day rolls forward.
- server.py: POST /api/admin/digest/send-now and GET/PATCH
  /api/admin/digest/policy; scheduler wired into main().
- Control lives in Settings -> Admin (enable toggle + send-time dropdown),
  not StartOS actions; env vars only seed the first-boot default.
- Tests: backend/test_digest_builder.py.
This commit is contained in:
Keysat
2026-06-15 22:32:27 -05:00
parent 036226ed74
commit 323f016f64
12 changed files with 1113 additions and 19 deletions
+94 -5
View File
@@ -7769,6 +7769,10 @@
const [usersLoading, setUsersLoading] = useState(false);
const [userActionLoadingId, setUserActionLoadingId] = useState(null);
const [testEmailLoading, setTestEmailLoading] = useState(false);
const [sendDigestLoading, setSendDigestLoading] = useState(false);
const [digestPolicy, setDigestPolicy] = useState({ enabled: false, send_hour: 18 });
const [digestPolicyLoading, setDigestPolicyLoading] = useState(false);
const [digestPolicySaving, setDigestPolicySaving] = useState(false);
const [auditLogs, setAuditLogs] = useState([]);
const [auditLoading, setAuditLoading] = useState(false);
const [automationRules, setAutomationRules] = useState([]);
@@ -7861,6 +7865,39 @@
}
}, [token, user?.role, onShowToast]);
const fetchDigestPolicy = useCallback(async () => {
if (user?.role !== 'admin') return;
setDigestPolicyLoading(true);
try {
const result = await api('/api/admin/digest/policy', {}, token);
const p = result?.data;
if (p) setDigestPolicy({ enabled: Boolean(p.enabled), send_hour: Number(p.send_hour) || 18 });
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to load digest settings'), 'error');
} finally {
setDigestPolicyLoading(false);
}
}, [token, user?.role, onShowToast]);
const handleSaveDigestPolicy = async (patch) => {
setDigestPolicy((p) => ({ ...p, ...patch })); // optimistic
setDigestPolicySaving(true);
try {
const result = await api('/api/admin/digest/policy', {
method: 'PATCH',
body: JSON.stringify(patch)
}, token);
const p = result?.data;
if (p) setDigestPolicy({ enabled: Boolean(p.enabled), send_hour: Number(p.send_hour) || 18 });
onShowToast('Digest settings saved', 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to save digest settings'), 'error');
fetchDigestPolicy(); // revert to server truth
} finally {
setDigestPolicySaving(false);
}
};
const fetchAuditLogs = useCallback(async () => {
if (user?.role !== 'admin') return;
setAuditLoading(true);
@@ -7935,11 +7972,12 @@
if (user?.role !== 'admin') return;
fetchBackupHistory();
fetchBackupPolicy();
fetchDigestPolicy();
fetchAuditLogs();
fetchAutomations();
fetchActivityFeed();
fetchSecurityStatus();
}, [user?.role, fetchBackupHistory, fetchBackupPolicy, fetchAuditLogs, fetchAutomations, fetchActivityFeed, fetchSecurityStatus]);
}, [user?.role, fetchBackupHistory, fetchBackupPolicy, fetchDigestPolicy, fetchAuditLogs, fetchAutomations, fetchActivityFeed, fetchSecurityStatus]);
const handleInviteUser = async (e) => {
e.preventDefault();
@@ -8233,6 +8271,26 @@
}
};
const handleSendDigestNow = async () => {
setSendDigestLoading(true);
try {
const result = await api('/api/admin/digest/send-now', {
method: 'POST',
body: JSON.stringify({})
}, token);
const d = result?.data || {};
const to = (d.recipients || []).join(', ');
const summary = d.has_activity
? `${d.user_count} member(s), ${d.email_count} email(s), ${d.investor_count} investor(s)`
: 'no activity in the last 24h';
onShowToast(`Digest sent (${summary})${to ? ` to ${to}` : ''}`, 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to send digest — is a transport (Gmail/DWD or SMTP) configured?'), 'error');
} finally {
setSendDigestLoading(false);
}
};
const handleContactsCsvFileUpload = async (event) => {
const file = event.target.files && event.target.files[0];
if (!file) return;
@@ -8516,11 +8574,42 @@
<div style={{ marginBottom: '20px', borderBottom: '1px solid #263548', paddingBottom: '16px' }}>
<div style={{ fontWeight: 600, marginBottom: '8px' }}>Daily Digest Email</div>
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '10px' }}>
Sends a test message to all active admins through the configured SMTP account, to verify outbound email works. Configure SMTP via the Start9 "Configure Digest SMTP" action, then restart the service.
A daily email to all active admins: each team member's activity per investor, plus a by-investor view (inbound + outbound), summarized locally (Spark), never Claude. <b>Send Test</b> verifies the outbound pipe with a fixed message; <b>Send Now</b> sends the real last-24h digest immediately.
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '14px', flexWrap: 'wrap', marginBottom: '12px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={Boolean(digestPolicy.enabled)}
disabled={digestPolicyLoading || digestPolicySaving}
onChange={(e) => handleSaveDigestPolicy({ enabled: e.target.checked })}
/>
Send automatically every day
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
at
<select
value={Number(digestPolicy.send_hour)}
disabled={digestPolicyLoading || digestPolicySaving || !digestPolicy.enabled}
onChange={(e) => handleSaveDigestPolicy({ send_hour: Number(e.target.value) })}
>
{Array.from({ length: 24 }, (_, h) => (
<option key={h} value={h}>
{((h % 12) || 12) + ':00 ' + (h < 12 ? 'AM' : 'PM')}
</option>
))}
</select>
<span style={{ fontSize: '11px', color: '#8ea2b7' }}>(server local time)</span>
</label>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button type="button" onClick={handleSendTestDigestEmail} disabled={testEmailLoading}>
{testEmailLoading ? <Spinner /> : 'Send Test Digest Email'}
</button>
<button type="button" onClick={handleSendDigestNow} disabled={sendDigestLoading}>
{sendDigestLoading ? <Spinner /> : 'Send Digest Now'}
</button>
</div>
<button type="button" onClick={handleSendTestDigestEmail} disabled={testEmailLoading}>
{testEmailLoading ? <Spinner /> : 'Send Test Digest Email'}
</button>
</div>
<div style={{ marginBottom: '20px', borderBottom: '1px solid #263548', paddingBottom: '16px' }}>