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:
@@ -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.
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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()
|
||||||
+120
-282
@@ -4253,318 +4253,154 @@
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 & 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 & 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="timeline">
|
<>
|
||||||
{communications.map((comm) => (
|
<div className="form-help" style={{ marginBottom: '10px' }}>
|
||||||
<div key={comm.id} className="timeline-item">
|
Showing {emails.length}{data?.truncated ? '+ (refine filters to see more)' : ''} email{emails.length === 1 ? '' : 's'}.
|
||||||
<div className="timeline-marker"></div>
|
</div>
|
||||||
<div className="timeline-content">
|
<div className="timeline">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px' }}>
|
{emails.map((em) => {
|
||||||
<div className="timeline-header">{typeLabel(comm.type)} · {contactName(comm)}</div>
|
const sent = em.direction === 'outbound';
|
||||||
<button className="button-danger" style={{ padding: '4px 8px', fontSize: '11px' }} onClick={() => setConfirmDelete(comm.id)}>Delete</button>
|
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-content">
|
||||||
|
<div className="timeline-header">
|
||||||
|
<span style={{ color: sent ? '#7fd1a8' : '#7fb0e0' }}>{sent ? '↗ Sent' : '↘ Received'}</span>
|
||||||
|
{' · '}{who}{em.has_attachments ? ' 📎' : ''}
|
||||||
|
</div>
|
||||||
|
<div className="timeline-meta">
|
||||||
|
{formatDate(em.sent_at)}
|
||||||
|
{(em.mailboxes || []).length > 0 && <span> · {em.mailboxes.join(', ')}</span>}
|
||||||
|
</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 style={{ marginTop: '6px' }}>
|
||||||
|
<span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '10px', border: '1px dashed #4a3a2a', color: '#c0a070' }}>Unmatched</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showForm && (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{confirmDelete && (
|
|
||||||
<ConfirmDialog
|
|
||||||
title="Delete Communication"
|
|
||||||
message="Are you sure?"
|
|
||||||
onConfirm={() => handleDeleteComm(confirmDelete)}
|
|
||||||
onCancel={() => setConfirmDelete(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
<button className={`nav-item ${page === 'communications' ? 'active' : ''}`} onClick={() => setPage('communications')}>
|
{user?.role === 'admin' && (
|
||||||
<span className="nav-item-icon">◌</span> Communications
|
<button className={`nav-item ${page === 'communications' ? 'active' : ''}`} onClick={() => setPage('communications')}>
|
||||||
</button>
|
<span className="nav-item-icon">◌</span> Communications
|
||||||
|
</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} />}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 () => {} },
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user