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/); + }); +});