Mobile Phase 8g: add-investor sheet — optional stage picker + Priority toggle + reminder

The mobile "New investor" sheet now captures three optional fields beyond name/contact/note,
matching the dc (GridApp.dc.html:737):

- Initial pipeline stage — a .stage-pick chip picker, defaulting to "Not in pipeline" so a
  plain directory add never auto-creates an opportunity row (Grant's call).
- A framed "Flag as Priority" toggle.
- An optional reminder (title + a progressive due-date field).

submitCreate orchestrates one-row calls in order: create (log-communication
create_investor_if_missing, now carrying priority) -> if a stage was picked, link to the
pipeline at that stage (reusing applyStage's idempotent link-then-PATCH) -> if a reminder
title was given, POST /api/reminders keyed on the new row's source_row_id. The link and
reminder steps are non-fatal: a failure toasts but never loses the created investor, and a
create that returns no row id warns instead of a clean success.

Backend: handle_log_fundraising_communication honors an optional priority flag only on its
create-if-missing branch (an existing-row log never touches priority).

Guarded by test_grid_add_investor.py (priority-on-create, defaults-False, the create-branch-
only invariant, and the create->link / create->reminder handshakes on a freshly-synced row).
40/40 backend green; the create sheet was interaction-verified in a throwaway jsdom harness.
This commit is contained in:
Keysat
2026-06-20 06:15:16 -05:00
parent e53a41ae80
commit abc614fc98
4 changed files with 279 additions and 11 deletions
+4 -1
View File
@@ -3341,6 +3341,9 @@ class CRMHandler(BaseHTTPRequestHandler):
contact_in = body.get('contact')
append_note = bool(body.get('append_note', True))
create_investor_if_missing = bool(body.get('create_investor_if_missing', False))
# Optional initial Priority flag for the mobile add-investor sheet (8g). Honored ONLY on the
# create branch below — an existing-row log-communication never touches its priority.
create_priority = bool(body.get('priority', False))
# Provenance: where this logged communication originated (grid UI vs the Matrix
# intake bot). Default preserves prior behavior; callers may override.
comm_source = (str(body.get('source') or 'fundraising_grid').strip() or 'fundraising_grid')[:64]
@@ -3391,7 +3394,7 @@ class CRMHandler(BaseHTTPRequestHandler):
"notes_last_modified": "",
"last_communication_date": "",
"lead": "",
"priority": False,
"priority": create_priority,
"follow_up": False,
"graveyard": False
}
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Tests for the mobile add-investor flow (Phase 8g).
Boots the REAL server against a temp DB and exercises the create path the mobile
"New investor" sheet drives:
- POST /api/fundraising/log-communication with create_investor_if_missing honors an
optional initial `priority` flag on the NEW row (and defaults it to False when omitted);
- the brand-new row's source_row_id resolves immediately for the follow-on
POST /api/fundraising/pipeline/link (the relational sync runs inside the create), so the
create -> link-at-stage handshake the UI does works end to end;
- a follow-on POST /api/reminders with the new row's source_row_id resolves to the synced
investor (the create -> reminder handshake).
Synthetic data only.
Run: cd backend && python3 test_grid_add_investor.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 = []
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(port, method, path, token=None, body=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
headers = {}
if token:
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 _db():
return sqlite3.connect(os.environ["CRM_DB_PATH"])
def seed():
c = _db()
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
c.commit()
c.close()
def _create(port, token, name, contact_name, **extra):
body = {
"investor_name": name, "create_investor_if_missing": True,
"contact": {"name": contact_name, "email": contact_name.split(" ")[0].lower() + "@firm.com"},
"type": "note", "body": extra.pop("note", ""), "append_note": bool(extra.pop("note_append", False)),
}
body.update(extra)
return _req(port, "POST", "/api/fundraising/log-communication", token, body)
def _grid_rows(port, token):
st, d = _req(port, "GET", "/api/fundraising/state", token)
return {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])}
def main():
server.init_db()
seed()
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:
# ── create with priority:true seeds the row's Priority flag ──
print("\n[create: optional initial priority flag honored]")
st, d = _create(port, token, "Acme Capital", "Jane Doe", priority=True, note="Intro call", note_append=True)
row = (d or {}).get("data", {}).get("row") or {}
acme_id = row.get("id")
check(st == 201, f"create -> 201 (got {st})")
check(row.get("priority") is True, f"returned row carries priority=true (got {row.get('priority')!r})")
rows = _grid_rows(port, token)
check(rows.get(acme_id, {}).get("priority") is True,
f"GET /state shows the new row priority=true (got {rows.get(acme_id, {}).get('priority')!r})")
check(len(rows.get(acme_id, {}).get("contacts", [])) == 1,
f"new row has its first contact (got {rows.get(acme_id, {}).get('contacts')})")
# ── create without priority defaults to False (no accidental flag) ──
print("\n[create: priority defaults False when omitted]")
st, d = _create(port, token, "Beta Partners", "Pat Roe") # no note, no priority
beta = (d or {}).get("data", {}).get("row") or {}
beta_id = beta.get("id")
check(st == 201, f"no-note create -> 201 (got {st})")
check(beta.get("priority") is False, f"omitted priority -> False (got {beta.get('priority')!r})")
# ── priority is honored ONLY on the create branch: logging against an EXISTING row
# with priority:true must not flip its flag (Beta was created without priority) ──
print("\n[invariant: priority on an existing-row log does NOT change its flag]")
st, _ = _req(port, "POST", "/api/fundraising/log-communication", token, {
"row_id": beta_id, "type": "note", "body": "follow-up", "append_note": True, "priority": True,
})
check(st in (200, 201), f"log against existing Beta -> ok (got {st})")
rows = _grid_rows(port, token)
check(rows.get(beta_id, {}).get("priority") is False,
f"existing-row priority untouched by the log's priority flag (got {rows.get(beta_id, {}).get('priority')!r})")
# ── create -> link handshake: the brand-new row links at the chosen stage ──
print("\n[create -> link: freshly-created row resolves for pipeline link at stage]")
st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {
"source_row_id": acme_id, "contact_index": 0, "name": "Acme Capital — Pipeline",
"stage": "engaged", "expected_amount": 0, "probability": 55, "fund_name": "",
})
opp = (d or {}).get("data") or {}
check(st == 201 and opp.get("stage") == "engaged",
f"link new row @engaged -> 201 (got {st}, stage={opp.get('stage')})")
rows = _grid_rows(port, token)
check(rows.get(acme_id, {}).get("pipeline") is True
and rows.get(acme_id, {}).get("pipeline_stage") == "engaged",
f"new row now in pipeline @engaged (got {rows.get(acme_id, {}).get('pipeline')}, "
f"{rows.get(acme_id, {}).get('pipeline_stage')})")
# ── create -> reminder handshake: source_row_id resolves to the synced investor ──
print("\n[create -> reminder: source_row_id resolves to the new investor]")
st, d = _req(port, "POST", "/api/reminders", token, {
"source_row_id": acme_id, "investor_name": "Acme Capital",
"title": "Send Fund III deck", "due_date": "2026-07-01", "details": "",
})
rem = (d or {}).get("data") or {}
check(st == 201, f"reminder create -> 201 (got {st})")
check(bool(rem.get("investor_id")) and rem.get("investor_name") == "Acme Capital",
f"reminder linked to the new investor (got id={rem.get('investor_id')!r}, "
f"name={rem.get('investor_name')!r})")
# ── unknown source_row_id is refused (guard) ──
print("\n[guard: reminder on an unknown source_row_id -> 404]")
st, _ = _req(port, "POST", "/api/reminders", token, {
"source_row_id": "nope", "title": "x",
})
check(st == 404, f"unknown source_row_id -> 404 (got {st})")
finally:
httpd.shutdown()
print("\n" + ("ALL PASS" if not FAILS else f"{len(FAILS)} FAILURE(S):"))
for f in FAILS:
print(" - " + f)
sys.exit(1 if FAILS else 0)
if __name__ == "__main__":
main()