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:
@@ -398,6 +398,110 @@ def start_sync_run(conn: sqlite3.Connection, *, account_id: str, kind: str) -> s
|
||||
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,
|
||||
stats: Optional[dict] = None, error: Optional[str] = None) -> None:
|
||||
stats = stats or {}
|
||||
|
||||
Reference in New Issue
Block a user