Repurpose Communications tab as admin-only email-activity panel (v0.1.0:80)

The Communications tab is now an admin-only search over captured Gmail
(email_* tables), part of consolidating on the fundraising grid + email
capture as the canonical system of record.

- New GET /api/email/activity (admin-enforced server-side): filter by
  investor / mailbox / direction with free-text search over subject,
  snippet, and sender. Query logic in db.query_email_activity.
  - Soft-delete honored on the per-mailbox sighting (emails carry no
    deleted_at; deletion lives on email_account_messages).
  - Direction decided at the email level (outbound if the sender is one of
    our mailboxes), mirroring digest_builder.
  - Graveyard investors are hidden from the filter dropdown (CRM-wide
    graveyard=0 convention) but their email stays visible in the list and
    findable by free-text search — this is an audit surface.
- Communications page rewritten to render the panel; the classic manual
  "Log Communication" form is retired (the grid context menu remains the
  manual-log path). Nav item + page are admin-only.
- Tests: email_integration/test_email_activity_panel.py (filters,
  per-sighting soft-delete, roll-ups, graveyard handling, route 401/403);
  full suite 22/22. Frontend render verified via a jsdom mount smoke test
  plus the pinned classic-runtime Babel transform.

Code-only, no schema migration (version migrations are no-ops).
This commit is contained in:
Keysat
2026-06-16 14:49:59 -05:00
parent f9705d2216
commit 42d2b4b245
8 changed files with 494 additions and 291 deletions
+5 -5
View File
@@ -101,14 +101,14 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
## Current state ## Current state
_Phase 0 substrate + Phase 1 thesis/outreach are built; **box and repo at v0.1.0:79** (latest: **P0 hotfix** — the unpinned Babel CDN auto-upgraded to v8 and blanked the entire UI; pinned `@babel/standalone@7.29.7` + closed 3 server-side admin gaps; prior: retired `lp_profiles` + LP Tracker). **Decision (2026-06-16): the fundraising grid + email capture is the canonical system of record** — vestigial classic-CRM surfaces get pruned or repurposed (see `ROADMAP.md` → "Consolidate on the fundraising grid as canonical"). Longer-term backlog: `ROADMAP.md`._ _Phase 0 substrate + Phase 1 thesis/outreach are built; **box and repo at v0.1.0:80** (latest: **email-activity panel** — the Communications tab is now the admin-only captured-Gmail search over the `email_*` tables; prior: P0 hotfix pinning `@babel/standalone@7.29.7` after the unpinned CDN auto-upgraded to v8 and blanked the UI). **Decision (2026-06-16): the fundraising grid + email capture is the canonical system of record** — vestigial classic-CRM surfaces get pruned or repurposed (see `ROADMAP.md` → "Consolidate on the fundraising grid as canonical"). Longer-term backlog: `ROADMAP.md`._
- **Working (all draft-only):** CRM + ingest (chunk→embed→Qdrant + retrieval) + redaction boundary; Gmail capture (DWD) + email-activity propose→approve; Thesis Workshop + Architect (Claude) with dual-approval gate; Outreach Draft Assistant + follow-up radar + per-user voice + Tier-B in-thread Gmail draft creation. - **Working (all draft-only):** CRM + ingest (chunk→embed→Qdrant + retrieval) + redaction boundary; Gmail capture (DWD) + email-activity propose→approve; Thesis Workshop + Architect (Claude) with dual-approval gate; Outreach Draft Assistant + follow-up radar + per-user voice + Tier-B in-thread Gmail draft creation.
- **Deployed & verified live: v0.1.0:79** (box `$START9_BOX_HOST`/immense-voyage.local; `installed-version``0.1.0:79`, migration chain `…78→79` clean, server up on `:8080`; **login screen render-confirmed in-browser by Grant after a hard refresh** — not just curl/health). **v0.1.0:79 was a P0 hotfix:** the page loaded `@babel/standalone` from unpkg **unpinned**, so the CDN served **Babel 8.0.0**, whose `@babel/preset-react` automatic JSX runtime prepends an ESM `import {jsx} from "react/jsx-runtime"` — illegal in this classic (non-module) inline `<script>`, so the browser rejected the whole bundle and React never mounted → **blank screen for every user**. Fix: pin `@babel/standalone@7.29.7` (classic runtime; verified via headless render locally + on the box). Same release closed **3 server-side admin gaps** from a permissions audit — `GET /api/users`, `/api/email/status`, `/api/email/accounts` were UI-hidden from members but not API-enforced; all now `require_admin` (write endpoints were already gated). **Prior — v0.1.0:78 retired `lp_profiles` + the orphaned LP Tracker** (endpoints/handlers/lp-breakdown report/contact-dossier LP section/frontend component+redirect removed; empty table left in place per never-hard-delete) and **repointed the Dashboard "Total Committed"** onto `fundraising_investors.total_invested` (graveyard-excluded; "Total Funded" dropped — the grid has no funded concept). **Digest is fully live:** capture (DWD) → propose→approve; transport routes Gmail-DWD→SMTP (no app password); and **daily activity digest (Phase B)**`digest_builder.py` (by-team-member Spark narrative + by-investor section, soft-delete filtered) + always-on `digest_scheduler.py` reading a DB policy + `send-now`. **Auto-send defaults OFF** (env seed unset → `app_settings.digest_policy` off) until Grant enables it in Settings → Admin. Detail: `docs/guides/email.md`. - **Deployed & verified live: v0.1.0:80** (box `$START9_BOX_HOST`/immense-voyage.local; `installed-version`→`0.1.0:80`, migration chain `…79→80` clean, server up on `:8080`, schedulers + Gmail integration up; **render-verified via a jsdom mount smoke test of the built frontend** — React actually mounts to the login screen, plus the inline JSX transforms cleanly under the pinned classic-runtime Babel; not just curl/health. In-browser confirmation by Grant still welcome). **v0.1.0:80 repurposed the Communications tab into the admin-only email-activity panel:** new `GET /api/email/activity` (admin-enforced server-side) over the `email_*` tables, filterable by investor / mailbox / direction + free-text search; soft-delete honored on the per-mailbox sighting; direction decided at the email level (mirrors `digest_builder`); graveyard investors hidden from the picker but their email stays visible + searchable (audit surface). The classic manual "Log Communication" form was retired (the grid context menu remains the manual-log path); nav item + page are admin-only. Query lives in `email_integration/db.py:query_email_activity`; tests in `email_integration/test_email_activity_panel.py`. **Prior — v0.1.0:79 was a P0 hotfix:** the page loaded `@babel/standalone` from unpkg **unpinned**, so the CDN served **Babel 8.0.0**, whose `@babel/preset-react` automatic JSX runtime prepends an ESM `import {jsx} from "react/jsx-runtime"` — illegal in this classic (non-module) inline `<script>`, so the browser rejected the whole bundle and React never mounted → **blank screen for every user**. Fix: pin `@babel/standalone@7.29.7` (classic runtime; verified via headless render locally + on the box). Same release closed **3 server-side admin gaps** from a permissions audit — `GET /api/users`, `/api/email/status`, `/api/email/accounts` were UI-hidden from members but not API-enforced; all now `require_admin` (write endpoints were already gated). **Prior — v0.1.0:78 retired `lp_profiles` + the orphaned LP Tracker** (endpoints/handlers/lp-breakdown report/contact-dossier LP section/frontend component+redirect removed; empty table left in place per never-hard-delete) and **repointed the Dashboard "Total Committed"** onto `fundraising_investors.total_invested` (graveyard-excluded; "Total Funded" dropped — the grid has no funded concept). **Digest is fully live:** capture (DWD) → propose→approve; transport routes Gmail-DWD→SMTP (no app password); and **daily activity digest (Phase B)** — `digest_builder.py` (by-team-member Spark narrative + by-investor section, soft-delete filtered) + always-on `digest_scheduler.py` reading a DB policy + `send-now`. **Auto-send defaults OFF** (env seed unset → `app_settings.digest_policy` off) until Grant enables it in Settings → Admin. Detail: `docs/guides/email.md`.
- **Live since v74 (2026-06-13):** login works; `/assets/` traversal 404s (plain + URL-encoded), root health 200. On boot, `ensure_thesis_v2_promoted` makes the v2.0 reserve-asset spine the working *approved* spine (node-level, reversible). Security/privacy hardening (path-traversal close, outreach NER backstop, get-by-id soft-delete) shipped in v74 — detail in `EVALUATION.md`. - **Live since v74 (2026-06-13):** login works; `/assets/` traversal 404s (plain + URL-encoded), root health 200. On boot, `ensure_thesis_v2_promoted` makes the v2.0 reserve-asset spine the working *approved* spine (node-level, reversible). Security/privacy hardening (path-traversal close, outreach NER backstop, get-by-id soft-delete) shipped in v74 — detail in `EVALUATION.md`.
- **Tests (2026-06-16):** **21/21 backend tests green** via `python3 backend/run_tests.py` (latest add: `test_dashboard_report.py`dashboard committed sourced from the grid [graveyard-excluded], `total_funded` key gone, retired `/api/lp-profiles*` + lp-breakdown routes 404; `test_soft_delete_reads.py` updated for the removed LP block; plus `test_digest_builder.py`). `py_compile` clean. The 2 stale thesis tests stay fixed (seed structure in `docs/guides/thesis.md`). - **Tests (2026-06-16):** **22/22 backend tests green** via `python3 backend/run_tests.py` (latest add: `email_integration/test_email_activity_panel.py` — investor/mailbox/search/direction filters, per-sighting soft-delete, email-level direction, mailbox + investor roll-ups incl. unmatched address fallback, graveyard hidden-from-picker-but-visible, facets, route 401/403 admin enforcement; prior: `test_dashboard_report.py`, `test_digest_builder.py`). `py_compile` clean. Frontend render checked locally (jsdom mount + pinned-Babel transform). The 2 stale thesis tests stay fixed (seed structure in `docs/guides/thesis.md`).
- **Decided, not yet built (detail in `ROADMAP.md`):** Pipeline adoption + a grid flag that auto-loads flagged investors as opportunities; email-search box + per-user/per-investor activity panel; NL→safe-query feature; CRM as canonical thesis backbone with the signal-engine reading from it (reconciliation unwired); reply-all for Tier-B drafts (currently reply to the LP only). - **Decided, not yet built (detail in `ROADMAP.md`):** Pipeline adoption + a grid flag that auto-loads flagged investors as opportunities; NL→safe-query feature; CRM as canonical thesis backbone with the signal-engine reading from it (reconciliation unwired); reply-all for Tier-B drafts (currently reply to the LP only). *(Done v80: the admin-only per-investor/per-mailbox email-activity panel.)*
- **Known debt (P2, not deploy-blocking):** **reports-subsystem soft-delete sweep** — `handle_pipeline_report` + remaining report/aggregate queries over opportunities/communications still count soft-deleted rows (v78 shrank this surface: the `lp_profiles`/lp-breakdown aggregates are gone and the dashboard "Total Committed" is now grid-sourced); needs a pass + report-endpoint tests. Also `?limit=abc` crashes the request thread (authenticated list path); scrub-gateway TLS verify off; `cryptography==42.0.5`; **front-end CDN libs still loaded from unpkg without SRI** — Babel is now version-pinned (v79, after an unpinned auto-upgrade to Babel 8 blanked the whole UI), but React/Babel should be **vendored into the package + SRI-pinned** so a CDN can never swap prod deps again; **deploy verification must include a browser-render smoke check** — v78's blank UI shipped as "verified live" because the checks were server-up/curl only, which can't catch a client render failure; stale user-visible `start9/0.4/assets/ABOUT.md`; hardcoded Spark/Qdrant IPs in the s9pk; the 5.4k-line `server.py` monolith. P3 batch + full list in `EVALUATION.md`. - **Known debt (P2, not deploy-blocking):** **reports-subsystem soft-delete sweep** — `handle_pipeline_report` + remaining report/aggregate queries over opportunities/communications still count soft-deleted rows (v78 shrank this surface: the `lp_profiles`/lp-breakdown aggregates are gone and the dashboard "Total Committed" is now grid-sourced); needs a pass + report-endpoint tests. Also `?limit=abc` crashes the request thread (authenticated list path); scrub-gateway TLS verify off; `cryptography==42.0.5`; **front-end CDN libs still loaded from unpkg without SRI** — Babel is now version-pinned (v79, after an unpinned auto-upgrade to Babel 8 blanked the whole UI), but React/Babel should be **vendored into the package + SRI-pinned** so a CDN can never swap prod deps again; **deploy verification must include a browser-render smoke check** — v78's blank UI shipped as "verified live" because the checks were server-up/curl only, which can't catch a client render failure; stale user-visible `start9/0.4/assets/ABOUT.md`; hardcoded Spark/Qdrant IPs in the s9pk; the 5.4k-line `server.py` monolith. P3 batch + full list in `EVALUATION.md`.
- **Doc drift to reconcile:** `crm-overview.md` + `EVALUATION.md` still describe `lp_profiles` as a live model in places — a doc-auditor pass should align them to "grid canonical, `lp_profiles` retired." - **Doc drift to reconcile:** `crm-overview.md` + `EVALUATION.md` still describe `lp_profiles` as a live model in places — a doc-auditor pass should align them to "grid canonical, `lp_profiles` retired."
- **Other gaps:** the v2.0 spine is the *working* spine but **not a canonical `thesis_version`** (needs Grant + Jonathan dual sign-off); Appendix-A conviction/exposure (incl. ~40% Strike) stay Grant's working read, not canonical, not fed to the engine; live features (Claude/Qdrant/Gmail) unverified on the box. - **Other gaps:** the v2.0 spine is the *working* spine but **not a canonical `thesis_version`** (needs Grant + Jonathan dual sign-off); Appendix-A conviction/exposure (incl. ~40% Strike) stay Grant's working read, not canonical, not fed to the engine; live features (Claude/Qdrant/Gmail) unverified on the box.
- **Next:** 1) **Vendor + SRI-pin the front-end libs** (serve React/Babel from the package, integrity-checked) **and add a browser-render smoke check to the deploy-verify step**both are fallout from the v79 blank-screen outage; 2) add an **auth regression test** asserting the 3 newly-gated GET endpoints (`/api/users`, `/api/email/status`, `/api/email/accounts`) reject members; 3) build the **email-activity search panel***decided 2026-06-16: repurpose the existing **Communications** tab, **admin-only*** (per-investor / per-mailbox over the `email_*` tables; the new `GET /api/email/activity` must enforce admin server-side); 4) Grant validates digest Phase B on the box — Settings→Admin **Send Digest Now**, then tick **Send automatically every day**; 5) **reports-subsystem soft-delete sweep** + report-endpoint tests; 6) **Pipeline adoption** — grid flag → auto-load opportunities; 7) `?limit=abc` crash; 8) **NL→safe-query** (separate, larger); 9) Grant + Jonathan freeze v2.0 canonical; 10) build reply-all. - **Next:** 1) **Vendor + SRI-pin the front-end libs** (serve React/Babel from the package, integrity-checked) so a CDN can never swap prod deps again, **and script the render smoke check into deploy-verify** — a working jsdom-mount + pinned-Babel-transform check was run manually for v80 (catches the v78/v79 blank-screen class); wire it into the build/install flow; 2) add an **auth regression test** asserting the 3 v79-gated GET endpoints (`/api/users`, `/api/email/status`, `/api/email/accounts`) reject members (v80 added the analogous test for `/api/email/activity`); 3) Grant validates digest Phase B on the box — Settings→Admin **Send Digest Now**, then tick **Send automatically every day**; 4) **reports-subsystem soft-delete sweep** + report-endpoint tests; 5) **Pipeline adoption** — grid flag → auto-load opportunities; 6) `?limit=abc` crash; 7) **NL→safe-query** (separate, larger); 8) Grant + Jonathan freeze v2.0 canonical; 9) build reply-all.
+104
View File
@@ -398,6 +398,110 @@ def start_sync_run(conn: sqlite3.Connection, *, account_id: str, kind: str) -> s
return run_id return run_id
def query_email_activity(conn: sqlite3.Connection, *, investor_id: Optional[str] = None,
account_id: Optional[str] = None, search: Optional[str] = None,
direction: Optional[str] = None, limit: int = 100) -> dict:
"""Captured-Gmail activity for the admin Communications panel, filterable by
investor (matched fundraising investor) and/or mailbox, with free-text search
over subject/snippet/sender. Returns the email rows plus the filter facets.
Soft-delete: an email is live only if it still has a non-tombstoned per-mailbox
sighting (`email_account_messages.deleted_at IS NULL`) — the `emails` row itself
carries no deleted_at, so deletion lives on the sighting. Direction is decided at
the email level (outbound if the sender is one of our mailboxes), mirroring the
digest builder, so a thread reads consistently regardless of which mailbox saw it.
"""
limit = max(1, min(int(limit or 100), 500))
cur = conn.cursor()
own = {(r["email_address"] or "").lower().strip()
for r in cur.execute("SELECT email_address FROM email_accounts")}
own.discard("")
where = ["EXISTS (SELECT 1 FROM email_account_messages eam "
"WHERE eam.email_id = e.id AND eam.deleted_at IS NULL)"]
params: list = []
if account_id:
where.append("EXISTS (SELECT 1 FROM email_account_messages eam "
"WHERE eam.email_id = e.id AND eam.account_id = ? "
"AND eam.deleted_at IS NULL)")
params.append(account_id)
if investor_id:
where.append("EXISTS (SELECT 1 FROM email_investor_links l WHERE l.email_id = e.id "
"AND (l.fundraising_investor_id = ? OR l.fundraising_contact_id IN "
"(SELECT id FROM fundraising_contacts WHERE investor_id = ?)))")
params.extend([investor_id, investor_id])
if search:
like = f"%{search.strip()}%"
where.append("(e.subject LIKE ? OR e.snippet LIKE ? "
"OR e.from_email LIKE ? OR e.from_name LIKE ?)")
params.extend([like, like, like, like])
direction = (direction or "").strip().lower()
if direction in ("inbound", "outbound") and own:
marks = ",".join("?" for _ in own)
op = "IN" if direction == "outbound" else "NOT IN"
where.append(f"LOWER(e.from_email) {op} ({marks})")
params.extend(sorted(own))
sql = ("SELECT e.id, e.subject, e.from_name, e.from_email, e.sent_at, e.snippet, "
"e.has_attachments, e.is_matched, e.match_status FROM emails e WHERE "
+ " AND ".join(where) + " ORDER BY e.sent_at DESC LIMIT ?")
rows = [dict(r) for r in cur.execute(sql, params + [limit + 1])]
truncated = len(rows) > limit
rows = rows[:limit]
by_id = {r["id"]: r for r in rows}
for r in rows:
r["direction"] = "outbound" if (r["from_email"] or "").lower().strip() in own else "inbound"
r["mailboxes"] = []
r["investors"] = []
r["investor_labels"] = []
ids = list(by_id)
if ids:
marks = ",".join("?" for _ in ids)
for s in cur.execute(
"SELECT eam.email_id AS eid, ea.email_address AS addr "
"FROM email_account_messages eam JOIN email_accounts ea ON ea.id = eam.account_id "
f"WHERE eam.deleted_at IS NULL AND eam.email_id IN ({marks}) "
"ORDER BY ea.email_address", ids):
mb = by_id[s["eid"]]["mailboxes"]
if s["addr"] and s["addr"] not in mb:
mb.append(s["addr"])
for lnk in cur.execute(
"SELECT l.email_id AS eid, l.matched_address AS addr, "
"COALESCE(fi.id, fic_inv.id) AS inv_id, "
"COALESCE(fi.investor_name, fic_inv.investor_name) AS inv_name "
"FROM email_investor_links l "
"LEFT JOIN fundraising_investors fi ON fi.id = l.fundraising_investor_id "
"LEFT JOIN fundraising_contacts fic ON fic.id = l.fundraising_contact_id "
"LEFT JOIN fundraising_investors fic_inv ON fic_inv.id = fic.investor_id "
f"WHERE l.email_id IN ({marks})", ids):
row = by_id[lnk["eid"]]
if lnk["inv_id"] and lnk["inv_name"]:
if not any(iv["id"] == lnk["inv_id"] for iv in row["investors"]):
row["investors"].append({"id": lnk["inv_id"], "name": lnk["inv_name"]})
elif lnk["addr"] and lnk["addr"] not in row["investor_labels"]:
row["investor_labels"].append(lnk["addr"])
accounts = [dict(r) for r in cur.execute(
"SELECT id, email_address FROM email_accounts ORDER BY email_address")]
# Facet dropdown = live investor relationships only (graveyard = 0, the CRM-wide
# convention). Graveyarded investors are excluded from the *picker*, but their
# captured email still shows in the unfiltered list and stays findable by free-text
# search — this is an audit surface, so history is never hidden, only the picker is.
investors = [dict(r) for r in cur.execute(
"SELECT id, investor_name AS name FROM fundraising_investors WHERE graveyard = 0 AND id IN ("
" SELECT fundraising_investor_id FROM email_investor_links "
" WHERE fundraising_investor_id IS NOT NULL"
" UNION"
" SELECT investor_id FROM fundraising_contacts WHERE id IN ("
" SELECT fundraising_contact_id FROM email_investor_links "
" WHERE fundraising_contact_id IS NOT NULL)"
") ORDER BY investor_name")]
return {"emails": rows, "accounts": accounts, "investors": investors,
"count": len(rows), "truncated": truncated}
def finish_sync_run(conn: sqlite3.Connection, run_id: str, *, status: str, def finish_sync_run(conn: sqlite3.Connection, run_id: str, *, status: str,
stats: Optional[dict] = None, error: Optional[str] = None) -> None: stats: Optional[dict] = None, error: Optional[str] = None) -> None:
stats = stats or {} stats = stats or {}
+28
View File
@@ -33,6 +33,7 @@ from . import scheduler as _sched
_GET_ROUTES = { _GET_ROUTES = {
"/api/email/status": "status", "/api/email/status": "status",
"/api/email/accounts": "list_accounts", "/api/email/accounts": "list_accounts",
"/api/email/activity": "activity",
"/api/email/threads": "list_threads", "/api/email/threads": "list_threads",
"/api/email/oauth/start": "oauth_start", "/api/email/oauth/start": "oauth_start",
"/api/email/oauth/callback": "oauth_callback", "/api/email/oauth/callback": "oauth_callback",
@@ -187,6 +188,33 @@ def _h_list_accounts(handler):
handler.send_json({"accounts": rows}) handler.send_json({"accounts": rows})
def _h_activity(handler):
# Admin-only: the Communications page renders captured-Gmail activity (the classic
# manual-log surface was retired). Mailbox/investor substance is admin-scoped, so
# enforce admin server-side, not just nav-hide.
user = _require_admin(handler)
if not user:
return
q = handler.get_query_params()
try:
limit = int(q.get("limit", 100))
except (TypeError, ValueError):
limit = 100
conn = _conn()
try:
result = _db.query_email_activity(
conn,
investor_id=(q.get("investor_id") or "").strip() or None,
account_id=(q.get("account_id") or "").strip() or None,
search=(q.get("q") or q.get("search") or "").strip() or None,
direction=(q.get("direction") or "").strip() or None,
limit=limit,
)
finally:
conn.close()
handler.send_json(result)
def _h_list_threads(handler): def _h_list_threads(handler):
user = _require_auth(handler) user = _require_auth(handler)
if not user: if not user:
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Test the admin-only email-activity panel (Communications tab, v0.1.0:80).
Covers the pure query (`db.query_email_activity`): investor/mailbox/search/direction
filters, per-sighting soft-delete, direction at the email level, mailbox + investor
roll-ups (incl. unmatched fallback to the matched address), and the filter facets.
Also asserts the route handler enforces admin server-side. Synthetic data only.
Run: cd backend && python3 email_integration/test_email_activity_panel.py
"""
import os
import sqlite3
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from email_integration import db as _db # noqa: E402
FAILS = []
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
def make_db():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.executescript("""
CREATE TABLE email_accounts (id TEXT PRIMARY KEY, email_address TEXT);
CREATE TABLE emails (id TEXT PRIMARY KEY, subject TEXT, from_name TEXT, from_email TEXT,
sent_at TEXT, snippet TEXT, has_attachments INT DEFAULT 0, is_matched INT DEFAULT 0,
match_status TEXT DEFAULT 'unmatched');
CREATE TABLE email_account_messages (id TEXT PRIMARY KEY, email_id TEXT, account_id TEXT,
is_sent INT DEFAULT 0, deleted_at TEXT);
CREATE TABLE email_investor_links (id TEXT PRIMARY KEY, email_id TEXT,
fundraising_investor_id TEXT, fundraising_contact_id TEXT, matched_address TEXT);
CREATE TABLE fundraising_investors (id TEXT PRIMARY KEY, investor_name TEXT, graveyard INTEGER DEFAULT 0);
CREATE TABLE fundraising_contacts (id TEXT PRIMARY KEY, investor_id TEXT, full_name TEXT);
""")
# Two mailboxes (us), three investors (one reached only via a contact link;
# one graveyarded but still with captured email history).
conn.executemany("INSERT INTO email_accounts VALUES (?,?)", [
("acc-grant", "grant@ten31.xyz"),
("acc-jon", "jonathan@ten31.xyz"),
])
conn.executemany("INSERT INTO fundraising_investors VALUES (?,?,?)", [
("inv-harbor", "Harbor & Vine", 0),
("inv-pacific", "Pacific Capital", 0),
("inv-dead", "Dead Deal LP", 1),
])
conn.execute("INSERT INTO fundraising_contacts VALUES ('fc-1','inv-pacific','Sarah Williams')")
# Emails:
# e1 outbound (from us) -> Harbor, seen by grant
# e2 inbound (from LP) -> Harbor, seen by grant + jonathan
# e3 inbound (from LP) -> Pacific via contact link, seen by jonathan
# e4 inbound, UNMATCHED (no investor link), seen by grant
# e5 inbound, only sighting is tombstoned -> must be excluded
conn.executemany(
"INSERT INTO emails (id,subject,from_name,from_email,sent_at,snippet,has_attachments,is_matched,match_status) VALUES (?,?,?,?,?,?,?,?,?)",
[
("e1", "Fund III update", "Grant", "grant@ten31.xyz", "2026-06-05T10:00:00", "here is the deck", 1, 1, "matched"),
("e2", "Re: Fund III update", "LP Harbor", "lp@harborvine.example", "2026-06-06T09:00:00", "thanks, one question", 0, 1, "matched"),
("e3", "Intro", "Sarah Williams", "sarah@pacificcap.example", "2026-06-07T08:00:00", "would love to chat", 0, 1, "matched"),
("e4", "Cold inbound", "Random", "noreply@spam.example", "2026-06-08T08:00:00", "buy now", 0, 0, "unmatched"),
("e5", "Deleted thread", "Ghost", "ghost@x.example", "2026-06-09T08:00:00", "gone", 0, 1, "matched"),
("e6", "Old dead-deal thread", "Dead LP", "lp@deaddeal.example", "2026-06-01T00:00:00", "we passed", 0, 1, "matched"),
])
conn.executemany(
"INSERT INTO email_account_messages (id,email_id,account_id,is_sent,deleted_at) VALUES (?,?,?,?,?)",
[
("m1", "e1", "acc-grant", 1, None),
("m2", "e2", "acc-grant", 0, None),
("m3", "e2", "acc-jon", 0, None),
("m4", "e3", "acc-jon", 0, None),
("m5", "e4", "acc-grant", 0, None),
("m6", "e5", "acc-grant", 0, "2026-06-10T00:00:00"), # tombstoned
("m7", "e6", "acc-grant", 0, None),
])
conn.executemany(
"INSERT INTO email_investor_links (id,email_id,fundraising_investor_id,fundraising_contact_id,matched_address) VALUES (?,?,?,?,?)",
[
("l1", "e1", "inv-harbor", None, "lp@harborvine.example"),
("l2", "e2", "inv-harbor", None, "lp@harborvine.example"),
("l3", "e3", None, "fc-1", "sarah@pacificcap.example"),
("l5", "e5", "inv-harbor", None, "lp@harborvine.example"),
("l6", "e6", "inv-dead", None, "lp@deaddeal.example"),
])
conn.commit()
return conn
def ids(res):
return [e["id"] for e in res["emails"]]
def main():
conn = make_db()
# --- baseline: live emails only, newest first, tombstoned excluded ---
res = _db.query_email_activity(conn)
check(ids(res) == ["e4", "e3", "e2", "e1", "e6"], f"live emails newest-first, e5 (tombstoned) excluded; got {ids(res)}")
check(res["count"] == 5 and res["truncated"] is False, "count + not truncated")
# --- direction at the email level ---
e1 = next(e for e in res["emails"] if e["id"] == "e1")
e2 = next(e for e in res["emails"] if e["id"] == "e2")
check(e1["direction"] == "outbound", "e1 from our mailbox -> outbound")
check(e2["direction"] == "inbound", "e2 from LP -> inbound")
check(_db.query_email_activity(conn, direction="outbound")["emails"][0]["id"] == "e1"
and len(_db.query_email_activity(conn, direction="outbound")["emails"]) == 1,
"direction=outbound returns only e1")
check(ids(_db.query_email_activity(conn, direction="inbound")) == ["e4", "e3", "e2", "e6"],
"direction=inbound excludes the outbound e1")
# --- mailbox roll-up + per-account filter ---
check(set(e2["mailboxes"]) == {"grant@ten31.xyz", "jonathan@ten31.xyz"}, "e2 seen by both mailboxes")
check(ids(_db.query_email_activity(conn, account_id="acc-jon")) == ["e3", "e2"],
"account_id=acc-jon returns only emails that mailbox saw")
# --- investor filter: direct link and via-contact link ---
check(set(ids(_db.query_email_activity(conn, investor_id="inv-harbor"))) == {"e2", "e1"},
"investor_id=inv-harbor -> e1,e2")
check(ids(_db.query_email_activity(conn, investor_id="inv-pacific")) == ["e3"],
"investor_id=inv-pacific resolved through fundraising_contacts -> e3")
# --- investor name roll-up + unmatched fallback ---
check(e1["investors"] == [{"id": "inv-harbor", "name": "Harbor & Vine"}], "e1 investor resolved to name")
e3 = next(e for e in res["emails"] if e["id"] == "e3")
check(e3["investors"] == [{"id": "inv-pacific", "name": "Pacific Capital"}], "e3 investor resolved via contact")
e4 = next(e for e in res["emails"] if e["id"] == "e4")
check(e4["investors"] == [] and e4["investor_labels"] == [], "e4 unmatched -> no investor, no link")
# --- free-text search over subject / snippet / sender ---
check(set(ids(_db.query_email_activity(conn, search="Fund III"))) == {"e1", "e2"}, "search subject")
check(ids(_db.query_email_activity(conn, search="pacificcap")) == ["e3"], "search sender address")
check(ids(_db.query_email_activity(conn, search="buy now")) == ["e4"], "search snippet")
# --- facets ---
check([a["email_address"] for a in res["accounts"]] == ["grant@ten31.xyz", "jonathan@ten31.xyz"],
"accounts facet sorted")
facet_inv = {i["id"] for i in res["investors"]}
check(facet_inv == {"inv-harbor", "inv-pacific"}, "investor facet covers direct + via-contact activity")
# --- graveyard: hidden from the picker, but its email stays visible + findable ---
check("inv-dead" not in facet_inv, "graveyard investor excluded from the facet dropdown")
check("e6" in ids(res), "graveyard investor's email still shows in the unfiltered list (audit completeness)")
e6 = next(e for e in res["emails"] if e["id"] == "e6")
check(e6["investors"] == [{"id": "inv-dead", "name": "Dead Deal LP"}], "graveyard email still shows its investor chip")
check(ids(_db.query_email_activity(conn, investor_id="inv-dead")) == ["e6"],
"explicit investor_id filter still works for a graveyard investor")
check(ids(_db.query_email_activity(conn, search="deaddeal")) == ["e6"],
"graveyard email remains findable by free-text search")
# --- truncation ---
tr = _db.query_email_activity(conn, limit=2)
check(tr["count"] == 2 and tr["truncated"] is True, "limit=2 -> truncated")
conn.close()
# --- route enforces admin server-side ---
test_route_admin_only()
if FAILS:
print(f"\nFAILED ({len(FAILS)})")
for f in FAILS:
print(" - " + f)
sys.exit(1)
print("\nALL PASS (email-activity panel)")
def test_route_admin_only():
try:
from email_integration import routes
except Exception as e: # pragma: no cover - optional deps missing in some dev envs
print(f" SKIP route admin test (routes import failed: {e})")
return
class FakeHandler:
def __init__(self, user):
self._user = user
self.json = None
self.err = None
self.code = None
def get_user(self):
return self._user
def get_query_params(self):
return {}
def send_json(self, obj):
self.json = obj
def send_error_json(self, msg, code):
self.err = msg
self.code = code
h = FakeHandler(None)
routes._h_activity(h)
check(h.code == 401 and h.json is None, "route: no user -> 401")
h = FakeHandler({"role": "member", "user_id": "u1"})
routes._h_activity(h)
check(h.code == 403 and h.json is None, "route: member -> 403 (admin enforced server-side)")
if __name__ == "__main__":
main()
+109 -271
View File
@@ -4253,319 +4253,155 @@
); );
}; };
const CommunicationsPage = ({ token, onShowToast }) => { const CommunicationsPage = ({ token, user, onShowToast }) => {
const [communications, setCommunications] = useState([]); // Repurposed (v0.1.0:80): the Communications tab is now the admin-only
const [contacts, setContacts] = useState([]); // email-activity panel over the captured email_* tables. The classic
const [investorNames, setInvestorNames] = useState([]); // manual "Log Communication" surface was retired (grid is canonical).
const isAdmin = user?.role === 'admin';
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [type, setType] = useState(''); const [disabled, setDisabled] = useState(false);
const [error, setError] = useState('');
const [investorId, setInvestorId] = useState('');
const [accountId, setAccountId] = useState('');
const [direction, setDirection] = useState('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [showForm, setShowForm] = useState(false); const [debouncedSearch, setDebouncedSearch] = useState('');
const [formData, setFormData] = useState({ type: 'email', contact_id: '', investor_selection: '', investor_name_new: '', append_note: true });
const [quickMapSearch, setQuickMapSearch] = useState('');
const [formError, setFormError] = useState('');
const [confirmDelete, setConfirmDelete] = useState(null);
const NEW_INVESTOR_VALUE = '__new_investor__';
const rankedContacts = useMemo(() => { // Debounce the free-text box so each keystroke doesn't hit the server.
const q = String(quickMapSearch || '').trim(); useEffect(() => {
if (!q) return contacts; const t = setTimeout(() => setDebouncedSearch(search.trim()), 300);
return contacts return () => clearTimeout(t);
.map((c, idx) => { }, [search]);
const label = `${contactName(c)} ${c.organization_name || c.organization || ''}`.trim();
const score = fuzzyScore(q, label);
return { c, idx, score };
})
.filter((x) => x.score >= 0.45)
.sort((a, b) => (b.score - a.score) || (a.idx - b.idx))
.map((x) => x.c);
}, [contacts, quickMapSearch]);
const rankedInvestors = useMemo(() => {
const q = String(quickMapSearch || '').trim();
if (!q) return investorNames;
return investorNames
.map((name, idx) => ({ name, idx, score: fuzzyScore(q, name) }))
.filter((x) => x.score >= 0.45)
.sort((a, b) => (b.score - a.score) || (a.idx - b.idx))
.map((x) => x.name);
}, [investorNames, quickMapSearch]);
useEffect(() => { useEffect(() => {
const fetchComms = async () => { if (!isAdmin) { setLoading(false); return undefined; }
let cancelled = false;
(async () => {
try { try {
setLoading(true); setLoading(true);
const [commResult, contactResult, fundraisingResult] = await Promise.all([ const params = new URLSearchParams();
api(`/api/communications?type=${type}&search=${search}&limit=200`, {}, token), if (investorId) params.set('investor_id', investorId);
api('/api/contacts?limit=1000', {}, token), if (accountId) params.set('account_id', accountId);
api('/api/fundraising/state', {}, token) if (direction) params.set('direction', direction);
]); if (debouncedSearch) params.set('q', debouncedSearch);
setCommunications(commResult.data || []); params.set('limit', '200');
setContacts(contactResult.data || []); const res = await api(`/api/email/activity?${params.toString()}`, {}, token);
const names = Array.from(new Set( if (cancelled) return;
((fundraisingResult?.data?.grid?.rows || []) setData(res);
.map((r) => String(r?.investor_name || '').trim()) setDisabled(false);
.filter(Boolean)) setError('');
)).sort((a, b) => a.localeCompare(b));
setInvestorNames(names);
} catch (err) { } catch (err) {
onShowToast(getErrorMessage(err, 'Failed to load communications'), 'error'); if (cancelled) return;
// Integration off on the server -> show the disabled state, not an error.
if (err?.status === 503 || /disabl/i.test(err?.payload?.error || '')) {
setDisabled(true);
setData(null);
} else {
setError(getErrorMessage(err, 'Failed to load email activity'));
}
} finally { } finally {
setLoading(false); if (!cancelled) setLoading(false);
} }
}; })();
fetchComms(); return () => { cancelled = true; };
}, [token, type, search, onShowToast]); }, [token, isAdmin, investorId, accountId, direction, debouncedSearch]);
const handleAddComm = async (e) => { if (!isAdmin) {
e.preventDefault(); return (
setFormError(''); <div className="page-container">
try { <h2 className="section-title" style={{ marginBottom: '20px' }}>Communications</h2>
const selected = contacts.find((c) => c.id === formData.contact_id); <div className="empty-state">Communications is an admin-only view (captured email activity).</div>
if (!selected) { </div>
setFormError('Contact is required'); );
return;
} }
const selectedInvestor = String(formData.investor_selection || '').trim();
const newInvestorInput = String(formData.investor_name_new || '').trim();
const investorName = selectedInvestor === NEW_INVESTOR_VALUE ? newInvestorInput : selectedInvestor;
if (!investorName) {
setFormError('Investor mapping is required. Select an investor or create a new one.');
return;
}
const fullName = contactName(selected || {});
await api('/api/fundraising/log-communication', {
method: 'POST',
body: JSON.stringify({
investor_name: investorName,
create_investor_if_missing: true,
contact: {
name: fullName,
email: selected?.email || '',
title: selected?.title || ''
},
type: formData.type || 'note',
subject: formData.subject || '',
body: formData.body || '',
outcome: formData.outcome || '',
next_action: formData.next_action || '',
next_action_date: formData.next_action_date || '',
append_note: !!formData.append_note
})
}, token);
setShowForm(false);
setFormData({ type: 'email', contact_id: '', investor_selection: '', investor_name_new: '', append_note: true });
setQuickMapSearch('');
const [result, fundraisingResult] = await Promise.all([
api(`/api/communications?type=${type}&search=${search}&limit=200`, {}, token),
api('/api/fundraising/state', {}, token)
]);
setCommunications(result.data || []);
const names = Array.from(new Set(
((fundraisingResult?.data?.grid?.rows || [])
.map((r) => String(r?.investor_name || '').trim())
.filter(Boolean))
)).sort((a, b) => a.localeCompare(b));
setInvestorNames(names);
onShowToast('Communication logged', 'success');
} catch (err) {
setFormError(getErrorMessage(err, 'Failed to log communication'));
}
};
const handleDeleteComm = async (id) => { const emails = data?.emails || [];
try { const accounts = data?.accounts || [];
await api(`/api/communications/${id}`, { method: 'DELETE' }, token); const investors = data?.investors || [];
setCommunications((prev) => prev.filter((c) => c.id !== id)); const hasFilter = !!(investorId || accountId || direction || debouncedSearch);
setConfirmDelete(null);
onShowToast('Communication deleted', 'success');
} catch (err) {
onShowToast(getErrorMessage(err, 'Failed to delete communication'), 'error');
}
};
const typeLabel = (v) => {
if (v === 'email') return 'Email';
if (v === 'call') return 'Call';
if (v === 'meeting') return 'Meeting';
if (v === 'text') return 'Text';
return 'Note';
};
return ( return (
<div className="page-container"> <div className="page-container">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2 className="section-title">Communications</h2> <h2 className="section-title">Communications</h2>
<button onClick={() => { <span className="form-help">Captured email activity. Logging &amp; drafts live in the Fundraising Grid and Outreach.</span>
setFormError('');
setFormData({ type: 'email', contact_id: '', investor_selection: '', investor_name_new: '', append_note: true });
setQuickMapSearch('');
setShowForm(true);
}}>+ Log Communication</button>
</div> </div>
<div className="section"> <div className="section">
<div className="controls"> <div className="controls">
<input <input
type="text" type="text"
className="search-input" className="search-input"
placeholder="Search subject/body..." placeholder="Search subject, sender, snippet..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
<select className="select-input" value={type} onChange={(e) => setType(e.target.value)}> <select className="select-input" value={investorId} onChange={(e) => setInvestorId(e.target.value)}>
<option value="">All Types</option> <option value="">All investors</option>
<option value="email">Email</option> {investors.map((iv) => <option key={iv.id} value={iv.id}>{iv.name}</option>)}
<option value="call">Call</option> </select>
<option value="meeting">Meeting</option> <select className="select-input" value={accountId} onChange={(e) => setAccountId(e.target.value)}>
<option value="note">Note</option> <option value="">All mailboxes</option>
<option value="text">Text</option> {accounts.map((a) => <option key={a.id} value={a.id}>{a.email_address}</option>)}
</select>
<select className="select-input" value={direction} onChange={(e) => setDirection(e.target.value)}>
<option value="">In &amp; out</option>
<option value="inbound">Received</option>
<option value="outbound">Sent</option>
</select> </select>
</div> </div>
{loading ? ( {loading ? (
<SkeletonBlock lines={8} /> <SkeletonBlock lines={8} />
) : communications.length === 0 ? ( ) : error ? (
<div className="empty-state">No communications</div> <div className="toast error" style={{ position: 'static' }}>{error}</div>
) : disabled ? (
<span className="index-job-pill idle"><span className="index-job-dot" /> Email integration is disabled — no Gmail service-account key on the server.</span>
) : emails.length === 0 ? (
<div className="empty-state">{hasFilter ? 'No email activity matches these filters.' : 'No captured email yet. Enroll mailboxes in Email Capture.'}</div>
) : ( ) : (
<>
<div className="form-help" style={{ marginBottom: '10px' }}>
Showing {emails.length}{data?.truncated ? '+ (refine filters to see more)' : ''} email{emails.length === 1 ? '' : 's'}.
</div>
<div className="timeline"> <div className="timeline">
{communications.map((comm) => ( {emails.map((em) => {
<div key={comm.id} className="timeline-item"> const sent = em.direction === 'outbound';
const who = em.from_name ? `${em.from_name} <${em.from_email}>` : (em.from_email || 'Unknown sender');
const tags = [...(em.investors || []).map((iv) => iv.name), ...(em.investor_labels || [])];
return (
<div key={em.id} className="timeline-item">
<div className="timeline-marker"></div> <div className="timeline-marker"></div>
<div className="timeline-content"> <div className="timeline-content">
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px' }}> <div className="timeline-header">
<div className="timeline-header">{typeLabel(comm.type)} · {contactName(comm)}</div> <span style={{ color: sent ? '#7fd1a8' : '#7fb0e0' }}>{sent ? '↗ Sent' : '↘ Received'}</span>
<button className="button-danger" style={{ padding: '4px 8px', fontSize: '11px' }} onClick={() => setConfirmDelete(comm.id)}>Delete</button> {' · '}{who}{em.has_attachments ? ' 📎' : ''}
</div>
<div className="timeline-meta">{formatDate(comm.communication_date)}</div>
{comm.subject && <div className="timeline-body">{comm.subject}</div>}
{comm.body && <div className="timeline-body" style={{ marginTop: '4px', color: '#9fb0c2' }}>{comm.body}</div>}
</div> </div>
<div className="timeline-meta">
{formatDate(em.sent_at)}
{(em.mailboxes || []).length > 0 && <span> · {em.mailboxes.join(', ')}</span>}
</div> </div>
{em.subject && <div className="timeline-body" style={{ fontWeight: 600 }}>{em.subject}</div>}
{em.snippet && <div className="timeline-body" style={{ marginTop: '4px', color: '#9fb0c2' }}>{em.snippet}</div>}
{tags.length > 0 ? (
<div style={{ marginTop: '6px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{tags.map((t) => (
<span key={t} style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '10px', border: '1px solid #263548', background: '#0d1622', color: em.is_matched ? '#cfe0f2' : '#9fb0c2' }}>{t}</span>
))} ))}
</div> </div>
)} ) : (
</div> <div style={{ marginTop: '6px' }}>
{showForm && ( <span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '10px', border: '1px dashed #4a3a2a', color: '#c0a070' }}>Unmatched</span>
<div className="modal-overlay">
<div className="modal">
<div className="modal-header">Log Communication</div>
{formError && <div className="toast error" style={{ position: 'static', marginBottom: '16px' }}>{formError}</div>}
<div className="form-help" style={{ marginBottom: '14px', padding: '10px', border: '1px solid #263548', borderRadius: '8px', background: '#0d1622' }}>
This writes a communication record to the shared timeline, updates <strong>Last Communication Date</strong> on the fundraising row, and can append a one-line summary into <strong>Notes / Communication / Outreach</strong>.
</div>
<form onSubmit={handleAddComm}>
<div className="form-group">
<label className="form-label">Quick Find</label>
<input
type="text"
className="text-input"
placeholder="Search investor or contact (fuzzy)"
value={quickMapSearch}
onChange={(e) => setQuickMapSearch(e.target.value)}
/>
<div className="form-help">Use one search box to narrow both lists below. Selecting a contact auto-populates investor mapping when available.</div>
</div>
<div className="form-group">
<label className="form-label">Contact *</label>
<select className="select-input" value={formData.contact_id || ''} onChange={(e) => {
const contactId = e.target.value;
const selected = contacts.find((c) => c.id === contactId);
const orgName = String(selected?.organization_name || selected?.organization || '').trim();
setFormData((f) => {
if (!orgName) return { ...f, contact_id: contactId };
if (investorNames.includes(orgName)) {
return { ...f, contact_id: contactId, investor_selection: orgName, investor_name_new: '' };
}
return { ...f, contact_id: contactId, investor_selection: NEW_INVESTOR_VALUE, investor_name_new: orgName };
});
}} required>
<option value="">Select contact</option>
{rankedContacts.map((c) => (
<option key={c.id} value={c.id}>
{contactName(c)}{(c.organization_name || c.organization) ? ` · ${c.organization_name || c.organization}` : ''}
</option>
))}
</select>
<div className="form-help">Person this communication is tied to.</div>
</div>
<div className="form-group">
<label className="form-label">Investor Mapping *</label>
<select className="select-input" value={formData.investor_selection || ''} onChange={(e) => setFormData((f) => ({ ...f, investor_selection: e.target.value }))} required>
<option value="">Select investor</option>
{rankedInvestors.map((name) => <option key={name} value={name}>{name}</option>)}
<option value={NEW_INVESTOR_VALUE}>+ Create new investor...</option>
</select>
{formData.investor_selection === NEW_INVESTOR_VALUE && (
<input
type="text"
className="text-input"
placeholder="New investor name"
value={formData.investor_name_new || ''}
onChange={(e) => setFormData((f) => ({ ...f, investor_name_new: e.target.value }))}
required
/>
)}
<div className="form-help">Ensures this communication lands on the right fundraising row. New investor names are auto-created in Fundraising Grid.</div>
</div>
<div className="form-group">
<label className="form-label">Type</label>
<select className="select-input" value={formData.type || 'note'} onChange={(e) => setFormData((f) => ({ ...f, type: e.target.value }))}>
<option value="email">Email</option>
<option value="call">Call</option>
<option value="meeting">Meeting</option>
<option value="note">Note</option>
<option value="text">Text</option>
</select>
<div className="form-help">Communication category for reporting and filtering.</div>
</div>
<div className="form-group">
<label className="form-label">Summary</label>
<input type="text" className="text-input" placeholder="Short summary (used in timeline and notes append)" value={formData.subject || ''} onChange={(e) => setFormData((f) => ({ ...f, subject: e.target.value }))} />
<div className="form-help">This is not an email subject line. It is the headline summary shown in Communications.</div>
</div>
<div className="form-group">
<label className="form-label">Details</label>
<textarea className="text-input" rows="4" placeholder="Full detail, context, and key points" value={formData.body || ''} onChange={(e) => setFormData((f) => ({ ...f, body: e.target.value }))} />
<div className="form-help">Saved in Communications history only (not appended to investor notes unless your summary references it).</div>
</div>
<div className="form-group">
<label className="form-label">Outcome</label>
<input type="text" className="text-input" value={formData.outcome || ''} onChange={(e) => setFormData((f) => ({ ...f, outcome: e.target.value }))} />
<div className="form-help">Result of this touchpoint (for quick review later).</div>
</div>
<div className="form-group">
<label className="form-label">Next Action</label>
<input type="text" className="text-input" value={formData.next_action || ''} onChange={(e) => setFormData((f) => ({ ...f, next_action: e.target.value }))} />
<div className="form-help">Explicit follow-up task (what should happen next).</div>
</div>
<div className="form-group">
<label className="form-label">Next Action Date</label>
<input type="date" className="text-input" value={formData.next_action_date || ''} onChange={(e) => setFormData((f) => ({ ...f, next_action_date: e.target.value }))} />
<div className="form-help">Target date for the next action.</div>
</div>
<div className="form-group">
<label className="form-label">
<input type="checkbox" checked={!!formData.append_note} onChange={(e) => setFormData((f) => ({ ...f, append_note: e.target.checked }))} />
{' '}Append summary to Fundraising Grid notes
</label>
<div className="form-help">Adds one line to <strong>Notes / Communication / Outreach</strong>: date + type + contact + summary.</div>
</div>
<div className="form-actions">
<button type="button" className="button-secondary" onClick={() => setShowForm(false)}>Cancel</button>
<button type="submit">Log</button>
</div>
</form>
</div>
</div> </div>
)} )}
{confirmDelete && ( </div>
<ConfirmDialog </div>
title="Delete Communication" );
message="Are you sure?" })}
onConfirm={() => handleDeleteComm(confirmDelete)} </div>
onCancel={() => setConfirmDelete(null)} </>
/>
)} )}
</div> </div>
</div>
); );
}; };
@@ -10785,9 +10621,11 @@
<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>
{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
</button> </button>
)}
<button className={`nav-item ${page === 'thesis' ? 'active' : ''}`} onClick={() => setPage('thesis')}> <button className={`nav-item ${page === 'thesis' ? 'active' : ''}`} onClick={() => setPage('thesis')}>
<span className="nav-item-icon">§</span> Thesis <span className="nav-item-icon">§</span> Thesis
</button> </button>
@@ -10862,7 +10700,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 === 'communications' && <CommunicationsPage token={token} 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} />}
{page === 'outreach' && <OutreachPage token={token} user={user} onShowToast={showToast} />} {page === 'outreach' && <OutreachPage token={token} user={user} onShowToast={showToast} />}
+3 -2
View File
@@ -44,8 +44,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
// * 0.1.0:76 (digest send via Gmail DWD: backend/email_integration/gmail_send.py uses the existing service account's gmail.compose scope for users.messages.send; digest_mailer prefers Gmail DWD and falls back to SMTP; the admin test endpoint + Settings button route through it — no app password needed when Gmail is enabled) // * 0.1.0:76 (digest send via Gmail DWD: backend/email_integration/gmail_send.py uses the existing service account's gmail.compose scope for users.messages.send; digest_mailer prefers Gmail DWD and falls back to SMTP; the admin test endpoint + Settings button route through it — no app password needed when Gmail is enabled)
// * 0.1.0:77 (daily activity digest — Phase B: digest_builder builds by-team-member [per-user Spark narrative, never Claude] + by-investor [inbound+outbound, deduped] sections; always-on digest_scheduler reads a DB-backed policy; enable/send-time in Settings→Admin via GET/PATCH /api/admin/digest/policy; POST /api/admin/digest/send-now + "Send Digest Now" button) // * 0.1.0:77 (daily activity digest — Phase B: digest_builder builds by-team-member [per-user Spark narrative, never Claude] + by-investor [inbound+outbound, deduped] sections; always-on digest_scheduler reads a DB-backed policy; enable/send-time in Settings→Admin via GET/PATCH /api/admin/digest/policy; POST /api/admin/digest/send-now + "Send Digest Now" button)
// * 0.1.0:78 (retire legacy lp_profiles + orphaned LP Tracker; Dashboard "Total Committed" repointed onto the fundraising grid [graveyard-excluded], "Total Funded" dropped; /api/lp-profiles* + lp-breakdown report removed; contact-dossier LP section + demo-seed LP block removed) // * 0.1.0:78 (retire legacy lp_profiles + orphaned LP Tracker; Dashboard "Total Committed" repointed onto the fundraising grid [graveyard-excluded], "Total Funded" dropped; /api/lp-profiles* + lp-breakdown report removed; contact-dossier LP section + demo-seed LP block removed)
// * Current: 0.1.0:79 (HOTFIX blank-screen: pin @babel/standalone@7.29.7 — the unpinned CDN upgraded to Babel 8, whose preset-react automatic JSX runtime emits an ESM import that blanks the classic inline-script app; plus close 3 server-side admin gaps: GET /api/users, /api/email/status, /api/email/accounts now require_admin) // * 0.1.0:79 (HOTFIX blank-screen: pin @babel/standalone@7.29.7 — the unpinned CDN upgraded to Babel 8, whose preset-react automatic JSX runtime emits an ESM import that blanks the classic inline-script app; plus close 3 server-side admin gaps: GET /api/users, /api/email/status, /api/email/accounts now require_admin)
export const PACKAGE_VERSION = '0.1.0:79' // * Current: 0.1.0:80 (repurpose Communications tab as the admin-only email-activity panel: new GET /api/email/activity [admin-enforced] over the email_* tables, filterable by investor/mailbox/direction + free-text search; classic manual log form retired; code-only, no schema change)
export const PACKAGE_VERSION = '0.1.0:80'
export const DATA_MOUNT_PATH = '/data' export const DATA_MOUNT_PATH = '/data'
export const WEB_PORT = 8080 export const WEB_PORT = 8080
+3 -2
View File
@@ -40,8 +40,9 @@ import { v_0_1_0_76 } from './v0.1.0.76'
import { v_0_1_0_77 } from './v0.1.0.77' import { v_0_1_0_77 } from './v0.1.0.77'
import { v_0_1_0_78 } from './v0.1.0.78' import { v_0_1_0_78 } from './v0.1.0.78'
import { v_0_1_0_79 } from './v0.1.0.79' import { v_0_1_0_79 } from './v0.1.0.79'
import { v_0_1_0_80 } from './v0.1.0.80'
export const versionGraph = VersionGraph.of({ export const versionGraph = VersionGraph.of({
current: v_0_1_0_79, current: v_0_1_0_80,
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], 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],
}) })
+21
View File
@@ -0,0 +1,21 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Repurpose the Communications tab as the admin-only email-activity panel. Code-only,
// no schema change (migrations are no-ops):
// * New GET /api/email/activity (admin-enforced server-side): captured-Gmail activity
// over the email_* tables, filterable by investor / mailbox / direction with
// free-text search. Soft-delete is honored on the per-mailbox sighting; direction is
// decided at the email level (outbound if the sender is one of our mailboxes).
// * Communications page rewritten to render that panel; the classic manual
// "Log Communication" form was retired (the grid context menu remains the log path).
// Nav item + page are admin-only.
export const v_0_1_0_80 = VersionInfo.of({
version: '0.1.0:80',
releaseNotes: {
en_US: [
'The Communications tab is now an admin-only email-activity view: search captured',
'Gmail by investor, mailbox, and direction. No data changes.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})