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
32 changes: 32 additions & 0 deletions .changeset/contact-email-unique-per-org-docs.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/sales/contacts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/sales/contacts.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ CEO

## 内置规则

- **同一个客户内邮箱地址必须唯一**。同一个人可以出现在多个公司之下(例如某位董事会成员),但不能在一个公司中出现两次
- **邮箱地址在你所在的组织内必须唯一**。同一个地址不能同时用于两个联系人,即使这两个联系人隶属于不同的客户——第二次保存会被拒绝,并提示 *"Another contact … with email … already exists."*。同一平台上的其他组织可以有自己使用该地址的联系人——这条规则只作用于你自己的数据,而不是整个平台。邮箱在存储和比较前会统一转为小写,因此 `Ada@Example.com` 与 `ada@example.com` 视为同一个地址。因此,若同一个人同时在两家公司与你往来,两条联系人记录需要各自使用不同的邮箱地址
- **欢迎邮件**——当创建一个联系人时,系统会自动向其发送 *contact_welcome* 邮件模板。

## 共享——谁能看到哪个联系人
Expand Down
2 changes: 1 addition & 1 deletion content/docs/sales/contacts.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ CEO

## 內建規則

- **同一個客戶內郵箱地址必須唯一**。同一個人可以出現在多個公司之下(例如某位董事會成員),但不能在一個公司中出現兩次
- **郵箱地址在你所屬的組織內必須唯一**。同一個地址不能同時用於兩個聯絡人,即使這兩個聯絡人隸屬於不同的客戶——第二次儲存會被拒絕,並提示 *"Another contact … with email … already exists."*。同一平台上的其他組織可以有自己使用該地址的聯絡人——這條規則只作用於你自己的資料,而不是整個平台。郵箱在儲存與比較前會統一轉為小寫,因此 `Ada@Example.com` 與 `ada@example.com` 視為同一個地址。因此,若同一個人同時在兩家公司與你往來,兩筆聯絡人記錄需要各自使用不同的郵箱地址
- **歡迎郵件**——當建立一個聯絡人時,系統會自動向其發送 *contact_welcome* 郵件範本。

## 共享——誰能看到哪個聯絡人
Expand Down
122 changes: 122 additions & 0 deletions test/docs-contact-email-uniqueness.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading