From 114916b789581cc3cda76296a2459caca15d20f4 Mon Sep 17 00:00:00 2001 From: Keysat Date: Thu, 18 Jun 2026 08:25:14 -0500 Subject: [PATCH] Retire the Pipeline page's "+ New Opportunity" button (v0.1.0:88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opportunities are now born only from a fundraising-grid investor row ("+ Pipeline"), which matches how the team works — they live in the grid, not on the board. The old "+ New Opportunity" button created a deal by picking a contact, a path that contradicts the grid-is-canonical model and the contact-vs-investor framing. Remove the button, its create-by-contact modal, the now-dead handler/state, and the Pipeline page's unused /api/contacts fetch. Replace the button with a muted "Add deals from the Fundraising Grid" hint. The board is now a view + stage-management surface. Frontend-only; no backend or schema change. Render-smoke green. --- AGENTS.md | 4 +- ROADMAP.md | 1 + frontend/index.html | 114 +---------------------- start9/0.4/startos/utils.ts | 5 +- start9/0.4/startos/versions/index.ts | 5 +- start9/0.4/startos/versions/v0.1.0.88.ts | 20 ++++ 6 files changed, 31 insertions(+), 118 deletions(-) create mode 100644 start9/0.4/startos/versions/v0.1.0.88.ts diff --git a/AGENTS.md b/AGENTS.md index 94233d5..f743d89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,9 +103,9 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude ## Current state -_Phase 0 + Phase 1 built; **box and repo at v0.1.0:87** (deployed & verified live 2026-06-18; migration chain …86→87 clean, `0005_grid_pipeline_link.sql` applied on the box, server up on :8080). **The fundraising grid + email capture is the canonical system of record** (2026-06-16) — vestigial classic-CRM surfaces get pruned/repurposed. Deploy/feature history lives in git log + `start9/0.4/startos/versions/`; longer-term backlog + debt in `ROADMAP.md` / `EVALUATION.md`._ +_Phase 0 + Phase 1 built; **box live on v0.1.0:87; repo at v0.1.0:88** (v87 deployed & verified live 2026-06-18 — chain …86→87 clean, `0005_grid_pipeline_link.sql` applied on the box, server up; v88 = frontend-only Pipeline cleanup, build+deploy pending). **The fundraising grid + email capture is the canonical system of record** (2026-06-16) — vestigial classic-CRM surfaces get pruned/repurposed. Deploy/feature history lives in git log + `start9/0.4/startos/versions/`; longer-term backlog + debt in `ROADMAP.md` / `EVALUATION.md`._ -- **Adopt the Pipeline — grid drives the deal board — DEPLOYED & verified live 2026-06-18 (v0.1.0:87); in-room/board live-smoke still pending.** An **"Add to Pipeline"** row action on the fundraising grid opens a seed modal (primary contact / target fund / expected amount / stage / probability) and creates a durably-linked `opportunities` row via the new **`opportunities.fundraising_investor_id`** (migration 0005, additive + reversible). **Grid owns the link + seed; the board owns stage/probability/owner** — a grid save never reseeds a live opp (`POST /api/fundraising/pipeline/link` is idempotent, one live opp/investor). Contact is **reused from the grid's synced `fundraising_contacts.contact_id`** (the `POST /api/contacts` side-door is gone); grid `lead`→owner. Two **read-only** grid columns (Pipeline action + Pipeline Stage) are **injected on read** from the live opp and **stripped on write** (never persisted, never dirty the autosave). **Remove from pipeline** (`POST .../unlink`) **soft-deletes the opp; the grid row stays fully intact**; deleting an investor from the grid archives its orphaned opp (`reconcile_grid_pipeline_links`, after `sync_fundraising_relational`). **Folded in:** the standing P2 soft-delete leak in `handle_pipeline_report` + dashboard pipeline aggregates (archived opps no longer counted). Tests: `backend/test_grid_pipeline_link.py`; 28/28 suite green, render-smoke green; migration verified on a copy of `data/crm.db` and **applied clean on the box**. **Next: live-smoke on the box — add an investor to the pipeline, confirm it lands on the board, advance a stage, and remove (opp archived, grid row intact).** Detail + locked decisions in `ROADMAP.md` "Adopt the Pipeline". +- **Adopt the Pipeline — grid drives the deal board — DEPLOYED & verified live 2026-06-18 (v0.1.0:87; board renders live). v88 (frontend-only, build+deploy pending): retired the Pipeline page's "+ New Opportunity" button + its create-by-contact modal** — opportunities are now born **only** from a grid investor row (matches how the team works; the board is view + stage-management). An **"Add to Pipeline"** row action on the fundraising grid opens a seed modal (primary contact / target fund / expected amount / stage / probability) and creates a durably-linked `opportunities` row via the new **`opportunities.fundraising_investor_id`** (migration 0005, additive + reversible). **Grid owns the link + seed; the board owns stage/probability/owner** — a grid save never reseeds a live opp (`POST /api/fundraising/pipeline/link` is idempotent, one live opp/investor). Contact is **reused from the grid's synced `fundraising_contacts.contact_id`** (the `POST /api/contacts` side-door is gone); grid `lead`→owner. Two **read-only** grid columns (Pipeline action + Pipeline Stage) are **injected on read** from the live opp and **stripped on write** (never persisted, never dirty the autosave). **Remove from pipeline** (`POST .../unlink`) **soft-deletes the opp; the grid row stays fully intact**; deleting an investor from the grid archives its orphaned opp (`reconcile_grid_pipeline_links`, after `sync_fundraising_relational`). **Folded in:** the standing P2 soft-delete leak in `handle_pipeline_report` + dashboard pipeline aggregates (archived opps no longer counted). Tests: `backend/test_grid_pipeline_link.py`; 28/28 suite green, render-smoke green; migration verified on a copy of `data/crm.db` and **applied clean on the box**. **Next: live-smoke on the box — add an investor to the pipeline, confirm it lands on the board, advance a stage, and remove (opp archived, grid row intact).** Detail + locked decisions in `ROADMAP.md` "Adopt the Pipeline". - **Matrix intake bot — DEPLOYED & LIVE (2026-06-17), `backend/matrix_intake/`:** a separate-process bot (its `matrix-nio` dep isolated from the stdlib CRM) turning a typed Matrix-room message into a proposed fundraising-grid add/edit, written only after **in-thread human approval** (`yes`/`edit field=value`/`no`). Parse = local Qwen via Spark Control (no Claude/scrub, like the digest); writes reuse the CRM's own `POST /api/fundraising/log-communication` tagged `source="matrix_intake"`; new-vs-existing via read-only `GET /api/intake/match` (returns the grid row id → no duplicate). **Runs on the Spark as a docker-compose service** (`modelo32`, container `matrix-intake`, `restart: unless-stopped` → survives a reboot; `docker-compose.yml` at the repo root + `backend/matrix_intake/Dockerfile` bundling `backend/matrix_intake` + the stdlib `backend/ingest` Spark client; retired the old nohup launch, 2026-06-17). A spark-control dashboard card is still pending (handoff: `docs/handoffs/add-intake-bot-to-spark-control.md`). **Live-smoked end-to-end** (new-investor create + existing-investor note matched & appended, no dup). Server side shipped to the box as **v0.1.0:84** (`/api/intake/match` + `source` provenance — these were missing on v83, so the bot 404'd until v84); then UX adds: main-timeline nudge pointer, top-level-`yes`→thread redirect, clearer commit wording, note text in the grid line (v85 dropped the `[note]` tag). M3 (business-card photo) deferred (no Spark vision model). Guide: `docs/guides/matrix-intake.md`. - **Matrix intake — fuzzy-match + conversational-edit pass — DEPLOYED & LIVE 2026-06-17 (box on v0.1.0:86, bot restarted on the Spark; `candidates` endpoint verified live); `revise` leg live-smoked 2026-06-17, fuzzy disambiguation grammar still un-smoked.** Closes the two locked post-deploy enhancements (ROADMAP). **(a) Fuzzy matching (server-side, ships in the s9pk):** `find_intake_candidates` in `server.py` (deterministic — stdlib `difflib` name similarity + token-set Jaccard, legal-suffix-aware via `_strip_legal_suffix`, + email Levenshtein ≤ 2; ranked, ≥0.62, top 5); `GET /api/intake/match` now returns `{match, candidates}`. The bot surfaces a numbered shortlist (`_stage="disambiguate"`) so a near-duplicate ("Charlie"/"Charles", "Acme Capital"/"Acme Capital LLC", a one-char email typo) is **confirmed by a human** instead of silently creating a second investor — never auto-attached. **The optional LLM-judge re-rank was deferred** (deterministic filter already surfaces the cases; LLM is the right shortlist *pruner* if noise proves real). **(b) Conversational edits (bot-side, ships on the Spark):** any in-thread reply that isn't `yes`/`no`/`edit field=value` → `parse.revise` re-runs `{proposal + instruction}` through local Qwen and re-renders the card; **email integrity preserved** (a changed address must literally appear in the instruction; the model's email field is never trusted); no-op revisions re-prompt (`same_fields`). **Deploy is split:** the `candidates` need an **s9pk build+install** (v86); the bot's disambiguation+revise need a **Spark `git pull` + restart** — a bot restart alone won't deliver `candidates` (box returns `[]`, bot safely proposes new). Tests green; the Qwen `revise` leg is now live-smoked (2026-06-17, with the roster fix below); the fuzzy **disambiguation** numbered-pick grammar is the one in-room path still un-smoked. Guide updated. diff --git a/ROADMAP.md b/ROADMAP.md index 4fe2f91..505fa47 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -179,6 +179,7 @@ Open design questions (settled at build time): send time = **6 PM box-local** (c - Revisit the stray contact-create side-door (the "Create Opportunity" modal `POST /api/contacts`) once the grid-driven flow exists. **As built (decisions locked with Grant 2026-06-17):** UX = **row action + seed modal** ("Add to Pipeline" per grid row → captures primary contact / target fund / expected amount / stage / probability). The durable link is `opportunities.fundraising_investor_id` (**migration 0005**, additive + reversible); "is in pipeline?" / "what stage?" are **derived from a live opp join**, never a denormalized flag (no drift). **Ownership split:** the grid owns whether the link exists + the seed; the **board owns stage/probability/owner/close/next-step** — a grid save never reseeds a live opp (`POST /api/fundraising/pipeline/link` is idempotent: one live opp/investor, re-link returns the existing one). Contact is **reused from the grid's synced `fundraising_contacts.contact_id`** — the `POST /api/contacts` side-door is **gone**. Grid `lead` → opp owner (fallback acting user). Two **read-only** grid columns (Pipeline action + Pipeline Stage) injected on read; their row values are stripped on write so they never persist or dirty the autosave. **Remove from pipeline** (`POST .../unlink`) **soft-deletes the opp; the grid row is left fully intact** (Grant's explicit ask). Deleting an investor from the grid archives its orphaned opp (`reconcile_grid_pipeline_links`, called after `sync_fundraising_relational`). **Folded in:** the P2 soft-delete leak in `handle_pipeline_report` + dashboard pipeline aggregates (archived opps no longer counted). Tests: `backend/test_grid_pipeline_link.py` (link/idempotent/round-trip/guards/unlink-intact/re-link/orphan/aggregates), 28/28 suite green, render-smoke green. **Deploy:** server-side → needs an **s9pk build + install** (v87); get authorization first. +- **Follow-up (v0.1.0:88, frontend-only, build+deploy pending):** retired the Pipeline page's **"+ New Opportunity"** button + its create-by-contact modal — an opportunity is now born **only** from a fundraising-grid investor row ("+ Pipeline"), matching how the team works (they live in the grid). The board is now a view + stage-management surface; button replaced with a muted "Add deals from the Fundraising Grid" hint. Removed the dead handler/state + the page's unused `/api/contacts` fetch. - **Deferred (not built):** no write-back of committed dollars into grid fund cells (grid stays canonical for committed $); a graveyarded investor with a live opp still shows its stage (deliberate — a live deal is a live deal). **Keep the Contacts table — as the read-only per-person directory it already is.** Confirmed 2026-06-16: the grid models **investor entity → many people** correctly today. The grid "contacts" column is a multi-pill editor; each pill syncs to a `fundraising_contacts` row AND its own classic `contacts` row (5-person family office → 1 investor + 5 contacts, linked via `fundraising_contacts.contact_id`, migration 0004). The Contacts page is **read-only for creation** (header: "added from the Fundraising Grid"; no New-Contact button), edit-only via the detail slide-over — the desired flow already holds. Email capture already rolls **multiple people up to one investor** (matcher indexes each pill's email separately, all → same `fundraising_investor_id`; `email_investor_links` records both investor and specific person). No build here — future email-surfacing UI should present comms grouped by investor across all its people. diff --git a/frontend/index.html b/frontend/index.html index 03f7573..97b86f6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3891,11 +3891,7 @@ const PipelinePage = ({ token, onShowToast }) => { const [opportunities, setOpportunities] = useState([]); - const [contacts, setContacts] = useState([]); const [loading, setLoading] = useState(true); - const [showForm, setShowForm] = useState(false); - const [formData, setFormData] = useState({ stage: 'lead', priority: 'medium', contact_id: '' }); - const [formError, setFormError] = useState(''); const [selectedOpp, setSelectedOpp] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); @@ -3905,12 +3901,8 @@ const fetchOpportunities = async () => { try { setLoading(true); - const [oppResult, contactResult] = await Promise.all([ - api('/api/opportunities?limit=1000', {}, token), - api('/api/contacts?limit=1000', {}, token) - ]); + const oppResult = await api('/api/opportunities?limit=1000', {}, token); setOpportunities(oppResult.data || []); - setContacts(contactResult.data || []); } catch (err) { onShowToast(getErrorMessage(err, 'Failed to load pipeline'), 'error'); } finally { @@ -3921,27 +3913,6 @@ fetchOpportunities(); }, [token, onShowToast]); - const handleAddOpportunity = async (e) => { - e.preventDefault(); - setFormError(''); - - try { - await api('/api/opportunities', { - method: 'POST', - body: JSON.stringify(formData) - }, token); - - setShowForm(false); - setFormData({ stage: 'lead', priority: 'medium', contact_id: '' }); - - const result = await api('/api/opportunities?limit=1000', {}, token); - setOpportunities(result.data || []); - onShowToast('Opportunity created', 'success'); - } catch (err) { - setFormError(err.message); - } - }; - const handleDeleteOpp = async (id) => { try { await api(`/api/opportunities/${id}`, { method: 'DELETE' }, token); @@ -4000,7 +3971,7 @@

Pipeline

- + Add deals from the Fundraising Grid — "+ Pipeline" on an investor row
{loading ? ( @@ -4041,87 +4012,6 @@ )} - {showForm && ( -
-
-
New Opportunity
- {formError &&
{formError}
} -
-
- - setFormData({ ...formData, name: e.target.value })} - required - /> -
-
- - -
-
- - -
-
- - setFormData({ ...formData, expected_amount: parseFloat(e.target.value) })} - /> -
-
- - -
-
- - setFormData({ ...formData, fund_name: e.target.value })} - /> -
-
- - -
-
-
-
- )} - {selectedOpp && ( {}, down: async () => {} }, +})