Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/territory-sharing-billing-country.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'hotcrm': patch
---

Fix territory sharing: the North America and Europe rules now actually grant
access. Both were declared against `record.billing_address.country`, a path
that reaches inside the structured Billing Address value. A sharing rule's
criteria have to compile into a database query, and a query cannot reach inside
a composite address — so the platform refused to install either rule (correctly
preferring that to widening them to "every account"), and `na_sales_team` /
`eu_sales_team` received no criteria-based account access at all while the
metadata and the admin docs said they did. The only sign was a WARN in the boot
log: `seeded: 7, skipped: 2, total: 9`.

Accounts now carry **Billing Country**, a read-only two-letter code projected
from the country you enter in Billing Address and maintained on every write, and
the two territory rules match on it. Territory membership is unchanged — the
same countries, read from a queryable column instead of from inside the address
— and the field is shown on the account's *Locations* section so an admin can
see at a glance why a territory team does or does not have an account. Enter the
billing country as its two-letter code (`US`, `DE`, …); a country spelled out in
full puts the account in no territory.

For anyone writing their own rules: **criteria may only filter on plain fields**,
never on part of an Address or Location value. `test/sharing-seeding.test.ts`
now compiles every declared rule with the platform's own compiler and fails the
build if any of them would be dropped at boot, so a rule can no longer ship
inert. Fixes #621.
11 changes: 11 additions & 0 deletions content/docs/administration/sharing-and-security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ Criteria-based rules are the enforced flavour: matching records materialise real

Create your own in **Setup → Sharing Settings**.

> **Criteria filter on plain fields, never on part of an Address.** The two
> territory rules match on the account's **Billing Country** — a read-only
> two-letter code derived from the country you type into **Billing Address** —
> and not on the address itself. A criteria rule has to run as a database
> query, and a query cannot reach inside a structured Address or Location
> value. A rule written against `Billing Address → Country` is therefore
> **rejected outright** rather than quietly widened to "every account": it is
> never installed, and the position it names receives nothing. Nothing in the
> UI marks such a rule as broken, so when a team reports missing records, check
> that every field the rule names is a plain field on the object.

### A rule widens one object, not the records underneath it

Sharing rules are authored **per object**. Widening `Account` widens accounts — the records hanging off a shared account keep their own baseline, and Contact is the only one derived from it:
Expand Down
2 changes: 2 additions & 0 deletions content/docs/administration/sharing-and-security.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ na_sales_team eu_sales_team (区域分组)

在 **设置 → 共享设置** 中创建你自己的规则。

> **条件只能过滤普通字段,不能过滤地址的某一部分。** 两条区域规则匹配的是客户上的 **账单国家** —— 一个只读的两位国家代码,由你在 **账单地址** 中填写的国家推导而来 —— 而不是地址本身。条件规则必须以数据库查询的形式运行,而查询无法深入结构化的地址或位置值内部。因此,针对 `账单地址 → 国家` 编写的规则会被**直接拒绝**,而不是被悄悄放宽成"所有客户":它根本不会被安装,它指定的岗位也就什么都拿不到。界面上不会把这样的规则标记为失效,所以当某个团队反馈记录缺失时,请检查规则里引用的每个字段是否都是该对象上的普通字段。

### 一条规则放开的是一个对象,而不是它下面的记录

共享规则是 **按对象** 编写的。放开 `Account` 只放开客户本身 —— 挂在这个客户下的记录仍然各自守着自己的基线,其中只有联系人是从客户派生的:
Expand Down
2 changes: 2 additions & 0 deletions content/docs/administration/sharing-and-security.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ na_sales_team eu_sales_team (區域分組)

在 **設定 → 共用設定** 中建立你自己的規則。

> **條件只能過濾普通欄位,不能過濾地址的某一部分。** 兩條區域規則比對的是客戶上的 **帳單國家** —— 一個唯讀的兩位國家代碼,由你在 **帳單地址** 中填寫的國家推導而來 —— 而不是地址本身。條件規則必須以資料庫查詢的形式執行,而查詢無法深入結構化的地址或位置值內部。因此,針對 `帳單地址 → 國家` 撰寫的規則會被**直接拒絕**,而不是被悄悄放寬成「所有客戶」:它根本不會被安裝,它指定的職位也就什麼都拿不到。介面上不會把這樣的規則標記為失效,所以當某個團隊回報記錄缺失時,請檢查規則裡引用的每個欄位是否都是該物件上的普通欄位。

### 一條規則放開的是一個物件,而不是它底下的記錄

共用規則是 **按物件** 撰寫的。放開 `Account` 只放開客戶本身 —— 掛在這個客戶底下的記錄仍各自守著自己的基線,其中只有聯絡人是從客戶衍生的:
Expand Down
8 changes: 8 additions & 0 deletions content/docs/sales/accounts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ On top of that:
- North America (US, CA, MX) → North America sales team
- Europe (UK, DE, FR, IT, ES) → Europe sales team

The territory rules match on **Billing Country**, a read-only field on the
account that is filled in from the country you enter in **Billing Address**.
Enter the country as its two-letter code (`US`, `DE`, …) — that is what the
rules compare against, so an account whose billing country reads
"United States" lands in no territory. The field is on the *Locations* section
of the account, which makes it the first thing to check when a territory team
says an account is missing from their list.

Admins can change these rules — see [Administration › Sharing](/docs/administration/sharing-and-security).

