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:
@@ -104,7 +104,9 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
|
|||||||
|
|
||||||
## Current state
|
## 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`.
|
- **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`.
|
||||||
|
|
||||||
|
|||||||
+12
@@ -86,6 +86,18 @@
|
|||||||
|
|
||||||
## Backlog (post-Phase-1 agentic)
|
## 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)
|
### 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).*
|
*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).*
|
||||||
|
|
||||||
|
|||||||
+105
-40
@@ -75,6 +75,24 @@ _SYSTEM = (
|
|||||||
"greeting, no bullet points, no sign-off."
|
"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
|
# ------------------------------------------------------------------ collection
|
||||||
|
|
||||||
@@ -180,6 +198,27 @@ def collect_investor_activity(conn, since_iso, until_iso):
|
|||||||
return out
|
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
|
# ------------------------------------------------------------------ policy
|
||||||
|
|
||||||
DIGEST_POLICY_KEY = "digest_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}"
|
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")
|
title_date = datetime.now().astimezone().strftime("%A, %b %d %Y")
|
||||||
window = f"{_fmt_local(since_iso)} – {_fmt_local(until_iso)}"
|
window = f"{_fmt_local(since_iso)} – {_fmt_local(until_iso)}"
|
||||||
L = ["Ten31 CRM — Daily Activity Digest", title_date, f"Window: {window}", ""]
|
L = ["Ten31 CRM — Daily Activity Digest", title_date, f"Window: {window}", ""]
|
||||||
|
|
||||||
if not user_groups:
|
if not user_groups:
|
||||||
L += ["No tracked email activity from any user in this window.", "", _FOOTER]
|
L.append("No tracked email activity from any user in this window.")
|
||||||
return "\n".join(L)
|
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)
|
# ── Section 1: by team member (who did what; per-user Spark narrative) ──
|
||||||
total_invs = len({i for g in user_groups for i in g["investors"]})
|
L += ["", _RULE, "BY TEAM MEMBER", _RULE]
|
||||||
L.append(f"{len(user_groups)} team member(s) active · {total_emails} email(s) "
|
for g in user_groups:
|
||||||
f"· {total_invs} investor(s)")
|
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) ──
|
# ── Section 2: by investor (team-wide; both directions, structured) ──
|
||||||
L += ["", _RULE, "BY TEAM MEMBER", _RULE]
|
L += ["", _RULE, "BY INVESTOR", _RULE]
|
||||||
for g in user_groups:
|
for inv in investor_groups:
|
||||||
invs = ", ".join(g["investors"]) or "(no matched investor)"
|
L += ["",
|
||||||
L += ["",
|
f"{inv['name']} · {inv['total']} email(s) "
|
||||||
f"{g['full_name'] or g['username']} · {g['account_email']}",
|
f"({inv['inbound']} in, {inv['outbound']} out)"]
|
||||||
f"{g['total']} email(s) ({g['sent']} sent, {g['received']} received) "
|
for em in inv["emails"]:
|
||||||
f"· {invs}", "",
|
subj = em.get("subject") or "(no subject)"
|
||||||
narratives.get(g["user_id"], ""), ""]
|
when = _fmt_local(em["sent_at"])
|
||||||
for em in g["emails"]:
|
if em["direction"] == "out":
|
||||||
arrow = "→ Sent" if em["direction"] == "sent" else "← Received"
|
who = ", ".join(em["members"]) or "team"
|
||||||
invs_e = ", ".join(em["investors"]) or "(unmatched)"
|
L.append(f" → Sent by {who} · \"{subj}\" ({when})")
|
||||||
subj = em.get("subject") or "(no subject)"
|
else:
|
||||||
L.append(f" {arrow} · {invs_e} · \"{subj}\" ({_fmt_local(em['sent_at'])})")
|
L.append(f" ← Received · \"{subj}\" ({when})")
|
||||||
|
|
||||||
# ── Section 2: by investor (team-wide; both directions, structured) ──
|
# ── Reminders due (current state — independent of the activity window) ──
|
||||||
L += ["", _RULE, "BY INVESTOR", _RULE]
|
L += _reminders_section(due_reminders or [])
|
||||||
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})")
|
|
||||||
|
|
||||||
L += ["", _RULE, _FOOTER]
|
L += ["", _RULE, _FOOTER]
|
||||||
return "\n".join(L)
|
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):
|
def build_digest(conn, since_iso, until_iso, chat_fn=None):
|
||||||
"""Build the daily digest for [since_iso, until_iso). Returns
|
"""Build the daily digest for [since_iso, until_iso). Returns
|
||||||
{subject, body, has_activity, user_count, email_count, investor_count}. Always
|
{subject, body, has_activity, user_count, email_count, investor_count,
|
||||||
returns a body (empty windows get a 'no activity' note — the team chose
|
reminder_count}. Always returns a body (empty windows get a 'no activity' note —
|
||||||
always-send). Two sections: by team member (per-user Spark narrative) and by
|
the team chose always-send). Sections: by team member (per-user Spark narrative),
|
||||||
investor (structured, both directions)."""
|
by investor (structured), and reminders due (overdue + due today, current-state)."""
|
||||||
user_groups = collect_user_activity(conn, since_iso, until_iso)
|
user_groups = collect_user_activity(conn, since_iso, until_iso)
|
||||||
investor_groups = collect_investor_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}
|
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")
|
stamp = datetime.now().astimezone().strftime("%b %d")
|
||||||
return {
|
return {
|
||||||
"subject": f"Ten31 CRM — Daily Activity Digest · {stamp}",
|
"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),
|
"user_count": len(user_groups),
|
||||||
"email_count": sum(g["total"] for g in user_groups),
|
"email_count": sum(g["total"] for g in user_groups),
|
||||||
"investor_count": len(investor_groups),
|
"investor_count": len(investor_groups),
|
||||||
|
"reminder_count": len(due_reminders),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Manual rollback for 0006_reminders.sql (never auto-applied).
|
||||||
|
-- Drops the whole reminders feature table. Per the never-hard-delete guardrail this
|
||||||
|
-- discards reminder history, so only run it to reverse a bad migration on a dev/copy DB.
|
||||||
|
DROP INDEX IF EXISTS idx_reminders_assignee;
|
||||||
|
DROP INDEX IF EXISTS idx_reminders_due;
|
||||||
|
DROP INDEX IF EXISTS idx_reminders_status;
|
||||||
|
DROP INDEX IF EXISTS idx_reminders_investor;
|
||||||
|
DROP TABLE IF EXISTS reminders;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- Reminders & follow-ups — a real tickler/task model tied to the fundraising grid.
|
||||||
|
--
|
||||||
|
-- ADDITIVE + REVERSIBLE (CLAUDE.md guardrail #3): one new table + indexes; nothing
|
||||||
|
-- existing is touched. Until now the only follow-up surfaces were the grid's binary
|
||||||
|
-- `follow_up` checkbox (no date, owner, or status) and communications.next_action_date
|
||||||
|
-- (tied to a single logged comm). This gives investors first-class reminders with a due
|
||||||
|
-- date, status lifecycle, assignee, and provenance — the foundation for "who needs a
|
||||||
|
-- follow-up?" queries, the daily digest's due/overdue section, and (later) bot-proposed
|
||||||
|
-- reminders behind the Matrix approval gate.
|
||||||
|
--
|
||||||
|
-- investor_id is a LOGICAL foreign key to fundraising_investors(id) — deliberately NOT a
|
||||||
|
-- declared SQLite FOREIGN KEY, matching opportunities.fundraising_investor_id (migration
|
||||||
|
-- 0005). fundraising_investors rows are upserted by source_row_id on every grid save with
|
||||||
|
-- a STABLE id (so the link survives saves), but a row dropped from the grid is DELETEd —
|
||||||
|
-- there is nothing to cascade, and reconcile_grid_reminders() cancels the orphans on the
|
||||||
|
-- next save (the pipeline reconciler's twin). investor_name is denormalized so a reminder
|
||||||
|
-- stays readable in history even after its grid row is gone. investor_id is nullable: a
|
||||||
|
-- reminder can be a standalone team task not tied to any investor.
|
||||||
|
--
|
||||||
|
-- contact_id is an optional logical FK to contacts(id) (the specific person). assignee_id
|
||||||
|
-- is a logical ref to users(id) (NULL = team-wide). created_by holds a users.id OR a
|
||||||
|
-- non-user sentinel ('bot'/'automation'), so it is plain TEXT with no FK.
|
||||||
|
CREATE TABLE IF NOT EXISTS reminders (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
investor_id TEXT, -- logical FK -> fundraising_investors.id (NULL = standalone task)
|
||||||
|
investor_name TEXT, -- denormalized; survives grid-row deletion
|
||||||
|
contact_id TEXT, -- optional logical FK -> contacts.id
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
due_date TEXT, -- ISO date 'YYYY-MM-DD' (or datetime)
|
||||||
|
status TEXT NOT NULL DEFAULT 'open', -- open | done | snoozed | cancelled
|
||||||
|
snoozed_until TEXT,
|
||||||
|
assignee_id TEXT, -- logical ref -> users.id; NULL = team-wide
|
||||||
|
created_by TEXT, -- users.id, or 'bot' / 'automation'
|
||||||
|
source TEXT NOT NULL DEFAULT 'human', -- human | bot | automation
|
||||||
|
completed_at TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reminders_investor ON reminders(investor_id) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reminders_status ON reminders(status) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reminders_due ON reminders(due_date) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reminders_assignee ON reminders(assignee_id) WHERE deleted_at IS NULL;
|
||||||
+312
-1
@@ -1596,7 +1596,7 @@ def sanitize_fundraising_grid(grid):
|
|||||||
# linked opportunity and injected on read — never persisted as row data (the GET handler
|
# 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
|
# re-injects them after sanitize). The column DEFINITIONS persist like any other column
|
||||||
# so their position / width / hidden state is kept.
|
# 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 = []
|
clean_columns = []
|
||||||
seen = set()
|
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):
|
def pipeline_stage_by_source_row(conn):
|
||||||
"""Return {grid source_row_id: current pipeline stage} for every investor with a
|
"""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
|
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']
|
out[srid] = r['stage']
|
||||||
return out
|
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():
|
def maybe_run_scheduled_backup():
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
try:
|
try:
|
||||||
@@ -2073,6 +2176,10 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
if path == '/api/outreach/radar':
|
if path == '/api/outreach/radar':
|
||||||
return self.handle_outreach_radar(user)
|
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
|
# Matrix intake bot — new-vs-existing lookup for its in-thread proposal
|
||||||
if path == '/api/intake/match':
|
if path == '/api/intake/match':
|
||||||
return self.handle_intake_match(user, params)
|
return self.handle_intake_match(user, params)
|
||||||
@@ -2159,6 +2266,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
return self.handle_pipeline_link(user, body)
|
return self.handle_pipeline_link(user, body)
|
||||||
if path == '/api/fundraising/pipeline/unlink':
|
if path == '/api/fundraising/pipeline/unlink':
|
||||||
return self.handle_pipeline_unlink(user, body)
|
return self.handle_pipeline_unlink(user, body)
|
||||||
|
if path == '/api/reminders':
|
||||||
|
return self.handle_create_reminder(user, body)
|
||||||
if path == '/api/fundraising/collab/heartbeat':
|
if path == '/api/fundraising/collab/heartbeat':
|
||||||
return self.handle_fundraising_collab_heartbeat(user, body)
|
return self.handle_fundraising_collab_heartbeat(user, body)
|
||||||
if path == '/api/admin/users':
|
if path == '/api/admin/users':
|
||||||
@@ -2266,6 +2375,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
if re.match(r'^/api/feature-requests/[^/]+$', path):
|
if re.match(r'^/api/feature-requests/[^/]+$', path):
|
||||||
fr_id = path.split('/')[-1]
|
fr_id = path.split('/')[-1]
|
||||||
return self.handle_update_feature_request(user, fr_id, body)
|
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):
|
if re.match(r'^/api/admin/users/[^/]+$', path):
|
||||||
target_user_id = path.split('/')[-1]
|
target_user_id = path.split('/')[-1]
|
||||||
return self.handle_admin_update_user(user, target_user_id, body)
|
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])
|
return self.handle_delete_opportunity(user, path.split('/')[-1])
|
||||||
if re.match(r'^/api/communications/[^/]+$', path):
|
if re.match(r'^/api/communications/[^/]+$', path):
|
||||||
return self.handle_delete_communication(user, path.split('/')[-1])
|
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):
|
if re.match(r'^/api/thesis/nodes/[^/]+$', path):
|
||||||
return self.handle_retire_thesis_node(user, path.split('/')[-1])
|
return self.handle_retire_thesis_node(user, path.split('/')[-1])
|
||||||
self.send_error_json("Not found", 404)
|
self.send_error_json("Not found", 404)
|
||||||
@@ -3309,6 +3422,197 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
conn.close()
|
conn.close()
|
||||||
return self.send_json({"data": {"archived": archived}})
|
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):
|
def handle_intake_match(self, user, params):
|
||||||
"""Read-only: does an investor matching this intake already exist? Used by the
|
"""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
|
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)
|
self._ensure_fundraising_state_row(conn)
|
||||||
row = conn.execute("SELECT * FROM fundraising_state WHERE id = 'main'").fetchone()
|
row = conn.execute("SELECT * FROM fundraising_state WHERE id = 'main'").fetchone()
|
||||||
stage_by_row = pipeline_stage_by_source_row(conn)
|
stage_by_row = pipeline_stage_by_source_row(conn)
|
||||||
|
reminder_by_row = reminder_status_by_source_row(conn)
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -5152,6 +5457,10 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
stage = stage_by_row.get(str(r.get('id') or ''))
|
stage = stage_by_row.get(str(r.get('id') or ''))
|
||||||
r['pipeline'] = bool(stage)
|
r['pipeline'] = bool(stage)
|
||||||
r['pipeline_stage'] = stage or ''
|
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({
|
return self.send_json({
|
||||||
"data": {
|
"data": {
|
||||||
@@ -5334,6 +5643,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
# Archive pipeline opps orphaned by an investor deleted from the grid (one-way
|
# Archive pipeline opps orphaned by an investor deleted from the grid (one-way
|
||||||
# cleanup; never creates or reseeds — see reconcile_grid_pipeline_links).
|
# cleanup; never creates or reseeds — see reconcile_grid_pipeline_links).
|
||||||
reconcile_grid_pipeline_links(conn)
|
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})
|
log_audit(conn, user['user_id'], 'fundraising_state', 'main', 'update', {"version": next_version})
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -193,6 +193,55 @@ def test_build_and_empty():
|
|||||||
conn.close()
|
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():
|
def test_policy():
|
||||||
conn = _conn()
|
conn = _conn()
|
||||||
# No DB row yet: CRM_DIGEST_ENABLED=1 (set at import) seeds enabled; hour defaults 18.
|
# 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_user_activity:"); test_collect()
|
||||||
print("collect_investor_activity:"); test_investor()
|
print("collect_investor_activity:"); test_investor()
|
||||||
print("build_digest + empty:"); test_build_and_empty()
|
print("build_digest + empty:"); test_build_and_empty()
|
||||||
|
print("reminders due:"); test_reminders_due()
|
||||||
print("summary fallback:"); test_summary_fallback()
|
print("summary fallback:"); test_summary_fallback()
|
||||||
print("digest policy:"); test_policy()
|
print("digest policy:"); test_policy()
|
||||||
print("window resolver:"); test_window_resolver()
|
print("window resolver:"); test_window_resolver()
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for reminders / follow-ups (W1).
|
||||||
|
|
||||||
|
Boots the REAL server against a temp DB and exercises the new endpoints end-to-end:
|
||||||
|
- POST /api/reminders creates an open reminder tied to a grid investor (denormalized
|
||||||
|
investor_name resolved from the grid), or a standalone task (no investor_id);
|
||||||
|
- GET /api/reminders lists + filters by status (active/open/done/...), overdue, investor_id,
|
||||||
|
assignee=me; every read is soft-delete filtered;
|
||||||
|
- PATCH completes (stamps completed_at) / snoozes / edits a reminder; status is validated;
|
||||||
|
- DELETE soft-deletes (gone from every list, never hard-deleted);
|
||||||
|
- GET /api/fundraising/state injects a read-only reminder_status (overdue/due_soon/open/'')
|
||||||
|
derived live from open reminders, and strips it on save (never persisted to the blob);
|
||||||
|
- deleting an investor from the grid cancels its orphaned reminders (reconcile twin),
|
||||||
|
while a standalone reminder is untouched;
|
||||||
|
- the usual guards (missing title -> 400, bad status -> 400, unknown investor -> 404,
|
||||||
|
unauthenticated -> 401).
|
||||||
|
Synthetic data only.
|
||||||
|
|
||||||
|
Run: cd backend && python3 test_reminders.py
|
||||||
|
"""
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
|
||||||
|
_DATA = tempfile.mkdtemp()
|
||||||
|
os.environ["CRM_DATA_DIR"] = _DATA
|
||||||
|
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import server # noqa: E402
|
||||||
|
|
||||||
|
FAILS = []
|
||||||
|
|
||||||
|
TODAY = datetime.utcnow().date()
|
||||||
|
TOMORROW = (TODAY + timedelta(days=1)).isoformat()
|
||||||
|
YESTERDAY = (TODAY - timedelta(days=1)).isoformat()
|
||||||
|
FAR = (TODAY + timedelta(days=30)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
print((" PASS " if cond else " FAIL ") + msg)
|
||||||
|
if not cond:
|
||||||
|
FAILS.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class _Quiet(server.CRMHandler):
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _req(port, method, path, token=None, body=None):
|
||||||
|
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||||
|
headers = {}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = "Bearer " + token
|
||||||
|
payload = None
|
||||||
|
if body is not None:
|
||||||
|
payload = json.dumps(body)
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
conn.request(method, path, body=payload, headers=headers)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
raw = resp.read().decode("utf-8", "replace")
|
||||||
|
conn.close()
|
||||||
|
data = None
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return resp.status, data
|
||||||
|
|
||||||
|
|
||||||
|
def _put_grid(port, token, rows):
|
||||||
|
return _req(port, "PUT", "/api/fundraising/state", token,
|
||||||
|
{"grid": {"columns": [], "rows": rows}, "views": []})
|
||||||
|
|
||||||
|
|
||||||
|
ROW_ACME = {"id": "rowAcme", "investor_name": "Acme Capital", "notes": "", "lead": "Grant",
|
||||||
|
"contacts": [{"name": "Jane Doe", "email": "jane@acme.com", "title": "GP"}]}
|
||||||
|
ROW_BETA = {"id": "rowBeta", "investor_name": "Beta Capital", "notes": "",
|
||||||
|
"contacts": [{"name": "Pat Roe", "email": "pat@beta.com"}]}
|
||||||
|
ROW_GAMMA = {"id": "rowGamma", "investor_name": "Gamma Partners", "notes": "",
|
||||||
|
"contacts": [{"name": "Sam Lee", "email": "sam@gamma.com"}]}
|
||||||
|
|
||||||
|
|
||||||
|
def _db():
|
||||||
|
return sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||||
|
|
||||||
|
|
||||||
|
def seed():
|
||||||
|
c = _db()
|
||||||
|
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
|
||||||
|
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
|
||||||
|
c.commit()
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _investor_id(source_row_id):
|
||||||
|
c = _db()
|
||||||
|
r = c.execute("SELECT id FROM fundraising_investors WHERE source_row_id = ?", (source_row_id,)).fetchone()
|
||||||
|
c.close()
|
||||||
|
return r[0] if r else None
|
||||||
|
|
||||||
|
|
||||||
|
def _grid_reminder_status(port, token):
|
||||||
|
st, d = _req(port, "GET", "/api/fundraising/state", token)
|
||||||
|
rows = (d or {}).get("data", {}).get("grid", {}).get("rows", [])
|
||||||
|
return {r["id"]: r.get("reminder_status") for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
server.init_db()
|
||||||
|
seed()
|
||||||
|
token = server.create_token("u1", "grant", "admin")
|
||||||
|
|
||||||
|
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
|
||||||
|
port = httpd.server_address[1]
|
||||||
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||||
|
try:
|
||||||
|
st, _ = _put_grid(port, token, [ROW_ACME, ROW_BETA, ROW_GAMMA])
|
||||||
|
check(st == 200, f"seed grid via PUT /state (got {st})")
|
||||||
|
acme_id = _investor_id("rowAcme")
|
||||||
|
beta_id = _investor_id("rowBeta")
|
||||||
|
gamma_id = _investor_id("rowGamma")
|
||||||
|
check(bool(acme_id and beta_id and gamma_id), "investor ids resolved from the grid")
|
||||||
|
|
||||||
|
# ── create: investor-linked + denormalized name resolved from the grid ──
|
||||||
|
print("\n[create: investor-linked reminder resolves the denormalized name]")
|
||||||
|
st, d = _req(port, "POST", "/api/reminders", token,
|
||||||
|
{"investor_id": acme_id, "title": "Send Fund III deck", "due_date": TOMORROW})
|
||||||
|
rem = (d or {}).get("data") or {}
|
||||||
|
acme_rem_id = rem.get("id")
|
||||||
|
check(st == 201 and rem.get("status") == "open", f"create -> 201 open (got {st}, {d})")
|
||||||
|
check(rem.get("investor_name") == "Acme Capital", f"name denormalized from grid (got {rem.get('investor_name')})")
|
||||||
|
|
||||||
|
# overdue reminder on Beta; far-future + standalone for filter coverage
|
||||||
|
st, d = _req(port, "POST", "/api/reminders", token,
|
||||||
|
{"investor_id": beta_id, "title": "Call Pat", "due_date": YESTERDAY})
|
||||||
|
beta_rem_id = (d or {}).get("data", {}).get("id")
|
||||||
|
check(st == 201, f"create overdue beta reminder (got {st})")
|
||||||
|
st, d = _req(port, "POST", "/api/reminders", token,
|
||||||
|
{"investor_id": gamma_id, "title": "Quarterly touch", "due_date": FAR})
|
||||||
|
gamma_rem_id = (d or {}).get("data", {}).get("id")
|
||||||
|
st, d = _req(port, "POST", "/api/reminders", token, {"title": "Team: refresh pipeline view"})
|
||||||
|
standalone_id = (d or {}).get("data", {}).get("id")
|
||||||
|
check(st == 201 and (d or {}).get("data", {}).get("investor_id") in (None, ""),
|
||||||
|
f"standalone reminder (no investor) created (got {st})")
|
||||||
|
|
||||||
|
# ── list + filters ──
|
||||||
|
print("\n[list + filters]")
|
||||||
|
st, d = _req(port, "GET", "/api/reminders", token)
|
||||||
|
items = (d or {}).get("data", [])
|
||||||
|
check(st == 200 and len(items) == 4, f"list returns all 4 open (got {st}, {len(items)})")
|
||||||
|
check(all("last_activity_at" in it for it in items), "each row carries last_activity_at")
|
||||||
|
# dated reminders sort before undated, soonest first -> YESTERDAY (beta) leads
|
||||||
|
check(items and items[0].get("id") == beta_rem_id, f"overdue sorts first (got {items[0].get('id') if items else None})")
|
||||||
|
|
||||||
|
st, d = _req(port, "GET", "/api/reminders?overdue=1", token)
|
||||||
|
ids = [it["id"] for it in (d or {}).get("data", [])]
|
||||||
|
check(ids == [beta_rem_id], f"overdue=1 -> only the past-due one (got {ids})")
|
||||||
|
# overdue owns the status constraint: a conflicting status= must not silently zero out
|
||||||
|
st, d = _req(port, "GET", "/api/reminders?overdue=1&status=done", token)
|
||||||
|
ids = [it["id"] for it in (d or {}).get("data", [])]
|
||||||
|
check(ids == [beta_rem_id], f"overdue wins over a conflicting status= (got {ids})")
|
||||||
|
|
||||||
|
st, d = _req(port, "GET", f"/api/reminders?investor_id={acme_id}", token)
|
||||||
|
ids = [it["id"] for it in (d or {}).get("data", [])]
|
||||||
|
check(ids == [acme_rem_id], f"investor_id filter (got {ids})")
|
||||||
|
|
||||||
|
st, d = _req(port, "GET", "/api/reminders?assignee=me", token)
|
||||||
|
check(len(d.get("data", [])) == 0, "assignee=me -> none (reminders created unassigned)")
|
||||||
|
|
||||||
|
# ── grid injection: reminder_status derived live, never persisted ──
|
||||||
|
print("\n[grid injection: read-only reminder_status]")
|
||||||
|
s = _grid_reminder_status(port, token)
|
||||||
|
check(s.get("rowAcme") == "due_soon", f"due-tomorrow -> due_soon (got {s.get('rowAcme')})")
|
||||||
|
check(s.get("rowBeta") == "overdue", f"past-due -> overdue (got {s.get('rowBeta')})")
|
||||||
|
check(s.get("rowGamma") == "open", f"far-future -> open (got {s.get('rowGamma')})")
|
||||||
|
# echo the injected value back on save; it must NOT persist into the blob
|
||||||
|
st, d = _req(port, "GET", "/api/fundraising/state", token)
|
||||||
|
echoed = (d or {}).get("data", {}).get("grid", {}).get("rows", [])
|
||||||
|
st, _ = _put_grid(port, token, echoed)
|
||||||
|
c = _db()
|
||||||
|
blob = json.loads(c.execute("SELECT grid_json FROM fundraising_state WHERE id='main'").fetchone()[0])
|
||||||
|
c.close()
|
||||||
|
acme_stored = {r["id"]: r for r in blob.get("rows", [])}.get("rowAcme", {})
|
||||||
|
check("reminder_status" not in acme_stored, "reminder_status not persisted into the grid blob")
|
||||||
|
|
||||||
|
# ── complete / reopen stamps completed_at ──
|
||||||
|
print("\n[complete + reopen]")
|
||||||
|
st, d = _req(port, "PATCH", f"/api/reminders/{acme_rem_id}", token, {"status": "done"})
|
||||||
|
check(st == 200 and (d or {}).get("data", {}).get("status") == "done"
|
||||||
|
and (d or {}).get("data", {}).get("completed_at"), f"done stamps completed_at (got {d})")
|
||||||
|
st, d = _req(port, "GET", "/api/reminders?status=open", token)
|
||||||
|
check(acme_rem_id not in [it["id"] for it in (d or {}).get("data", [])], "done reminder drops from status=open")
|
||||||
|
st, d = _req(port, "GET", "/api/reminders?status=done", token)
|
||||||
|
check(acme_rem_id in [it["id"] for it in (d or {}).get("data", [])], "done reminder shows under status=done")
|
||||||
|
check(_grid_reminder_status(port, token).get("rowAcme") in (None, ""), "completed reminder clears the grid chip")
|
||||||
|
st, d = _req(port, "PATCH", f"/api/reminders/{acme_rem_id}", token, {"status": "open"})
|
||||||
|
check((d or {}).get("data", {}).get("completed_at") in (None, ""), "reopen clears completed_at")
|
||||||
|
|
||||||
|
# ── snooze: out of 'open', still in 'active' ──
|
||||||
|
print("\n[snooze]")
|
||||||
|
st, d = _req(port, "PATCH", f"/api/reminders/{beta_rem_id}", token,
|
||||||
|
{"status": "snoozed", "snoozed_until": FAR})
|
||||||
|
check(st == 200 and (d or {}).get("data", {}).get("status") == "snoozed", f"snooze (got {st})")
|
||||||
|
st, d = _req(port, "GET", "/api/reminders?status=open", token)
|
||||||
|
check(beta_rem_id not in [it["id"] for it in (d or {}).get("data", [])], "snoozed drops from status=open")
|
||||||
|
st, d = _req(port, "GET", "/api/reminders?status=active", token)
|
||||||
|
check(beta_rem_id in [it["id"] for it in (d or {}).get("data", [])], "snoozed stays in status=active")
|
||||||
|
|
||||||
|
# ── edit title + due date ──
|
||||||
|
print("\n[edit]")
|
||||||
|
st, d = _req(port, "PATCH", f"/api/reminders/{gamma_rem_id}", token,
|
||||||
|
{"title": "Quarterly touch — Q3", "due_date": TOMORROW})
|
||||||
|
check((d or {}).get("data", {}).get("title") == "Quarterly touch — Q3"
|
||||||
|
and (d or {}).get("data", {}).get("due_date") == TOMORROW, f"title+due edited (got {d})")
|
||||||
|
|
||||||
|
# ── soft-delete: gone from every list, tombstoned not hard-deleted ──
|
||||||
|
print("\n[soft-delete]")
|
||||||
|
st, d = _req(port, "DELETE", f"/api/reminders/{standalone_id}", token)
|
||||||
|
check(st == 200 and (d or {}).get("data", {}).get("deleted") is True, f"delete -> 200 (got {st})")
|
||||||
|
st, d = _req(port, "GET", "/api/reminders", token)
|
||||||
|
check(standalone_id not in [it["id"] for it in (d or {}).get("data", [])], "deleted reminder hidden from list")
|
||||||
|
c = _db()
|
||||||
|
gone = c.execute("SELECT deleted_at FROM reminders WHERE id = ?", (standalone_id,)).fetchone()[0]
|
||||||
|
c.close()
|
||||||
|
check(gone is not None, "deleted reminder tombstoned (deleted_at set), not hard-deleted")
|
||||||
|
|
||||||
|
# ── orphan reconcile: drop the investor from the grid -> its reminders cancelled ──
|
||||||
|
print("\n[orphan reconcile: deleting the grid investor cancels its reminders]")
|
||||||
|
# create a fresh standalone task to confirm it is NOT cancelled by the reconciler
|
||||||
|
st, d = _req(port, "POST", "/api/reminders", token, {"title": "Standalone keeps living"})
|
||||||
|
keep_id = (d or {}).get("data", {}).get("id")
|
||||||
|
st, _ = _put_grid(port, token, [ROW_ACME, ROW_BETA]) # drop rowGamma
|
||||||
|
c = _db()
|
||||||
|
gamma_status = c.execute("SELECT status FROM reminders WHERE id = ?", (gamma_rem_id,)).fetchone()[0]
|
||||||
|
keep_status = c.execute("SELECT status FROM reminders WHERE id = ?", (keep_id,)).fetchone()[0]
|
||||||
|
gamma_name = c.execute("SELECT investor_name FROM reminders WHERE id = ?", (gamma_rem_id,)).fetchone()[0]
|
||||||
|
c.close()
|
||||||
|
check(gamma_status == "cancelled", f"orphaned investor's reminder cancelled (got {gamma_status})")
|
||||||
|
check(gamma_name == "Gamma Partners", "cancelled reminder keeps denormalized investor_name for history")
|
||||||
|
check(keep_status == "open", f"standalone reminder untouched by reconcile (got {keep_status})")
|
||||||
|
|
||||||
|
# ── recency rollup: a tombstoned email sighting must not inflate last_activity_at ──
|
||||||
|
print("\n[recency: soft-deleted email sighting excluded from last_activity_at]")
|
||||||
|
c = _db()
|
||||||
|
c.execute("INSERT INTO emails (id, rfc_message_id, from_email, sent_at, is_matched, match_status) "
|
||||||
|
"VALUES ('em1','<em1@x>','lp@acme.com','2026-06-10T00:00:00Z',1,'matched')")
|
||||||
|
c.execute("INSERT INTO email_investor_links (id, email_id, fundraising_investor_id, match_kind, match_confidence, matched_address) "
|
||||||
|
"VALUES ('eil1','em1',?, 'exact_email',1.0,'lp@acme.com')", (acme_id,))
|
||||||
|
c.execute("INSERT INTO email_account_messages (id, email_id, account_id, gmail_message_id, gmail_thread_id, deleted_at) "
|
||||||
|
"VALUES ('eam1','em1','acct1','g1','t1','2026-06-11T00:00:00Z')") # tombstoned sighting only
|
||||||
|
c.commit(); c.close()
|
||||||
|
st, d = _req(port, "GET", f"/api/reminders?investor_id={acme_id}", token)
|
||||||
|
items = (d or {}).get("data", [])
|
||||||
|
la = items[0].get("last_activity_at") if items else "MISSING"
|
||||||
|
check(bool(items) and la is None, f"tombstoned-only email -> no last_activity (got {la})")
|
||||||
|
c = _db()
|
||||||
|
c.execute("INSERT INTO email_account_messages (id, email_id, account_id, gmail_message_id, gmail_thread_id, deleted_at) "
|
||||||
|
"VALUES ('eam2','em1','acct2','g2','t2',NULL)")
|
||||||
|
c.commit(); c.close()
|
||||||
|
st, d = _req(port, "GET", f"/api/reminders?investor_id={acme_id}", token)
|
||||||
|
items = (d or {}).get("data", [])
|
||||||
|
la = items[0].get("last_activity_at") if items else "MISSING"
|
||||||
|
check(bool(items) and la == '2026-06-10T00:00:00Z', f"live sighting -> last_activity set (got {la})")
|
||||||
|
|
||||||
|
# ── guards ──
|
||||||
|
print("\n[guards]")
|
||||||
|
st, _ = _req(port, "POST", "/api/reminders", token, {"investor_id": acme_id})
|
||||||
|
check(st == 400, f"missing title -> 400 (got {st})")
|
||||||
|
st, _ = _req(port, "POST", "/api/reminders", token, {"title": "x", "investor_id": "nope"})
|
||||||
|
check(st == 404, f"unknown investor_id -> 404 (got {st})")
|
||||||
|
st, _ = _req(port, "PATCH", f"/api/reminders/{gamma_rem_id}", token, {"status": "bogus"})
|
||||||
|
check(st == 400, f"invalid status -> 400 (got {st})")
|
||||||
|
st, _ = _req(port, "DELETE", "/api/reminders/doesnotexist", token)
|
||||||
|
check(st == 404, f"delete unknown -> 404 (got {st})")
|
||||||
|
st, _ = _req(port, "GET", "/api/reminders", None)
|
||||||
|
check(st == 401, f"unauthenticated list -> 401 (got {st})")
|
||||||
|
finally:
|
||||||
|
httpd.shutdown()
|
||||||
|
|
||||||
|
print("\n" + ("ALL PASS" if not FAILS else f"{len(FAILS)} FAILURE(S):"))
|
||||||
|
for f in FAILS:
|
||||||
|
print(" - " + f)
|
||||||
|
sys.exit(1 if FAILS else 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+447
-1
@@ -3308,6 +3308,7 @@
|
|||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [dueReminders, setDueReminders] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchDashboard = async () => {
|
const fetchDashboard = async () => {
|
||||||
@@ -3324,6 +3325,20 @@
|
|||||||
fetchDashboard();
|
fetchDashboard();
|
||||||
}, [token]);
|
}, [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 <div style={{ padding: '20px' }}><SkeletonBlock lines={7} /></div>;
|
if (loading) return <div style={{ padding: '20px' }}><SkeletonBlock lines={7} /></div>;
|
||||||
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
|
if (error) return <div className="toast error" style={{ position: 'static' }}>{error}</div>;
|
||||||
if (!data) return <div className="empty-state">No data</div>;
|
if (!data) return <div className="empty-state">No data</div>;
|
||||||
@@ -3364,6 +3379,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{dueReminders.length > 0 && (
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-title">Reminders Due ({dueReminders.length})</div>
|
||||||
|
<div className="timeline">
|
||||||
|
{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 (
|
||||||
|
<div key={r.id} className="timeline-item">
|
||||||
|
<div className="timeline-marker"></div>
|
||||||
|
<div className="timeline-content">
|
||||||
|
<div className="timeline-header">
|
||||||
|
{r.investor_name ? `${r.investor_name} — ` : ''}{r.title}
|
||||||
|
</div>
|
||||||
|
<div className="timeline-meta" style={{ color: overdue ? '#e06c6c' : '#e0b341' }}>
|
||||||
|
{overdue ? 'Overdue' : 'Due today'}: {formatDate(r.due_date)}
|
||||||
|
{r.assignee_name ? ` · ${r.assignee_name}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="section">
|
<div className="section">
|
||||||
<div className="section-title">Pipeline by Stage</div>
|
<div className="section-title">Pipeline by Stage</div>
|
||||||
<div className="pipeline-summary">
|
<div className="pipeline-summary">
|
||||||
@@ -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 (
|
||||||
|
<div className="page-container">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px', flexWrap: 'wrap', gap: '10px' }}>
|
||||||
|
<h2 className="section-title" style={{ margin: 0 }}>Reminders</h2>
|
||||||
|
<button type="button" onClick={() => setShowCreate(true)}>+ New reminder</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', marginBottom: '16px', flexWrap: 'wrap' }}>
|
||||||
|
<select className="select-input" style={{ maxWidth: '200px' }} value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||||
|
<option value="active">Active (open + snoozed)</option>
|
||||||
|
<option value="open">Open</option>
|
||||||
|
<option value="overdue">Overdue</option>
|
||||||
|
<option value="done">Done</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
<option value="all">All</option>
|
||||||
|
</select>
|
||||||
|
<label style={{ fontSize: '13px', color: '#8ea2b7', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<input type="checkbox" checked={onlyMine} onChange={(e) => setOnlyMine(e.target.checked)} /> Only mine
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <SkeletonBlock lines={6} />
|
||||||
|
: error ? <div className="toast error" style={{ position: 'static' }}>{error}</div>
|
||||||
|
: items.length === 0 ? <div className="empty-state">No reminders</div>
|
||||||
|
: (
|
||||||
|
<div>
|
||||||
|
{items.map((r) => {
|
||||||
|
const u = urgency(r);
|
||||||
|
const dueColor = u === 'overdue' ? '#e06c6c' : u === 'due_soon' ? '#e0b341' : '#8ea2b7';
|
||||||
|
return (
|
||||||
|
<div key={r.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '12px', padding: '12px 14px', border: '1px solid #263548', borderRadius: '8px', marginBottom: '8px', background: '#0d1622' }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: '14px', marginBottom: '3px' }}>
|
||||||
|
{r.title}
|
||||||
|
<span style={{ marginLeft: '8px', fontSize: '11px', textTransform: 'uppercase', color: badgeColor[r.status] || '#70859b' }}>{r.status}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#8ea2b7' }}>
|
||||||
|
{r.investor_name ? <span>{r.investor_name} · </span> : null}
|
||||||
|
<span style={{ color: dueColor }}>{r.due_date ? `Due ${formatDate(r.due_date)}` : 'No due date'}</span>
|
||||||
|
{r.assignee_name ? <span> · {r.assignee_name}</span> : null}
|
||||||
|
{r.last_activity_at ? <span> · last activity {formatDate(r.last_activity_at)}</span> : null}
|
||||||
|
</div>
|
||||||
|
{r.details ? <div style={{ fontSize: '12px', color: '#a9bcd0', marginTop: '4px' }}>{r.details}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '6px', flexShrink: 0, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
|
{(r.status === 'open' || r.status === 'snoozed') && <button type="button" className="button-secondary" style={{ padding: '4px 9px', fontSize: '12px' }} onClick={() => patch(r.id, { status: 'done' }, 'Marked done')}>Done</button>}
|
||||||
|
{r.status === 'open' && <button type="button" className="button-secondary" style={{ padding: '4px 9px', fontSize: '12px' }} onClick={() => snooze(r)}>Snooze 7d</button>}
|
||||||
|
<button type="button" className="button-secondary" style={{ padding: '4px 9px', fontSize: '12px' }} onClick={() => openEdit(r)}>Edit</button>
|
||||||
|
<button type="button" className="button-secondary" style={{ padding: '4px 9px', fontSize: '12px', color: '#e06c6c' }} onClick={() => del(r.id)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal">
|
||||||
|
<div className="modal-header">New reminder</div>
|
||||||
|
<div className="form-group"><label className="form-label">Title</label>
|
||||||
|
<input className="text-input" value={createForm.title} onChange={(e) => setCreateForm((f) => ({ ...f, title: e.target.value }))} placeholder="What needs doing?" /></div>
|
||||||
|
<div className="form-group"><label className="form-label">Due date</label>
|
||||||
|
<input type="date" className="text-input" value={createForm.due_date} onChange={(e) => setCreateForm((f) => ({ ...f, due_date: e.target.value }))} /></div>
|
||||||
|
<div className="form-group"><label className="form-label">Investor (optional)</label>
|
||||||
|
<input className="text-input" value={createForm.investor_name} onChange={(e) => setCreateForm((f) => ({ ...f, investor_name: e.target.value }))} placeholder="Name only, or leave blank for a team task" />
|
||||||
|
<div className="form-help">To link a reminder to a specific investor row (and its grid chip), set it from the Fundraising Grid's <strong>Reminder</strong> column. A name typed here is a free-text label only.</div></div>
|
||||||
|
<div className="form-group"><label className="form-label">Assignee (optional)</label>
|
||||||
|
<select className="select-input" value={createForm.assignee_id} onChange={(e) => setCreateForm((f) => ({ ...f, assignee_id: e.target.value }))}>
|
||||||
|
<option value="">Unassigned (team)</option>
|
||||||
|
{users.map((u) => <option key={u.id} value={u.id}>{u.full_name || u.username}</option>)}
|
||||||
|
</select></div>
|
||||||
|
<div className="form-group"><label className="form-label">Details (optional)</label>
|
||||||
|
<textarea className="text-input" rows="2" value={createForm.details} onChange={(e) => setCreateForm((f) => ({ ...f, details: e.target.value }))} /></div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="button-secondary" onClick={() => setShowCreate(false)}>Cancel</button>
|
||||||
|
<button type="button" onClick={submitCreate} disabled={creating}>{creating ? <Spinner /> : 'Create'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal">
|
||||||
|
<div className="modal-header">Edit reminder</div>
|
||||||
|
<div className="form-group"><label className="form-label">Title</label>
|
||||||
|
<input className="text-input" value={editForm.title} onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))} /></div>
|
||||||
|
<div className="form-group"><label className="form-label">Due date</label>
|
||||||
|
<input type="date" className="text-input" value={editForm.due_date} onChange={(e) => setEditForm((f) => ({ ...f, due_date: e.target.value }))} /></div>
|
||||||
|
<div className="form-group"><label className="form-label">Status</label>
|
||||||
|
<select className="select-input" value={editForm.status} onChange={(e) => setEditForm((f) => ({ ...f, status: e.target.value }))}>
|
||||||
|
<option value="open">Open</option>
|
||||||
|
<option value="snoozed">Snoozed</option>
|
||||||
|
<option value="done">Done</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
</select></div>
|
||||||
|
<div className="form-group"><label className="form-label">Assignee</label>
|
||||||
|
<select className="select-input" value={editForm.assignee_id} onChange={(e) => setEditForm((f) => ({ ...f, assignee_id: e.target.value }))}>
|
||||||
|
<option value="">Unassigned (team)</option>
|
||||||
|
{users.map((u) => <option key={u.id} value={u.id}>{u.full_name || u.username}</option>)}
|
||||||
|
</select></div>
|
||||||
|
<div className="form-group"><label className="form-label">Details</label>
|
||||||
|
<textarea className="text-input" rows="2" value={editForm.details} onChange={(e) => setEditForm((f) => ({ ...f, details: e.target.value }))} /></div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="button-secondary" onClick={() => setEditing(null)}>Cancel</button>
|
||||||
|
<button type="button" onClick={submitEdit} disabled={savingEdit}>{savingEdit ? <Spinner /> : 'Save'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const ContactsPage = ({ token, onShowToast }) => {
|
const ContactsPage = ({ token, onShowToast }) => {
|
||||||
const CONTACTS_PAGE_SIZE = 100;
|
const CONTACTS_PAGE_SIZE = 100;
|
||||||
const [contacts, setContacts] = useState([]);
|
const [contacts, setContacts] = useState([]);
|
||||||
@@ -4846,6 +5116,12 @@
|
|||||||
const [logCommContext, setLogCommContext] = useState(null);
|
const [logCommContext, setLogCommContext] = useState(null);
|
||||||
const [logCommForm, setLogCommForm] = useState({ type: 'note', subject: '', body: '', outcome: '', next_action: '', next_action_date: '', append_note: true });
|
const [logCommForm, setLogCommForm] = useState({ type: 'note', subject: '', body: '', outcome: '', next_action: '', next_action_date: '', append_note: true });
|
||||||
const [logCommSubmitting, setLogCommSubmitting] = useState(false);
|
const [logCommSubmitting, setLogCommSubmitting] = useState(false);
|
||||||
|
const [showReminderModal, setShowReminderModal] = useState(false);
|
||||||
|
const [reminderContext, setReminderContext] = useState(null); // { rowId, investorName }
|
||||||
|
const [reminderList, setReminderList] = useState([]);
|
||||||
|
const [reminderLoading, setReminderLoading] = useState(false);
|
||||||
|
const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' });
|
||||||
|
const [reminderSubmitting, setReminderSubmitting] = useState(false);
|
||||||
const [showContactCardModal, setShowContactCardModal] = useState(false);
|
const [showContactCardModal, setShowContactCardModal] = useState(false);
|
||||||
const [contactCardContext, setContactCardContext] = useState(null);
|
const [contactCardContext, setContactCardContext] = useState(null);
|
||||||
const [contactCardLoading, setContactCardLoading] = useState(false);
|
const [contactCardLoading, setContactCardLoading] = useState(false);
|
||||||
@@ -4950,6 +5226,17 @@
|
|||||||
else cols.push(col);
|
else cols.push(col);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
// Reminder status: read-only chip whose VALUE is server-computed on read from
|
||||||
|
// the reminders table (overdue/due_soon/open/'' empty). A saved view can filter
|
||||||
|
// on it to drive the follow-up view off real reminders, not the binary checkbox.
|
||||||
|
const hasReminderStatus = cols.some((c) => c.id === 'reminder_status');
|
||||||
|
if (!hasReminderStatus) {
|
||||||
|
const fu = cols.findIndex((c) => c.id === 'follow_up');
|
||||||
|
const col = { id: 'reminder_status', label: 'Reminder', type: 'text', readOnly: true, width: 130 };
|
||||||
|
if (fu >= 0) cols.splice(fu + 1, 0, col);
|
||||||
|
else cols.push(col);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
const rowsIn = Array.isArray(incomingRows) ? incomingRows : defaultRows;
|
const rowsIn = Array.isArray(incomingRows) ? incomingRows : defaultRows;
|
||||||
const rowsOut = rowsIn.map((r) => {
|
const rowsOut = rowsIn.map((r) => {
|
||||||
const next = { ...r };
|
const next = { ...r };
|
||||||
@@ -4976,7 +5263,7 @@
|
|||||||
// autosave + version bump. Strip them at every snapshot / persist boundary.
|
// autosave + version bump. Strip them at every snapshot / persist boundary.
|
||||||
const stripComputedRows = (rs) => (Array.isArray(rs) ? rs.map((r) => {
|
const stripComputedRows = (rs) => (Array.isArray(rs) ? rs.map((r) => {
|
||||||
if (!r || typeof r !== 'object') return r;
|
if (!r || typeof r !== 'object') return r;
|
||||||
const { pipeline, pipeline_stage, ...rest } = r;
|
const { pipeline, pipeline_stage, reminder_status, ...rest } = r;
|
||||||
return rest;
|
return rest;
|
||||||
}) : rs);
|
}) : rs);
|
||||||
|
|
||||||
@@ -5498,6 +5785,85 @@
|
|||||||
setShowLogCommModal(true);
|
setShowLogCommModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mirror the server's reminder_status_by_source_row: the most-urgent OPEN reminder
|
||||||
|
// (overdue > due_soon > open). Lets us refresh the grid chip without a full re-hydrate.
|
||||||
|
const computeReminderChip = (reminders) => {
|
||||||
|
const open = (reminders || []).filter((r) => r.status === 'open');
|
||||||
|
if (!open.length) return '';
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const soon = new Date(Date.now() + 7 * 864e5).toISOString().slice(0, 10);
|
||||||
|
let best = '';
|
||||||
|
for (const r of open) {
|
||||||
|
const due = String(r.due_date || '').slice(0, 10);
|
||||||
|
const s = (due && due < today) ? 'overdue' : (due && due <= soon) ? 'due_soon' : 'open';
|
||||||
|
if (s === 'overdue') return 'overdue';
|
||||||
|
if (s === 'due_soon') best = 'due_soon';
|
||||||
|
else if (!best) best = 'open';
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadReminders = async (rowId) => {
|
||||||
|
setReminderLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api(`/api/reminders?source_row_id=${encodeURIComponent(rowId)}&status=active`, {}, token);
|
||||||
|
const list = Array.isArray(res?.data) ? res.data : [];
|
||||||
|
setReminderList(list);
|
||||||
|
setRows((prev) => prev.map((r) => (r.id === rowId ? { ...r, reminder_status: computeReminderChip(list) } : r)));
|
||||||
|
return list;
|
||||||
|
} catch (err) {
|
||||||
|
onShowToast(getErrorMessage(err, 'Failed to load reminders'), 'error');
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
setReminderLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openReminderModal = async (row) => {
|
||||||
|
if (!row) return;
|
||||||
|
setReminderContext({ rowId: row.id, investorName: row.investor_name || '' });
|
||||||
|
setReminderForm({ title: '', due_date: '', details: '' });
|
||||||
|
setReminderList([]);
|
||||||
|
setShowReminderModal(true);
|
||||||
|
await loadReminders(row.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitReminder = async () => {
|
||||||
|
if (!reminderContext?.rowId) return;
|
||||||
|
const title = (reminderForm.title || '').trim();
|
||||||
|
if (!title) { onShowToast('A reminder needs a title', 'error'); return; }
|
||||||
|
setReminderSubmitting(true);
|
||||||
|
try {
|
||||||
|
await api('/api/reminders', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
source_row_id: reminderContext.rowId,
|
||||||
|
investor_name: reminderContext.investorName || '',
|
||||||
|
title,
|
||||||
|
due_date: reminderForm.due_date || '',
|
||||||
|
details: reminderForm.details || '',
|
||||||
|
}),
|
||||||
|
}, token);
|
||||||
|
onShowToast('Reminder set', 'success');
|
||||||
|
setReminderForm({ title: '', due_date: '', details: '' });
|
||||||
|
await loadReminders(reminderContext.rowId);
|
||||||
|
} catch (err) {
|
||||||
|
onShowToast(getErrorMessage(err, 'Failed to set reminder'), 'error');
|
||||||
|
} finally {
|
||||||
|
setReminderSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeReminder = async (reminderId) => {
|
||||||
|
if (!reminderContext?.rowId) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/reminders/${reminderId}`, { method: 'PATCH', body: JSON.stringify({ status: 'done' }) }, token);
|
||||||
|
await loadReminders(reminderContext.rowId);
|
||||||
|
} catch (err) {
|
||||||
|
onShowToast(getErrorMessage(err, 'Failed to update reminder'), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openContactCardModal = async (row, contact = null) => {
|
const openContactCardModal = async (row, contact = null) => {
|
||||||
if (!row || !contact) return;
|
if (!row || !contact) return;
|
||||||
const norm = (v) => String(v || '').trim().toLowerCase();
|
const norm = (v) => String(v || '').trim().toLowerCase();
|
||||||
@@ -6347,6 +6713,27 @@
|
|||||||
if (!stage) return <span style={{ color: '#70859b' }}>—</span>;
|
if (!stage) return <span style={{ color: '#70859b' }}>—</span>;
|
||||||
return <span style={{ textTransform: 'capitalize' }}>{stage.replace(/_/g, ' ')}</span>;
|
return <span style={{ textTransform: 'capitalize' }}>{stage.replace(/_/g, ' ')}</span>;
|
||||||
}
|
}
|
||||||
|
if (col.id === 'reminder_status') {
|
||||||
|
const rs = String(row.reminder_status || '');
|
||||||
|
const meta = {
|
||||||
|
overdue: { label: '⏰ Overdue', color: '#e06c6c', border: '#7a3030' },
|
||||||
|
due_soon: { label: '⏰ Due soon', color: '#e0b341', border: '#7a6320' },
|
||||||
|
open: { label: '⏰ Open', color: '#7fb0d3', border: '#2f5170' },
|
||||||
|
}[rs];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button-secondary"
|
||||||
|
style={{ padding: '5px 10px', fontSize: '12px',
|
||||||
|
color: meta ? meta.color : undefined,
|
||||||
|
borderColor: meta ? meta.border : undefined }}
|
||||||
|
title={rs ? 'View / manage reminders for this investor' : 'Set a reminder for this investor'}
|
||||||
|
onClick={(e) => { e.stopPropagation(); openReminderModal(row); }}
|
||||||
|
>
|
||||||
|
{meta ? meta.label : '+ Reminder'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (col.type === 'action' || col.id === 'log_action') {
|
if (col.type === 'action' || col.id === 'log_action') {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -7205,6 +7592,60 @@
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showReminderModal && (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal">
|
||||||
|
<div className="modal-header">Reminders</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '12px' }}>
|
||||||
|
{reminderContext?.investorName || 'Investor'}
|
||||||
|
</div>
|
||||||
|
<div className="form-help" style={{ marginBottom: '14px', padding: '10px', border: '1px solid #263548', borderRadius: '8px', background: '#0d1622' }}>
|
||||||
|
Set a follow-up with a due date. Open reminders drive the <strong>Reminder</strong> column and the daily digest. Manage all reminders on the Reminders page.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reminderLoading ? (
|
||||||
|
<div style={{ padding: '4px 0 12px' }}><Spinner /></div>
|
||||||
|
) : reminderList.length > 0 ? (
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Open reminders</label>
|
||||||
|
{reminderList.map((r) => (
|
||||||
|
<div key={r.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '10px', padding: '8px 10px', border: '1px solid #263548', borderRadius: '8px', marginBottom: '6px' }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: '13px' }}>{r.title}</div>
|
||||||
|
<div style={{ fontSize: '11px', color: '#8ea2b7' }}>
|
||||||
|
{r.due_date ? `Due ${formatDate(r.due_date)}` : 'No due date'}{r.status === 'snoozed' ? ' · snoozed' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="button-secondary" style={{ padding: '4px 9px', fontSize: '12px' }} onClick={() => completeReminder(r.id)}>Done</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-state" style={{ marginBottom: '12px' }}>No open reminders</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">New reminder</label>
|
||||||
|
<input className="text-input" placeholder="What needs doing? (e.g. Send Fund III deck)" value={reminderForm.title} onChange={(e) => setReminderForm((f) => ({ ...f, title: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Due date</label>
|
||||||
|
<input type="date" className="text-input" value={reminderForm.due_date} onChange={(e) => setReminderForm((f) => ({ ...f, due_date: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Details (optional)</label>
|
||||||
|
<textarea className="text-input" rows="2" value={reminderForm.details} onChange={(e) => setReminderForm((f) => ({ ...f, details: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="button-secondary" onClick={() => { setShowReminderModal(false); setReminderContext(null); }}>Close</button>
|
||||||
|
<button type="button" onClick={submitReminder} disabled={reminderSubmitting}>
|
||||||
|
{reminderSubmitting ? <Spinner /> : 'Add reminder'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showContactCardModal && (
|
{showContactCardModal && (
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay">
|
||||||
<div className="modal">
|
<div className="modal">
|
||||||
@@ -10887,6 +11328,9 @@
|
|||||||
<button className={`nav-item ${page === 'pipeline' ? 'active' : ''}`} onClick={() => setPage('pipeline')}>
|
<button className={`nav-item ${page === 'pipeline' ? 'active' : ''}`} onClick={() => setPage('pipeline')}>
|
||||||
<span className="nav-item-icon">↗</span> Pipeline
|
<span className="nav-item-icon">↗</span> Pipeline
|
||||||
</button>
|
</button>
|
||||||
|
<button className={`nav-item ${page === 'reminders' ? 'active' : ''}`} onClick={() => setPage('reminders')}>
|
||||||
|
<span className="nav-item-icon">⏰</span> Reminders
|
||||||
|
</button>
|
||||||
{user?.role === 'admin' && (
|
{user?.role === 'admin' && (
|
||||||
<button className={`nav-item ${page === 'communications' ? 'active' : ''}`} onClick={() => setPage('communications')}>
|
<button className={`nav-item ${page === 'communications' ? 'active' : ''}`} onClick={() => setPage('communications')}>
|
||||||
<span className="nav-item-icon">◌</span> Communications
|
<span className="nav-item-icon">◌</span> Communications
|
||||||
@@ -10933,6 +11377,7 @@
|
|||||||
{page === 'dashboard' && 'Dashboard'}
|
{page === 'dashboard' && 'Dashboard'}
|
||||||
{page === 'contacts' && 'Contacts'}
|
{page === 'contacts' && 'Contacts'}
|
||||||
{page === 'pipeline' && 'Pipeline'}
|
{page === 'pipeline' && 'Pipeline'}
|
||||||
|
{page === 'reminders' && 'Reminders'}
|
||||||
{page === 'communications' && 'Communications'}
|
{page === 'communications' && 'Communications'}
|
||||||
{page === 'thesis' && 'Thesis'}
|
{page === 'thesis' && 'Thesis'}
|
||||||
{page === 'thesis-workshop' && 'Thesis Workshop'}
|
{page === 'thesis-workshop' && 'Thesis Workshop'}
|
||||||
@@ -10966,6 +11411,7 @@
|
|||||||
{page === 'dashboard' && <DashboardPage token={token} />}
|
{page === 'dashboard' && <DashboardPage token={token} />}
|
||||||
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} />}
|
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} />}
|
||||||
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} />}
|
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} />}
|
||||||
|
{page === 'reminders' && <RemindersPage token={token} user={user} onShowToast={showToast} />}
|
||||||
{page === 'communications' && <CommunicationsPage token={token} user={user} onShowToast={showToast} />}
|
{page === 'communications' && <CommunicationsPage token={token} user={user} onShowToast={showToast} />}
|
||||||
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
|
{page === 'thesis' && <ThesisPage token={token} user={user} onShowToast={showToast} />}
|
||||||
{page === 'thesis-workshop' && <ThesisWorkshopPage token={token} user={user} onShowToast={showToast} />}
|
{page === 'thesis-workshop' && <ThesisWorkshopPage token={token} user={user} onShowToast={showToast} />}
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
|
|||||||
// * 0.1.0:88 (frontend-only: retire the Pipeline page's "+ New Opportunity" button + its create-by-contact modal — opportunities are now born only from a fundraising-grid investor row ["+ Pipeline"], so the board is a view + stage-management surface; replaced the button with a muted "Add deals from the Fundraising Grid" hint; removed the now-dead handler/state + the page's unused /api/contacts fetch)
|
// * 0.1.0:88 (frontend-only: retire the Pipeline page's "+ New Opportunity" button + its create-by-contact modal — opportunities are now born only from a fundraising-grid investor row ["+ Pipeline"], so the board is a view + stage-management surface; replaced the button with a muted "Add deals from the Fundraising Grid" hint; removed the now-dead handler/state + the page's unused /api/contacts fetch)
|
||||||
// * 0.1.0:89 (email-proposal review over Matrix + a dedicated agent role: Email Capture's proposed grid notes gain a click-to-view inline popup of the source email [from/to/cc/date/subject/scrollable body, via the existing GET /api/email/detail]; and a CRM→Matrix review bridge — the intake bot [Spark] pulls pending proposals, posts a review card to a dedicated review room [MATRIX_EMAIL_REVIEW_ROOM], and relays in-thread yes/no/NL-edit back to the CRM, with web panel ↔ Matrix kept in sync [decide on either surface; the other reflects it]. New side table email_proposal_matrix [email-integration migration 0003, additive + idempotent] holds per-proposal Matrix thread state; new bot-or-admin endpoints GET /api/intake/email-proposals + .../{id}/matrix + .../{id}/decide, gated by a new 'bot' role [authenticated, never admin]. Bot poll loop + review-room handling ship on the Spark, not the s9pk)
|
// * 0.1.0:89 (email-proposal review over Matrix + a dedicated agent role: Email Capture's proposed grid notes gain a click-to-view inline popup of the source email [from/to/cc/date/subject/scrollable body, via the existing GET /api/email/detail]; and a CRM→Matrix review bridge — the intake bot [Spark] pulls pending proposals, posts a review card to a dedicated review room [MATRIX_EMAIL_REVIEW_ROOM], and relays in-thread yes/no/NL-edit back to the CRM, with web panel ↔ Matrix kept in sync [decide on either surface; the other reflects it]. New side table email_proposal_matrix [email-integration migration 0003, additive + idempotent] holds per-proposal Matrix thread state; new bot-or-admin endpoints GET /api/intake/email-proposals + .../{id}/matrix + .../{id}/decide, gated by a new 'bot' role [authenticated, never admin]. Bot poll loop + review-room handling ship on the Spark, not the s9pk)
|
||||||
// * 0.1.0:90 (give admins a UI path to provision the 'bot' role added in v89: the Settings → Admin edit-user role dropdown now offers "bot" alongside member/admin [the teammate-invite form stays member/admin only — provisioning an agent account is an admin re-classification, not an invite]; backend already accepted it; frontend-only, no schema change)
|
// * 0.1.0:90 (give admins a UI path to provision the 'bot' role added in v89: the Settings → Admin edit-user role dropdown now offers "bot" alongside member/admin [the teammate-invite form stays member/admin only — provisioning an agent account is an admin re-classification, not an invite]; backend already accepted it; frontend-only, no schema change)
|
||||||
// * Current: 0.1.0:91 (clarify email-proposal note wording: the proposed grid note now NAMES who emailed whom — "{teammate} emailed {investor}" outbound / "{sender} emailed the team" inbound — instead of a bare "Sent"/"Received"; also fixes a misclassification where a sender on our corporate domain whose mailbox isn't enrolled read as "Received" [outbound now also matches our domain, public providers like gmail excluded so an LP's gmail never reads as ours]; going-forward only, no schema change. Matrix-side review tweaks — dash separators + redacting decided cards — ship on the Spark, not the s9pk)
|
// * 0.1.0:91 (clarify email-proposal note wording: the proposed grid note now NAMES who emailed whom — "{teammate} emailed {investor}" outbound / "{sender} emailed the team" inbound — instead of a bare "Sent"/"Received"; also fixes a misclassification where a sender on our corporate domain whose mailbox isn't enrolled read as "Received" [outbound now also matches our domain, public providers like gmail excluded so an LP's gmail never reads as ours]; going-forward only, no schema change. Matrix-side review tweaks — dash separators + redacting decided cards — ship on the Spark, not the s9pk)
|
||||||
export const PACKAGE_VERSION = '0.1.0:91'
|
// * Current: 0.1.0:92 (reminders & follow-ups, W1: new `reminders` table [in-app migration 0006; logical FK to fundraising_investors.id + denormalized name], full CRUD at /api/reminders [soft-delete; open/done/snoozed/cancelled; assignee; source human/bot/automation], read-only derived `reminder_status` grid column [overdue/due_soon/open — injected like pipeline_stage, filterable], orphan reconciler, Reminders page + Dashboard "Reminders Due" card + daily-digest "reminders due" section, and a per-investor last_activity_at recency rollup. Pure local CRM, no LLM path)
|
||||||
|
export const PACKAGE_VERSION = '0.1.0:92'
|
||||||
|
|
||||||
export const DATA_MOUNT_PATH = '/data'
|
export const DATA_MOUNT_PATH = '/data'
|
||||||
export const WEB_PORT = 8080
|
export const WEB_PORT = 8080
|
||||||
|
|||||||
@@ -52,8 +52,9 @@ import { v_0_1_0_88 } from './v0.1.0.88'
|
|||||||
import { v_0_1_0_89 } from './v0.1.0.89'
|
import { v_0_1_0_89 } from './v0.1.0.89'
|
||||||
import { v_0_1_0_90 } from './v0.1.0.90'
|
import { v_0_1_0_90 } from './v0.1.0.90'
|
||||||
import { v_0_1_0_91 } from './v0.1.0.91'
|
import { v_0_1_0_91 } from './v0.1.0.91'
|
||||||
|
import { v_0_1_0_92 } from './v0.1.0.92'
|
||||||
|
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_0_1_0_91,
|
current: v_0_1_0_92,
|
||||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90],
|
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
// Reminders & follow-ups (W1). First-class tickler tied to the fundraising grid: a new
|
||||||
|
// `reminders` table (in-app migration 0006; logical FK to fundraising_investors.id +
|
||||||
|
// denormalized name, like the pipeline link), full CRUD at /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 a saved view can drive the follow-up view off real reminders), an orphan
|
||||||
|
// reconciler (cancels reminders when their investor leaves the grid), a Reminders page, a
|
||||||
|
// Dashboard "Reminders Due" card, and a "Reminders due" daily-digest section. Pure local CRM
|
||||||
|
// data — no LLM path. up/down are no-ops; the real SQLite migration runs in-app at startup.
|
||||||
|
export const v_0_1_0_92 = VersionInfo.of({
|
||||||
|
version: '0.1.0:92',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US: [
|
||||||
|
'Reminders & follow-ups: set a due-dated reminder on any investor from the grid,',
|
||||||
|
'track them on the new Reminders page + Dashboard card, and get an overdue/due-today',
|
||||||
|
'section in the daily digest.',
|
||||||
|
].join(' '),
|
||||||
|
},
|
||||||
|
migrations: { up: async () => {}, down: async () => {} },
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user