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),
}
@@ -0,0 +1,8 @@
-- Manual rollback for 0006_reminders.sql (never auto-applied).
-- Drops the whole reminders feature table. Per the never-hard-delete guardrail this
-- discards reminder history, so only run it to reverse a bad migration on a dev/copy DB.
DROP INDEX IF EXISTS idx_reminders_assignee;
DROP INDEX IF EXISTS idx_reminders_due;
DROP INDEX IF EXISTS idx_reminders_status;
DROP INDEX IF EXISTS idx_reminders_investor;
DROP TABLE IF EXISTS reminders;
+45
View File
@@ -0,0 +1,45 @@
-- Reminders & follow-ups — a real tickler/task model tied to the fundraising grid.
--
-- ADDITIVE + REVERSIBLE (CLAUDE.md guardrail #3): one new table + indexes; nothing
-- existing is touched. Until now the only follow-up surfaces were the grid's binary
-- `follow_up` checkbox (no date, owner, or status) and communications.next_action_date
-- (tied to a single logged comm). This gives investors first-class reminders with a due
-- date, status lifecycle, assignee, and provenance — the foundation for "who needs a
-- follow-up?" queries, the daily digest's due/overdue section, and (later) bot-proposed
-- reminders behind the Matrix approval gate.
--
-- investor_id is a LOGICAL foreign key to fundraising_investors(id) — deliberately NOT a
-- declared SQLite FOREIGN KEY, matching opportunities.fundraising_investor_id (migration
-- 0005). fundraising_investors rows are upserted by source_row_id on every grid save with
-- a STABLE id (so the link survives saves), but a row dropped from the grid is DELETEd —
-- there is nothing to cascade, and reconcile_grid_reminders() cancels the orphans on the
-- next save (the pipeline reconciler's twin). investor_name is denormalized so a reminder
-- stays readable in history even after its grid row is gone. investor_id is nullable: a
-- reminder can be a standalone team task not tied to any investor.
--
-- contact_id is an optional logical FK to contacts(id) (the specific person). assignee_id
-- is a logical ref to users(id) (NULL = team-wide). created_by holds a users.id OR a
-- non-user sentinel ('bot'/'automation'), so it is plain TEXT with no FK.
CREATE TABLE IF NOT EXISTS reminders (
id TEXT PRIMARY KEY,
investor_id TEXT, -- logical FK -> fundraising_investors.id (NULL = standalone task)
investor_name TEXT, -- denormalized; survives grid-row deletion
contact_id TEXT, -- optional logical FK -> contacts.id
title TEXT NOT NULL,
details TEXT,
due_date TEXT, -- ISO date 'YYYY-MM-DD' (or datetime)
status TEXT NOT NULL DEFAULT 'open', -- open | done | snoozed | cancelled
snoozed_until TEXT,
assignee_id TEXT, -- logical ref -> users.id; NULL = team-wide
created_by TEXT, -- users.id, or 'bot' / 'automation'
source TEXT NOT NULL DEFAULT 'human', -- human | bot | automation
completed_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_reminders_investor ON reminders(investor_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_reminders_status ON reminders(status) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_reminders_due ON reminders(due_date) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_reminders_assignee ON reminders(assignee_id) WHERE deleted_at IS NULL;
+312 -1
View File
@@ -1596,7 +1596,7 @@ def sanitize_fundraising_grid(grid):
# linked opportunity and injected on read — never persisted as row data (the GET handler
# re-injects them after sanitize). The column DEFINITIONS persist like any other column
# so their position / width / hidden state is kept.
_computed_row_values = ('longshot_followup', 'pipeline', 'pipeline_stage')
_computed_row_values = ('longshot_followup', 'pipeline', 'pipeline_stage', 'reminder_status')
clean_columns = []
seen = set()
@@ -1672,6 +1672,25 @@ def reconcile_grid_pipeline_links(conn):
)
def reconcile_grid_reminders(conn):
"""After a grid save + relational sync, cancel any open/snoozed reminder whose linked
grid investor row no longer exists (the investor was deleted from the grid) the twin
of reconcile_grid_pipeline_links. We CANCEL rather than soft-delete so the reminder stays
visible in history with its denormalized investor_name; standalone reminders
(investor_id IS NULL) are never touched."""
conn.execute(
"""
UPDATE reminders
SET status = 'cancelled', updated_at = ?
WHERE investor_id IS NOT NULL
AND deleted_at IS NULL
AND status IN ('open', 'snoozed')
AND investor_id NOT IN (SELECT id FROM fundraising_investors)
""",
(now(),)
)
def pipeline_stage_by_source_row(conn):
"""Return {grid source_row_id: current pipeline stage} for every investor with a
live (non-deleted) linked opportunity. The opportunities table is the single source
@@ -1691,6 +1710,90 @@ def pipeline_stage_by_source_row(conn):
out[srid] = r['stage']
return out
def last_activity_by_investor(conn):
"""Return {fundraising_investors.id: latest activity ISO timestamp} across captured
emails (grid-linked) and logged communications the per-investor recency signal behind
the reminders surface and (later) the "not nurtured in a while" query. The grid is the
higher-signal side. Computed fresh on read (a few hundred investors); materialize only if
it ever shows as slow. The email/comm tables can be absent on a minimal DB, so each leg
is guarded independently."""
out = {}
def _bump(inv_id, ts):
if inv_id and ts and (out.get(inv_id) is None or str(ts) > str(out[inv_id])):
out[inv_id] = ts
# Communications logged against any of the investor's people (via the grid contact link).
try:
for r in conn.execute(
"""
SELECT fc.investor_id AS inv, MAX(cm.communication_date) AS last_ts
FROM communications cm
JOIN fundraising_contacts fc ON fc.contact_id = cm.contact_id
WHERE cm.deleted_at IS NULL AND fc.contact_id IS NOT NULL
GROUP BY fc.investor_id
"""
).fetchall():
_bump(r["inv"], r["last_ts"])
except sqlite3.OperationalError:
pass
# Captured emails matched to the grid investor (sent_at is set on matched + unmatched).
try:
for r in conn.execute(
"""
SELECT eil.fundraising_investor_id AS inv, MAX(e.sent_at) AS last_ts
FROM email_investor_links eil
JOIN emails e ON e.id = eil.email_id
WHERE eil.fundraising_investor_id IS NOT NULL
-- soft-delete lives on the per-mailbox sighting, not emails/links; require a
-- live sighting so a tombstoned email doesn't inflate recency (matches the digest).
AND EXISTS (SELECT 1 FROM email_account_messages eam
WHERE eam.email_id = e.id AND eam.deleted_at IS NULL)
GROUP BY eil.fundraising_investor_id
"""
).fetchall():
_bump(r["inv"], r["last_ts"])
except sqlite3.OperationalError:
pass
return out
REMINDER_DUE_SOON_DAYS = 7
def reminder_status_by_source_row(conn):
"""Return {grid source_row_id: 'overdue'|'due_soon'|'open'} for every investor with at
least one open reminder, derived live from the reminders table (never stored in the grid
blob like pipeline_stage). Injected as a read-only grid column so a saved view can later
filter "has an open reminder" instead of the binary follow_up checkbox. Most-urgent wins."""
out = {}
try:
rows = conn.execute(
"""
SELECT fi.source_row_id AS srid, r.due_date AS due
FROM reminders r
JOIN fundraising_investors fi ON r.investor_id = fi.id
WHERE r.deleted_at IS NULL AND r.status = 'open'
"""
).fetchall()
except sqlite3.OperationalError:
return out
today = now()[:10]
soon = (datetime.utcnow() + timedelta(days=REMINDER_DUE_SOON_DAYS)).date().isoformat()
rank = {'open': 0, 'due_soon': 1, 'overdue': 2}
for r in rows:
srid = str(r['srid'] or '')
if not srid:
continue
due = str(r['due'] or '')[:10]
if due and due < today:
st = 'overdue'
elif due and due <= soon:
st = 'due_soon'
else:
st = 'open'
if srid not in out or rank[st] > rank[out[srid]]:
out[srid] = st
return out
def maybe_run_scheduled_backup():
conn = get_db()
try:
@@ -2073,6 +2176,10 @@ class CRMHandler(BaseHTTPRequestHandler):
if path == '/api/outreach/radar':
return self.handle_outreach_radar(user)
# Reminders / follow-ups
if path == '/api/reminders':
return self.handle_list_reminders(user, params)
# Matrix intake bot — new-vs-existing lookup for its in-thread proposal
if path == '/api/intake/match':
return self.handle_intake_match(user, params)
@@ -2159,6 +2266,8 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.handle_pipeline_link(user, body)
if path == '/api/fundraising/pipeline/unlink':
return self.handle_pipeline_unlink(user, body)
if path == '/api/reminders':
return self.handle_create_reminder(user, body)
if path == '/api/fundraising/collab/heartbeat':
return self.handle_fundraising_collab_heartbeat(user, body)
if path == '/api/admin/users':
@@ -2266,6 +2375,8 @@ class CRMHandler(BaseHTTPRequestHandler):
if re.match(r'^/api/feature-requests/[^/]+$', path):
fr_id = path.split('/')[-1]
return self.handle_update_feature_request(user, fr_id, body)
if re.match(r'^/api/reminders/[^/]+$', path):
return self.handle_update_reminder(user, path.split('/')[-1], body)
if re.match(r'^/api/admin/users/[^/]+$', path):
target_user_id = path.split('/')[-1]
return self.handle_admin_update_user(user, target_user_id, body)
@@ -2297,6 +2408,8 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.handle_delete_opportunity(user, path.split('/')[-1])
if re.match(r'^/api/communications/[^/]+$', path):
return self.handle_delete_communication(user, path.split('/')[-1])
if re.match(r'^/api/reminders/[^/]+$', path):
return self.handle_delete_reminder(user, path.split('/')[-1])
if re.match(r'^/api/thesis/nodes/[^/]+$', path):
return self.handle_retire_thesis_node(user, path.split('/')[-1])
self.send_error_json("Not found", 404)
@@ -3309,6 +3422,197 @@ class CRMHandler(BaseHTTPRequestHandler):
conn.close()
return self.send_json({"data": {"archived": archived}})
# ═══════════════════════════════════════════════════════════════════════════
# REMINDERS / FOLLOW-UPS
# ═══════════════════════════════════════════════════════════════════════════
# First-class tickler tied to the fundraising grid (logical FK to
# fundraising_investors.id, denormalized name). Supersedes the binary follow_up
# checkbox + per-comm next_action_date as the place follow-ups live. All reads filter
# deleted_at IS NULL (standing soft-delete rule). Pure local CRM data — no LLM path.
_REMINDER_STATUSES = ('open', 'done', 'snoozed', 'cancelled')
def handle_list_reminders(self, user, params):
params = params or {}
where = ["r.deleted_at IS NULL"]
args = []
# `overdue` owns the status constraint (open + strictly past due) so it can never
# conflict with a stray status= param — two contradictory r.status predicates would
# silently return zero rows. So overdue wins and an accompanying status= is ignored.
overdue = str(params.get('overdue') or '').strip().lower() in ('1', 'true', 'yes')
status = str(params.get('status') or '').strip()
if overdue:
where.append("r.status = 'open'")
where.append("r.due_date IS NOT NULL AND r.due_date != '' AND r.due_date < ?")
args.append(now()[:10])
elif status == 'active': # open + snoozed (the actionable set)
where.append("r.status IN ('open','snoozed')")
elif status in self._REMINDER_STATUSES:
where.append("r.status = ?")
args.append(status)
assignee = str(params.get('assignee') or '').strip()
if assignee == 'me':
where.append("r.assignee_id = ?")
args.append(user['user_id'])
elif assignee:
where.append("r.assignee_id = ?")
args.append(assignee)
investor_id = str(params.get('investor_id') or '').strip()
if investor_id:
where.append("r.investor_id = ?")
args.append(investor_id)
# The grid only knows the source_row_id, not the backend investor id (mirrors the
# pipeline-link endpoint) — resolve it via a subquery so the grid stays decoupled.
source_row_id = str(params.get('source_row_id') or '').strip()
if source_row_id:
where.append("r.investor_id IN (SELECT id FROM fundraising_investors WHERE source_row_id = ?)")
args.append(source_row_id)
due_before = str(params.get('due_before') or '').strip()
if due_before:
where.append("r.due_date IS NOT NULL AND r.due_date != '' AND r.due_date <= ?")
args.append(due_before)
conn = get_db()
try:
rows = conn.execute(
"SELECT r.*, ua.username AS assignee_name, uc.username AS creator_name "
"FROM reminders r "
"LEFT JOIN users ua ON ua.id = r.assignee_id "
"LEFT JOIN users uc ON uc.id = r.created_by "
"WHERE " + " AND ".join(where) +
# dated reminders first (soonest due), then undated by recency
" ORDER BY (r.due_date IS NULL OR r.due_date = ''), r.due_date ASC, r.created_at ASC",
args
).fetchall()
last_by_inv = last_activity_by_investor(conn)
data = []
for row in rows:
d = row_to_dict(row)
d['last_activity_at'] = last_by_inv.get(d.get('investor_id'))
data.append(d)
return self.send_json({"data": data, "total": len(data)})
finally:
conn.close()
def handle_create_reminder(self, user, body):
body = body or {}
title = str(body.get('title') or '').strip()
if not title:
return self.send_error_json("title is required")
investor_id = str(body.get('investor_id') or '').strip() or None
investor_name = str(body.get('investor_name') or '').strip()
contact_id = str(body.get('contact_id') or '').strip() or None
source_row_id = str(body.get('source_row_id') or '').strip()
due_date = str(body.get('due_date') or '').strip() or None
assignee_id = str(body.get('assignee_id') or '').strip() or None
details = str(body.get('details') or '').strip() or None
source = str(body.get('source') or 'human').strip() or 'human'
conn = get_db()
try:
# The grid hands us a source_row_id, not the backend investor id (mirrors the
# pipeline-link endpoint) — resolve it to the stable investor id + name.
if not investor_id and source_row_id:
inv = conn.execute(
"SELECT id, investor_name FROM fundraising_investors WHERE source_row_id = ?",
(source_row_id,)
).fetchone()
if not inv:
conn.close()
return self.send_error_json("Unknown source_row_id", 404)
investor_id = inv['id']
investor_name = investor_name or str(inv['investor_name'] or '').strip()
# Refresh the denormalized name from the grid when we have a live investor id;
# reject an id that doesn't resolve unless the caller supplied a name to keep.
elif investor_id:
inv = conn.execute(
"SELECT investor_name FROM fundraising_investors WHERE id = ?", (investor_id,)
).fetchone()
if inv:
investor_name = str(inv['investor_name'] or '').strip() or investor_name
elif not investor_name:
conn.close()
return self.send_error_json("Unknown investor_id", 404)
rid = generate_id()
conn.execute(
"INSERT INTO reminders (id, investor_id, investor_name, contact_id, title, details, "
"due_date, status, assignee_id, created_by, source, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?)",
(rid, investor_id, investor_name or None, contact_id, title, details,
due_date, assignee_id, user['user_id'], source, now(), now())
)
log_audit(conn, user['user_id'], 'reminder', rid, 'create',
{"title": title, "investor_id": investor_id, "due_date": due_date})
conn.commit()
row = conn.execute("SELECT * FROM reminders WHERE id = ?", (rid,)).fetchone()
return self.send_json({"data": row_to_dict(row)}, 201)
finally:
conn.close()
def handle_update_reminder(self, user, reminder_id, body):
body = body or {}
conn = get_db()
try:
existing = conn.execute(
"SELECT id FROM reminders WHERE id = ? AND deleted_at IS NULL", (reminder_id,)
).fetchone()
if not existing:
conn.close()
return self.send_error_json("Reminder not found", 404)
sets, args = [], []
if 'title' in body:
t = str(body.get('title') or '').strip()
if not t:
conn.close()
return self.send_error_json("title cannot be empty")
sets.append("title = ?")
args.append(t)
for field in ('details', 'due_date', 'assignee_id', 'contact_id', 'snoozed_until'):
if field in body:
sets.append(f"{field} = ?")
args.append(str(body.get(field) or '').strip() or None)
if 'status' in body:
st = str(body.get('status') or '').strip()
if st not in self._REMINDER_STATUSES:
conn.close()
return self.send_error_json(
f"Invalid status. Must be one of: {', '.join(self._REMINDER_STATUSES)}")
sets.append("status = ?")
args.append(st)
# Stamp completion time on done; clear it if the reminder is reopened.
sets.append("completed_at = ?")
args.append(now() if st == 'done' else None)
if not sets:
conn.close()
return self.send_error_json("No updatable fields provided")
sets.append("updated_at = ?")
args.append(now())
args.append(reminder_id)
conn.execute(f"UPDATE reminders SET {', '.join(sets)} WHERE id = ?", args)
log_audit(conn, user['user_id'], 'reminder', reminder_id, 'update', body)
conn.commit()
row = conn.execute("SELECT * FROM reminders WHERE id = ?", (reminder_id,)).fetchone()
return self.send_json({"data": row_to_dict(row)})
finally:
conn.close()
def handle_delete_reminder(self, user, reminder_id):
conn = get_db()
try:
existing = conn.execute(
"SELECT id FROM reminders WHERE id = ? AND deleted_at IS NULL", (reminder_id,)
).fetchone()
if not existing:
conn.close()
return self.send_error_json("Reminder not found", 404)
conn.execute("UPDATE reminders SET deleted_at = ?, updated_at = ? WHERE id = ?",
(now(), now(), reminder_id))
log_audit(conn, user['user_id'], 'reminder', reminder_id, 'delete', None)
conn.commit()
return self.send_json({"data": {"deleted": True}})
finally:
conn.close()
def handle_intake_match(self, user, params):
"""Read-only: does an investor matching this intake already exist? Used by the
Matrix intake bot to label its in-thread proposal new-vs-existing. Returns the
@@ -5125,6 +5429,7 @@ class CRMHandler(BaseHTTPRequestHandler):
self._ensure_fundraising_state_row(conn)
row = conn.execute("SELECT * FROM fundraising_state WHERE id = 'main'").fetchone()
stage_by_row = pipeline_stage_by_source_row(conn)
reminder_by_row = reminder_status_by_source_row(conn)
conn.close()
try:
@@ -5152,6 +5457,10 @@ class CRMHandler(BaseHTTPRequestHandler):
stage = stage_by_row.get(str(r.get('id') or ''))
r['pipeline'] = bool(stage)
r['pipeline_stage'] = stage or ''
# Read-only reminder status, derived live from the reminders table (never stored
# in the blob). '' = no open reminder; a saved view can filter on this column to
# supersede the binary follow_up checkbox.
r['reminder_status'] = reminder_by_row.get(str(r.get('id') or ''), '')
return self.send_json({
"data": {
@@ -5334,6 +5643,8 @@ class CRMHandler(BaseHTTPRequestHandler):
# Archive pipeline opps orphaned by an investor deleted from the grid (one-way
# cleanup; never creates or reseeds — see reconcile_grid_pipeline_links).
reconcile_grid_pipeline_links(conn)
# Cancel reminders orphaned by the same investor deletion (one-way cleanup twin).
reconcile_grid_reminders(conn)
log_audit(conn, user['user_id'], 'fundraising_state', 'main', 'update', {"version": next_version})
conn.commit()
conn.close()
+50
View File
@@ -193,6 +193,55 @@ def test_build_and_empty():
conn.close()
def test_reminders_due():
"""The reminders-due section: overdue + due-today only (future / done / soft-deleted
excluded), rendered even on an empty email window. Creates + drops the reminders table
so the rest of the suite still exercises the table-absent path."""
from datetime import date, timedelta
conn = _conn()
conn.execute("""CREATE TABLE reminders (id TEXT PRIMARY KEY, investor_id TEXT,
investor_name TEXT, contact_id TEXT, title TEXT, details TEXT, due_date TEXT,
status TEXT DEFAULT 'open', snoozed_until TEXT, assignee_id TEXT, created_by TEXT,
source TEXT, completed_at TEXT, created_at TEXT, updated_at TEXT, deleted_at TEXT)""")
today = date.today().isoformat()
yest = (date.today() - timedelta(days=1)).isoformat()
future = (date.today() + timedelta(days=30)).isoformat()
conn.executemany(
"INSERT INTO reminders (id,investor_name,title,due_date,status,assignee_id,deleted_at) "
"VALUES (?,?,?,?,?,?,?)", [
("r1", "Harbor & Vine", "Send wire instructions", yest, "open", "u1", None), # overdue
("r2", "Brightwater Capital", "Call about allocation", today, "open", None, None), # due today
("r3", "Vela Partners", "Quarterly touch", future, "open", "u1", None), # future -> hidden
("r4", "Gone LP", "Done already", yest, "done", "u1", None), # done -> hidden
("r5", "Deleted LP", "Tombstoned", yest, "open", "u1", "2026-06-01T00:00:00Z"), # deleted -> hidden
])
conn.commit()
due = digest_builder.collect_due_reminders(conn, today)
titles = {r["title"] for r in due}
check(titles == {"Send wire instructions", "Call about allocation"},
f"due collector = overdue + due-today only (got {titles})")
overdue = [r for r in due if r["overdue"]]
check(len(overdue) == 1 and overdue[0]["title"] == "Send wire instructions", "overdue flagged")
stub = lambda prompt, system=None, max_tokens=220: "narrative"
d = digest_builder.build_digest(conn, SINCE, UNTIL, chat_fn=stub)
check(d["reminder_count"] == 2, f"reminder_count = 2 (got {d['reminder_count']})")
check("REMINDERS DUE (2)" in d["body"], "body has reminders section header")
check("Overdue (1):" in d["body"] and "Due today (1):" in d["body"], "body splits overdue / due today")
check("Harbor & Vine — Send wire instructions" in d["body"]
and "[Grant Gilliam]" in d["body"], "reminder line shows investor + title + resolved assignee")
check("Quarterly touch" not in d["body"], "future reminder excluded from due section")
empty = digest_builder.build_digest(conn, "2030-01-01T00:00:00Z", "2030-01-02T00:00:00Z", chat_fn=stub)
check("No tracked email activity" in empty["body"] and "REMINDERS DUE (2)" in empty["body"],
"reminders render even on an empty email window (current-state addendum)")
conn.execute("DROP TABLE reminders")
conn.commit()
conn.close()
def test_policy():
conn = _conn()
# No DB row yet: CRM_DIGEST_ENABLED=1 (set at import) seeds enabled; hour defaults 18.
@@ -352,6 +401,7 @@ def main():
print("collect_user_activity:"); test_collect()
print("collect_investor_activity:"); test_investor()
print("build_digest + empty:"); test_build_and_empty()
print("reminders due:"); test_reminders_due()
print("summary fallback:"); test_summary_fallback()
print("digest policy:"); test_policy()
print("window resolver:"); test_window_resolver()
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Tests for reminders / follow-ups (W1).
Boots the REAL server against a temp DB and exercises the new endpoints end-to-end:
- POST /api/reminders creates an open reminder tied to a grid investor (denormalized
investor_name resolved from the grid), or a standalone task (no investor_id);
- GET /api/reminders lists + filters by status (active/open/done/...), overdue, investor_id,
assignee=me; every read is soft-delete filtered;
- PATCH completes (stamps completed_at) / snoozes / edits a reminder; status is validated;
- DELETE soft-deletes (gone from every list, never hard-deleted);
- GET /api/fundraising/state injects a read-only reminder_status (overdue/due_soon/open/'')
derived live from open reminders, and strips it on save (never persisted to the blob);
- deleting an investor from the grid cancels its orphaned reminders (reconcile twin),
while a standalone reminder is untouched;
- the usual guards (missing title -> 400, bad status -> 400, unknown investor -> 404,
unauthenticated -> 401).
Synthetic data only.
Run: cd backend && python3 test_reminders.py
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
from datetime import datetime, timedelta
from http.server import ThreadingHTTPServer
_DATA = tempfile.mkdtemp()
os.environ["CRM_DATA_DIR"] = _DATA
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import server # noqa: E402
FAILS = []
TODAY = datetime.utcnow().date()
TOMORROW = (TODAY + timedelta(days=1)).isoformat()
YESTERDAY = (TODAY - timedelta(days=1)).isoformat()
FAR = (TODAY + timedelta(days=30)).isoformat()
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
class _Quiet(server.CRMHandler):
def log_message(self, *a):
pass
def _req(port, method, path, token=None, body=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
headers = {}
if token:
headers["Authorization"] = "Bearer " + token
payload = None
if body is not None:
payload = json.dumps(body)
headers["Content-Type"] = "application/json"
conn.request(method, path, body=payload, headers=headers)
resp = conn.getresponse()
raw = resp.read().decode("utf-8", "replace")
conn.close()
data = None
if raw:
try:
data = json.loads(raw)
except ValueError:
pass
return resp.status, data
def _put_grid(port, token, rows):
return _req(port, "PUT", "/api/fundraising/state", token,
{"grid": {"columns": [], "rows": rows}, "views": []})
ROW_ACME = {"id": "rowAcme", "investor_name": "Acme Capital", "notes": "", "lead": "Grant",
"contacts": [{"name": "Jane Doe", "email": "jane@acme.com", "title": "GP"}]}
ROW_BETA = {"id": "rowBeta", "investor_name": "Beta Capital", "notes": "",
"contacts": [{"name": "Pat Roe", "email": "pat@beta.com"}]}
ROW_GAMMA = {"id": "rowGamma", "investor_name": "Gamma Partners", "notes": "",
"contacts": [{"name": "Sam Lee", "email": "sam@gamma.com"}]}
def _db():
return sqlite3.connect(os.environ["CRM_DB_PATH"])
def seed():
c = _db()
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
c.commit()
c.close()
def _investor_id(source_row_id):
c = _db()
r = c.execute("SELECT id FROM fundraising_investors WHERE source_row_id = ?", (source_row_id,)).fetchone()
c.close()
return r[0] if r else None
def _grid_reminder_status(port, token):
st, d = _req(port, "GET", "/api/fundraising/state", token)
rows = (d or {}).get("data", {}).get("grid", {}).get("rows", [])
return {r["id"]: r.get("reminder_status") for r in rows}
def main():
server.init_db()
seed()
token = server.create_token("u1", "grant", "admin")
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
st, _ = _put_grid(port, token, [ROW_ACME, ROW_BETA, ROW_GAMMA])
check(st == 200, f"seed grid via PUT /state (got {st})")
acme_id = _investor_id("rowAcme")
beta_id = _investor_id("rowBeta")
gamma_id = _investor_id("rowGamma")
check(bool(acme_id and beta_id and gamma_id), "investor ids resolved from the grid")
# ── create: investor-linked + denormalized name resolved from the grid ──
print("\n[create: investor-linked reminder resolves the denormalized name]")
st, d = _req(port, "POST", "/api/reminders", token,
{"investor_id": acme_id, "title": "Send Fund III deck", "due_date": TOMORROW})
rem = (d or {}).get("data") or {}
acme_rem_id = rem.get("id")
check(st == 201 and rem.get("status") == "open", f"create -> 201 open (got {st}, {d})")
check(rem.get("investor_name") == "Acme Capital", f"name denormalized from grid (got {rem.get('investor_name')})")
# overdue reminder on Beta; far-future + standalone for filter coverage
st, d = _req(port, "POST", "/api/reminders", token,
{"investor_id": beta_id, "title": "Call Pat", "due_date": YESTERDAY})
beta_rem_id = (d or {}).get("data", {}).get("id")
check(st == 201, f"create overdue beta reminder (got {st})")
st, d = _req(port, "POST", "/api/reminders", token,
{"investor_id": gamma_id, "title": "Quarterly touch", "due_date": FAR})
gamma_rem_id = (d or {}).get("data", {}).get("id")
st, d = _req(port, "POST", "/api/reminders", token, {"title": "Team: refresh pipeline view"})
standalone_id = (d or {}).get("data", {}).get("id")
check(st == 201 and (d or {}).get("data", {}).get("investor_id") in (None, ""),
f"standalone reminder (no investor) created (got {st})")
# ── list + filters ──
print("\n[list + filters]")
st, d = _req(port, "GET", "/api/reminders", token)
items = (d or {}).get("data", [])
check(st == 200 and len(items) == 4, f"list returns all 4 open (got {st}, {len(items)})")
check(all("last_activity_at" in it for it in items), "each row carries last_activity_at")
# dated reminders sort before undated, soonest first -> YESTERDAY (beta) leads
check(items and items[0].get("id") == beta_rem_id, f"overdue sorts first (got {items[0].get('id') if items else None})")
st, d = _req(port, "GET", "/api/reminders?overdue=1", token)
ids = [it["id"] for it in (d or {}).get("data", [])]
check(ids == [beta_rem_id], f"overdue=1 -> only the past-due one (got {ids})")
# overdue owns the status constraint: a conflicting status= must not silently zero out
st, d = _req(port, "GET", "/api/reminders?overdue=1&status=done", token)
ids = [it["id"] for it in (d or {}).get("data", [])]
check(ids == [beta_rem_id], f"overdue wins over a conflicting status= (got {ids})")
st, d = _req(port, "GET", f"/api/reminders?investor_id={acme_id}", token)
ids = [it["id"] for it in (d or {}).get("data", [])]
check(ids == [acme_rem_id], f"investor_id filter (got {ids})")
st, d = _req(port, "GET", "/api/reminders?assignee=me", token)
check(len(d.get("data", [])) == 0, "assignee=me -> none (reminders created unassigned)")
# ── grid injection: reminder_status derived live, never persisted ──
print("\n[grid injection: read-only reminder_status]")
s = _grid_reminder_status(port, token)
check(s.get("rowAcme") == "due_soon", f"due-tomorrow -> due_soon (got {s.get('rowAcme')})")
check(s.get("rowBeta") == "overdue", f"past-due -> overdue (got {s.get('rowBeta')})")
check(s.get("rowGamma") == "open", f"far-future -> open (got {s.get('rowGamma')})")
# echo the injected value back on save; it must NOT persist into the blob
st, d = _req(port, "GET", "/api/fundraising/state", token)
echoed = (d or {}).get("data", {}).get("grid", {}).get("rows", [])
st, _ = _put_grid(port, token, echoed)
c = _db()
blob = json.loads(c.execute("SELECT grid_json FROM fundraising_state WHERE id='main'").fetchone()[0])
c.close()
acme_stored = {r["id"]: r for r in blob.get("rows", [])}.get("rowAcme", {})
check("reminder_status" not in acme_stored, "reminder_status not persisted into the grid blob")
# ── complete / reopen stamps completed_at ──
print("\n[complete + reopen]")
st, d = _req(port, "PATCH", f"/api/reminders/{acme_rem_id}", token, {"status": "done"})
check(st == 200 and (d or {}).get("data", {}).get("status") == "done"
and (d or {}).get("data", {}).get("completed_at"), f"done stamps completed_at (got {d})")
st, d = _req(port, "GET", "/api/reminders?status=open", token)
check(acme_rem_id not in [it["id"] for it in (d or {}).get("data", [])], "done reminder drops from status=open")
st, d = _req(port, "GET", "/api/reminders?status=done", token)
check(acme_rem_id in [it["id"] for it in (d or {}).get("data", [])], "done reminder shows under status=done")
check(_grid_reminder_status(port, token).get("rowAcme") in (None, ""), "completed reminder clears the grid chip")
st, d = _req(port, "PATCH", f"/api/reminders/{acme_rem_id}", token, {"status": "open"})
check((d or {}).get("data", {}).get("completed_at") in (None, ""), "reopen clears completed_at")
# ── snooze: out of 'open', still in 'active' ──
print("\n[snooze]")
st, d = _req(port, "PATCH", f"/api/reminders/{beta_rem_id}", token,
{"status": "snoozed", "snoozed_until": FAR})
check(st == 200 and (d or {}).get("data", {}).get("status") == "snoozed", f"snooze (got {st})")
st, d = _req(port, "GET", "/api/reminders?status=open", token)
check(beta_rem_id not in [it["id"] for it in (d or {}).get("data", [])], "snoozed drops from status=open")
st, d = _req(port, "GET", "/api/reminders?status=active", token)
check(beta_rem_id in [it["id"] for it in (d or {}).get("data", [])], "snoozed stays in status=active")
# ── edit title + due date ──
print("\n[edit]")
st, d = _req(port, "PATCH", f"/api/reminders/{gamma_rem_id}", token,
{"title": "Quarterly touch — Q3", "due_date": TOMORROW})
check((d or {}).get("data", {}).get("title") == "Quarterly touch — Q3"
and (d or {}).get("data", {}).get("due_date") == TOMORROW, f"title+due edited (got {d})")
# ── soft-delete: gone from every list, tombstoned not hard-deleted ──
print("\n[soft-delete]")
st, d = _req(port, "DELETE", f"/api/reminders/{standalone_id}", token)
check(st == 200 and (d or {}).get("data", {}).get("deleted") is True, f"delete -> 200 (got {st})")
st, d = _req(port, "GET", "/api/reminders", token)
check(standalone_id not in [it["id"] for it in (d or {}).get("data", [])], "deleted reminder hidden from list")
c = _db()
gone = c.execute("SELECT deleted_at FROM reminders WHERE id = ?", (standalone_id,)).fetchone()[0]
c.close()
check(gone is not None, "deleted reminder tombstoned (deleted_at set), not hard-deleted")
# ── orphan reconcile: drop the investor from the grid -> its reminders cancelled ──
print("\n[orphan reconcile: deleting the grid investor cancels its reminders]")
# create a fresh standalone task to confirm it is NOT cancelled by the reconciler
st, d = _req(port, "POST", "/api/reminders", token, {"title": "Standalone keeps living"})
keep_id = (d or {}).get("data", {}).get("id")
st, _ = _put_grid(port, token, [ROW_ACME, ROW_BETA]) # drop rowGamma
c = _db()
gamma_status = c.execute("SELECT status FROM reminders WHERE id = ?", (gamma_rem_id,)).fetchone()[0]
keep_status = c.execute("SELECT status FROM reminders WHERE id = ?", (keep_id,)).fetchone()[0]
gamma_name = c.execute("SELECT investor_name FROM reminders WHERE id = ?", (gamma_rem_id,)).fetchone()[0]
c.close()
check(gamma_status == "cancelled", f"orphaned investor's reminder cancelled (got {gamma_status})")
check(gamma_name == "Gamma Partners", "cancelled reminder keeps denormalized investor_name for history")
check(keep_status == "open", f"standalone reminder untouched by reconcile (got {keep_status})")
# ── recency rollup: a tombstoned email sighting must not inflate last_activity_at ──
print("\n[recency: soft-deleted email sighting excluded from last_activity_at]")
c = _db()
c.execute("INSERT INTO emails (id, rfc_message_id, from_email, sent_at, is_matched, match_status) "
"VALUES ('em1','<em1@x>','lp@acme.com','2026-06-10T00:00:00Z',1,'matched')")
c.execute("INSERT INTO email_investor_links (id, email_id, fundraising_investor_id, match_kind, match_confidence, matched_address) "
"VALUES ('eil1','em1',?, 'exact_email',1.0,'lp@acme.com')", (acme_id,))
c.execute("INSERT INTO email_account_messages (id, email_id, account_id, gmail_message_id, gmail_thread_id, deleted_at) "
"VALUES ('eam1','em1','acct1','g1','t1','2026-06-11T00:00:00Z')") # tombstoned sighting only
c.commit(); c.close()
st, d = _req(port, "GET", f"/api/reminders?investor_id={acme_id}", token)
items = (d or {}).get("data", [])
la = items[0].get("last_activity_at") if items else "MISSING"
check(bool(items) and la is None, f"tombstoned-only email -> no last_activity (got {la})")
c = _db()
c.execute("INSERT INTO email_account_messages (id, email_id, account_id, gmail_message_id, gmail_thread_id, deleted_at) "
"VALUES ('eam2','em1','acct2','g2','t2',NULL)")
c.commit(); c.close()
st, d = _req(port, "GET", f"/api/reminders?investor_id={acme_id}", token)
items = (d or {}).get("data", [])
la = items[0].get("last_activity_at") if items else "MISSING"
check(bool(items) and la == '2026-06-10T00:00:00Z', f"live sighting -> last_activity set (got {la})")
# ── guards ──
print("\n[guards]")
st, _ = _req(port, "POST", "/api/reminders", token, {"investor_id": acme_id})
check(st == 400, f"missing title -> 400 (got {st})")
st, _ = _req(port, "POST", "/api/reminders", token, {"title": "x", "investor_id": "nope"})
check(st == 404, f"unknown investor_id -> 404 (got {st})")
st, _ = _req(port, "PATCH", f"/api/reminders/{gamma_rem_id}", token, {"status": "bogus"})
check(st == 400, f"invalid status -> 400 (got {st})")
st, _ = _req(port, "DELETE", "/api/reminders/doesnotexist", token)
check(st == 404, f"delete unknown -> 404 (got {st})")
st, _ = _req(port, "GET", "/api/reminders", None)
check(st == 401, f"unauthenticated list -> 401 (got {st})")
finally:
httpd.shutdown()
print("\n" + ("ALL PASS" if not FAILS else f"{len(FAILS)} FAILURE(S):"))
for f in FAILS:
print(" - " + f)
sys.exit(1 if FAILS else 0)
if __name__ == "__main__":
main()