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