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
41 changes: 41 additions & 0 deletions .changeset/account-name-unique-per-tenant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
'hotcrm': patch
---

Account names are now unique **per organization** instead of platform-wide, so two organizations can each have an "Acme Corp".

`crm_account` was the last core object still spelling uniqueness as a table-level
declared index — `indexes: [{ fields: ['name'], unique: true }]`. A declared
index is materialized over exactly its `fields`, i.e. platform-wide, while
field-level `unique: true` has been tenant-scoped since framework #3696. The
physical constraint was therefore `UNIQUE (name)`, and the SECOND organization to
create an account called "Acme Corp" was refused by the database. Account name is
also the seed data's external-id / upsert key, so this bit on the very first
multi-tenant install, before anyone had typed a record.

The declaration moves onto the field, matching `crm_contact.email` and
`crm_product.sku`, and the table-level entry is **removed** rather than kept
alongside it: declaring both leaves the platform-wide index enforcing the old
behaviour and the per-tenant composite unreachable (framework#3991
`unique/double-declaration`) — the fix would have looked applied and done
nothing. A freshly migrated database now carries
`uniq_crm_account_organization_id_name (organization_id, name)`, which also
indexes the column for `searchableFields` and the seed upsert.

Uniqueness within one organization is unchanged: a second "Acme Corp" in the same
org is still rejected.

**Upgrading an existing deployment.** Every HotCRM install today is a fresh one,
where the database is built from current metadata and the platform-wide
`uniq_crm_account_name` index is simply never created. If an installation with an
existing populated database ever needs this version, the old index must be
dropped with one `os migrate apply --allow-destructive` run: it is strictly
tighter than the new composite, so while it survives it keeps enforcing the
platform-wide rule and **this fix silently does nothing**. The boot-time
reconciler creates the new index (`create_index`, `safe`) but skips the drop
(`drop_index`, `destructive`) — see `docs/MAINTENANCE.md` §3.1.

Note that account names are matched exactly, so `Acme Corp` and `ACME Corp` are
two different accounts; the user documentation no longer claims otherwise.

Fixes #625.
2 changes: 1 addition & 1 deletion content/docs/sales/accounts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ The hierarchy gives you:

## Built-in rules

- The account **name must be unique** across the system (case-insensitive). You'll get an error if you try to create a duplicate.
- The account **name must be unique within your organization**. You'll get an error if you try to create a second account with a name you already use. Another organization on the same platform is free to have its own "Acme Corp" — the rule is scoped to your data, not the whole platform. Names are compared exactly as typed, so `Acme Corp` and `ACME Corp` currently count as two different accounts.
- **Annual revenue must be zero or positive** — no negative numbers allowed.
- You **cannot delete** an account that has open opportunities or active contracts. Set it to *Inactive* instead (`is_active = false`).

Expand Down
2 changes: 1 addition & 1 deletion content/docs/sales/accounts.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Acme Corp Global

## 内置规则

- 客户**名称在整个系统中必须唯一**(不区分大小写)。如果你尝试创建重复项,会收到错误。
- 客户**名称在你所在的组织内必须唯一**。如果你尝试用一个已存在的名称再建一个客户,会收到错误。同一平台上的其他组织可以有自己的 "Acme Corp" —— 这条规则只作用于你自己的数据,而不是整个平台。名称按原样精确比较,因此 `Acme Corp` 与 `ACME Corp` 目前算作两个不同的客户
- **年营收必须为零或正数**——不允许负数。
- 你**无法删除**一个有开放商机或活跃合同的客户。改为将其设为*非活跃*(`is_active = false`)。

Expand Down
2 changes: 1 addition & 1 deletion content/docs/sales/accounts.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Acme Corp Global

## 內建規則

- 客戶**名稱在整個系統中必須唯一**(不區分大小寫)。如果你嘗試建立重複項,會收到錯誤。
- 客戶**名稱在你所屬的組織內必須唯一**。如果你嘗試用一個已存在的名稱再建一個客戶,會收到錯誤。同一平台上的其他組織可以有自己的 "Acme Corp" —— 這條規則只作用於你自己的資料,而不是整個平台。名稱按原樣精確比較,因此 `Acme Corp` 與 `ACME Corp` 目前算作兩個不同的客戶
- **年營收必須為零或正數**——不允許負數。
- 你**無法刪除**一個有開放商機或活躍合約的客戶。改為將其設為*非活躍*(`is_active = false`)。

