From f1815259261eecc625272edc3cb68911f171e867 Mon Sep 17 00:00:00 2001 From: Keysat Date: Thu, 18 Jun 2026 14:45:46 -0500 Subject: [PATCH] Add reminders & follow-ups (W1) (v0.1.0:92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- AGENTS.md | 4 +- ROADMAP.md | 12 + backend/digest_builder.py | 145 +++++-- backend/migrations/0006_reminders.down.sql | 8 + backend/migrations/0006_reminders.sql | 45 +++ backend/server.py | 313 +++++++++++++- backend/test_digest_builder.py | 50 +++ backend/test_reminders.py | 296 ++++++++++++++ frontend/index.html | 448 ++++++++++++++++++++- start9/0.4/startos/utils.ts | 5 +- start9/0.4/startos/versions/index.ts | 5 +- start9/0.4/startos/versions/v0.1.0.92.ts | 22 + 12 files changed, 1306 insertions(+), 47 deletions(-) create mode 100644 backend/migrations/0006_reminders.down.sql create mode 100644 backend/migrations/0006_reminders.sql create mode 100644 backend/test_reminders.py create mode 100644 start9/0.4/startos/versions/v0.1.0.92.ts diff --git a/AGENTS.md b/AGENTS.md index 724fbed..fa3e17d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,9 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude ## Current state -_Phase 0 + Phase 1 built; **box and repo at v0.1.0:91** (deployed & verified live 2026-06-18 — `installed-version`=0.1.0:91, server up on :8080, clean; the StartOS version-graph traversal logs an inert down-to-39-then-up because the per-version `up`/`down` hooks are no-ops — real SQLite migrations run in-app at startup). **The fundraising grid + email capture is the canonical system of record.** Deploy/feature history: git log + `start9/0.4/startos/versions/`; longer-term backlog/debt: `ROADMAP.md` / `EVALUATION.md`._ +_Phase 0 + Phase 1 built; **box live at v0.1.0:91; repo at v0.1.0:92** (v92 = reminders/follow-ups — built + tested locally 2026-06-18, **deploy pending**. Box deployed & verified live 2026-06-18 — `installed-version`=0.1.0:91, server up on :8080, clean; the StartOS version-graph traversal logs an inert down-to-39-then-up because the per-version `up`/`down` hooks are no-ops — real SQLite migrations run in-app at startup). **The fundraising grid + email capture is the canonical system of record.** Deploy/feature history: git log + `start9/0.4/startos/versions/`; longer-term backlog/debt: `ROADMAP.md` / `EVALUATION.md`._ + +- **Reminders & follow-ups (W1) — BUILT + tested locally 2026-06-18 (repo v0.1.0:92, deploy pending).** First step of the agreed reminders → NL-search → bot-mutations plan (`ROADMAP.md` "Follow-ups/reminders + NL search + bot grid-mutations"; **overarching constraint: keep LP data off third-party LLMs — the dominant risk, above write-safety**). First-class tickler tied to the grid: `reminders` table (in-app migration `0006`; logical FK to `fundraising_investors.id` + denormalized name, like `0005`), full CRUD (`GET/POST/PATCH/DELETE /api/reminders`; soft-delete; open/done/snoozed/cancelled; assignee; `source` human/bot/automation; accepts `source_row_id` so the grid stays decoupled), a read-only **derived `reminder_status` grid column** (overdue/due_soon/open — injected + stripped like `pipeline_stage`; **filterable so a saved view can later drive the follow-up view off real reminders, not the binary `follow_up` checkbox**), an orphan reconciler (`reconcile_grid_reminders`), a **Reminders** page + Dashboard **"Reminders Due"** card + **"Reminders due"** daily-digest section, and a per-investor **`last_activity_at`** recency rollup (shared building block for the W2 NL "not nurtured" query). **Pure local CRM — no LLM path, no leak surface.** Tests: `test_reminders.py` + digest reminders test (**31/31 green, render-smoke green**). Deferred fast-follow **W1b** = nurture-gap auto-suggested reminders. Next per plan: **W2 NL→safe-query** (web + Matrix), then **W3 bot grid-mutations** behind a Matrix approval gate (any member approves; money is low-stakes here — the concern is the bot can't silently mass-change numbers). - **Email-proposal review over Matrix + a `bot` role — DEPLOYED, LIVE & smoke-tested 2026-06-18 (box v0.1.0:91, Spark bot `b2690c4`).** The CRM-drafted "proposed grid notes" gain: (1) a click-to-view **inline source-email popup** on the Email Capture page (`GET /api/email/detail` — from/to/cc/date/subject + scrollable body); (2) a **CRM→Matrix review bridge** — the bot pulls pending proposals (`GET /api/intake/email-proposals`), posts dash-framed review cards (note names who emailed whom, not "Sent/Received") to a dedicated room (`MATRIX_EMAIL_REVIEW_ROOM`), and relays in-thread yes/no/NL-edit (`POST .../decide`), kept in sync with the web panel (decide on either → the other reflects it). **Decided threads are redacted whole** (card + replies; the bot holds a redact/mod power level) so — with Element's "show deleted messages" OFF — the main chat *and* threads view clear completely (confirmed the intended UX). New **`bot` role** (authenticated, never admin; `require_bot_or_admin`) gates the agent endpoints; state in `email_proposal_matrix` (email-migration `0003`). Full mechanics, deploy gotchas, and the `redact_resolved.py` backfill tool: `docs/guides/matrix-intake.md`. diff --git a/ROADMAP.md b/ROADMAP.md index fdde9a2..e26cfcc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -86,6 +86,18 @@ ## Backlog (post-Phase-1 agentic) +### Follow-ups/reminders + NL search + bot grid-mutations (agreed plan, 2026-06-18) +*Agreed with Grant 2026-06-18. Three workstreams, sequenced **W1 → W2 → W3**. **Overarching constraint (Grant):** the dominant risk is **leaking LP data (names, $, notes, contacts) to third-party LLMs — NOT write-safety.** A wrong number is recoverable; investor substance reaching Claude is not. Consequences: W2 keeps LP rows off Claude (only the question text + schema vocabulary leave the box; entity names resolved locally); W3 keeps bot mutation-parsing on local Qwen. Because this DB *logs* commitments/pipeline but doesn't move money, a bot mutation is low-stakes → **any team member may approve one in Matrix**; the guardrail is "the bot can't silently mass-change numbers," enforced by the per-mutation human approval gate, not a tight money gate.* + +**W1 — Reminders & follow-ups — BUILT + tested locally 2026-06-18 (v0.1.0:92, deploy pending).** First-class tickler tied to the grid: `reminders` table (in-app migration `0006`; logical FK to `fundraising_investors.id` + denormalized name, like `0005`), full CRUD (`GET/POST/PATCH/DELETE /api/reminders`; soft-delete; status open/done/snoozed/cancelled; assignee; `source` human/bot/automation), a read-only **derived `reminder_status` grid column** (overdue/due_soon/open — injected like `pipeline_stage`, **filterable so the follow-up view can later key off reminders instead of the binary `follow_up` checkbox**, per Grant), an orphan reconciler (`reconcile_grid_reminders` — cancels reminders when their investor leaves the grid, the pipeline reconciler's twin), a **Reminders** nav page (filter/complete/snooze/edit/delete + create), a Dashboard **"Reminders Due"** card, a **"Reminders due"** daily-digest section, and a per-investor **`last_activity_at`** recency rollup (the shared building block W2's "not nurtured" query needs). Pure local CRM — no LLM path, no leak surface. Tests: `test_reminders.py` + digest reminders test; **31/31 suite green, render-smoke green**. **Deploy:** needs an s9pk build + install (version bumped to 92); get authorization first. +- **W1b (deferred fast-follow):** nurture-gap automation — a daily job flags "committed / in-pipeline + no activity in N days + no open reminder" → auto-suggests a reminder (`source='automation'`, human confirms). Build once the recency rollup is proven in practice. +- **Left untouched (deliberate):** the grid `follow_up` checkbox + automation list-memberships, and `communications.next_action_date` + `/api/outreach/radar` — reminders are the new richer layer; folding those into it is a later cleanup, not now. + +**W2 — Natural-language query (read-only).** = the **"Email/communication search + NL query → item 3 (NL→safe structured query)"** below, now sequenced second. Locked stance: the LLM emits a **validated filter/query AST** over a curated field set (committed $, fund, stage, lead, `follow_up`, `last_activity_at`, `reminder_status`, …); the backend owns the SQL against soft-delete-filtered views with row/time caps — **never raw SQL**. Claude behind the redaction boundary; only the question text + schema vocabulary leave the box, never investor rows. Deliver in **both** web (search box) and Matrix (`@bot who needs follow-up?`). Reads need no approval gate. Builds on W1's `last_activity_at`. + +**W3 — Bot grid-mutations behind a Matrix approval gate.** Generalize the email-proposal scaffold (`email_proposal_matrix` + propose→post→decide→apply) into one `agent_proposals` table (kind discriminator + JSON payload + target). Bot proposes set-commitment / assign-fund / change-stage / set-reminder; a human approves/edits/rejects in Matrix (**any member**); then apply. **Surgical, version-checked mutations — never blob RMW:** stage rides the existing `opportunities` link + validated stage endpoint; reminders write the W1 table; set-commitment/assign-fund need a version-checked single-cell upsert into the grid blob. Triggers the deferred **scoped service-token** item below (per-mutation-kind allowlist on the bot credential; money/merge/delete always require human approval regardless of scope — the autonomy axis). Parse on local Qwen, not Claude. + + ### Matrix-bridge intake for the fundraising grid — M1+M2 BUILT (deploy + live smoke pending) *Requested 2026-06-16. **M1 (scaffold + parse + in-thread propose) and M2 (match + write-on-approve) built, tested (26/26), not yet deployed** — code in `backend/matrix_intake/`, guide at `docs/guides/matrix-intake.md`. Remaining: install `matrix-nio` + creds on the Spark, create the CRM bot user, and a **live Matrix smoke** (can't run in CI). M3 (business-card photo) deferred until Spark Control has a vision model. Next major build after this is **Pipeline adoption** (see below).* diff --git a/backend/digest_builder.py b/backend/digest_builder.py index 54a0c55..95c697b 100644 --- a/backend/digest_builder.py +++ b/backend/digest_builder.py @@ -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), } diff --git a/backend/migrations/0006_reminders.down.sql b/backend/migrations/0006_reminders.down.sql new file mode 100644 index 0000000..040dced --- /dev/null +++ b/backend/migrations/0006_reminders.down.sql @@ -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; diff --git a/backend/migrations/0006_reminders.sql b/backend/migrations/0006_reminders.sql new file mode 100644 index 0000000..07703f5 --- /dev/null +++ b/backend/migrations/0006_reminders.sql @@ -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; diff --git a/backend/server.py b/backend/server.py index 13cf857..c1719b2 100644 --- a/backend/server.py +++ b/backend/server.py @@ -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() diff --git a/backend/test_digest_builder.py b/backend/test_digest_builder.py index 75dceee..c9a7a46 100644 --- a/backend/test_digest_builder.py +++ b/backend/test_digest_builder.py @@ -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() diff --git a/backend/test_reminders.py b/backend/test_reminders.py new file mode 100644 index 0000000..94e80e8 --- /dev/null +++ b/backend/test_reminders.py @@ -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','','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() diff --git a/frontend/index.html b/frontend/index.html index 27ab146..b73f59a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3308,6 +3308,7 @@ const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [dueReminders, setDueReminders] = useState([]); useEffect(() => { const fetchDashboard = async () => { @@ -3324,6 +3325,20 @@ fetchDashboard(); }, [token]); + useEffect(() => { + // Reminders due now (overdue + due today) — open reminders with a past/today date. + const fetchDue = async () => { + try { + const today = new Date().toISOString().slice(0, 10); + const res = await api(`/api/reminders?status=open&due_before=${today}`, {}, token); + const list = Array.isArray(res?.data) ? res.data : []; + list.sort((a, b) => String(a.due_date || '').localeCompare(String(b.due_date || ''))); + setDueReminders(list); + } catch (_) { /* non-fatal: dashboard still renders */ } + }; + fetchDue(); + }, [token]); + if (loading) return
; if (error) return
{error}
; if (!data) return
No data
; @@ -3364,6 +3379,32 @@ + {dueReminders.length > 0 && ( +
+
Reminders Due ({dueReminders.length})
+
+ {dueReminders.slice(0, 10).map((r) => { + const today = new Date().toISOString().slice(0, 10); + const overdue = String(r.due_date || '').slice(0, 10) < today; + return ( +
+
+
+
+ {r.investor_name ? `${r.investor_name} — ` : ''}{r.title} +
+
+ {overdue ? 'Overdue' : 'Due today'}: {formatDate(r.due_date)} + {r.assignee_name ? ` · ${r.assignee_name}` : ''} +
+
+
+ ); + })} +
+
+ )} +
Pipeline by Stage
@@ -3421,6 +3462,235 @@ ); }; + const RemindersPage = ({ token, onShowToast, user }) => { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [statusFilter, setStatusFilter] = useState('active'); + const [onlyMine, setOnlyMine] = useState(false); + const [users, setUsers] = useState([]); + const [showCreate, setShowCreate] = useState(false); + const [createForm, setCreateForm] = useState({ title: '', due_date: '', details: '', investor_name: '', assignee_id: '' }); + const [creating, setCreating] = useState(false); + const [editing, setEditing] = useState(null); + const [editForm, setEditForm] = useState({ title: '', due_date: '', details: '', status: 'open', assignee_id: '' }); + const [savingEdit, setSavingEdit] = useState(false); + + const load = async () => { + setLoading(true); + try { + const params = new URLSearchParams(); + if (statusFilter && statusFilter !== 'all') { + if (statusFilter === 'overdue') params.set('overdue', '1'); + else params.set('status', statusFilter); + } + if (onlyMine) params.set('assignee', 'me'); + const res = await api(`/api/reminders?${params.toString()}`, {}, token); + setItems(Array.isArray(res?.data) ? res.data : []); + setError(''); + } catch (err) { + setError(getErrorMessage(err, 'Failed to load reminders')); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(); }, [token, statusFilter, onlyMine]); + useEffect(() => { + (async () => { + try { const r = await api('/api/users', {}, token); setUsers(Array.isArray(r?.data) ? r.data : []); } + catch (_) { /* assignee dropdown is optional */ } + })(); + }, [token]); + + const todayStr = new Date().toISOString().slice(0, 10); + const soonStr = new Date(Date.now() + 7 * 864e5).toISOString().slice(0, 10); + const urgency = (r) => { + if (r.status !== 'open' || !r.due_date) return null; + const due = String(r.due_date).slice(0, 10); + if (due < todayStr) return 'overdue'; + if (due <= soonStr) return 'due_soon'; + return null; + }; + + const patch = async (id, body, okMsg) => { + try { + await api(`/api/reminders/${id}`, { method: 'PATCH', body: JSON.stringify(body) }, token); + if (okMsg) onShowToast(okMsg, 'success'); + load(); + } catch (err) { onShowToast(getErrorMessage(err, 'Update failed'), 'error'); } + }; + const del = async (id) => { + try { + await api(`/api/reminders/${id}`, { method: 'DELETE' }, token); + onShowToast('Reminder deleted', 'success'); + load(); + } catch (err) { onShowToast(getErrorMessage(err, 'Delete failed'), 'error'); } + }; + const snooze = (r) => { + // Keep it actionable: push the due date out so it reliably reappears. A + // 'snoozed' status has no wake mechanism, so it would hide the reminder + // permanently — that status is reserved for an explicit "mute" via Edit. + const d = new Date(Date.now() + 7 * 864e5).toISOString().slice(0, 10); + patch(r.id, { status: 'open', due_date: d }, 'Snoozed 7 days'); + }; + + const submitCreate = async () => { + const title = (createForm.title || '').trim(); + if (!title) { onShowToast('A reminder needs a title', 'error'); return; } + setCreating(true); + try { + await api('/api/reminders', { method: 'POST', body: JSON.stringify({ + title, due_date: createForm.due_date || '', details: createForm.details || '', + investor_name: createForm.investor_name || '', assignee_id: createForm.assignee_id || '', + }) }, token); + onShowToast('Reminder created', 'success'); + setShowCreate(false); + setCreateForm({ title: '', due_date: '', details: '', investor_name: '', assignee_id: '' }); + load(); + } catch (err) { onShowToast(getErrorMessage(err, 'Create failed'), 'error'); } + finally { setCreating(false); } + }; + + const openEdit = (r) => { + setEditing(r); + setEditForm({ title: r.title || '', due_date: (r.due_date || '').slice(0, 10), + details: r.details || '', status: r.status || 'open', assignee_id: r.assignee_id || '' }); + }; + const submitEdit = async () => { + if (!editing) return; + const title = (editForm.title || '').trim(); + if (!title) { onShowToast('A reminder needs a title', 'error'); return; } + setSavingEdit(true); + try { + await api(`/api/reminders/${editing.id}`, { method: 'PATCH', body: JSON.stringify({ + title, due_date: editForm.due_date || '', details: editForm.details || '', + status: editForm.status, assignee_id: editForm.assignee_id || '', + }) }, token); + onShowToast('Reminder updated', 'success'); + setEditing(null); + load(); + } catch (err) { onShowToast(getErrorMessage(err, 'Update failed'), 'error'); } + finally { setSavingEdit(false); } + }; + + const badgeColor = { open: '#7fb0d3', snoozed: '#b08fd3', done: '#7fd3a3', cancelled: '#70859b' }; + + return ( +
+
+

Reminders

+ +
+ +
+ + +
+ + {loading ? + : error ?
{error}
+ : items.length === 0 ?
No reminders
+ : ( +
+ {items.map((r) => { + const u = urgency(r); + const dueColor = u === 'overdue' ? '#e06c6c' : u === 'due_soon' ? '#e0b341' : '#8ea2b7'; + return ( +
+
+
+ {r.title} + {r.status} +
+
+ {r.investor_name ? {r.investor_name} · : null} + {r.due_date ? `Due ${formatDate(r.due_date)}` : 'No due date'} + {r.assignee_name ? · {r.assignee_name} : null} + {r.last_activity_at ? · last activity {formatDate(r.last_activity_at)} : null} +
+ {r.details ?
{r.details}
: null} +
+
+ {(r.status === 'open' || r.status === 'snoozed') && } + {r.status === 'open' && } + + +
+
+ ); + })} +
+ )} + + {showCreate && ( +
+
+
New reminder
+
+ setCreateForm((f) => ({ ...f, title: e.target.value }))} placeholder="What needs doing?" />
+
+ setCreateForm((f) => ({ ...f, due_date: e.target.value }))} />
+
+ setCreateForm((f) => ({ ...f, investor_name: e.target.value }))} placeholder="Name only, or leave blank for a team task" /> +
To link a reminder to a specific investor row (and its grid chip), set it from the Fundraising Grid's Reminder column. A name typed here is a free-text label only.
+
+
+
+