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']