From 7f9a15ebf3f319634564f0e61b94daa69320a3cb Mon Sep 17 00:00:00 2001 From: Keysat Date: Wed, 17 Jun 2026 23:08:36 -0500 Subject: [PATCH] Adopt the Pipeline: grid-driven opportunities link (v0.1.0:87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fundraising grid (canonical) now drives the classic opportunities Pipeline board, instead of the board being a disconnected second data-entry surface. An "Add to Pipeline" row action creates a durably-linked opportunity via the new opportunities.fundraising_investor_id (migration 0005, additive + reversible), reusing the grid's already-synced contact — retiring the POST /api/contacts side-door — and mapping the grid lead to the opp owner. Ownership is split so the two stay reconciled: the grid owns whether the link exists and the seed; the board owns stage/probability/owner. The link endpoint is idempotent (one live opp per investor; a re-link never reseeds funnel fields). "Is in pipeline?"/"what stage?" are derived from a live opp join and injected as read-only grid columns on read, stripped on write, so they never persist or dirty the autosave. Remove-from-pipeline soft-deletes the opp and leaves the grid row fully intact; deleting an investor from the grid archives its orphaned opp. Also fixes the standing soft-delete leak in handle_pipeline_report and the dashboard pipeline aggregates, which counted tombstoned opportunities. 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. --- AGENTS.md | 10 +- ROADMAP.md | 7 +- .../0005_grid_pipeline_link.down.sql | 7 + .../migrations/0005_grid_pipeline_link.sql | 22 ++ backend/server.py | 254 +++++++++++++++- backend/test_grid_pipeline_link.py | 270 ++++++++++++++++++ frontend/index.html | 208 +++++++++----- start9/0.4/startos/utils.ts | 5 +- start9/0.4/startos/versions/index.ts | 5 +- start9/0.4/startos/versions/v0.1.0.87.ts | 25 ++ 10 files changed, 724 insertions(+), 89 deletions(-) create mode 100644 backend/migrations/0005_grid_pipeline_link.down.sql create mode 100644 backend/migrations/0005_grid_pipeline_link.sql create mode 100644 backend/test_grid_pipeline_link.py create mode 100644 start9/0.4/startos/versions/v0.1.0.87.ts diff --git a/AGENTS.md b/AGENTS.md index a231769..2493f38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude ## Conventions - **Investor model — the grid is canonical (since v0.1.0:78).** The `fundraising_*` grid is the **system of record**: an investor entity (row) → many contact "pills" → per-fund commitments. The classic `contacts` table is a **read-only per-person directory**, auto-populated from the grid — create/edit people in the grid, not the Contacts page. Email capture rolls multiple people up to one investor. The legacy single-fund `lp_profiles` model is **retired** (empty table kept, per never-hard-delete). Reconciling grid ↔ classic `contacts` to canonical IDs is the core entity-resolution task — see `docs/crm-overview.md`. -- **Soft-delete only:** `deleted_at` and/or `status='retired'`; never hard-delete. Every READ path must filter `deleted_at IS NULL` — list handlers, get-by-id, nested related-data sub-selects, **and aggregate sub-selects (`COUNT`/`SUM`/`MAX`)**. Audits found leaks in all of these (2026-06-12 detail + nested; 2026-06-13 list-view `contact_count`/`total_funded`/`comm_count`); the **reports** subsystem aggregates still leak (see Current state). Regression-guarded by `backend/test_soft_delete_reads.py`. (Thesis has a subtlety here — see the thesis guide.) +- **Soft-delete only:** `deleted_at` and/or `status='retired'`; never hard-delete. Every READ path must filter `deleted_at IS NULL` — list handlers, get-by-id, nested related-data sub-selects, **and aggregate sub-selects (`COUNT`/`SUM`/`MAX`)**. Audits found leaks in all of these (2026-06-12 detail + nested; 2026-06-13 list-view `contact_count`/`total_funded`/`comm_count`); the **opportunities/pipeline** aggregates were fixed in v0.1.0:87 (`handle_pipeline_report` + dashboard pipeline metrics now filter `deleted_at`), but the **reports** subsystem's **communications-side** aggregates (dashboard `recent_comms`/`comms_this_month`/`meetings_this_month`, activity report) still leak (see Current state). Regression-guarded by `backend/test_soft_delete_reads.py`. (Thesis has a subtlety here — see the thesis guide.) - **Env:** secrets in `.env` (gitignored); names in `.env.example`. Verified names: `ANTHROPIC_API_KEY`, `SPARK_CONTROL_URL`, `SPARK_CONTROL_VERIFY_TLS`, `QDRANT_URL`, `X_API_KEY`, `CRM_DB_PATH`, `CRM_DEV_DB_PATH`. Also used: `CRM_SECRET_KEY` (beta/prod), `CRM_HOST`/`CRM_PORT`, `CRM_DATA_DIR`; digest mailer: `CRM_DIGEST_SENDER` (DWD impersonation sender) + `SMTP_HOST`/`SMTP_PORT`/`SMTP_SECURITY`/`SMTP_FROM`/`SMTP_USERNAME`/`SMTP_PASSWORD` (SMTP fallback); daily digest (Phase B): `CRM_DIGEST_ENABLED` + `CRM_DIGEST_SEND_HOUR` **only seed the first-boot default** — the live control is the DB policy (`app_settings.digest_policy`, set in Settings → Admin). - **Config placement:** operational/feature toggles live in the **admin panel**, DB-backed via `app_settings` (read-merge through a `load_*_policy(conn)` helper shared by the API + any scheduler; precedence DB-row → env-seed → default), so they're discoverable and take effect live. Reserve StartOS actions / env for **secrets and deploy-time config** (SMTP creds, API keys, DWD sender). Precedent: `digest_policy` (`GET/PATCH /api/admin/digest/policy`), `fundraising_backup_policy`. - **Commit style:** imperative subject, concise body explaining the *why*; put the package version in the subject (`… (v0.1.0:NN)`) for shippable changes. **No AI co-author / attribution trailers** — commits are authored by the user. @@ -103,7 +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:86** (deployed & verified live 2026-06-17). **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:86; repo at v0.1.0:87 (built + locally verified, s9pk build + install 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 — BUILT + locally verified 2026-06-17 (v0.1.0:87); s9pk build + box install 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`. **Next: build the s9pk (v87) and install to the box (needs authorization).** 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. @@ -111,6 +113,6 @@ _Phase 0 + Phase 1 built; **box and repo at v0.1.0:86** (deployed & verified liv - **Working (all draft-only):** CRM + ingest (chunk→embed→Qdrant + retrieval) + redaction boundary; Gmail capture (DWD) + email-activity propose→approve; Thesis Workshop + Architect (Claude) with dual-approval gate; Outreach Draft Assistant + follow-up radar + per-user voice + Tier-B in-thread Gmail draft creation. - **Deploy history (done — git log + `start9/0.4/startos/versions/` + guides):** v74 security/path-traversal hardening; v78 retired `lp_profiles`/LP Tracker (grid is canonical); v80–83 email-activity Communications tab (typed investor facet, date filter, full-body view, semantic content search) + daily-digest windowed preview→send (`docs/guides/email.md`); v82 vendored + SRI-pinned front-end libs + jsdom render-smoke build gate (`docs/guides/packaging.md`). - **Tests:** **27/27 backend green** (`python3 backend/run_tests.py`), `py_compile` clean; frontend render-smoke gates the default `make` build. -- **Debt (P2, not deploy-blocking; full list `EVALUATION.md`):** reports-subsystem soft-delete sweep (`handle_pipeline_report` + report aggregates still count tombstoned rows) + report-endpoint tests; `?limit=abc` crashes the request thread; auth regression test for the 3 v79-gated GETs (`/api/users`, `/api/email/status`, `/api/email/accounts`); scrub-gateway TLS verify off; hardcoded Spark/Qdrant IPs + **oversized StartOS package icon** (fix before the next s9pk upload); the 5.4k-line `server.py` monolith. +- **Debt (P2, not deploy-blocking; full list `EVALUATION.md`):** reports-subsystem soft-delete sweep — **pipeline/opportunities aggregates fixed v87**; remaining: the dashboard **communications** aggregates (`recent_comms`/`comms_this_month`/`meetings_this_month`) + activity report + report-endpoint tests; `?limit=abc` crashes the request thread; auth regression test for the 3 v79-gated GETs (`/api/users`, `/api/email/status`, `/api/email/accounts`); scrub-gateway TLS verify off; hardcoded Spark/Qdrant IPs + **oversized StartOS package icon** (fix before the next s9pk upload); the 5.4k-line `server.py` monolith. - **Open / risks:** the v2.0 reserve-asset spine is the *working* approved spine but **not a canonical `thesis_version`** (needs Grant + Jonathan dual sign-off; Appendix-A conviction incl. ~40% Strike stays Grant's working read, not fed to the engine); **Claude/Architect path still unverified live on the box**; the intake matcher reads only the grid blob (not classic `contacts`); doc drift — `crm-overview.md` + `EVALUATION.md` still call `lp_profiles` live (doc-auditor pass). -- **Next:** 1) **Pipeline adoption** — grid flag → auto-sync an `opportunities` row onto the Pipeline board (the agreed next major build; **design the grid↔pipeline link first** — ROADMAP "Adopt the Pipeline"); 2) **spark-control intake dashboard card** (separate session in the spark-control repo — handoff at `docs/handoffs/add-intake-bot-to-spark-control.md`), and longer-term **extract the bot to its own repo** (ROADMAP); 3) in-room smoke of the intake **disambiguation** numbered-pick grammar (the one unexercised path) — and a roster-tuning pass if any teammate name/initial still slips through; 4) **NL→safe-query** (search item 3 — separate, larger build); 5) Grant + Jonathan freeze v2.0 canonical; 6) reply-all for Tier-B drafts; then clear the P2 debt above. +- **Next:** 1) **Pipeline adoption — BUILT (v0.1.0:87, above); ship it** — build the s9pk + install to the box (needs authorization), then live-smoke the "Add to Pipeline"/board round-trip; 2) **spark-control intake dashboard card** (separate session in the spark-control repo — handoff at `docs/handoffs/add-intake-bot-to-spark-control.md`), and longer-term **extract the bot to its own repo** (ROADMAP); 3) in-room smoke of the intake **disambiguation** numbered-pick grammar (the one unexercised path) — and a roster-tuning pass if any teammate name/initial still slips through; 4) **NL→safe-query** (search item 3 — separate, larger build); 5) Grant + Jonathan freeze v2.0 canonical; 6) reply-all for Tier-B drafts; then clear the P2 debt above. diff --git a/ROADMAP.md b/ROADMAP.md index b34c5cd..db99f6e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -173,10 +173,13 @@ Open design questions (settled at build time): send time = **6 PM box-local** (c - **Dashboard KPIs repointed:** "Total Committed" now sums `fundraising_investors.total_invested` (the canonical grid rollup), **excluding graveyarded investors** so the headline reflects live committed capital — a deliberate divergence from `/api/fundraising/relational-summary`, which sums all rows. "Total Funded" dropped — the grid has no funded-vs-committed concept and the frontend never rendered it. (If a funded/wired status is wanted later, that's a new grid feature, not a revival of lp_profiles.) Regression-guarded by `test_dashboard_report.py`. - **Left in place (intentional):** the empty `lp_profiles` table + index (no destructive drop, per never-hard-delete); the contact-delete soft-delete cascade; the `--reset-all-data` clear; and the inert MOCK_MODE `mockDb.lp_profiles` fixtures (dev-only fallback, never hits the backend — its dashboard mock still reads mock lp_profiles, a known dev-only divergence from the real backend). Updated `test_soft_delete_reads.py` to drop the now-removed `lp_profile` assertions (kept its org `total_funded` opportunities-aggregate checks). -**Adopt the Pipeline — wire it to the grid.** *(Priority: second build, after the Matrix-bridge intake — confirmed 2026-06-16.)* +**Adopt the Pipeline — wire it to the grid. — BUILT + locally verified 2026-06-17 (v0.1.0:87); s9pk build + box install pending.** *(Was: second build after the Matrix-bridge intake.)* - Pipeline (`opportunities`) is fully built and functional but unused. Keep it: it's the one classic surface that tracks something the grid doesn't — a forward-looking deal funnel (stage, `expected_amount × probability`, owner, close date) vs. the grid's actual committed dollars + flags. - New idea (Grant, 2026-06-16): let users **flag an investor in the grid as a pipeline opportunity** (a grid column/control) so it **auto-creates / syncs an `opportunities` row** that loads into the Pipeline board. Design the grid↔pipeline link (which fund seeds it? what sets stage/expected amount? keep them reconciled). Turns Pipeline from a disconnected second data-entry surface into a view driven by the canonical grid. -- Revisit the stray contact-create side-door (the "Create Opportunity" modal `POST /api/contacts`, `frontend/index.html:6030`) once the grid-driven flow exists. +- 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. +- **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/backend/migrations/0005_grid_pipeline_link.down.sql b/backend/migrations/0005_grid_pipeline_link.down.sql new file mode 100644 index 0000000..a4960b6 --- /dev/null +++ b/backend/migrations/0005_grid_pipeline_link.down.sql @@ -0,0 +1,7 @@ +-- Reversal of 0005_grid_pipeline_link.sql (manual; .down files are never auto-applied). +-- +-- SQLite < 3.35 cannot DROP COLUMN. The added column is nullable and ignored by any code +-- path predating it, so leaving it in place is harmless. The index drops freely. On +-- SQLite >= 3.35 the column itself may also be dropped. +DROP INDEX IF EXISTS idx_opportunities_fr_investor; +-- ALTER TABLE opportunities DROP COLUMN fundraising_investor_id; -- SQLite >= 3.35 only diff --git a/backend/migrations/0005_grid_pipeline_link.sql b/backend/migrations/0005_grid_pipeline_link.sql new file mode 100644 index 0000000..5af111f --- /dev/null +++ b/backend/migrations/0005_grid_pipeline_link.sql @@ -0,0 +1,22 @@ +-- Grid → Pipeline adoption — a durable link from a fundraising-grid investor to its +-- Pipeline opportunity row. +-- +-- ADDITIVE + REVERSIBLE (CLAUDE.md guardrail #3): adds one nullable column + index. +-- Until now the grid's "Create Opportunity" button fired a one-shot POST with no +-- back-reference, so a grid investor could spawn unlimited duplicate opportunities and +-- an opp never knew which grid row it belonged to. opportunities.fundraising_investor_id +-- records the link (set by the new POST /api/fundraising/pipeline/link endpoint), making +-- the relationship dedup-able and reconcilable. "Is this investor in the pipeline?" and +-- "what stage?" are then DERIVED from a live join on this column — deliberately not a +-- denormalized mirror flag on fundraising_investors, which would only reintroduce the +-- two-model drift this CRM exists to fight. +-- +-- fundraising_investor_id is a LOGICAL foreign key to fundraising_investors(id). It is +-- intentionally NOT a declared SQLite FOREIGN KEY: opportunities are soft-deleted (never +-- hard-deleted) and fundraising_investors rows are rebuilt on every grid save, so there +-- is nothing to cascade; SQLite's ALTER TABLE ADD COLUMN cannot add an enforced FK +-- cleanly anyway. Nullable so every existing opportunity stays valid — a manually-created, +-- non-grid opportunity simply has NULL here. +ALTER TABLE opportunities ADD COLUMN fundraising_investor_id TEXT; + +CREATE INDEX IF NOT EXISTS idx_opportunities_fr_investor ON opportunities(fundraising_investor_id); diff --git a/backend/server.py b/backend/server.py index 3cbe3fa..acba154 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1584,6 +1584,12 @@ def sanitize_fundraising_grid(grid): if not isinstance(rows, list): rows = deep_copy_json(DEFAULT_FUNDRAISING_ROWS) + # `pipeline` / `pipeline_stage` are read-only columns whose VALUES are derived from the + # linked opportunity and injected on read — never persisted as row data (the GET handler + # re-injects them after sanitize). The column DEFINITIONS persist like any other column + # so their position / width / hidden state is kept. + _computed_row_values = ('longshot_followup', 'pipeline', 'pipeline_stage') + clean_columns = [] seen = set() for col in columns: @@ -1600,11 +1606,83 @@ def sanitize_fundraising_grid(grid): if not isinstance(row, dict): continue next_row = dict(row) - next_row.pop('longshot_followup', None) + for _k in _computed_row_values: + next_row.pop(_k, None) clean_rows.append(next_row) return {"columns": clean_columns, "rows": clean_rows} +# ─── Grid ↔ Pipeline link (Adopt the Pipeline) ──────────────────────────────── +# The fundraising grid is canonical; the Pipeline board is a view of the deals it +# drives. opportunities.fundraising_investor_id is the durable join. Two ownership +# rules keep them reconciled: +# * Grid owns: whether the link exists, the investor, the primary contact, the seed. +# * Pipeline owns: stage / probability / owner / close date / next step. +# So a grid save NEVER reseeds an existing linked opp (it would clobber funnel state), +# and "is in pipeline?" / "what stage?" are DERIVED from the live opp join — never a +# denormalized flag that could drift. + +def _resolve_owner_from_lead(conn, lead_value, fallback_user_id): + """Map a grid 'lead' cell (a team member's name/initials) to a users.id. + opportunities.owner_id is NOT NULL, so a value is always returned — the acting user + is the fallback when no confident match exists. Owner is reassignable on the board, + so a forgiving prefix match is acceptable here.""" + lead = str(lead_value or '').strip().lower() + if lead: + row = conn.execute( + "SELECT id FROM users WHERE is_active = 1 AND (lower(full_name) = ? OR lower(username) = ?) LIMIT 1", + (lead, lead) + ).fetchone() + if row: + return row['id'] + row = conn.execute( + "SELECT id FROM users WHERE is_active = 1 AND (lower(full_name) LIKE ? OR lower(username) LIKE ?) " + "ORDER BY length(full_name) LIMIT 1", + (lead + '%', lead + '%') + ).fetchone() + if row: + return row['id'] + return fallback_user_id + + +def reconcile_grid_pipeline_links(conn): + """After a grid save + relational sync, archive (soft-delete) any pipeline + opportunity whose linked grid investor row no longer exists — i.e. the investor was + deleted from the grid. Creation is NEVER done here: a pipeline opp is only created + via the explicit /api/fundraising/pipeline/link endpoint (which carries the seed + fields), so this reconciler is a one-way orphan cleanup that can never spawn an + empty opp or reseed a live one.""" + conn.execute( + """ + UPDATE opportunities + SET deleted_at = ?, updated_at = ? + WHERE fundraising_investor_id IS NOT NULL + AND deleted_at IS NULL + AND fundraising_investor_id NOT IN (SELECT id FROM fundraising_investors) + """, + (now(), now()) + ) + + +def pipeline_stage_by_source_row(conn): + """Return {grid source_row_id: current pipeline stage} for every investor with a + live (non-deleted) linked opportunity. The opportunities table is the single source + of truth, so this is always derived fresh and injected as read-only grid columns — + never stored in the grid blob, where it could go stale.""" + out = {} + for r in conn.execute( + """ + SELECT fi.source_row_id AS srid, o.stage AS stage + FROM opportunities o + JOIN fundraising_investors fi ON o.fundraising_investor_id = fi.id + WHERE o.deleted_at IS NULL + """ + ).fetchall(): + srid = str(r['srid'] or '') + if srid: + out[srid] = r['stage'] + return out + def maybe_run_scheduled_backup(): conn = get_db() try: @@ -2066,6 +2144,10 @@ class CRMHandler(BaseHTTPRequestHandler): return self.handle_create_feature_request(user, body) if path == '/api/fundraising/log-communication': return self.handle_log_fundraising_communication(user, body) + if path == '/api/fundraising/pipeline/link': + return self.handle_pipeline_link(user, body) + if path == '/api/fundraising/pipeline/unlink': + return self.handle_pipeline_unlink(user, body) if path == '/api/fundraising/collab/heartbeat': return self.handle_fundraising_collab_heartbeat(user, body) if path == '/api/admin/users': @@ -3066,6 +3148,152 @@ class CRMHandler(BaseHTTPRequestHandler): conn.close() return self.send_json({"data": {"communication": comm, "row": target_row, "version": next_version}}, 201) + def _fetch_opportunity_row(self, conn, opp_id): + return row_to_dict(conn.execute(""" + SELECT op.*, c.first_name, c.last_name, c.email as contact_email, + o.name as organization_name, u.full_name as owner_name + FROM opportunities op + LEFT JOIN contacts c ON op.contact_id = c.id + LEFT JOIN organizations o ON op.organization_id = o.id + LEFT JOIN users u ON op.owner_id = u.id + WHERE op.id = ? + """, (opp_id,)).fetchone()) + + def _resolve_grid_primary_contact(self, conn, investor_id, contact_index, actor_user_id): + """Resolve the classic contacts.id for a grid investor's chosen contact pill, + reusing the link sync already records in fundraising_contacts.contact_id (no + bare POST /api/contacts side-door). contact_index matches the pill order + (fundraising_contacts.sort_order).""" + try: + idx = int(contact_index) + except (TypeError, ValueError): + idx = 0 + if idx < 0: + idx = 0 + fc = conn.execute( + "SELECT contact_id, full_name, email FROM fundraising_contacts " + "WHERE investor_id = ? ORDER BY sort_order, rowid LIMIT 1 OFFSET ?", + (investor_id, idx) + ).fetchone() + if not fc: + # requested index out of range — fall back to the first pill + fc = conn.execute( + "SELECT contact_id, full_name, email FROM fundraising_contacts " + "WHERE investor_id = ? ORDER BY sort_order, rowid LIMIT 1", + (investor_id,) + ).fetchone() + if not fc: + return None + if fc['contact_id']: + return fc['contact_id'] + # Rows predating the contact_id backfill: resolve via the same grid→classic + # upsert the relational sync uses, not a fresh side-door create. + inv = conn.execute("SELECT investor_name FROM fundraising_investors WHERE id = ?", (investor_id,)).fetchone() + investor_name = str(inv['investor_name'] if inv else '') or '' + return _upsert_contact_from_fundraising( + conn, investor_name, {"name": fc['full_name'], "email": fc['email']}, actor_user_id=actor_user_id + ) + + def handle_pipeline_link(self, user, body): + """Create (or return the existing) Pipeline opportunity for a fundraising-grid + investor row and link it durably via opportunities.fundraising_investor_id. + Idempotent: one live opp per investor — a re-link returns the existing opp + without reseeding its Pipeline-owned funnel fields.""" + source_row_id = str(body.get('source_row_id') or '').strip() + if not source_row_id: + return self.send_error_json("source_row_id is required") + + conn = get_db() + investor = conn.execute( + "SELECT id, investor_name, lead FROM fundraising_investors WHERE source_row_id = ?", + (source_row_id,) + ).fetchone() + if not investor: + conn.close() + return self.send_error_json("Investor not found for that grid row — save the grid first", 404) + investor_id = investor['id'] + investor_name = str(investor['investor_name'] or '').strip() or 'Untitled Investor' + + existing = conn.execute( + "SELECT id FROM opportunities WHERE fundraising_investor_id = ? AND deleted_at IS NULL " + "ORDER BY created_at LIMIT 1", + (investor_id,) + ).fetchone() + if existing: + opp = self._fetch_opportunity_row(conn, existing['id']) + conn.close() + return self.send_json({"data": opp, "already_linked": True}) + + contact_id = self._resolve_grid_primary_contact( + conn, investor_id, body.get('contact_index'), actor_user_id=user['user_id'] + ) + if not contact_id: + conn.close() + return self.send_error_json("Add at least one contact to the investor row before adding it to the pipeline") + + contact = conn.execute("SELECT organization_id FROM contacts WHERE id = ?", (contact_id,)).fetchone() + org_id = contact['organization_id'] if contact else None + + stage = str(body.get('stage') or 'lead').strip() or 'lead' + if stage not in PIPELINE_STAGES: + conn.close() + return self.send_error_json(f"Invalid stage. Must be one of: {', '.join(PIPELINE_STAGES)}") + try: + expected_amount = float(body.get('expected_amount') or 0) + except (TypeError, ValueError): + expected_amount = 0.0 + try: + probability = int(body.get('probability')) + except (TypeError, ValueError): + probability = 35 + probability = max(0, min(100, probability)) + fund_name = str(body.get('fund_name') or '').strip() + name = str(body.get('name') or '').strip() or f"{investor_name} — Pipeline" + owner_id = _resolve_owner_from_lead(conn, investor['lead'], user['user_id']) + + opp_id = generate_id() + conn.execute(""" + INSERT INTO opportunities (id, name, contact_id, organization_id, stage, + commitment_amount, expected_amount, probability, fund_name, + owner_id, priority, fundraising_investor_id) + VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 'medium', ?) + """, (opp_id, name, contact_id, org_id, stage, expected_amount, probability, + fund_name or None, owner_id, investor_id)) + log_audit(conn, user['user_id'], 'opportunity', opp_id, 'create', {"source": "grid_pipeline_link"}) + conn.commit() + + opp = self._fetch_opportunity_row(conn, opp_id) + conn.close() + return self.send_json({"data": opp, "already_linked": False}, 201) + + def handle_pipeline_unlink(self, user, body): + """Remove a grid investor from the Pipeline: soft-delete its linked opportunity. + The fundraising-grid row (investor, contacts, commitments, notes) is left fully + intact — only the opportunity is archived (recoverable via the board).""" + source_row_id = str(body.get('source_row_id') or '').strip() + if not source_row_id: + return self.send_error_json("source_row_id is required") + conn = get_db() + investor = conn.execute( + "SELECT id FROM fundraising_investors WHERE source_row_id = ?", (source_row_id,) + ).fetchone() + if not investor: + conn.close() + return self.send_error_json("Investor not found for that grid row", 404) + opp_rows = conn.execute( + "SELECT id FROM opportunities WHERE fundraising_investor_id = ? AND deleted_at IS NULL", + (investor['id'],) + ).fetchall() + archived = 0 + for opp in opp_rows: + conn.execute("UPDATE opportunities SET deleted_at = ?, updated_at = ? WHERE id = ?", + (now(), now(), opp['id'])) + log_audit(conn, user['user_id'], 'opportunity', opp['id'], 'delete', {"source": "grid_pipeline_unlink"}) + archived += 1 + conn.commit() + conn.close() + return self.send_json({"data": {"archived": archived}}) + def handle_intake_match(self, user, params): """Read-only: does an investor matching this intake already exist? Used by the Matrix intake bot to label its in-thread proposal new-vs-existing. Returns the @@ -3154,11 +3382,11 @@ class CRMHandler(BaseHTTPRequestHandler): ).fetchone()['total'] pipeline_value = conn.execute( - "SELECT COALESCE(SUM(expected_amount), 0) as total FROM opportunities WHERE stage NOT IN ('funded', 'lost')" + "SELECT COALESCE(SUM(expected_amount), 0) as total FROM opportunities WHERE stage NOT IN ('funded', 'lost') AND deleted_at IS NULL" ).fetchone()['total'] active_opportunities = conn.execute( - "SELECT COUNT(*) as c FROM opportunities WHERE stage NOT IN ('funded', 'lost')" + "SELECT COUNT(*) as c FROM opportunities WHERE stage NOT IN ('funded', 'lost') AND deleted_at IS NULL" ).fetchone()['c'] # Pipeline by stage @@ -3166,7 +3394,7 @@ class CRMHandler(BaseHTTPRequestHandler): SELECT stage, COUNT(*) as count, COALESCE(SUM(expected_amount), 0) as total_value, COALESCE(SUM(commitment_amount), 0) as committed_value FROM opportunities - WHERE stage != 'lost' + WHERE stage != 'lost' AND deleted_at IS NULL GROUP BY stage ORDER BY CASE stage WHEN 'lead' THEN 1 WHEN 'outreach' THEN 2 WHEN 'meeting' THEN 3 @@ -3243,6 +3471,7 @@ class CRMHandler(BaseHTTPRequestHandler): COALESCE(SUM(commitment_amount), 0) as total_committed, COALESCE(AVG(probability), 0) as avg_probability FROM opportunities + WHERE deleted_at IS NULL GROUP BY stage ORDER BY CASE stage WHEN 'lead' THEN 1 WHEN 'outreach' THEN 2 WHEN 'meeting' THEN 3 @@ -3255,6 +3484,7 @@ class CRMHandler(BaseHTTPRequestHandler): COALESCE(SUM(op.expected_amount), 0) as total_expected FROM opportunities op LEFT JOIN users u ON op.owner_id = u.id + WHERE op.deleted_at IS NULL GROUP BY op.owner_id, op.stage ORDER BY u.full_name, op.stage """).fetchall()) @@ -3263,7 +3493,7 @@ class CRMHandler(BaseHTTPRequestHandler): SELECT priority, COUNT(*) as count, COALESCE(SUM(expected_amount), 0) as total_expected FROM opportunities - WHERE stage NOT IN ('funded', 'lost') + WHERE stage NOT IN ('funded', 'lost') AND deleted_at IS NULL GROUP BY priority """).fetchall()) @@ -4824,6 +5054,7 @@ class CRMHandler(BaseHTTPRequestHandler): conn = get_db() self._ensure_fundraising_state_row(conn) row = conn.execute("SELECT * FROM fundraising_state WHERE id = 'main'").fetchone() + stage_by_row = pipeline_stage_by_source_row(conn) conn.close() try: @@ -4842,6 +5073,16 @@ class CRMHandler(BaseHTTPRequestHandler): columns = grid.get('columns', []) rows = grid.get('rows', []) + # Inject the read-only pipeline columns, derived from the live linked opportunity + # (the opportunities table is canonical — never stored in the grid blob, so it + # can't go stale). The frontend renders these read-only and strips them on save. + for r in rows: + if not isinstance(r, dict): + continue + stage = stage_by_row.get(str(r.get('id') or '')) + r['pipeline'] = bool(stage) + r['pipeline_stage'] = stage or '' + return self.send_json({ "data": { "grid": {"columns": columns, "rows": rows}, @@ -5020,6 +5261,9 @@ class CRMHandler(BaseHTTPRequestHandler): WHERE id = 'main' """, (json.dumps(grid), json.dumps(next_views), next_version, user['user_id'], now())) sync_fundraising_relational(conn, grid, next_views, actor_user_id=user['user_id']) + # Archive pipeline opps orphaned by an investor deleted from the grid (one-way + # cleanup; never creates or reseeds — see reconcile_grid_pipeline_links). + reconcile_grid_pipeline_links(conn) log_audit(conn, user['user_id'], 'fundraising_state', 'main', 'update', {"version": next_version}) conn.commit() conn.close() diff --git a/backend/test_grid_pipeline_link.py b/backend/test_grid_pipeline_link.py new file mode 100644 index 0000000..9ef162b --- /dev/null +++ b/backend/test_grid_pipeline_link.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Tests for the grid → Pipeline link ("Adopt the Pipeline", v0.1.0:87). + +Boots the REAL server against a temp DB and exercises the new endpoints end-to-end: + - POST /api/fundraising/pipeline/link creates exactly ONE opportunity, linked via + opportunities.fundraising_investor_id, reusing the grid's synced contact (no + POST /api/contacts side-door) and mapping the grid 'lead' -> owner; + - the link is idempotent: a re-link returns the existing opp and NEVER reseeds its + Pipeline-owned funnel fields (stage/probability) — the board owns those; + - GET /api/fundraising/state injects read-only pipeline / pipeline_stage row values + derived from the live opp; + - linking a contactless row, or an unknown row, is refused; + - POST .../unlink soft-deletes the opp (off the board, recoverable) while leaving the + grid investor row fully intact; + - deleting an investor from the grid archives its orphaned opp on the next save; + - the pipeline report + dashboard aggregates exclude archived (soft-deleted) opps. +Synthetic data only. + +Run: cd backend && python3 test_grid_pipeline_link.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 _put_grid(port, token, rows): + return _req(port, "PUT", "/api/fundraising/state", token, + {"grid": {"columns": [], "rows": rows}, "views": []}) + + +ROW_ACME = {"id": "rowAcme", "investor_name": "Acme Capital", "notes": "", "lead": "Grant", + "contacts": [{"name": "Jane Doe", "email": "jane@acme.com", "title": "GP"}]} +ROW_BETA = {"id": "rowBeta", "investor_name": "Beta Capital LLC", "notes": "", "lead": "", + "contacts": [{"name": "Pat Roe", "email": "pat@beta.com", "title": ""}]} +ROW_EMPTY = {"id": "rowEmpty", "investor_name": "Empty LP", "notes": "", "contacts": []} + + +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 _opp_count_live(fr_investor_id=None): + c = _db() + if fr_investor_id: + n = c.execute("SELECT COUNT(*) FROM opportunities WHERE fundraising_investor_id = ? " + "AND deleted_at IS NULL", (fr_investor_id,)).fetchone()[0] + else: + n = c.execute("SELECT COUNT(*) FROM opportunities WHERE deleted_at IS NULL").fetchone()[0] + c.close() + return n + + +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: + st, _ = _put_grid(port, token, [ROW_ACME, ROW_BETA, ROW_EMPTY]) + check(st == 200, f"seed grid via PUT /state (got {st})") + + # ── link creates one linked opp with the seeds + resolved contact + mapped owner ── + print("\n[link: creates one linked opportunity with seeds]") + st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, { + "source_row_id": "rowAcme", "fund_name": "Fund III", + "expected_amount": 250000, "probability": 40, "stage": "outreach", + }) + opp = (d or {}).get("data") or {} + check(st == 201 and (d or {}).get("already_linked") is False, f"link -> 201 new (got {st}, {d})") + check(opp.get("stage") == "outreach" and opp.get("expected_amount") == 250000 + and opp.get("probability") == 40 and opp.get("fund_name") == "Fund III", + f"seeds applied (got {{stage:{opp.get('stage')}, amt:{opp.get('expected_amount')}, " + f"prob:{opp.get('probability')}, fund:{opp.get('fund_name')}}})") + check(opp.get("first_name") == "Jane", f"reused synced contact Jane Doe (got {opp.get('first_name')})") + check(opp.get("owner_name") == "Grant", f"grid lead 'Grant' -> owner Grant (got {opp.get('owner_name')})") + fr_id = opp.get("fundraising_investor_id") + check(bool(fr_id), f"opportunity carries fundraising_investor_id (got {fr_id})") + check(_opp_count_live(fr_id) == 1, "exactly one live opp linked to the investor") + opp_id = opp.get("id") + + # ── idempotent re-link: returns existing, board-owned stage NOT reseeded ── + print("\n[idempotent: re-link returns existing opp without reseeding funnel fields]") + st, _ = _req(port, "PATCH", f"/api/opportunities/{opp_id}/stage", token, {"stage": "meeting"}) + check(st == 200, f"advance stage on the board -> meeting (got {st})") + st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, { + "source_row_id": "rowAcme", "stage": "lead", "expected_amount": 999, "probability": 5, + }) + opp2 = (d or {}).get("data") or {} + check(st == 200 and (d or {}).get("already_linked") is True, f"re-link -> already_linked (got {st}, {d})") + check(opp2.get("stage") == "meeting" and opp2.get("expected_amount") == 250000, + 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)") + + # ── 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) + rows = {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])} + check(rows.get("rowAcme", {}).get("pipeline") is True + and rows.get("rowAcme", {}).get("pipeline_stage") == "meeting", + f"rowAcme pipeline true @meeting (got {rows.get('rowAcme', {}).get('pipeline')}, " + f"{rows.get('rowAcme', {}).get('pipeline_stage')})") + check(rows.get("rowBeta", {}).get("pipeline") is False + and rows.get("rowBeta", {}).get("pipeline_stage") == "", + f"rowBeta not in pipeline (got {rows.get('rowBeta', {}).get('pipeline')})") + + # ── round-trip: a save echoing the injected read-only values is lossless ── + print("\n[round-trip: PUT carrying injected pipeline values strips them, link intact]") + st, d = _req(port, "GET", "/api/fundraising/state", token) + echoed = (d or {}).get("data", {}).get("grid", {}).get("rows", []) + st, _ = _put_grid(port, token, echoed) # as the frontend autosave would, rows still carry pipeline* + check(st == 200, f"echo-back save -> 200 (got {st})") + check(_opp_count_live(fr_id) == 1, "link survives the round-trip (no dup, not archived)") + c = _db() + blob = json.loads(c.execute("SELECT grid_json FROM fundraising_state WHERE id='main'").fetchone()[0]) + c.close() + stored_acme = {r["id"]: r for r in blob.get("rows", [])}.get("rowAcme", {}) + check("pipeline" not in stored_acme and "pipeline_stage" not in stored_acme, + "computed keys are NOT persisted into the grid blob") + st, d = _req(port, "GET", "/api/fundraising/state", token) + rt = {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])}.get("rowAcme", {}) + check(rt.get("pipeline") is True and rt.get("pipeline_stage") == "meeting", + f"pipeline values re-injected after round-trip (got {rt.get('pipeline')}, {rt.get('pipeline_stage')})") + + # ── guards ── + print("\n[guard: a contactless row cannot be added to the pipeline]") + st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {"source_row_id": "rowEmpty"}) + check(st == 400, f"no contact -> 400 (got {st}, {d})") + check(_opp_count_live() == 1, "no stray opp created for the contactless row") + + print("\n[guard: unknown grid row -> 404]") + st, _ = _req(port, "POST", "/api/fundraising/pipeline/link", token, {"source_row_id": "nope"}) + check(st == 404, f"unknown row -> 404 (got {st})") + + print("\n[guard: unauthenticated -> 401]") + st, _ = _req(port, "POST", "/api/fundraising/pipeline/link", None, {"source_row_id": "rowAcme"}) + check(st == 401, f"no token -> 401 (got {st})") + + # ── the opp loads on the board + counts in the dashboard while live ── + print("\n[board + dashboard count the live opp]") + st, d = _req(port, "GET", "/api/opportunities?limit=1000", token) + ids = [o["id"] for o in (d or {}).get("data", [])] + check(opp_id in ids, "linked opp appears on the board") + st, d = _req(port, "GET", "/api/reports/dashboard", token) + active = (d or {}).get("data", {}).get("metrics", {}).get("active_opportunities") + check(active == 1, f"dashboard active_opportunities == 1 (got {active})") + + # ── unlink soft-deletes the opp; the GRID ROW stays fully intact ── + print("\n[unlink: archives the opp, leaves the grid investor intact]") + st, d = _req(port, "POST", "/api/fundraising/pipeline/unlink", token, {"source_row_id": "rowAcme"}) + check(st == 200 and (d or {}).get("data", {}).get("archived") == 1, f"unlink -> archived 1 (got {st}, {d})") + check(_opp_count_live(fr_id) == 0, "opp is no longer live (soft-deleted)") + c = _db() + gone = c.execute("SELECT deleted_at FROM opportunities WHERE id = ?", (opp_id,)).fetchone()[0] + inv_still = c.execute("SELECT investor_name FROM fundraising_investors WHERE source_row_id = 'rowAcme'").fetchone() + contact_still = c.execute("SELECT COUNT(*) FROM fundraising_contacts WHERE investor_id = ?", (fr_id,)).fetchone()[0] + c.close() + check(gone is not None, "opp row tombstoned (deleted_at set), not hard-deleted") + check(inv_still and inv_still[0] == "Acme Capital", "grid investor row untouched by unlink") + check(contact_still >= 1, "grid investor's contacts untouched by unlink") + st, d = _req(port, "GET", "/api/opportunities?limit=1000", token) + check(opp_id not in [o["id"] for o in (d or {}).get("data", [])], "archived opp left the board") + st, d = _req(port, "GET", "/api/fundraising/state", token) + rows = {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])} + check(rows.get("rowAcme", {}).get("pipeline") is False, "grid no longer flags rowAcme as in-pipeline") + + # ── aggregates exclude the archived opp ── + print("\n[aggregates exclude archived opps]") + st, d = _req(port, "GET", "/api/reports/dashboard", token) + active = (d or {}).get("data", {}).get("metrics", {}).get("active_opportunities") + check(active == 0, f"dashboard active_opportunities back to 0 (got {active})") + st, d = _req(port, "GET", "/api/reports/pipeline", token) + by_stage = (d or {}).get("data", {}).get("by_stage", []) + total = sum(s.get("count", 0) for s in by_stage) + check(total == 0, f"pipeline report by_stage excludes archived (got total {total})") + + # ── re-link after unlink: a fresh opp is created (the archived one stays archived) ── + print("\n[re-link after unlink: creates a new opp, flag reappears]") + st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, { + "source_row_id": "rowAcme", "stage": "outreach", "expected_amount": 50000, + }) + relinked = (d or {}).get("data") or {} + check(st == 201 and (d or {}).get("already_linked") is False and relinked.get("id") != opp_id, + f"re-link -> a NEW opp distinct from the archived one (got {st}, {relinked.get('id')} vs {opp_id})") + check(_opp_count_live(fr_id) == 1, "exactly one live opp again after re-link") + st, _ = _req(port, "POST", "/api/fundraising/pipeline/unlink", token, {"source_row_id": "rowAcme"}) + check(st == 200, "reset: unlink the re-linked opp") + + # ── orphan reconciler: deleting the investor from the grid archives its opp ── + print("\n[orphan: deleting the grid investor archives its linked opp on next save]") + st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, { + "source_row_id": "rowBeta", "stage": "lead", "expected_amount": 100000, + }) + beta = (d or {}).get("data") or {} + beta_opp_id, beta_fr = beta.get("id"), beta.get("fundraising_investor_id") + check(st == 201 and _opp_count_live(beta_fr) == 1, f"beta linked (got {st})") + # drop rowBeta from the grid (keep the others) + st, _ = _put_grid(port, token, [ROW_ACME, ROW_EMPTY]) + check(st == 200, f"save grid without rowBeta (got {st})") + check(_opp_count_live(beta_fr) == 0, "beta's orphaned opp archived by the reconciler") + st, d = _req(port, "GET", "/api/opportunities?limit=1000", token) + check(beta_opp_id not in [o["id"] for o in (d or {}).get("data", [])], "orphaned opp left the board") + 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() diff --git a/frontend/index.html b/frontend/index.html index b67bcb8..03f7573 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4890,6 +4890,8 @@ { id: 'follow_up', label: 'Follow up', type: 'checkbox', width: 110 }, { id: 'lead', label: 'Lead', type: 'select', options: teamMembers, width: 130 }, { id: 'graveyard', label: 'Graveyard', type: 'checkbox', width: 115 }, + { id: 'pipeline', label: 'Pipeline', type: 'action', readOnly: true, width: 120 }, + { id: 'pipeline_stage', label: 'Pipeline Stage', type: 'text', readOnly: true, width: 150 }, { id: 'fund_i', label: 'Fund I', type: 'currency', isFund: true, width: 130 }, { id: 'fund_ii', label: 'Fund II', type: 'currency', isFund: true, width: 130 }, { id: 'fund_iii', label: 'Fund III', type: 'currency', isFund: true, width: 130 }, @@ -5039,6 +5041,25 @@ else cols.push(col); changed = true; } + // Pipeline-adoption columns: a per-row "Add to / In Pipeline" control and a + // read-only mirror of the linked opportunity's stage. Both are injected here so + // existing saved grids pick them up; their VALUES are server-computed on read. + const hasPipeline = cols.some((c) => c.id === 'pipeline'); + if (!hasPipeline) { + const gy = cols.findIndex((c) => c.id === 'graveyard'); + const col = { id: 'pipeline', label: 'Pipeline', type: 'action', readOnly: true, width: 120 }; + if (gy >= 0) cols.splice(gy + 1, 0, col); + else cols.push(col); + changed = true; + } + const hasPipelineStage = cols.some((c) => c.id === 'pipeline_stage'); + if (!hasPipelineStage) { + const pi = cols.findIndex((c) => c.id === 'pipeline'); + const col = { id: 'pipeline_stage', label: 'Pipeline Stage', type: 'text', readOnly: true, width: 150 }; + if (pi >= 0) cols.splice(pi + 1, 0, col); + else cols.push(col); + changed = true; + } const rowsIn = Array.isArray(incomingRows) ? incomingRows : defaultRows; const rowsOut = rowsIn.map((r) => { const next = { ...r }; @@ -5059,6 +5080,16 @@ return { columns: cols, rows: rowsOut, changed }; }; + // `pipeline` / `pipeline_stage` are read-only, server-computed (from the linked + // opportunity) and injected on GET. They must never participate in dirty-detection + // or be persisted — otherwise a link/unlink flips a value and triggers a no-op + // autosave + version bump. Strip them at every snapshot / persist boundary. + const stripComputedRows = (rs) => (Array.isArray(rs) ? rs.map((r) => { + if (!r || typeof r !== 'object') return r; + const { pipeline, pipeline_stage, ...rest } = r; + return rest; + }) : rs); + useEffect(() => { let cancelled = false; const hydrateFundraisingState = async () => { @@ -5089,8 +5120,8 @@ setActiveView(incomingViews[0]?.id || 'view-main'); } setRemoteVersion(Number.isFinite(incomingVersion) && incomingVersion > 0 ? incomingVersion : 1); - lastSyncedSnapshotRef.current = JSON.stringify({ columns: incomingColumns, rows: incomingRows, views: incomingViews }); - localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: incomingColumns, rows: incomingRows })); + lastSyncedSnapshotRef.current = JSON.stringify({ columns: incomingColumns, rows: stripComputedRows(incomingRows), views: incomingViews }); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: incomingColumns, rows: stripComputedRows(incomingRows) })); localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(incomingViews)); } catch (err) { onShowToast(getErrorMessage(err, 'Using local fundraising data'), 'error'); @@ -5108,7 +5139,8 @@ useEffect(() => { if (!stateHydrated || !Number.isFinite(remoteVersion)) return; - const snapshot = JSON.stringify({ columns, rows, views }); + const persistRows = stripComputedRows(rows); + const snapshot = JSON.stringify({ columns, rows: persistRows, views }); if (snapshot === lastSyncedSnapshotRef.current) return; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); @@ -5117,7 +5149,7 @@ const response = await api('/api/fundraising/state', { method: 'PUT', body: JSON.stringify({ - grid: { columns, rows }, + grid: { columns, rows: persistRows }, views, expected_version: remoteVersion }) @@ -5136,7 +5168,7 @@ next_version: Number.isFinite(nextVersion) ? nextVersion : null }); lastSyncedSnapshotRef.current = snapshot; - localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns, rows })); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns, rows: persistRows })); localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(views)); } catch (err) { if (err?.status === 409) { @@ -5167,7 +5199,7 @@ } onShowToast(getErrorMessage(err, 'Failed to save fundraising state'), 'error'); pushImportDebugEvent('autosave-error', { message: getErrorMessage(err, 'save failed') }); - localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns, rows })); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns, rows: persistRows })); localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(views)); } }, 550); @@ -5724,7 +5756,11 @@ const openCreateOpportunityModal = (row) => { if (!row) return; const contacts = Array.isArray(row.contacts) ? row.contacts : []; - const defaultName = `${row.investor_name || 'Investor'} Opportunity`; + if (contacts.length === 0) { + onShowToast('Add at least one contact to this investor row before adding it to the pipeline', 'error'); + return; + } + const defaultName = `${row.investor_name || 'Investor'} — Pipeline`; setCreateOppContext({ rowId: row.id, investorName: row.investor_name || '', contacts }); setCreateOppForm({ name: defaultName, @@ -5737,78 +5773,65 @@ setShowCreateOppModal(true); }; + // Grid is canonical: the server resolves the contact from the row's already-synced + // contact pill (no POST /api/contacts side-door) and links the opp durably, so the + // bot/board can never spawn a duplicate. We only pass the seed. const submitCreateOpportunity = async () => { - if (!createOppContext?.investorName) return; + if (!createOppContext?.rowId) return; setCreateOppSubmitting(true); + const rowId = createOppContext.rowId; + const payload = { + source_row_id: rowId, + contact_index: Number(createOppForm.contactIndex) || 0, + name: String(createOppForm.name || '').trim(), + stage: createOppForm.stage || 'lead', + expected_amount: parseNumericInput(createOppForm.expected_amount), + probability: Number(createOppForm.probability) || 35, + fund_name: String(createOppForm.fund_name || '').trim() + }; + const doLink = () => api('/api/fundraising/pipeline/link', { method: 'POST', body: JSON.stringify(payload) }, token); try { - const contacts = Array.isArray(createOppContext.contacts) ? createOppContext.contacts : []; - const selected = contacts[createOppForm.contactIndex] || contacts[0] || null; - if (!selected) { - onShowToast('Add at least one contact on the investor row first', 'error'); - return; + let resp; + try { + resp = await doLink(); + } catch (err) { + // A row added moments ago may not have autosaved/synced yet — wait for the + // debounced save to flush, then retry once. + if (err?.status === 404) { + await new Promise((r) => setTimeout(r, 700)); + resp = await doLink(); + } else { + throw err; + } } - - const allContactsResp = await api('/api/contacts?limit=1000', {}, token); - const allContacts = Array.isArray(allContactsResp?.data) ? allContactsResp.data : []; - const selectedEmail = String(selected.email || '').trim().toLowerCase(); - const selectedName = String(selected.name || '').trim().toLowerCase(); - const investorName = String(createOppContext.investorName || '').trim().toLowerCase(); - - let matched = null; - if (selectedEmail) { - matched = allContacts.find((c) => String(c.email || '').trim().toLowerCase() === selectedEmail) || null; - } - if (!matched && selectedName) { - matched = allContacts.find((c) => { - const n = `${c.first_name || ''} ${c.last_name || ''}`.trim().toLowerCase(); - const org = String(c.organization_name || c.organization || '').trim().toLowerCase(); - return n === selectedName && org === investorName; - }) || null; - } - - let contactId = matched?.id || null; - if (!contactId) { - const split = splitFullName(selected.name || ''); - const created = await api('/api/contacts', { - method: 'POST', - body: JSON.stringify({ - first_name: split.first, - last_name: split.last, - email: selected.email || '', - title: selected.title || '', - organization: createOppContext.investorName, - contact_type: 'investor', - status: 'active' - }) - }, token); - contactId = created?.data?.id || null; - } - - if (!contactId) throw new Error('Could not resolve contact'); - - await api('/api/opportunities', { - method: 'POST', - body: JSON.stringify({ - name: String(createOppForm.name || '').trim() || `${createOppContext.investorName} Opportunity`, - contact_id: contactId, - stage: createOppForm.stage || 'lead', - expected_amount: parseNumericInput(createOppForm.expected_amount), - probability: Number(createOppForm.probability) || 35, - fund_name: String(createOppForm.fund_name || '').trim(), - priority: rows.find((r) => r.id === createOppContext.rowId)?.priority ? 'high' : 'medium' - }) - }, token); - - onShowToast('Pipeline opportunity created', 'success'); + const stage = resp?.data?.stage || payload.stage; + const already = resp?.already_linked; + setRows((prev) => prev.map((r) => (r.id === rowId ? { ...r, pipeline: true, pipeline_stage: stage } : r))); + onShowToast(already ? 'Already in the pipeline' : 'Added to the pipeline', already ? 'info' : 'success'); setShowCreateOppModal(false); setCreateOppContext(null); } catch (err) { - onShowToast(getErrorMessage(err, 'Failed to create pipeline opportunity'), 'error'); + onShowToast(getErrorMessage(err, 'Failed to add to the pipeline'), 'error'); } finally { setCreateOppSubmitting(false); } }; + // Remove from pipeline: archives (soft-deletes) the linked opportunity. The grid + // investor row — contacts, commitments, notes — is left fully intact. + const removeFromPipeline = async (row) => { + if (!row?.id) return; + const label = row.investor_name || 'this investor'; + if (!window.confirm(`Remove ${label} from the pipeline? The deal is archived (recoverable); the grid row is untouched.`)) return; + try { + await api('/api/fundraising/pipeline/unlink', { method: 'POST', body: JSON.stringify({ source_row_id: row.id }) }, token); + setRows((prev) => prev.map((r) => (r.id === row.id ? { ...r, pipeline: false, pipeline_stage: '' } : r))); + onShowToast('Removed from the pipeline', 'success'); + } catch (err) { + onShowToast(getErrorMessage(err, 'Failed to remove from the pipeline'), 'error'); + } + }; + const addRow = () => { const base = buildEmptyRow(); setRows((prev) => [base, ...prev]); @@ -6109,10 +6132,11 @@ const latest = await api('/api/fundraising/state', {}, token); expectedVersion = Number(latest?.data?.version); } + const persistRows = stripComputedRows(nextRows); const saveResponse = await api('/api/fundraising/state', { method: 'PUT', body: JSON.stringify({ - grid: { columns: combinedColumns, rows: nextRows }, + grid: { columns: combinedColumns, rows: persistRows }, views: nextViews, expected_version: Number.isFinite(expectedVersion) && expectedVersion > 0 ? expectedVersion : 1 }) @@ -6121,8 +6145,8 @@ if (Number.isFinite(persistedVersion) && persistedVersion > 0) { setRemoteVersion(persistedVersion); } - lastSyncedSnapshotRef.current = JSON.stringify({ columns: combinedColumns, rows: nextRows, views: nextViews }); - localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: combinedColumns, rows: nextRows })); + lastSyncedSnapshotRef.current = JSON.stringify({ columns: combinedColumns, rows: persistRows, views: nextViews }); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ columns: combinedColumns, rows: persistRows })); localStorage.setItem(FUNDRAISING_VIEWS_STORAGE_KEY, JSON.stringify(nextViews)); pushImportDebugEvent('import-persist-success', { persisted_version: Number.isFinite(persistedVersion) ? persistedVersion : null, @@ -6402,6 +6426,37 @@ if (typeof computed === 'number' && Number.isFinite(computed)) return computed.toLocaleString(); return String(computed ?? ''); } + if (col.id === 'pipeline') { + if (row.pipeline) { + return ( + + ); + } + return ( + + ); + } + if (col.id === 'pipeline_stage') { + const stage = String(row.pipeline_stage || ''); + if (!stage) return ; + return {stage.replace(/_/g, ' ')}; + } if (col.type === 'action' || col.id === 'log_action') { return ( diff --git a/start9/0.4/startos/utils.ts b/start9/0.4/startos/utils.ts index df5f384..8d38937 100644 --- a/start9/0.4/startos/utils.ts +++ b/start9/0.4/startos/utils.ts @@ -51,8 +51,9 @@ export const PACKAGE_TITLE = 'Ten31 Database' // * 0.1.0:83 (email search/query + windowed digest preview, code-only: Communications investor dropdown now mirrors the list with typed keys [fund:/org:/contact:] so classic-contact/org-domain matches show + are pickable [fixes the empty-dropdown bug], plus a date-range filter, a click-to-expand full-body view [GET /api/email/detail], and a semantic "Search content" mode over indexed email bodies [GET /api/email/search -> ingest hybrid_search, soft-delete-filtered, 503 if Spark/Qdrant down]; Daily Digest gains an in-app windowed preview before send [POST /api/admin/digest/preview, send-now takes the same window] that exercises the real Spark summarizer without touching the daily cursor) // * 0.1.0:84 (Matrix intake bot CRM support — ships the server side of commit 7ad0ee7, which was never packaged: new read-only GET /api/intake/match [new-vs-existing lookup against the canonical fundraising grid blob; returns the grid row id so an approved note lands on the matched investor, no duplicate] + source provenance on POST /api/fundraising/log-communication [audit records source, default "fundraising_grid"]; code-only, no schema change) // * 0.1.0:85 (cosmetic: drop the redundant "[note]" tag from the fundraising-grid note line — now "YYYY-MM-DD Contact: summary"; informative comm types [call, meeting, …] keep their "[type]" tag; shared by the Matrix intake bot + grid-UI logging; no schema change) -// * Current: 0.1.0:86 (Matrix intake fuzzy matching: GET /api/intake/match now returns ranked `candidates` [fuzzy near-matches — deterministic difflib name similarity + token overlap + email edit-distance ≤ 2, legal-suffix-aware] alongside the exact `match`, so the bot can surface near-duplicates ["Charlie"/"Charles", "Acme Capital"/"Acme Capital LLC", a one-char email typo] for human confirmation instead of silently creating a second investor; the bot-side disambiguation + conversational-edit UX ships on the Spark, not the s9pk; code-only, no schema change) -export const PACKAGE_VERSION = '0.1.0:86' +// * 0.1.0:86 (Matrix intake fuzzy matching: GET /api/intake/match now returns ranked `candidates` [fuzzy near-matches — deterministic difflib name similarity + token overlap + email edit-distance ≤ 2, legal-suffix-aware] alongside the exact `match`, so the bot can surface near-duplicates ["Charlie"/"Charles", "Acme Capital"/"Acme Capital LLC", a one-char email typo] for human confirmation instead of silently creating a second investor; the bot-side disambiguation + conversational-edit UX ships on the Spark, not the s9pk; code-only, no schema change) +// * Current: 0.1.0:87 (Adopt the Pipeline — grid drives the deal board: new "Add to Pipeline" row action creates+links an opportunity via opportunities.fundraising_investor_id [migration 0005, additive], reusing the grid's synced contact [no POST /api/contacts side-door] and mapping the grid lead→owner; idempotent [one live opp/investor, re-link never reseeds board-owned stage/probability]; read-only Pipeline + Pipeline Stage grid columns derived live from the linked opp; "Remove from Pipeline" soft-deletes the opp [grid row untouched]; deleting a grid investor archives its orphaned opp; folds in the soft-delete fix for the pipeline report + dashboard aggregates [archived opps no longer counted]) +export const PACKAGE_VERSION = '0.1.0:87' export const DATA_MOUNT_PATH = '/data' export const WEB_PORT = 8080 diff --git a/start9/0.4/startos/versions/index.ts b/start9/0.4/startos/versions/index.ts index 91c77f0..a27a422 100644 --- a/start9/0.4/startos/versions/index.ts +++ b/start9/0.4/startos/versions/index.ts @@ -47,8 +47,9 @@ import { v_0_1_0_83 } from './v0.1.0.83' import { v_0_1_0_84 } from './v0.1.0.84' import { v_0_1_0_85 } from './v0.1.0.85' import { v_0_1_0_86 } from './v0.1.0.86' +import { v_0_1_0_87 } from './v0.1.0.87' export const versionGraph = VersionGraph.of({ - current: v_0_1_0_86, - other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85], + current: v_0_1_0_87, + other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86], }) diff --git a/start9/0.4/startos/versions/v0.1.0.87.ts b/start9/0.4/startos/versions/v0.1.0.87.ts new file mode 100644 index 0000000..3a61951 --- /dev/null +++ b/start9/0.4/startos/versions/v0.1.0.87.ts @@ -0,0 +1,25 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +// Adopt the Pipeline — the fundraising grid (canonical) now drives the deal board. +// An "Add to Pipeline" row action creates and durably links an opportunity via the new +// opportunities.fundraising_investor_id column (backend migration 0005, additive + +// reversible — applied at app startup by core_migrations, so this StartOS migration is a +// no-op). The link reuses the grid's already-synced contact (no POST /api/contacts +// side-door) and maps the grid `lead` → owner; it is idempotent (one live opp per +// investor; a re-link never reseeds the board-owned stage/probability). Two read-only +// grid columns — Pipeline + Pipeline Stage — are derived live from the linked opp. +// "Remove from Pipeline" soft-deletes the opp (the grid row is untouched); deleting an +// investor from the grid archives its orphaned opp. Also folds in the soft-delete fix for +// the pipeline report + dashboard aggregates (archived opps are no longer counted). +export const v_0_1_0_87 = VersionInfo.of({ + version: '0.1.0:87', + releaseNotes: { + en_US: [ + 'Adopt the Pipeline: flag an investor in the fundraising grid as a deal and it', + 'creates a linked opportunity on the Pipeline board — no duplicate data entry, the', + 'board owns the stage/odds, and a read-only Pipeline Stage column mirrors it back', + 'into the grid. Removing from the pipeline archives the deal but leaves the grid intact.', + ].join(' '), + }, + migrations: { up: async () => {}, down: async () => {} }, +})