Mobile foundation (Phase 1) + harden opportunity stage validation

Phase 1 mobile foundation (additive, no desktop change): :root mobile vars, a
4-tab bottom nav bar + mobile account/logout popover wired into App, a
bottom-sheet CSS primitive, and .mobile-only/.desktop-only utilities -- all
display:none >=768px. The <BottomSheet> React component + useIsMobile() + the
per-surface 15px type bump are deferred to Phase 2 (first use); light theme to
Phase 6.

Review hardening (fresh-eyes pass on the Phase 0+1 diff): validate stage in
handle_create_opportunity + handle_update_opportunity against PIPELINE_STAGES --
the narrower 4-stage enum makes a stale-client write of a legacy value invisible
to the report ORDER BY CASEs and unsettable from the UI. Use the canonical
pipelineStageLabel in the opportunity detail select; document the intentional
graveyard omission in the existing_investor / staleness helpers.

Tests: stage-validation regression in test_grid_pipeline_link.py + empty
source_row_id guard in test_pipeline_stages_v2.py; 36/36 green, render-smoke green.
This commit is contained in:
Keysat
2026-06-19 13:15:53 -05:00
parent e46dd36517
commit 634fc4260f
6 changed files with 221 additions and 15 deletions
+15 -2
View File
@@ -1807,7 +1807,10 @@ def existing_investor_by_source_row(conn):
"""Return the set of grid source_row_ids whose investor has any committed capital
(fundraising_investors.total_invested > 0) the auto-derived "Existing Investor" flag
(locked spec 2026-06-19). Injected read-only on grid read like pipeline_stage; never a
maintained column. Orthogonal to stage: a re-solicited LP shows the star AND a live stage."""
maintained column. Orthogonal to stage: a re-solicited LP shows the star AND a live stage.
Deliberately NOT filtered by `graveyard` (unlike the dashboard total-invested aggregate):
committed capital makes an investor "existing" regardless of disposition; graveyard rows are
muted/filtered by the view, not by this signal. Same intentional omission in staleness below."""
out = set()
for r in conn.execute(
"SELECT source_row_id FROM fundraising_investors WHERE total_invested > 0"
@@ -2968,6 +2971,12 @@ class CRMHandler(BaseHTTPRequestHandler):
def handle_create_opportunity(self, user, body):
if not body.get('name') or not body.get('contact_id'):
return self.send_error_json("name and contact_id are required")
# Validate stage (mirrors handle_update_stage). Matters more since the 4-stage migration:
# a stale cached client could otherwise write a legacy value that's invisible to the
# report ORDER BY CASEs and unsettable from the UI.
stage = body.get('stage', 'lead')
if stage not in PIPELINE_STAGES:
return self.send_error_json(f"Invalid stage. Must be one of: {', '.join(PIPELINE_STAGES)}")
opp_id = generate_id()
conn = get_db()
@@ -2988,7 +2997,7 @@ class CRMHandler(BaseHTTPRequestHandler):
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
opp_id, body['name'], body['contact_id'], org_id,
body.get('stage', 'lead'),
stage,
body.get('commitment_amount', 0), body.get('expected_amount', 0),
body.get('probability', 10), body.get('expected_close_date'),
body.get('fund_name'), body.get('description'), body.get('next_step'),
@@ -3014,6 +3023,10 @@ class CRMHandler(BaseHTTPRequestHandler):
conn.close()
return self.send_error_json("Opportunity not found", 404)
if 'stage' in body and body['stage'] not in PIPELINE_STAGES:
conn.close()
return self.send_error_json(f"Invalid stage. Must be one of: {', '.join(PIPELINE_STAGES)}")
updatable = ['name', 'contact_id', 'organization_id', 'stage', 'commitment_amount',
'expected_amount', 'probability', 'expected_close_date', 'fund_name',
'description', 'next_step', 'owner_id', 'priority', 'lost_reason']
+10
View File
@@ -149,6 +149,16 @@ def main():
f"funnel fields preserved, not reseeded (got stage={opp2.get('stage')}, amt={opp2.get('expected_amount')})")
check(_opp_count_live(fr_id) == 1, "still exactly one live opp (no duplicate)")
# ── stage validation: legacy/invalid values rejected (4-stage enum guard) ──
# The stage check precedes the contact lookup in handle_create_opportunity, so a fake
# contact_id still surfaces the stage error first.
print("\n[validation: legacy stage values rejected by stage + create endpoints]")
st, _ = _req(port, "PATCH", f"/api/opportunities/{opp_id}/stage", token, {"stage": "outreach"})
check(st >= 400, f"PATCH legacy stage 'outreach' rejected (got {st})")
st, _ = _req(port, "POST", "/api/opportunities", token,
{"name": "X", "contact_id": "x", "stage": "due_diligence"})
check(st >= 400, f"POST opportunity with legacy stage 'due_diligence' rejected (got {st})")
# ── read-injection: GET state shows pipeline flag + stage, derived live ──
print("\n[read-injection: GET /state exposes read-only pipeline + pipeline_stage]")
st, d = _req(port, "GET", "/api/fundraising/state", token)
+5 -1
View File
@@ -106,10 +106,14 @@ def test_derivations(conn):
_investor(conn, "rowB59", 0, contact_id="c_b59", comm_days_ago=59) # -> aging
_investor(conn, "rowB30", 0, contact_id="c_b30", comm_days_ago=30) # boundary -> aging
_investor(conn, "rowB29", 0, contact_id="c_b29", comm_days_ago=29) # -> fresh
# Empty source_row_id with committed capital — must be EXCLUDED by the `if not srid` guard
# (would otherwise key the injection under '' and clobber a real row).
_investor(conn, "", 9_999, contact_id="c_empty", comm_days_ago=100)
conn.commit()
existing = server.existing_investor_by_source_row(conn)
check(existing == {"rowExist"}, f"existing_investor = total_invested>0 only (got {sorted(existing)})")
check(existing == {"rowExist"},
f"existing_investor = total_invested>0 with a non-empty source_row_id only (got {sorted(existing)})")
st = server.staleness_by_source_row(conn)
level = lambda srid: st.get(srid, (None, "MISSING"))[1]