From 6f1c1137204b9a2c166fdbc13d12ff370ace2ace Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Tue, 4 Aug 2026 00:31:40 +0000 Subject: [PATCH] docs(contacts): state the email rule as per-organization, not per-account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `content/docs/sales/contacts.mdx` and its two translations promised that "an email address must be unique within the same account" and that "the same person can appear under multiple companies (a board member, for example)". Both halves were wrong, and the second described the exact write the product refuses. What ships, on two independent layers: - `crm_contact.email` declares FIELD-level `unique: true`, which since framework#3696 materializes as the tenant composite `(organization_id, email)` — one address per ORGANIZATION, spanning every account in it. The object deliberately declares no table-level index on `email`: a declared index is taken verbatim (platform-wide) and would leave the composite unreachable (framework#3991 `unique/double-declaration`). - `contact_integrity` (`src/objects/contact.hook.ts`) lowercases the address and looks it up with NO account scope, throwing `Another contact (…) with email … already exists.` before the database index can. `test/hooks-runtime-sales.test.ts` already carries the behavioural proof ("rejects a duplicate email GLOBALLY, not just within one account"). So a reader following the board-member example got an error the documentation said they would not get. The three rule lines now read like the `crm_account` name rule corrected in #625 / PR #646: unique within your organization, another organization on the same platform may hold its own contact at that address, the address is normalised to lower case before it is stored and compared, and a person you deal with at two companies needs a different address on each record. `test/docs-contact-email-uniqueness.test.ts` pins both sides so the prose cannot drift back: the rule line per language (organization scope present, per-account claim and board-member example absent) plus the metadata and hook behaviour the prose describes. Reverting any one of the three docs files turns 4 of its 15 assertions red. Documentation only — no metadata, hook or constraint changed. Whether "one person, two companies" SHOULD be supported is a product decision and a schema change; it is deliberately out of scope here. Fixes #648 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rvtsew6XgsSjxVa59HRPRK --- .../contact-email-unique-per-org-docs.md | 32 +++++ content/docs/sales/contacts.mdx | 2 +- content/docs/sales/contacts.zh-Hans.mdx | 2 +- content/docs/sales/contacts.zh-Hant.mdx | 2 +- test/docs-contact-email-uniqueness.test.ts | 122 ++++++++++++++++++ 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 .changeset/contact-email-unique-per-org-docs.md create mode 100644 test/docs-contact-email-uniqueness.test.ts diff --git a/.changeset/contact-email-unique-per-org-docs.md b/.changeset/contact-email-unique-per-org-docs.md new file mode 100644 index 00000000..6cea84cc --- /dev/null +++ b/.changeset/contact-email-unique-per-org-docs.md @@ -0,0 +1,32 @@ +--- +'hotcrm': patch +--- + +Contact documentation now states the email rule that actually ships: an email +address is unique **per organization**, not "within the same account". + +`content/docs/sales/contacts.mdx` and its two Chinese translations promised that +"an email address must be unique within the same account" and that "the same +person can appear under multiple companies (a board member, for example)". Both +halves were wrong, and the second one walked the reader straight into an error: + +- `crm_contact.email` carries field-level `unique: true`, which since framework + #3696 materializes as the tenant composite `(organization_id, email)` — one + address per organization, across every account in it. +- The `contact_integrity` hook (`src/objects/contact.hook.ts`) looks the address + up with **no account scope** and rejects the duplicate first, with + `Another contact (…) with email … already exists.`, so the friendly check is at + least as strict as the index. + +So the documented board-member workflow — the same person under two companies — +is exactly the write the product refuses. The rule lines now read like the +corrected `crm_account` ones (#625 / #646): unique within your organization, +another organization may hold its own contact at the same address, the address is +normalised to lower case before it is stored and compared, and a person you deal +with at two companies needs a different address on each contact record. + +Documentation only — no metadata, hook or constraint changed. Whether "one +person, two companies" *should* be supported is a separate product question; this +change only stops the docs describing a write that fails. + +Fixes #648. diff --git a/content/docs/sales/contacts.mdx b/content/docs/sales/contacts.mdx index d399e55b..44471b27 100644 --- a/content/docs/sales/contacts.mdx +++ b/content/docs/sales/contacts.mdx @@ -59,7 +59,7 @@ Reps can request a flag change but cannot remove these themselves — it's an ad ## Built-in rules -- An **email address must be unique within the same account**. The same person can appear under multiple companies (a board member, for example) but not twice in one company. +- An **email address must be unique within your organization**. The same address cannot be used by two contacts, even when those contacts sit under different accounts — the second save is rejected with *"Another contact … with email … already exists."* Another organization on the same platform is free to have its own contact at that address; the rule is scoped to your data, not the whole platform. Email is normalised to lower case before it is stored and compared, so `Ada@Example.com` and `ada@example.com` count as the same address. A person you deal with at two companies therefore needs a different address on each contact record. - **Welcome email** — when a contact is created, the system automatically sends them the *contact_welcome* email template. ## Sharing — who can see which contact diff --git a/content/docs/sales/contacts.zh-Hans.mdx b/content/docs/sales/contacts.zh-Hans.mdx index a532523b..d5490198 100644 --- a/content/docs/sales/contacts.zh-Hans.mdx +++ b/content/docs/sales/contacts.zh-Hans.mdx @@ -59,7 +59,7 @@ CEO ## 内置规则 -- **同一个客户内邮箱地址必须唯一**。同一个人可以出现在多个公司之下(例如某位董事会成员),但不能在一个公司中出现两次。 +- **邮箱地址在你所在的组织内必须唯一**。同一个地址不能同时用于两个联系人,即使这两个联系人隶属于不同的客户——第二次保存会被拒绝,并提示 *"Another contact … with email … already exists."*。同一平台上的其他组织可以有自己使用该地址的联系人——这条规则只作用于你自己的数据,而不是整个平台。邮箱在存储和比较前会统一转为小写,因此 `Ada@Example.com` 与 `ada@example.com` 视为同一个地址。因此,若同一个人同时在两家公司与你往来,两条联系人记录需要各自使用不同的邮箱地址。 - **欢迎邮件**——当创建一个联系人时,系统会自动向其发送 *contact_welcome* 邮件模板。 ## 共享——谁能看到哪个联系人 diff --git a/content/docs/sales/contacts.zh-Hant.mdx b/content/docs/sales/contacts.zh-Hant.mdx index 283cb6cc..ba8b9197 100644 --- a/content/docs/sales/contacts.zh-Hant.mdx +++ b/content/docs/sales/contacts.zh-Hant.mdx @@ -59,7 +59,7 @@ CEO ## 內建規則 -- **同一個客戶內郵箱地址必須唯一**。同一個人可以出現在多個公司之下(例如某位董事會成員),但不能在一個公司中出現兩次。 +- **郵箱地址在你所屬的組織內必須唯一**。同一個地址不能同時用於兩個聯絡人,即使這兩個聯絡人隸屬於不同的客戶——第二次儲存會被拒絕,並提示 *"Another contact … with email … already exists."*。同一平台上的其他組織可以有自己使用該地址的聯絡人——這條規則只作用於你自己的資料,而不是整個平台。郵箱在儲存與比較前會統一轉為小寫,因此 `Ada@Example.com` 與 `ada@example.com` 視為同一個地址。因此,若同一個人同時在兩家公司與你往來,兩筆聯絡人記錄需要各自使用不同的郵箱地址。 - **歡迎郵件**——當建立一個聯絡人時,系統會自動向其發送 *contact_welcome* 郵件範本。 ## 共享——誰能看到哪個聯絡人 diff --git a/test/docs-contact-email-uniqueness.test.ts b/test/docs-contact-email-uniqueness.test.ts new file mode 100644 index 00000000..bcd4a528 --- /dev/null +++ b/test/docs-contact-email-uniqueness.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { REPO_ROOT } from './helpers/repo-root'; +import { Contact } from '../src/objects/contact.object'; +import contactHooks from '../src/objects/contact.hook'; +import { makeHarness, makeCtx, hookNamed, type Rec } from './helpers/hook-harness'; + +/** + * The contact-email rule, as documented vs. as enforced (#648). + * + * `content/docs/sales/contacts.mdx` and its two translations used to promise + * that "an email address must be unique within the same account" and that "the + * same person can appear under multiple companies (a board member, for + * example)". Both halves were wrong, and the second one described the exact + * write the product refuses: a reader following the documentation got + * `Another contact (…) with email … already exists.` + * + * Two independent layers enforce the real rule, and both are pinned here so the + * prose cannot drift back: + * + * 1. `crm_contact.email` declares FIELD-level `unique: true`, which since + * framework#3696 materializes as the tenant composite + * `(organization_id, email)` — one address per organization, spanning + * every account in it. A table-level `indexes` entry would be taken + * verbatim (platform-wide) and would make the composite unreachable + * (framework#3991 `unique/double-declaration`), so there must not be one. + * 2. `contact_integrity` (`src/objects/contact.hook.ts`) looks the address up + * with NO account scope and rejects the duplicate before the database + * does. `test/hooks-runtime-sales.test.ts` owns the full behavioural + * coverage of that hook ("rejects a duplicate email GLOBALLY, not just + * within one account"); the single run below is here to tie the DOC + * sentence to executed behaviour, not to re-test the hook. + * + * Same shape as the `crm_account` name rule corrected in #625 / PR #646. This + * file takes no position on whether "one person, two companies" *should* be + * supported — that is a product decision and a schema change, not a doc fix. + */ + +const DOCS: Array<{ file: string; lang: string }> = [ + { file: 'content/docs/sales/contacts.mdx', lang: 'en' }, + { file: 'content/docs/sales/contacts.zh-Hans.mdx', lang: 'zh-Hans' }, + { file: 'content/docs/sales/contacts.zh-Hant.mdx', lang: 'zh-Hant' }, +]; + +/** The one bullet under "Built-in rules" that states the email constraint. */ +function emailRuleLine(file: string): string { + const text = readFileSync(join(REPO_ROOT, file), 'utf8'); + const line = text + .split('\n') + .find((l) => l.trimStart().startsWith('-') && /email|邮箱|郵箱/i.test(l)); + expect(line, `${file}: no email bullet found under the rules list`).toBeTruthy(); + return line as string; +} + +describe('contact email uniqueness — docs match what ships (#648)', () => { + describe.each(DOCS)('$file', ({ file, lang }) => { + const rule = () => emailRuleLine(file); + + it('scopes the rule to the organization, not to one account', () => { + const expected = + lang === 'en' ? /unique within your organization/i + : lang === 'zh-Hans' ? /在你所在的组织内必须唯一/ + : /在你所屬的組織內必須唯一/; + expect(rule()).toMatch(expected); + }); + + it('does not resurrect the per-account claim', () => { + const retired = + lang === 'en' ? /unique within the same account/i + : lang === 'zh-Hans' ? /同一个客户内邮箱地址必须唯一/ + : /同一個客戶內郵箱地址必須唯一/; + expect(rule()).not.toMatch(retired); + }); + + it('does not describe the one-person-two-companies write the product rejects', () => { + const rejectedExample = + lang === 'en' ? /appear under multiple companies|board member/i + : lang === 'zh-Hans' ? /可以出现在多个公司|董事会成员/ + : /可以出現在多個公司|董事會成員/; + expect(rule()).not.toMatch(rejectedExample); + }); + + it('says the address cannot be reused across accounts', () => { + const crossAccount = + lang === 'en' ? /different accounts/i + : lang === 'zh-Hans' ? /不同的客户/ + : /不同的客戶/; + expect(rule()).toMatch(crossAccount); + }); + }); +}); + +describe('the enforcement the contact docs now describe (#648)', () => { + it('declares email uniqueness on the FIELD, so it is scoped per organization', () => { + const email = (Contact as any).fields?.email; + expect(email?.unique).toBe(true); + }); + + it('declares no table-level email index that would re-globalise the constraint', () => { + const indexes: Array<{ fields?: string[] }> = (Contact as any).indexes ?? []; + const emailIndex = indexes.find((i) => (i.fields ?? []).includes('email')); + expect( + emailIndex, + 'a declared index on `email` is materialized verbatim (platform-wide) and ' + + 'makes the per-tenant composite unreachable — framework#3991', + ).toBeUndefined(); + }); + + it('rejects the same address on two contacts under DIFFERENT accounts', async () => { + const hook = hookNamed(contactHooks, 'contact_integrity'); + const h = makeHarness({ + crm_contact: [{ id: 'c1', email: 'ada@example.com', crm_account: 'accA' }], + }); + const input: Rec = { email: 'Ada@Example.com', crm_account: 'accB' }; + await expect( + hook.handler(makeCtx({ event: 'beforeInsert', input, user: { id: 'user_1' }, api: h.api })), + ).rejects.toThrow(/already exists/); + }); +});