Add reminders & follow-ups (W1) (v0.1.0:92)

First-class reminders tied to the fundraising grid — foundation of the agreed
reminders -> NL-search -> bot-mutations plan (keep LP data off third-party LLMs).

- reminders table (migration 0006; logical FK to fundraising_investors.id +
  denormalized name), CRUD at /api/reminders (soft-delete; open/done/snoozed/
  cancelled; assignee; source; source_row_id resolution)
- read-only derived reminder_status grid column (overdue/due_soon/open),
  filterable; orphan reconciler cancels reminders when an investor leaves the grid
- Reminders page, Dashboard "Reminders Due" card, daily-digest reminders section
- per-investor last_activity_at recency rollup (shared block for the W2 NL query)
- tests: test_reminders.py + digest reminders test (31/31 green, render-smoke green)
This commit is contained in:
Keysat
2026-06-18 14:45:46 -05:00
parent ee6a4e52d2
commit f181525926
12 changed files with 1306 additions and 47 deletions
+105 -40
View File
@@ -75,6 +75,24 @@ _SYSTEM = (
"greeting, no bullet points, no sign-off."
)
# Reminders due is a current-state addendum (what needs action now), NOT bound to the
# email-activity window — a 6 PM digest should surface what's overdue / due today.
# status='open' only: a 'snoozed' reminder is an explicit mute, so it stays out of the
# digest by design (the quick-snooze UI keeps a reminder 'open' with a pushed-out date).
_REMINDERS_SQL = """
SELECT r.title AS title,
r.due_date AS due_date,
r.investor_name AS investor_name,
COALESCE(NULLIF(TRIM(u.full_name), ''), u.username) AS assignee
FROM reminders r
LEFT JOIN users u ON u.id = r.assignee_id
WHERE r.deleted_at IS NULL
AND r.status = 'open'
AND r.due_date IS NOT NULL AND TRIM(r.due_date) != ''
AND substr(r.due_date, 1, 10) <= ?
ORDER BY r.due_date ASC
"""
# ------------------------------------------------------------------ collection
@@ -180,6 +198,27 @@ def collect_investor_activity(conn, since_iso, until_iso):
return out
def collect_due_reminders(conn, today_iso):
"""Open reminders due on or before `today_iso` (overdue + due today), soft-delete
filtered. Returns [{title, due_date, investor_name, assignee, overdue}] sorted soonest
first. Empty if the reminders table is absent (feature not migrated on this box)."""
try:
rows = conn.execute(_REMINDERS_SQL, (today_iso,)).fetchall()
except sqlite3.OperationalError:
return []
out = []
for r in rows:
due = str(r["due_date"] or "")[:10]
out.append({
"title": (r["title"] or "").strip(),
"due_date": due,
"investor_name": (r["investor_name"] or "").strip(),
"assignee": (r["assignee"] or "").strip(),
"overdue": bool(due and due < today_iso),
})
return out
# ------------------------------------------------------------------ policy
DIGEST_POLICY_KEY = "digest_policy"
@@ -349,49 +388,72 @@ def _fmt_local(iso):
return f"{dt.strftime('%b')} {dt.day} {hour12}:{dt.minute:02d} {ampm}"
def _compose_body(user_groups, investor_groups, narratives, since_iso, until_iso):
def _reminders_section(due_reminders):
"""Render the 'reminders due' block (overdue + due today). An empty list renders
nothing, so a clear deck adds no noise to the digest."""
if not due_reminders:
return []
overdue = [r for r in due_reminders if r["overdue"]]
due_today = [r for r in due_reminders if not r["overdue"]]
def _line(r):
inv = f"{r['investor_name']}" if r["investor_name"] else ""
who = f" [{r['assignee']}]" if r["assignee"] else ""
return f"{inv}{r['title']} (due {r['due_date']}){who}"
L = ["", _RULE, f"REMINDERS DUE ({len(due_reminders)})", _RULE]
if overdue:
L += ["", f"Overdue ({len(overdue)}):"] + [_line(r) for r in overdue]
if due_today:
L += ["", f"Due today ({len(due_today)}):"] + [_line(r) for r in due_today]
return L
def _compose_body(user_groups, investor_groups, narratives, since_iso, until_iso, due_reminders=None):
title_date = datetime.now().astimezone().strftime("%A, %b %d %Y")
window = f"{_fmt_local(since_iso)} {_fmt_local(until_iso)}"
L = ["Ten31 CRM — Daily Activity Digest", title_date, f"Window: {window}", ""]
if not user_groups:
L += ["No tracked email activity from any user in this window.", "", _FOOTER]
return "\n".join(L)
L.append("No tracked email activity from any user in this window.")
else:
total_emails = sum(g["total"] for g in user_groups)
total_invs = len({i for g in user_groups for i in g["investors"]})
L.append(f"{len(user_groups)} team member(s) active · {total_emails} email(s) "
f"· {total_invs} investor(s)")
total_emails = sum(g["total"] for g in user_groups)
total_invs = len({i for g in user_groups for i in g["investors"]})
L.append(f"{len(user_groups)} team member(s) active · {total_emails} email(s) "
f"· {total_invs} investor(s)")
# ── Section 1: by team member (who did what; per-user Spark narrative) ──
L += ["", _RULE, "BY TEAM MEMBER", _RULE]
for g in user_groups:
invs = ", ".join(g["investors"]) or "(no matched investor)"
L += ["",
f"{g['full_name'] or g['username']} · {g['account_email']}",
f"{g['total']} email(s) ({g['sent']} sent, {g['received']} received) "
f"· {invs}", "",
narratives.get(g["user_id"], ""), ""]
for em in g["emails"]:
arrow = "→ Sent" if em["direction"] == "sent" else "← Received"
invs_e = ", ".join(em["investors"]) or "(unmatched)"
subj = em.get("subject") or "(no subject)"
L.append(f" {arrow} · {invs_e} · \"{subj}\" ({_fmt_local(em['sent_at'])})")
# ── Section 1: by team member (who did what; per-user Spark narrative) ──
L += ["", _RULE, "BY TEAM MEMBER", _RULE]
for g in user_groups:
invs = ", ".join(g["investors"]) or "(no matched investor)"
L += ["",
f"{g['full_name'] or g['username']} · {g['account_email']}",
f"{g['total']} email(s) ({g['sent']} sent, {g['received']} received) "
f"· {invs}", "",
narratives.get(g["user_id"], ""), ""]
for em in g["emails"]:
arrow = "→ Sent" if em["direction"] == "sent" else "← Received"
invs_e = ", ".join(em["investors"]) or "(unmatched)"
subj = em.get("subject") or "(no subject)"
L.append(f" {arrow} · {invs_e} · \"{subj}\" ({_fmt_local(em['sent_at'])})")
# ── Section 2: by investor (team-wide; both directions, structured) ──
L += ["", _RULE, "BY INVESTOR", _RULE]
for inv in investor_groups:
L += ["",
f"{inv['name']} · {inv['total']} email(s) "
f"({inv['inbound']} in, {inv['outbound']} out)"]
for em in inv["emails"]:
subj = em.get("subject") or "(no subject)"
when = _fmt_local(em["sent_at"])
if em["direction"] == "out":
who = ", ".join(em["members"]) or "team"
L.append(f" → Sent by {who} · \"{subj}\" ({when})")
else:
L.append(f" ← Received · \"{subj}\" ({when})")
# ── Section 2: by investor (team-wide; both directions, structured) ──
L += ["", _RULE, "BY INVESTOR", _RULE]
for inv in investor_groups:
L += ["",
f"{inv['name']} · {inv['total']} email(s) "
f"({inv['inbound']} in, {inv['outbound']} out)"]
for em in inv["emails"]:
subj = em.get("subject") or "(no subject)"
when = _fmt_local(em["sent_at"])
if em["direction"] == "out":
who = ", ".join(em["members"]) or "team"
L.append(f" → Sent by {who} · \"{subj}\" ({when})")
else:
L.append(f" ← Received · \"{subj}\" ({when})")
# ── Reminders due (current state — independent of the activity window) ──
L += _reminders_section(due_reminders or [])
L += ["", _RULE, _FOOTER]
return "\n".join(L)
@@ -399,14 +461,16 @@ def _compose_body(user_groups, investor_groups, narratives, since_iso, until_iso
def build_digest(conn, since_iso, until_iso, chat_fn=None):
"""Build the daily digest for [since_iso, until_iso). Returns
{subject, body, has_activity, user_count, email_count, investor_count}. Always
returns a body (empty windows get a 'no activity' note — the team chose
always-send). Two sections: by team member (per-user Spark narrative) and by
investor (structured, both directions)."""
{subject, body, has_activity, user_count, email_count, investor_count,
reminder_count}. Always returns a body (empty windows get a 'no activity' note —
the team chose always-send). Sections: by team member (per-user Spark narrative),
by investor (structured), and reminders due (overdue + due today, current-state)."""
user_groups = collect_user_activity(conn, since_iso, until_iso)
investor_groups = collect_investor_activity(conn, since_iso, until_iso)
narratives = {g["user_id"]: summarize_user_day(g, chat_fn) for g in user_groups}
body = _compose_body(user_groups, investor_groups, narratives, since_iso, until_iso)
today_iso = datetime.now().astimezone().strftime("%Y-%m-%d")
due_reminders = collect_due_reminders(conn, today_iso)
body = _compose_body(user_groups, investor_groups, narratives, since_iso, until_iso, due_reminders)
stamp = datetime.now().astimezone().strftime("%b %d")
return {
"subject": f"Ten31 CRM — Daily Activity Digest · {stamp}",
@@ -415,4 +479,5 @@ def build_digest(conn, since_iso, until_iso, chat_fn=None):
"user_count": len(user_groups),
"email_count": sum(g["total"] for g in user_groups),
"investor_count": len(investor_groups),
"reminder_count": len(due_reminders),
}