Skip to content
Merged
19 changes: 19 additions & 0 deletions .changeset/2344-campaign-member-direct-create-gates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"hotcrm": patch
---

Clause-②: no
Metadata-protocol: none

**A campaign member created by hand now meets the same two rules as Add to Campaign and Enroll Members (#2344).**

Since #2336, **Add to Campaign** and **Enroll Members** refuse a campaign that is not *Planning* or *In Progress* and a person whose **Email Opt Out** is ticked. A member created any other way skipped both rules: **New** on a campaign's Campaign Members list, a data import, and `POST /api/v1/data/crm_campaign_member` all saved a member of a *Completed* campaign naming an opted-out lead, and that campaign's closed figures moved.

- Saving a new member on a campaign that is not *Planning* or *In Progress*, or that no longer exists, is refused with `VALIDATION_FAILED` (400). The message is the one Add to Campaign shows, naming the campaign.
- Saving a new member for a Lead or Contact whose **Email Opt Out** is ticked is refused with `FORBIDDEN` (403), the code a Do Not Call refusal uses. The message is the one Add to Campaign shows, naming the person.
- Moving an existing member to another campaign, Lead or Contact is checked the same way. Editing a member without moving it is not: marking a *Completed* campaign's member *Responded*, or a member *Unsubscribed*, still saves.
- System writes still pass: seed data, imports run as the system, and the promotion of a converting lead's members to *Converted*.

These two messages are in English in every language for now, because a refusal raised while saving a record has no translation key yet. The same refusals from Add to Campaign stay translated.

**FROM → TO.** Members already stored stay as they are. No data migration is needed.
2 changes: 1 addition & 1 deletion content/docs/marketing/campaign-members.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Three ways:

**Who can enroll.** Enrolling someone creates a campaign member, and all three ways check that one right, as the person doing it: their profile's permission to create campaign members. Marketing User and the administrators hold it. Sales Rep and Sales Manager do not: their profiles read campaign members and leave enrollment to marketing, so they are not offered **Enroll Members** or **Add to Campaign**, and a member they try to create anyway — through either of those, the panel's **New** or the API — is refused.

**Which campaigns, and which people.** A campaign takes members only while it is *Planning* or *In Progress*, and nobody who has opted out of email is enrolled. **Enroll Members** is offered only on such a campaign and leaves opted-out people out of its segment. **Add to Campaign** holds the same two rules, whichever way you start it. On a row's ⋮ menu its campaign picker lists only *Planning* and *In Progress* campaigns; the selection toolbar's picker lists every campaign. If a closed campaign or an opted-out person reaches it either way, nobody is added, the campaign's numbers do not move, and a message names the closed campaign or the person who opted out. So a *Completed* campaign keeps the sent, lead and response figures it closed with.
**Which campaigns, and which people.** A campaign takes members only while it is *Planning* or *In Progress*, and nobody who has opted out of email is enrolled. **Enroll Members** is offered only on such a campaign and leaves opted-out people out of its segment. **Add to Campaign** holds the same two rules, whichever way you start it. On a row's ⋮ menu its campaign picker lists only *Planning* and *In Progress* campaigns; the selection toolbar's picker lists every campaign. If a closed campaign or an opted-out person reaches it either way, nobody is added, the campaign's numbers do not move, and a message names the closed campaign or the person who opted out. So a *Completed* campaign keeps the sent, lead and response figures it closed with. The same two rules hold when a member is added any other way: **New** on a campaign's **Campaign Members** list, an import, or the API is refused with the same message, and nothing is saved. That message is shown in English in every language for now.

A person can be in many campaigns over time, but only once per campaign (one membership per person per campaign).

Expand Down
2 changes: 1 addition & 1 deletion content/docs/marketing/campaign-members.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ description: 加入每个营销活动的线索和联系人,以及如何跟踪

**谁能加入成员。** 把一个人加入活动就是创建一条营销活动成员记录,三种方式都以操作者本人的身份检查同一项权限:其权限档案是否允许创建营销活动成员。Marketing User 和管理员拥有这项权限。Sales Rep 和 Sales Manager 没有:他们的权限档案只能查看营销活动成员,加入成员交给市场部,所以他们看不到 **Enroll Members** 和 **Add to Campaign**;无论经由这两个操作、面板上的 **New** 还是 API,他们想创建的成员都会被拒绝。

**哪些营销活动、哪些人可以加入。** 只有处于 *Planning* 或 *In Progress* 的营销活动接受成员,已退订邮件的人不会被加入。**Enroll Members** 只在这样的营销活动上提供,并把已退订的人排除在所选人群之外。**Add to Campaign** 无论从哪里启动,都遵守同样的两条规则。从某一行的 ⋮ 菜单运行时,营销活动选择器只列出 *Planning* 和 *In Progress* 的营销活动;选择工具栏上的选择器会列出全部营销活动。无论哪种方式,只要选中的是已结束的营销活动或已退订的人,就不会加入任何人,营销活动的数字不变,并会弹出一条消息,指明是哪个营销活动已结束、或哪个人已退订。因此 *Completed* 的营销活动会保留它结束时的发送数、线索数和响应数。
**哪些营销活动、哪些人可以加入。** 只有处于 *Planning* 或 *In Progress* 的营销活动接受成员,已退订邮件的人不会被加入。**Enroll Members** 只在这样的营销活动上提供,并把已退订的人排除在所选人群之外。**Add to Campaign** 无论从哪里启动,都遵守同样的两条规则。从某一行的 ⋮ 菜单运行时,营销活动选择器只列出 *Planning* 和 *In Progress* 的营销活动;选择工具栏上的选择器会列出全部营销活动。无论哪种方式,只要选中的是已结束的营销活动或已退订的人,就不会加入任何人,营销活动的数字不变,并会弹出一条消息,指明是哪个营销活动已结束、或哪个人已退订。因此 *Completed* 的营销活动会保留它结束时的发送数、线索数和响应数。用其他方式添加成员时,这两条规则同样适用:在营销活动的 **Campaign Members** 列表上点 **New**、通过导入或通过 API 添加,都会被拒绝并显示同样的消息,什么都不会保存。这条消息目前在所有语言下都以英文显示。

一个人随着时间推移可以加入多个营销活动,但每个营销活动只能加入一次(每人每个营销活动一个成员身份)。

Expand Down
2 changes: 1 addition & 1 deletion content/docs/marketing/campaign-members.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ description: 加入每個行銷活動的潛在客戶和聯絡人,以及如何

**誰能加入成員。** 把一個人加入活動就是建立一筆行銷活動成員記錄,三種方式都以操作者本人的身分檢查同一項權限:其權限設定檔是否允許建立行銷活動成員。Marketing User 和管理員擁有這項權限。Sales Rep 和 Sales Manager 沒有:他們的權限設定檔只能檢視行銷活動成員,加入成員交給行銷部,所以他們看不到 **Enroll Members** 和 **Add to Campaign**;無論經由這兩個操作、面板上的 **New** 還是 API,他們想建立的成員都會被拒絕。

**哪些行銷活動、哪些人可以加入。** 只有處於 *Planning* 或 *In Progress* 的行銷活動接受成員,已退訂電子郵件的人不會被加入。**Enroll Members** 只在這樣的行銷活動上提供,並把已退訂的人排除在所選人群之外。**Add to Campaign** 無論從哪裡啟動,都遵守同樣的兩條規則。從某一列的 ⋮ 選單執行時,行銷活動選擇器只列出 *Planning* 和 *In Progress* 的行銷活動;選擇工具列上的選擇器會列出全部行銷活動。無論哪種方式,只要選中的是已結束的行銷活動或已退訂的人,就不會加入任何人,行銷活動的數字不變,並會彈出一則訊息,指明是哪個行銷活動已結束、或哪個人已退訂。因此 *Completed* 的行銷活動會保留它結束時的發送數、潛在客戶數和回應數。
**哪些行銷活動、哪些人可以加入。** 只有處於 *Planning* 或 *In Progress* 的行銷活動接受成員,已退訂電子郵件的人不會被加入。**Enroll Members** 只在這樣的行銷活動上提供,並把已退訂的人排除在所選人群之外。**Add to Campaign** 無論從哪裡啟動,都遵守同樣的兩條規則。從某一列的 ⋮ 選單執行時,行銷活動選擇器只列出 *Planning* 和 *In Progress* 的行銷活動;選擇工具列上的選擇器會列出全部行銷活動。無論哪種方式,只要選中的是已結束的行銷活動或已退訂的人,就不會加入任何人,行銷活動的數字不變,並會彈出一則訊息,指明是哪個行銷活動已結束、或哪個人已退訂。因此 *Completed* 的行銷活動會保留它結束時的發送數、潛在客戶數和回應數。用其他方式新增成員時,這兩條規則同樣適用:在行銷活動的 **Campaign Members** 清單上點 **New**、透過匯入或透過 API 新增,都會被拒絕並顯示同樣的訊息,什麼都不會儲存。這則訊息目前在所有語言下都以英文顯示。

一個人隨著時間推移可以加入多個行銷活動,但每個行銷活動只能加入一次(每人每個行銷活動一個成員身分)。

Expand Down
12 changes: 10 additions & 2 deletions src/marketing/flows/campaign-member-enroll.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,23 @@ type Flow = Automation.Flow;
*
* ## Who calls these
*
* Every enrollment door in the app reaches the membership INSERT through one
* of these two flows, and only through them:
* Every enrollment door the app ships reaches the membership INSERT through
* one of these two flows:
*
* - **Enroll Members** (`campaign_enrollment`) calls them per eligible
* person through its `subflow` nodes;
* - **Add to Campaign** on a lead (`create_campaign`) and on a contact
* (`add_contact_to_campaign`) is a `type: 'flow'` action that starts the
* side's flow directly, once per selected row.
*
* A member created without them (New on a campaign's Campaign Members
* panel, an import run as the user, a REST create) meets the same two gates
* on the object instead: `campaign_member_enrollment_gate` in
* `src/marketing/objects/campaign_member.hook.ts` is this flow pair's
* object-side twin (#2344). It is a second copy of the gates, held to these
* flows' verdicts and sentences by
* `test/campaign-member-direct-create-gates.test.ts`.
*
* ## `runAs: 'user'` — the object's own `allowCreate` is the one gate (#2167)
*
* Ruled on #2167 (Q1 letter B): every enrollment door writes in the caller's
Expand Down
167 changes: 167 additions & 0 deletions src/marketing/objects/campaign_member.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,179 @@ import type { HookApi } from '../../sales/objects/_hook-api';
* metric block on every membership change, which is what makes a live
* campaign show live numbers.
*
* - `campaign_member_enrollment_gate` (before) — refuses a user's own create
* of a member, or move of one, into a campaign that is not open or for a
* person who opted out of email: the enroll flows' two gates, held on the
* object for the doors that skip those flows (#2344).
*
* ⚠️ The recompute block below is written out here and three more times in
* `campaign.hook.ts`, deliberately: a sandboxed hook body cannot reach module
* scope, so a shared import would stop the handler lowering to metadata (see
* that file's header for the full reasoning). All four copies are asserted
* character-identical by `test/campaign-member-lifecycle.test.ts`.
*/

/**
* The enroll flows' two gates, on the object itself (#2344).
*
* `campaign_lead_member_enroll` and `campaign_contact_member_enroll` refuse a
* campaign that is not Planning or In Progress (or no longer exists) and a
* person with Email Opt Out ticked (#2336). Enroll Members and Add to Campaign
* reach the insert only through them. A member created any other way did not:
* the campaign's Campaign Members panel's **New**, a data import run as the
* user, and `POST /api/v1/data/crm_campaign_member` all answered 201 on a
* Completed campaign with an opted-out lead (measured on 18.2.0 at `0f17c6c`).
* So this hook refuses the same two things on the object, for every user write.
*
* Why a `before*` hook and not something smaller, measured on 18.2.0:
*
* - a `validations[]` rule sees only the member's own columns, and both
* gates read another record (the campaign's status, the person's flag);
* - there is no before-save flow trigger, so the flows' `end` refusals
* cannot be reused at the insert.
*
* What passes:
*
* - system writes, which carry no `ctx.user` (this repo's system-write
* signal): the seed, an import run as system, and
* `campaign_lead_conversion_refresh`, whose promotion writes `status`
* alone and so is no enrollment either;
* - the enroll flows' own insert. It carries `ctx.provenance.flowRunId`
* (measured), but it is ⛔ deliberately not exempted on that mark: the
* flow has already checked the same two things a moment earlier, so this
* check answers the same and the insert goes through. Exempting every
* flow-run write would let the next flow that writes a member skip both
* gates, the same hole as before in a new place;
* - an edit that does not move the member, e.g. Mark Responded on a member
* of a Completed campaign, or Unsubscribed on a person who opted out.
*
* An edit that MOVES a member (a new campaign, Lead or Contact) is a new
* enrollment, so it is checked like a create, on the record it would become.
*
* Reads run as the caller, as the flows' reads do: `crm_campaign` is
* `public_read`, and a person the caller cannot read passes here as it passes
* there.
*
* The codes follow the house refusal envelope (`src/sales/objects/_refusal.ts`):
*
* - a campaign that is not open: `VALIDATION_FAILED` / 400. The campaign
* lookup's own `lookupFilters` already declare only Planning and In
* Progress campaigns as values for this field, so a closed one is a
* submitted value the object's own rules reject;
* - an opted-out person: `FORBIDDEN` / 403, a compliance flag that
* prohibits the write outright, the code `task_do_not_call_guard` and
* `event_do_not_call_guard` give Do Not Call, its phone-side twin.
*
* ⚠️ TWO COPIES, HELD TOGETHER BY A TEST. The open statuses, the `=== true`
* opt-out reading and the three sentences are written out here and again as
* CEL and templates in `src/marketing/flows/campaign-member-enroll.flow.ts`. A
* lowered body cannot import, and a flow edge cannot call a hook, so they
* cannot share one source. `test/campaign-member-direct-create-gates.test.ts`
* drives both doors over every campaign status the object declares and both
* opt-out states, and fails when their verdicts or their sentences part ways.
* The English sentences are the flows' own; the flows' translations do not
* reach a hook refusal, which 18.2.0 gives no translation key.
*/
const campaignMemberEnrollmentGate: Hook = {
name: 'campaign_member_enrollment_gate',
object: 'crm_campaign_member',
events: ['beforeInsert', 'beforeUpdate'],
priority: 100,
description: 'Refuse a user enrolling a person in a campaign that is not open, or a person who opted out of email.',
handler: async (ctx: HookContext) => {
// The refusal envelope (#1075). Mirrored from `src/sales/objects/_refusal.ts` because a
// lowered body has no module scope and `extractHookBody` THROWS on an
// import; `test/refusal-envelope.test.ts` pins every copy against it.
function refuse(
message: string,
code: string,
status: number,
userMessage: string = message,
): Error {
const err = new Error(message) as Error & {
code: string;
status: number;
userMessage: string;
};
err.code = code;
err.status = status;
err.userMessage = userMessage;
return err;
}
if (!ctx.user?.id) return;
const api = ctx.api as HookApi | undefined;
if (!api) return;
const input = ctx.input ?? {};
const previous = (ctx.previous ?? {}) as Record<string, unknown>;
const idOf = (value: unknown): string => (typeof value === 'string' ? value : '');

// An update enrolls only when it moves the member. A key the write does
// not carry, or carries unchanged (an edit form re-sends every field),
// moves nothing.
if (ctx.event === 'beforeUpdate') {
const moves = ['crm_campaign', 'crm_lead', 'crm_contact'].some(
(key) => key in input && idOf(input[key]) !== idOf(previous[key]),
);
if (!moves) return;
}
const pick = (key: string): string => idOf(key in input ? input[key] : previous[key]);
const campaignId = pick('crm_campaign');
const leadId = pick('crm_lead');
const contactId = pick('crm_contact');

// The campaign first, as the flows check it first. A blank campaign is
// the required field's to refuse; an id that names no campaign is
// refused here, as the flows refuse it.
if (campaignId) {
const campaign = await api.object('crm_campaign').findOne({
where: { id: campaignId }, fields: ['id', 'status', 'display_title'],
});
const status = campaign && typeof campaign.status === 'string' ? campaign.status : '';
if (status !== 'planning' && status !== 'in_progress') {
const title = campaign && typeof campaign.display_title === 'string' && campaign.display_title !== ''
? campaign.display_title
: 'The chosen campaign';
throw refuse(
`${title} is not open for enrollment, or no longer exists: only a Planning or In Progress campaign takes members. Nobody was enrolled.`,
'VALIDATION_FAILED',
400,
);
}
}

// Then the person. Refused only on a stored `true`, as the flows read it.
if (leadId) {
const lead = await api.object('crm_lead').findOne({
where: { id: leadId }, fields: ['id', 'first_name', 'last_name', 'email_opt_out'],
});
if (lead?.email_opt_out === true) {
const first = typeof lead.first_name === 'string' ? lead.first_name : '';
const last = typeof lead.last_name === 'string' ? lead.last_name : '';
throw refuse(
`${first} ${last} has opted out of email, so the lead was not added to the campaign.`,
'FORBIDDEN',
403,
);
}
}
if (contactId) {
const contact = await api.object('crm_contact').findOne({
where: { id: contactId }, fields: ['id', 'full_name', 'email_opt_out'],
});
if (contact?.email_opt_out === true) {
const name = typeof contact.full_name === 'string' && contact.full_name !== ''
? contact.full_name
: 'This contact';
throw refuse(
`${name} has opted out of email, so the contact was not added to the campaign.`,
'FORBIDDEN',
403,
);
}
}
},
};

/**
* Keep the response summary fields honest, on every write path.
*
Expand Down Expand Up @@ -254,6 +420,7 @@ const campaignMemberMetricsRefresh: Hook = {
};

export default [
campaignMemberEnrollmentGate,
campaignMemberLifecycle,
campaignMemberOptOutSync,
campaignMemberMetricsRefresh,
Expand Down
Loading
Loading