From 7ec609adaae41911d30905dba017afa87e507e98 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 21 Jul 2026 02:03:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(hooks):=20freeze-guards=20skip=20system/see?= =?UTF-8?q?d=20writes=20=E2=80=94=20fixes=2023=20boot=20errors=20+=20close?= =?UTF-8?q?d-won=20probability=20(2.2.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #459. The opportunity_lifecycle and quote freeze-guards ran on every write to a closed/accepted record. The seed re-applies rows on each reboot, and its `close_date: daysAgo(15)` / quote dates re-evaluate to a *new* date every boot, so the re-seed genuinely changes a guarded field on an already-closed record — which the guard rejected. Result: 23 `BodyRunner` errors per boot, and the seed could never set closed-won `probability` to 100 (it fell back to the field default 10). Both guards now fire ONLY for genuine user edits (`ctx.user?.id` present). System / seed / backfill writes (no user) pass — matching this repo's system-write convention (case/lead hooks) and the guards' own stated intent. A user editing a closed opportunity / accepted quote via the UI is still blocked. Verified: reboot on the existing (persisted) DB — the exact 23-error scenario — now logs 0 BodyRunner errors, and all closed_won opps report probability 100 (open stages keep their stage-derived values). verify (validate/build/test 17/17) green. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 ++++++++ objectstack.config.ts | 2 +- package.json | 2 +- src/objects/opportunity.hook.ts | 20 ++++++++++++-------- src/objects/quote.hook.ts | 7 ++++++- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa37d4a4..561c5b14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to HotCRM are documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); HotCRM follows [Semantic Versioning](https://semver.org/). +## [2.2.2] — 2026-07-21 + +Patch before the ObjectStack 16 marketplace release. Fixes [#459](https://github.com/objectstack-ai/hotcrm/issues/459) — the highest-severity issue from the v2.2.1 QA dogfood. + +### Fixed + +- **Opportunity/quote freeze-guards no longer reject system/seed writes → 23 boot errors gone + closed-won probability corrected.** The `opportunity_lifecycle` and quote freeze-guards ran on **every** write to a closed/accepted record, so the seed re-applying rows on reboot (its `close_date: daysAgo(15)` / `quote_date` re-evaluate to a *new* date each boot) was rejected — logging 23 `BodyRunner` errors per boot and blocking the seed from setting closed-won `probability` to 100 (it fell back to the field default `10`). Both guards now fire **only for genuine user edits** (`ctx.user?.id` present); system/seed/backfill writes (no user) pass — matching this repo's system-write convention (`case`/`lead` hooks) and the guards' own stated intent. A user editing a closed opportunity or accepted quote through the UI is still blocked. Fixes [opportunity.hook.ts](src/objects/opportunity.hook.ts) + [quote.hook.ts](src/objects/quote.hook.ts). + ## [2.2.1] — 2026-07-20 Follow-up patch to 2.2.0. The dashboard-filter fix in 2.2.0 only covered the built-in `dateRange` picker; the **`globalFilters[]`** have the same propagation behaviour (ObjectStack 15 / framework#2501 injects every dashboard filter into each widget's query) and were still crashing widgets on objects that lack the filtered field. Browser-verified by actually selecting the filter values, not just loading the dashboards. diff --git a/objectstack.config.ts b/objectstack.config.ts index 558ac4ce..effcfafd 100644 --- a/objectstack.config.ts +++ b/objectstack.config.ts @@ -31,7 +31,7 @@ export default defineStack({ manifest: { id: 'app.objectstack.hotcrm', namespace: 'crm', - version: '2.2.1', + version: '2.2.2', type: 'app', name: 'HotCRM', description: 'AI-Native CRM for the ObjectStack marketplace — Accounts, Contacts, Leads, Opportunities, Cases, Knowledge, Forecasts, Campaigns, Contracts.', diff --git a/package.json b/package.json index 50dbdc7d..f4cbea8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hotcrm", - "version": "2.2.1", + "version": "2.2.2", "description": "HotCRM - Production CRM built on ObjectStack", "private": true, "main": "./objectstack.config.ts", diff --git a/src/objects/opportunity.hook.ts b/src/objects/opportunity.hook.ts index 79c0eb72..086c0adb 100644 --- a/src/objects/opportunity.hook.ts +++ b/src/objects/opportunity.hook.ts @@ -58,14 +58,18 @@ const opportunityValidationHook: Hook = { const { event, input } = ctx; const previous = ctx.previous; - // Freeze closed opportunities FIRST, judging only the caller's actual - // edits. This must run before the derived-field recompute below injects - // expected_revenue/probability into `input` — otherwise a system write - // (e.g. the post-seed ownership backfill) on a record whose stored - // derived values are stale gets rejected for fields the caller never - // touched, and the backfill silently fails (seen as 23 boot-time - // BodyRunner errors on every fresh-DB boot). - if (event === 'beforeUpdate' && previous) { + // Freeze closed opportunities — but guard ONLY genuine USER edits. A write + // with no authenticated user (`ctx.user?.id` absent) is a system / seed / + // backfill write and must pass: the seed's `close_date: daysAgo(15)` + // re-evaluates to a new date on every reboot, so a re-seed legitimately + // changes close_date on already-closed opps. Guarding those threw 23 + // boot-time BodyRunner errors AND blocked the seed from correcting + // closed-won `probability` to 100 (#459). `ctx.user?.id` is this repo's + // system-write signal (cf. case/lead/quote hooks) and matches the + // SYSTEM_FIELDS intent above ("only user edits to business fields"). + // Still runs before the derived-field recompute below so a genuine user + // edit is judged on the caller's own fields, not injected ones. + if (event === 'beforeUpdate' && previous && ctx.user?.id) { const prevStage = previous.stage as string | undefined; const isClosed = prevStage === 'closed_won' || prevStage === 'closed_lost'; if (isClosed) { diff --git a/src/objects/quote.hook.ts b/src/objects/quote.hook.ts index 518b7dcd..9e36f0f2 100644 --- a/src/objects/quote.hook.ts +++ b/src/objects/quote.hook.ts @@ -41,7 +41,12 @@ const quoteValidation: Hook = { input.expiration_date = addDays(base, 30); } - if (event === 'beforeUpdate' && previous) { + // Guard ONLY genuine USER edits (`ctx.user?.id` present). System / seed / + // backfill writes carry no user and legitimately re-apply business fields + // (the seed's quote_date/expiration_date re-evaluate on every reboot), so + // guarding them threw boot-time BodyRunner errors (#459). Matches the + // system-write convention used across the case/lead/opportunity hooks. + if (event === 'beforeUpdate' && previous && ctx.user?.id) { const frozen = previous.status === 'accepted' || previous.status === 'expired'; if (frozen) { const allowed = new Set(['internal_notes']);