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:
@@ -1817,6 +1817,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_list_fundraising_backups(user)
|
||||
if path == '/api/fundraising/backup-policy':
|
||||
return self.handle_get_backup_policy(user)
|
||||
if path == '/api/admin/digest/policy':
|
||||
return self.handle_get_digest_policy(user)
|
||||
if path == '/api/fundraising/relational-summary':
|
||||
return self.handle_get_fundraising_relational_summary(user)
|
||||
if path == '/api/fundraising/automations':
|
||||
@@ -1921,6 +1923,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_admin_reset_all_data(user, body)
|
||||
if path == '/api/admin/digest/test-email':
|
||||
return self.handle_admin_send_test_email(user, body)
|
||||
if path == '/api/admin/digest/send-now':
|
||||
return self.handle_admin_send_digest_now(user, body)
|
||||
if path == '/api/fundraising/backup':
|
||||
return self.handle_backup_fundraising_state(user)
|
||||
if path == '/api/fundraising/restore-preview':
|
||||
@@ -2019,6 +2023,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_admin_update_user(user, target_user_id, body)
|
||||
if path == '/api/fundraising/backup-policy':
|
||||
return self.handle_update_backup_policy(user, body)
|
||||
if path == '/api/admin/digest/policy':
|
||||
return self.handle_update_digest_policy(user, body)
|
||||
if re.match(r'^/api/fundraising/automations/[^/]+$', path):
|
||||
rule_id = path.split('/')[-1]
|
||||
return self.handle_update_fundraising_automation_rule(user, rule_id, body)
|
||||
@@ -4292,6 +4298,63 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
|
||||
return self.send_json({"data": {"status": "sent", **result}})
|
||||
|
||||
def handle_admin_send_digest_now(self, user, body):
|
||||
"""Build the REAL daily activity digest (last 24h) on demand and send it to
|
||||
the active-admin set now. An on-demand preview of Phase B — it does not
|
||||
touch the daily schedule's cursor, so it never suppresses the scheduled send.
|
||||
Content is summarized on Spark (local), never Claude."""
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin only", 403)
|
||||
|
||||
import digest_mailer
|
||||
try:
|
||||
from email_integration.digest_scheduler import maybe_send_digest
|
||||
result = maybe_send_digest(force=True)
|
||||
except digest_mailer.NoTransport as exc:
|
||||
return self.send_error_json(str(exc), 400)
|
||||
except Exception as exc:
|
||||
# Never echo the exception — an auth error can carry a token/credential.
|
||||
print(f"[digest] send-now failed: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
return self.send_error_json("Send failed — see server logs for details.", 502)
|
||||
|
||||
return self.send_json({"data": result})
|
||||
|
||||
def handle_get_digest_policy(self, user):
|
||||
"""Return the live daily-digest policy (enabled + send hour). DB-backed
|
||||
(app_settings), set from this same panel — see digest_builder.load_digest_policy."""
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin access required", 403)
|
||||
import digest_builder
|
||||
conn = get_db()
|
||||
try:
|
||||
return self.send_json({"data": digest_builder.load_digest_policy(conn)})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def handle_update_digest_policy(self, user, body):
|
||||
"""Update the daily-digest policy. Takes effect on the scheduler's next
|
||||
cycle (no restart). Recipients stay the active-admin set; sender/transport
|
||||
are env/StartOS config, not toggled here."""
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin access required", 403)
|
||||
import digest_builder
|
||||
conn = get_db()
|
||||
try:
|
||||
policy = digest_builder.load_digest_policy(conn)
|
||||
if 'enabled' in body:
|
||||
policy['enabled'] = bool(body.get('enabled'))
|
||||
if 'send_hour' in body:
|
||||
try:
|
||||
policy['send_hour'] = max(0, min(23, int(body.get('send_hour'))))
|
||||
except (ValueError, TypeError):
|
||||
return self.send_error_json("send_hour must be an integer from 0 to 23")
|
||||
normalized = {"enabled": bool(policy['enabled']), "send_hour": int(policy['send_hour'])}
|
||||
set_app_setting(conn, digest_builder.DIGEST_POLICY_KEY, normalized)
|
||||
conn.commit()
|
||||
return self.send_json({"data": normalized})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def handle_list_audit_log(self, user, params):
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin access required", 403)
|
||||
@@ -5425,6 +5488,16 @@ def main():
|
||||
except Exception as _e:
|
||||
print(f"[email_integration] failed to start scheduler: {_e}")
|
||||
|
||||
# ─── Daily activity digest scheduler ─────────────────────────────
|
||||
# Always started; it reads the digest policy (enabled + send hour) from the DB
|
||||
# each cycle, so the Settings → Admin toggle controls it live (no restart).
|
||||
try:
|
||||
from email_integration.digest_scheduler import start_digest_scheduler
|
||||
start_digest_scheduler()
|
||||
print("[digest] daily activity digest scheduler started (policy-controlled)")
|
||||
except Exception as _e:
|
||||
print(f"[digest] failed to start digest scheduler: {_e}")
|
||||
|
||||
# ThreadingHTTPServer lets one slow request (or a wave of scanner probes)
|
||||
# not block legit users. SQLite is opened per-request via get_db(), and
|
||||
# WAL mode allows concurrent readers + a single writer, so this is safe.
|
||||
|
||||
Reference in New Issue
Block a user