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:
@@ -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'])
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user