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:
+312
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user