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
+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()