## Who can edit what
Expand Down
67 changes: 34 additions & 33 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
},
"devDependencies": {
"@changesets/cli": "^2.31.1",
"@objectstack/formula": "17.0.0-rc.1",
"@playwright/test": "^1.61.1",
"@vitest/coverage-v8": "^4.1.10",
"tsx": "^4.23.1",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 39 additions & 0 deletions src/objects/account.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type { HookApi } from './_hook-api';
* Account protection hook.
*
* - Validates `website` format and `annual_revenue` non-negative.
* - Projects `billing_address.country` onto the flat `billing_country` column
* the territory sharing rules filter on (#621).
* - Refuses to delete a `customer` account that still has open opportunities.
*/
const accountHook: Hook = {
Expand All @@ -28,6 +30,43 @@ const accountHook: Hook = {
if (typeof input.annual_revenue === 'number' && input.annual_revenue < 0) {
throw new Error('Annual Revenue must be greater than or equal to 0');
}

// ─── Territory projection (#621) ───────────────────────────────────
//
// `billing_country` is the flat column the two territory sharing rules
// filter on, and this block is its only writer. It exists because a
// sharing rule's CEL condition is compiled into a pushdown-able query
// filter, and that compiler rejects any path reaching INSIDE a composite
// `address` value — `record.billing_address.country in [...]` is not
// translatable, so plugin-sharing dropped both rules on every boot and
// `na_sales_team` / `eu_sales_team` got nothing at all.
//
// Recompute ONLY when the write carries the address: a partial update
// that never mentions `billing_address` must leave `billing_country`
// alone, or every unrelated edit would blank the column and silently
// evict the account from its territory. A write that CLEARS the address
// (`billing_address: null`) does clear the projection — the key is
// present, the value is empty.
//
// Only `country` is read. `countryCode` is the ISO 3166-1 alpha-2 slot,
// where the United Kingdom is `GB`, while the Europe rule is authored
// against `UK`; preferring the ISO slot would silently drop UK accounts
// out of their own territory. Mirroring the one slot the rules have
// always named keeps this a change of STORAGE LOCATION, not of rule
// semantics.
//
// Written inline rather than as a module-scope helper on purpose: hook
// bodies must lower to metadata-only (no free identifiers), which
// `test/action-sandbox.test.ts` enforces for every registered hook.
if ('billing_address' in input) {
const address = input.billing_address;
const country =
address !== null && typeof address === 'object' && !Array.isArray(address)
? (address as { country?: unknown }).country
: undefined;
const normalized = typeof country === 'string' ? country.trim().toUpperCase() : '';
input.billing_country = normalized === '' ? null : normalized;
}
}

// Stamp last_activity_date when ownership or type changes (migrated from the
Expand Down
51 changes: 51 additions & 0 deletions src/objects/account.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,54 @@ export const Account = ObjectSchema.create({
group: 'contact_info',
}),

/**
* Flat projection of `billing_address.country` — the column the territory
* sharing rules filter on (#621).
*
* ### Why this field exists
*
* `billing_address` is an `address` field: the platform stores the whole
* {street, city, state, postalCode, country, countryCode, formatted}
* value in ONE column. A sharing rule's CEL condition is compiled to a
* pushdown-able `FilterCondition` by `compileCelToFilter`, and that
* compiler rejects every path that reaches INSIDE such a value:
*
* record.billing_address.country in ["US","CA","MX"]
* → unsupported: cross-object/nested field path
* "record.billing_address.country" is not pushdown-able
*
* `plugin-sharing` then refuses to seed the rule rather than degrade it to
* match-all, so both territory rules were dropped on every boot and
* `na_sales_team` / `eu_sales_team` received nothing at all. Measured: the
* blocker is the NESTED PATH, not the `in [...]` operator — `in [...]`,
* `==`, `!=`, `<`, `>`, `&&`, `||`, `!`, `startsWith()` and `== null` all
* compile fine against a FLAT field. Rewriting the condition as a
* disjunction of `==` (issue #621 option A) would therefore NOT have
* helped; only a flat column does. See `test/sharing-seeding.test.ts`,
* which measures that matrix instead of assuming it.
*
* ### What it holds
*
* `billing_address.country`, trimmed and upper-cased — nothing else.
* `countryCode` is deliberately NOT consulted: it carries ISO 3166-1
* alpha-2, where the United Kingdom is `GB`, while the Europe rule is
* authored against `UK`. Preferring the ISO slot would silently drop UK
* accounts out of the EU territory, so this projection mirrors exactly the
* one slot the rules have always named and changes no rule semantics.
*
* Derived, never authored: `account.hook.ts` recomputes it on every write
* that carries `billing_address`, and leaves it untouched on every write
* that does not.
*/
billing_country: Field.text({
label: 'Billing Country',
description:
'Derived from Billing Address — the country code territory sharing rules match on. Enter the country as a 2-letter code (US, DE, …) in the address.',
readonly: true,
maxLength: 64,
group: 'contact_info',
}),

// Office Location (new field type)
office_location: Field.location({
label: 'Office Location',
Expand Down Expand Up @@ -223,6 +271,9 @@ export const Account = ObjectSchema.create({
{ fields: ['name'], unique: true },
{ fields: ['owner'] },
{ fields: ['type', 'is_active'] },
// The territory sharing rules filter on this column, so it is read on
// every account query a territory recipient makes (#621).
{ fields: ['billing_country'] },
],

// API surface + capabilities. `trash` / `mru` were removed in @objectstack 12
Expand Down
Loading
Loading