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:
+94
-5
@@ -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' }}>
|
||||
|
||||
Reference in New Issue
Block a user