Mobile Phase 8c: Grid-detail notes timeline + top-bar quick-log pencil

Grid detail (G6): replace the single row.notes blob with a NoteTimeline fed by
a new investor-level read, GET /api/communications?source_row_id=<grid row id>
(filter maps the grid row -> fundraising_investors.source_row_id ->
fundraising_contacts.contact_id -> communications, soft-delete-respecting;
cancelled-flag fetch + commsReload after a log). Note-logging now uses the
shared LogCommunicationSheet, retiring the bespoke noteForm select sheet and the
dead .fs-note-log style.

New MobileQuickLog: a shell mobile-top-bar pencil reachable from every tab —
two-step sheet (pick investor via search + recent-first pool -> inline log form)
writing through the one-row /api/fundraising/log-communication path.

source_row_id and contact_id are kept mutually exclusive in
handle_list_communications so a future caller passing both can't get the empty
intersection. Guarded by test_grid_comm_timeline.py (cross-contact aggregation,
investor isolation, soft-delete); 39/39 backend green.
This commit is contained in:
Keysat
2026-06-19 21:43:05 -05:00
parent e57b154a6d
commit 93ac0c240f
4 changed files with 373 additions and 32 deletions
+14
View File
@@ -3238,6 +3238,20 @@ class CRMHandler(BaseHTTPRequestHandler):
if params.get('contact_id'):
query += " AND cm.contact_id = ?"
args.append(params['contact_id'])
# source_row_id is an investor-scope filter; contact_id is a contact-scope filter. They are
# never combined by any caller (the /contacts/{id}/communications subpath sets contact_id;
# the mobile Grid detail sets source_row_id) — keep them mutually exclusive so a future
# caller passing both can't silently get the empty intersection.
elif params.get('source_row_id'):
# Investor-level timeline for a fundraising-grid row (the mobile Grid detail): map the
# grid JSON row id → its canonical contacts via the relational mirror, then return every
# communication across those contacts. cm.deleted_at is still filtered above (soft-delete).
query += """ AND cm.contact_id IN (
SELECT fc.contact_id FROM fundraising_contacts fc
JOIN fundraising_investors fi ON fc.investor_id = fi.id
WHERE fi.source_row_id = ? AND fc.contact_id IS NOT NULL
)"""
args.append(params['source_row_id'])
if params.get('type'):
query += " AND cm.type = ?"
args.append(params['type'])
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Regression test for the Grid-detail communications timeline filter (Phase 8c, G6).
The mobile Grid detail's notes timeline pulls an investor-level communication stream
via GET /api/communications?source_row_id=<grid row id>. That filter (added to
handle_list_communications) maps the grid JSON row id → fundraising_investors.source_row_id
→ fundraising_contacts.contact_id → communications, so it must:
- return every communication across ALL the investor's contacts,
- stay isolated (one investor's row id never returns another's comms),
- respect soft-delete (cm.deleted_at IS NULL) through the join.
Boots the REAL server, seeds investors by driving the one-row log path (which creates the
grid row + contact + communication AND syncs the relational mirror the filter joins on),
then drives the live read path with a real token. Synthetic only (guardrail #9).
Run: cd backend && python3 test_grid_comm_timeline.py
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
from http.server import ThreadingHTTPServer
_DATA = tempfile.mkdtemp()
os.environ["CRM_DATA_DIR"] = _DATA
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import server # noqa: E402
FAILS = []
DEL = "2026-06-01T00:00:00"
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
class _Quiet(server.CRMHandler):
def log_message(self, *a):
pass
def _req(method, port, path, token, body=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
headers = {"Authorization": "Bearer " + token}
payload = None
if body is not None:
payload = json.dumps(body)
headers["Content-Type"] = "application/json"
conn.request(method, path, body=payload, headers=headers)
resp = conn.getresponse()
raw = resp.read().decode("utf-8", "replace")
conn.close()
data = None
if raw:
try:
data = json.loads(raw)
except ValueError:
pass
return resp.status, data
def _log_comm(port, token, investor_name, contact, subject, create=False):
"""Drive the one-row log path; returns (status, grid_row_id)."""
st, data = _req("POST", port, "/api/fundraising/log-communication", token, {
"investor_name": investor_name,
"create_investor_if_missing": create,
"contact": contact,
"type": "note",
"subject": subject,
"body": subject,
"append_note": True,
})
row_id = ((data or {}).get("data", {}).get("row") or {}).get("id")
return st, row_id
def main():
server.init_db()
conn = sqlite3.connect(os.environ["CRM_DB_PATH"])
# password_hash is intentionally a non-bcrypt placeholder — we mint the token directly via
# create_token(), so the password-verify path is never exercised.
conn.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
conn.commit()
conn.close()
token = server.create_token("u1", "grant", "admin")
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
# Investor A: two contacts, one communication per contact. Create seeds the row with
# Jane + logs "Intro call"; update-row adds John as a second pill (so the relational mirror
# links BOTH contacts to A's row); then a comm is logged against John. The timeline must
# aggregate across both contacts — the point of the source_row_id join over a single contact.
st, rowA = _log_comm(port, token, "Acme Capital",
{"name": "Jane Doe", "email": "jane@acme.example"}, "Intro call", create=True)
check(st == 201 and bool(rowA), f"create investor A via log path -> 201 + row id (got {st}, {rowA})")
st, _ = _req("POST", port, "/api/fundraising/update-row", token, {
"row_id": rowA, "investor_name": "Acme Capital",
"contacts": [{"name": "Jane Doe", "email": "jane@acme.example"},
{"name": "John Roe", "email": "john@acme.example"}],
})
check(st == 200, f"add John as a second contact on A via update-row (got {st})")
st, _ = _log_comm(port, token, "Acme Capital",
{"name": "John Roe", "email": "john@acme.example"}, "Follow-up email")
check(st == 201, f"second contact's comm logged on A (got {st})")
# Investor B: a separate investor, one communication (isolation control).
st, rowB = _log_comm(port, token, "Beacon Ventures",
{"name": "Sam Poe", "email": "sam@beacon.example"}, "Beacon note", create=True)
check(st == 201 and bool(rowB), f"create investor B via log path -> 201 + row id (got {st}, {rowB})")
# ── source_row_id returns the whole investor (across contacts) ──
print("\n[source_row_id timeline]")
st, data = _req("GET", port, f"/api/communications?source_row_id={rowA}", token)
subsA = {c.get("subject") for c in (data or {}).get("data", [])}
check(st == 200, f"GET timeline for A -> 200 (got {st})")
check(subsA == {"Intro call", "Follow-up email"},
f"A's timeline spans both contacts' comms (got {subsA})")
# ── isolation: A's row id never returns B's comms ──
print("\n[isolation]")
check("Beacon note" not in subsA, "A's timeline excludes investor B's comm")
st, dataB = _req("GET", port, f"/api/communications?source_row_id={rowB}", token)
subsB = {c.get("subject") for c in (dataB or {}).get("data", [])}
check(subsB == {"Beacon note"}, f"B's timeline is its own comm only (got {subsB})")
# ── soft-delete respected through the join ──
print("\n[soft-delete]")
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
c.execute("UPDATE communications SET deleted_at=? WHERE subject='Intro call'", (DEL,))
c.commit()
c.close()
st, data2 = _req("GET", port, f"/api/communications?source_row_id={rowA}", token)
subsA2 = {c.get("subject") for c in (data2 or {}).get("data", [])}
check(subsA2 == {"Follow-up email"},
f"soft-deleted comm filtered from A's timeline (got {subsA2})")
# ── unknown row id returns empty, not an error ──
st, data3 = _req("GET", port, "/api/communications?source_row_id=does-not-exist", token)
check(st == 200 and (data3 or {}).get("data") == [],
f"unknown source_row_id -> 200 + empty (got {st}, {(data3 or {}).get('data')})")
finally:
httpd.shutdown()
print()
if FAILS:
print(f"FAILED ({len(FAILS)}):")
for f in FAILS:
print(f" - {f}")
sys.exit(1)
print("ALL PASS (grid comm timeline source_row_id filter)")
if __name__ == "__main__":
main()