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:
+105
-40
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user