Adopt the Pipeline: grid-driven opportunities link (v0.1.0:87)

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.
This commit is contained in:
Keysat
2026-06-17 23:08:36 -05:00
parent 06482247df
commit 7f9a15ebf3
10 changed files with 724 additions and 89 deletions
+6 -4
View File
@@ -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); v8083 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.
+5 -2
View File
@@ -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.
@@ -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
@@ -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);
+249 -5
View File
@@ -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()
+270
View File
@@ -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()
+133 -73
View File
@@ -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);
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;
}
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,
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(),
priority: rows.find((r) => r.id === createOppContext.rowId)?.priority ? 'high' : 'medium'
})
}, token);
onShowToast('Pipeline opportunity created', 'success');
fund_name: String(createOppForm.fund_name || '').trim()
};
const doLink = () => api('/api/fundraising/pipeline/link', { method: 'POST', body: JSON.stringify(payload) }, token);
try {
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 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 (
<button
type="button"
className="button-secondary"
style={{ padding: '5px 10px', fontSize: '12px', borderColor: '#2f6f4f', color: '#7fd3a3' }}
title="In the deal pipeline — click to remove (archives the deal; grid row stays)"
onClick={(e) => { e.stopPropagation(); removeFromPipeline(row); }}
>
✓ In pipeline
</button>
);
}
return (
<button
type="button"
className="button-secondary"
style={{ padding: '5px 10px', fontSize: '12px' }}
title="Add this investor to the deal pipeline"
onClick={(e) => { e.stopPropagation(); openCreateOpportunityModal(row); }}
>
+ Pipeline
</button>
);
}
if (col.id === 'pipeline_stage') {
const stage = String(row.pipeline_stage || '');
if (!stage) return <span style={{ color: '#70859b' }}></span>;
return <span style={{ textTransform: 'capitalize' }}>{stage.replace(/_/g, ' ')}</span>;
}
if (col.type === 'action' || col.id === 'log_action') {
return (
<button
@@ -7315,7 +7370,7 @@
{showCreateOppModal && (
<div className="modal-overlay">
<div className="modal">
<div className="modal-header">Create Pipeline Opportunity</div>
<div className="modal-header">Add to Pipeline</div>
<div style={{ fontSize: '12px', color: '#8ea2b7', marginBottom: '12px' }}>
{createOppContext?.investorName || 'Investor'}
</div>
@@ -7357,13 +7412,18 @@
<input type="number" min="0" max="100" className="text-input" value={createOppForm.probability} onChange={(e) => setCreateOppForm((f) => ({ ...f, probability: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">Fund Name (optional)</label>
<input className="text-input" value={createOppForm.fund_name} onChange={(e) => setCreateOppForm((f) => ({ ...f, fund_name: e.target.value }))} />
<label className="form-label">Fund (optional)</label>
<select className="select-input" value={createOppForm.fund_name} onChange={(e) => setCreateOppForm((f) => ({ ...f, fund_name: e.target.value }))}>
<option value="">— None —</option>
{columns.filter((c) => c.isFund).map((c) => (
<option key={c.id} value={c.label}>{c.label}</option>
))}
</select>
</div>
<div className="form-actions">
<button type="button" className="button-secondary" onClick={() => setShowCreateOppModal(false)}>Cancel</button>
<button type="button" onClick={submitCreateOpportunity} disabled={createOppSubmitting}>
{createOppSubmitting ? <Spinner /> : 'Create'}
{createOppSubmitting ? <Spinner /> : 'Add to Pipeline'}
</button>
</div>
</div>
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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],
})
+25
View File
@@ -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 () => {} },
})