Expand Down
29 changes: 24 additions & 5 deletions src/objects/account.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,25 @@ export const Account = ObjectSchema.create({
}),

// Basic Information
//
// `unique: true` is declared HERE, on the field, and deliberately NOT as a
// `{ fields: ['name'], unique: true }` entry in `indexes[]` below (#625).
// Since framework #3696 the field-level form is tenant-scoped — it
// materializes as `(organization_id, name)`, unique WITHIN an organization —
// while a declared index is taken verbatim, i.e. platform-wide. The table
// form is what this object used to carry, and it meant the SECOND
// organization to create an "Acme Corp" was rejected by the database.
// Account name is also the seed data's external-id / upsert key
// (`src/data/sales.seed.ts`), so that bit the very first multi-tenant
// install. The composite also indexes the column, so no separate
// `{ fields: ['name'] }` entry is needed for the `searchableFields` /
// seed-upsert read paths.
name: Field.text({
label: 'Account Name',
required: true,
storage: { notNull: true },
searchable: true,
unique: true,
maxLength: 255,
group: 'basic',
}),
Expand Down Expand Up @@ -264,11 +278,15 @@ export const Account = ObjectSchema.create({
},

// Database indexes for performance
//
// No `{ fields: ['name'], unique: true }` here (#625). Account-name
// uniqueness is declared on the `name` field itself, which since framework
// #3696 builds the tenant composite `(organization_id, name)`. Declaring the
// single-column index too makes the platform-wide constraint win and leaves
// the per-tenant one unreachable (framework#3991 `unique/double-declaration`)
// — the same trap `crm_contact`, `crm_lead` and `crm_product` document. Two
// organizations must be able to each have their own "Acme Corp".
indexes: [
// Account name is the external id / upsert key (see src/data) and must be
// unique. In 7.6 uniqueness is expressed as a unique index — the standalone
// `type: 'unique'` validation rule was removed (ADR-0032 validation union).
{ fields: ['name'], unique: true },
{ fields: ['owner'] },
{ fields: ['type', 'is_active'] },
// The territory sharing rules filter on this column, so it is read on
Expand All @@ -294,7 +312,8 @@ export const Account = ObjectSchema.create({
// This object declares none. Two entries used to live here:
//
// - `account_name_unique` (type: 'unique') was removed in 7.6 — uniqueness
// now lives on the `name` index above (unique: true).
// now lives on the `name` field above (`unique: true`), which the driver
// materializes as the per-tenant `(organization_id, name)` index (#625).
// - `revenue_positive` was removed in #514 (item 7) as a duplicate. It
// restated a check `account.hook.ts` already performs on beforeInsert /
// beforeUpdate, and the two disagreed in wording: the validation said
Expand Down
158 changes: 158 additions & 0 deletions test/account-name-tenant-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { applySystemFields } from '@objectstack/objectql';
import { expectedIndexes } from '@objectstack/driver-sql';
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
import stack from '../objectstack.config';

/**
* Account-name uniqueness is PER ORGANIZATION, not platform-wide (#625).
*
* `crm_account` used to spell the constraint as a table-level declared index,
* `indexes: [{ fields: ['name'], unique: true }]`. `driver-sql` keeps a
* declared index's columns verbatim — only FIELD-level `unique: true` gets the
* tenant composite `(organization_id, ...)` (framework#3696). So the physical
* index was `UNIQUE (name)` and the SECOND organization to create an account
* called "Acme Corp" was rejected by the database. Account name is also the
* seed data's external-id / upsert key, so it bit the first multi-tenant
* install. `crm_contact.email`, `crm_lead.email` and `crm_product.sku` already
* carry the field-level spelling and document the trap; `crm_account` did not.
*
* The two acceptance criteria are exercised against a REAL SQLite database
* driven by the REAL `crm_account` metadata, not against the metadata alone: a
* declaration is not a constraint until the driver turns it into DDL, and the
* whole defect was a mismatch between the two. The metadata assertions below
* exist to name WHICH spelling is required, so a future edit that reverts to
* the table form fails with the reason rather than a bare SQL error.
*/

type AnyRec = Record<string, any>;

const objects: AnyRec[] = (stack as any).objects ?? [];
const account = objects.find((o) => o.name === 'crm_account') as AnyRec;

/** What the runtime actually hands the driver on a multi-org stack. */
const orgScoped = applySystemFields(account as any, { multiTenant: true }) as AnyRec;

/** Columns that get materialized — formula fields never do (`fieldHasColumn`). */
const physicalColumns = new Set<string>([
'id',
...Object.entries(orgScoped.fields as Record<string, AnyRec>)
.filter(([, f]) => (f?.type ?? 'string') !== 'formula')
.map(([name]) => name),
]);

// ───────────────────────────────────── the shape the rule is declared in ──

describe('crm_account declares name uniqueness on the FIELD', () => {
it('carries field-level `unique: true` on name', () => {
expect(account.fields.name.unique).toBe(true);
});

it('declares NO single-column unique index on name', () => {
// framework#3991 `unique/double-declaration`: declaring both makes the
// platform-wide index win and leaves the per-tenant composite unreachable,
// so the field-level declaration above would silently do nothing.
const uniqueIndexes = ((account.indexes ?? []) as AnyRec[]).filter((i) => i.unique === true);
expect(
uniqueIndexes,
'a declared unique index on crm_account re-imposes the platform-wide constraint #625 removed',
).toEqual([]);
});

it('matches how crm_contact / crm_product spell the same intent', () => {
const contact = objects.find((o) => o.name === 'crm_contact') as AnyRec;
const product = objects.find((o) => o.name === 'crm_product') as AnyRec;
expect(contact.fields.email.unique).toBe(true);
expect(product.fields.sku.unique).toBe(true);
});
});

// ─────────────────────────────── the index the driver derives from it ──

describe('the physical index set is tenant-scoped', () => {
const indexes = expectedIndexes({
table: 'crm_account',
fields: orgScoped.fields as Record<string, unknown>,
tenantField: 'organization_id',
declaredIndexes: (orgScoped.indexes ?? []) as AnyRec[],
physicalColumns,
});

it('expects exactly one unique index, on (organization_id, name)', () => {
const unique = indexes.filter((i) => i.unique);
expect(unique).toEqual([
{
name: 'uniq_crm_account_organization_id_name',
columns: ['organization_id', 'name'],
unique: true,
},
]);
});

it('expects no platform-wide uniq_crm_account_name', () => {
expect(indexes.map((i) => i.name)).not.toContain('uniq_crm_account_name');
});

it('still indexes name — the seed upsert key and $search read it', () => {
// `searchableFields: ['name', ...]` promises a real indexed column, and
// `src/data/sales.seed.ts` upserts accounts on `externalId: 'name'` (a
// per-organization lookup). The tenant composite leads with
// organization_id, so `WHERE organization_id = ? AND name = ?` is a prefix
// match and no separate `{ fields: ['name'] }` entry is needed.
const covering = indexes.filter((i) => i.columns.includes('name'));
expect(covering.length).toBeGreaterThan(0);
});
});

// ───────────────────────────────── the constraint a real database enforces ──

describe('two organizations can each have an "Acme Corp" (real SQLite)', () => {
let driver: SqliteWasmDriver;

beforeAll(async () => {
driver = new SqliteWasmDriver({ filename: ':memory:' });
await driver.connect();
// The exact call the runtime makes at boot: name + fields + declared
// indexes. This is what turns the metadata above into DDL.
await driver.initObjects([
{
name: 'crm_account',
fields: orgScoped.fields as Record<string, unknown>,
indexes: orgScoped.indexes,
} as never,
]);
}, 60_000);

afterAll(async () => {
await driver?.disconnect();
});

it('materialized the per-tenant unique index and nothing platform-wide', async () => {
const rows = (await driver
.getKnex()
.raw("select name from sqlite_master where type = 'index' and tbl_name = 'crm_account'")) as Array<{
name: string;
}>;
const names = rows.map((r) => r.name);
expect(names).toContain('uniq_crm_account_organization_id_name');
expect(names).not.toContain('uniq_crm_account_name');
});

it('accepts the same account name in two different organizations', async () => {
const first = await driver.create('crm_account', { name: 'Acme Corp' }, { tenantId: 'org_a' });
const second = await driver.create('crm_account', { name: 'Acme Corp' }, { tenantId: 'org_b' });
expect(first?.id).toBeTruthy();
expect(second?.id).toBeTruthy();
expect(second.id).not.toBe(first.id);
expect(second.organization_id).toBe('org_b');
});

it('still rejects a duplicate account name WITHIN one organization', async () => {
await driver.create('crm_account', { name: 'Globex' }, { tenantId: 'org_a' });
await expect(
driver.create('crm_account', { name: 'Globex' }, { tenantId: 'org_a' }),
).rejects.toThrow(/UNIQUE constraint failed: crm_account\.organization_id, crm_account\.name/);
});
});
Loading