From feb0fd97986786254f40df1658b78e2ad7bceed7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 23:07:45 +0000 Subject: [PATCH 1/6] fix(marketing): refuse a user's direct campaign-member create for a closed campaign or an opted-out person A beforeInsert/beforeUpdate guard on crm_campaign_member holds the enroll flows' two gates for the doors that skip those flows: the members panel's New, an import run as the user, and REST. System writes pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAnCuJoiB6P78EsvrL5 --- src/marketing/objects/campaign_member.hook.ts | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/src/marketing/objects/campaign_member.hook.ts b/src/marketing/objects/campaign_member.hook.ts index 0d360aedb..7f04b8959 100644 --- a/src/marketing/objects/campaign_member.hook.ts +++ b/src/marketing/objects/campaign_member.hook.ts @@ -22,6 +22,11 @@ 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 @@ -29,6 +34,167 @@ import type { HookApi } from '../../sales/objects/_hook-api'; * 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; + 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. * @@ -254,6 +420,7 @@ const campaignMemberMetricsRefresh: Hook = { }; export default [ + campaignMemberEnrollmentGate, campaignMemberLifecycle, campaignMemberOptOutSync, campaignMemberMetricsRefresh, From 35811252169d7388bd4c709d214909112736dd7d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 23:09:19 +0000 Subject: [PATCH 2/6] test(marketing): pin the direct-create gates against the enroll flows' verdicts and words Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAnCuJoiB6P78EsvrL5 --- ...ampaign-member-direct-create-gates.test.ts | 198 ++++++++++++++++++ test/runtime-coverage.test.ts | 4 + 2 files changed, 202 insertions(+) create mode 100644 test/campaign-member-direct-create-gates.test.ts diff --git a/test/campaign-member-direct-create-gates.test.ts b/test/campaign-member-direct-create-gates.test.ts new file mode 100644 index 000000000..1f8c54041 --- /dev/null +++ b/test/campaign-member-direct-create-gates.test.ts @@ -0,0 +1,198 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll } from 'vitest'; +import type { VerifyStack } from '@objectstack/verify'; +import stack, { hookNamed } from './helpers/composed-stack'; +import { hotcrmStack, signUpPerson, daysFromNow, runShippedHook, type Person } from './helpers/verify-stack'; + +type Rec = Record; + +/** + * A member made without the enroll flows meets the same two gates (#2344). + * + * #2336 put both enrollment gates (a campaign that is not Planning or In + * Progress, a person with Email Opt Out ticked) into the two enroll flows, + * which Enroll Members and Add to Campaign reach the insert through. The + * Campaign Members panel's **New**, an import run as the user and REST create + * the member directly, and answered 201 on a Completed campaign with an + * opted-out lead (measured on 18.2.0 at `0f17c6c`). + * `campaign_member_enrollment_gate` now refuses those on the object. + * + * The gate is written twice, as CEL in the flows and as JS in the hook, because + * a lowered hook body cannot import and a flow edge cannot call a hook. The + * first block below is what holds the two copies together: it drives the flow + * door and the REST door over EVERY campaign status `crm_campaign` declares and + * both opt-out states, and asks for the same verdict in the same words. A + * status added to the object later is driven through both without an edit + * here. + * + * Every door is driven as a marketing user, who may enroll, through + * `@objectstack/verify` over the shipped app; the panel's New is the same + * `POST /api/v1/data/crm_campaign_member` the REST cases send. + */ + +let verify: VerifyStack; +let marketer: Person; +let k = 0; + +beforeAll(async () => { + verify = await hotcrmStack(); + marketer = await signUpPerson(verify, 'marketer@campaign-member-direct-create-gates.test', { + name: 'Direct Marketer', positions: ['marketing_user'], permissionSets: ['marketing_user'], + }); +}, 180_000); + +const campaignOf = async (status: string): Promise => (await verify.seed('crm_campaign', [{ + name: `Direct ${status} ${++k}`, status, start_date: daysFromNow(-30), end_date: daysFromNow(30), owner_id: marketer.id, +}]))[0]!; + +const leadOf = async (over: Rec = {}): Promise => (await verify.seed('crm_lead', [{ + first_name: 'Dee', last_name: `Direct ${++k}`, company: `Direct Co ${k}`, email: `lead${k}@campaign-member-direct-create-gates.test`, + status: 'new', is_converted: false, email_opt_out: false, owner_id: marketer.id, ...over, +}]))[0]!; + +const contactOf = async (over: Rec = {}): Promise => { + const [account] = await verify.seed('crm_account', [{ name: `Direct Account ${++k}`, owner_id: marketer.id }]); + return (await verify.seed('crm_contact', [{ + first_name: 'Cy', last_name: `Direct ${k}`, crm_account: account!.id, department: 'engineering', + email: `contact${k}@campaign-member-direct-create-gates.test`, email_opt_out: false, owner_id: marketer.id, ...over, + }]))[0]!; +}; + +/** The flow door: Add to Campaign, which starts the side's enroll flow. */ +const addLead = (leadId: string, campaignId: string) => + verify.actions.run('crm_lead', 'create_campaign', { as: marketer.token, recordId: leadId, params: { campaignId } }) as Promise; +const addContact = (contactId: string, campaignId: string) => + verify.actions.run('crm_contact', 'add_contact_to_campaign', { as: marketer.token, recordId: contactId, params: { campaignId } }) as Promise; + +/** The direct doors: the panel's New and REST are this one request. */ +const create = async (doc: Rec) => { + const res = await verify.apiAs(marketer.token, 'POST', '/data/crm_campaign_member', doc); + return { status: res.status, body: (await res.json()) as Rec }; +}; +const edit = async (id: string, doc: Rec) => { + const res = await verify.apiAs(marketer.token, 'PATCH', `/data/crm_campaign_member/${id}`, doc); + return { status: res.status, body: (await res.json()) as Rec }; +}; + +const membersOf = (campaignId: string) => verify.rows('crm_campaign_member', { crm_campaign: campaignId }); + +const SIDES = [ + { side: 'lead', add: addLead, person: leadOf, column: 'crm_lead' }, + { side: 'contact', add: addContact, person: contactOf, column: 'crm_contact' }, +] as const; + +/** Every status the campaign object declares, read off the shipped metadata. */ +const CAMPAIGN_STATUSES: string[] = ((((stack as Rec).objects as Rec[]).find((o) => o.name === 'crm_campaign')! + .fields.status.options) as Rec[]).map((o) => String(o.value)); + +describe('the direct door answers as the flow door does (#2344)', () => { + it('drives every declared campaign status, so a new one is checked on both doors', () => { + expect(CAMPAIGN_STATUSES, 'the statuses the gate was written against').toEqual( + expect.arrayContaining(['planning', 'in_progress', 'completed', 'aborted']), + ); + }); + + it.each(SIDES.flatMap((s) => CAMPAIGN_STATUSES.map((status) => [s.side, status, s] as const)))( + '%s side, %s campaign: the same verdict, in the same words', + async (_side, status, s) => { + const campaign = await campaignOf(status); + const viaFlow = await s.add((await s.person()).id, campaign.id); + const person = await s.person(); + const direct = await create({ crm_campaign: campaign.id, [s.column]: person.id, status: 'sent' }); + + if (viaFlow.status === 'refused') { + expect([direct.status, direct.body?.code], JSON.stringify(direct.body)).toEqual([400, 'VALIDATION_FAILED']); + expect(direct.body?.userMessage, 'the direct refusal reads as the flow refusal').toBe(viaFlow.refusalMessage); + expect((await membersOf(campaign.id)).filter((m) => m[s.column] === person.id)).toHaveLength(0); + } else { + expect(viaFlow, 'fixture: the flow door enrolled').toMatchObject({ success: true, output: { enrolled: true } }); + expect(direct.status, JSON.stringify(direct.body)).toBe(201); + } + }, + ); + + it.each(SIDES)('$side side, opted out: refused 403 in the flow\'s words, and no member is written', async (s) => { + const campaign = await campaignOf('in_progress'); + const person = await s.person({ email_opt_out: true }); + const viaFlow = await s.add(person.id, campaign.id); + expect(viaFlow, 'fixture: the flow door refused').toMatchObject({ status: 'refused' }); + + const direct = await create({ crm_campaign: campaign.id, [s.column]: person.id, status: 'sent' }); + expect([direct.status, direct.body?.code], JSON.stringify(direct.body)).toEqual([403, 'FORBIDDEN']); + expect(direct.body?.userMessage, 'the direct refusal reads as the flow refusal').toBe(viaFlow.refusalMessage); + expect(await membersOf(campaign.id)).toHaveLength(0); + }); + + it('a closed campaign is named before an opted-out person, as the flows order them', async () => { + const campaign = await campaignOf('completed'); + const direct = await create({ crm_campaign: campaign.id, crm_lead: (await leadOf({ email_opt_out: true })).id, status: 'sent' }); + expect([direct.status, direct.body?.code], JSON.stringify(direct.body)).toEqual([400, 'VALIDATION_FAILED']); + }); +}); + +describe('what still passes (#2344)', () => { + it('a system write: the seed stores a member of a Completed campaign naming an opted-out lead', async () => { + const campaign = await campaignOf('completed'); + const lead = await leadOf({ email_opt_out: true }); + await verify.seed('crm_campaign_member', [{ crm_campaign: campaign.id, crm_lead: lead.id, status: 'sent' }]); + expect((await membersOf(campaign.id)).map((m) => m.crm_lead)).toEqual([lead.id]); + }); + + it('an edit that moves nothing: Responded on a Completed campaign\'s member, the form re-sending its campaign and lead', async () => { + const campaign = await campaignOf('completed'); + const lead = await leadOf(); + const [member] = await verify.seed('crm_campaign_member', [{ crm_campaign: campaign.id, crm_lead: lead.id, status: 'sent' }]); + const edited = await edit(member!.id, { crm_campaign: campaign.id, crm_lead: lead.id, status: 'responded' }); + expect(edited.status, JSON.stringify(edited.body)).toBe(200); + expect((await verify.rows('crm_campaign_member', { id: member!.id }))[0]?.status).toBe('responded'); + }); + + it('an edit that moves nothing: Unsubscribed on a member whose lead already opted out', async () => { + const campaign = await campaignOf('in_progress'); + const lead = await leadOf({ email_opt_out: true }); + const [member] = await verify.seed('crm_campaign_member', [{ crm_campaign: campaign.id, crm_lead: lead.id, status: 'sent' }]); + const edited = await edit(member!.id, { status: 'unsubscribed' }); + expect(edited.status, JSON.stringify(edited.body)).toBe(200); + }); +}); + +describe('an edit that moves a member is checked like a create (#2344)', () => { + it('moving a member to a Completed campaign is refused, and the member stays where it was', async () => { + const open = await campaignOf('in_progress'); + const closed = await campaignOf('completed'); + const [member] = await verify.seed('crm_campaign_member', [{ crm_campaign: open.id, crm_lead: (await leadOf()).id, status: 'sent' }]); + const edited = await edit(member!.id, { crm_campaign: closed.id }); + expect([edited.status, edited.body?.code], JSON.stringify(edited.body)).toEqual([400, 'VALIDATION_FAILED']); + expect((await verify.rows('crm_campaign_member', { id: member!.id }))[0]?.crm_campaign).toBe(open.id); + }); + + it('moving a member to an opted-out lead is refused, and the member keeps its lead', async () => { + const campaign = await campaignOf('in_progress'); + const lead = await leadOf(); + const [member] = await verify.seed('crm_campaign_member', [{ crm_campaign: campaign.id, crm_lead: lead.id, status: 'sent' }]); + const edited = await edit(member!.id, { crm_lead: (await leadOf({ email_opt_out: true })).id }); + expect([edited.status, edited.body?.code], JSON.stringify(edited.body)).toEqual([403, 'FORBIDDEN']); + expect((await verify.rows('crm_campaign_member', { id: member!.id }))[0]?.crm_lead).toBe(lead.id); + }); +}); + +describe('the gate holds in the body that ships (#2344)', () => { + // The boot above runs the hook in-process; the artifact ships its lowered + // body and runs it in the runtime's QuickJS sandbox. These run that body. + const gate = hookNamed('campaign_member_enrollment_gate'); + const insert = (input: Rec, as?: string): Promise => + runShippedHook(verify, gate, { event: 'beforeInsert', as, input }).then(() => null, (e: Rec) => e); + + it('refuses a closed campaign and an opted-out person, with the envelope the REST door answers', async () => { + const closed = await insert({ crm_campaign: (await campaignOf('aborted')).id, crm_lead: (await leadOf()).id }, marketer.token); + expect([closed?.name, closed?.code, closed?.status]).toEqual(['SandboxError', 'VALIDATION_FAILED', 400]); + const optedOut = await insert({ crm_campaign: (await campaignOf('planning')).id, crm_contact: (await contactOf({ email_opt_out: true })).id }, marketer.token); + expect([optedOut?.name, optedOut?.code, optedOut?.status]).toEqual(['SandboxError', 'FORBIDDEN', 403]); + }); + + it('lets an open campaign and an opted-in person through, and lets a write with no user through', async () => { + expect(await insert({ crm_campaign: (await campaignOf('in_progress')).id, crm_lead: (await leadOf()).id }, marketer.token)).toBeNull(); + expect(await insert({ crm_campaign: (await campaignOf('completed')).id, crm_lead: (await leadOf({ email_opt_out: true })).id })).toBeNull(); + }); +}); diff --git a/test/runtime-coverage.test.ts b/test/runtime-coverage.test.ts index 756ef54ec..d8b0ebd71 100644 --- a/test/runtime-coverage.test.ts +++ b/test/runtime-coverage.test.ts @@ -121,6 +121,10 @@ const RUNTIME_TEST_FILES = [ // product per book) and who may write the books, one feature's runtime // evidence in one file, as the lines above. 'price-book-hooks.test.ts', + // #2344 — `campaign_member_enrollment_gate`: the direct doors against the + // flow door over every campaign status, the system-write and no-move + // controls, the moving edits, and the lowered body. + 'campaign-member-direct-create-gates.test.ts', ]; /** From fc5a8ab3f77b1acd1918e1fc48d60ceb5fcc52a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 23:09:37 +0000 Subject: [PATCH 3/6] chore(changeset): campaign members created by hand meet the enrollment gates Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAnCuJoiB6P78EsvrL5 --- .../2344-campaign-member-direct-create-gates.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/2344-campaign-member-direct-create-gates.md diff --git a/.changeset/2344-campaign-member-direct-create-gates.md b/.changeset/2344-campaign-member-direct-create-gates.md new file mode 100644 index 000000000..baa0527ff --- /dev/null +++ b/.changeset/2344-campaign-member-direct-create-gates.md @@ -0,0 +1,16 @@ +--- +"hotcrm": patch +--- + +**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. From 89e61cf3e52f68f478c7e4a2bd66d6d794e7bcce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 23:20:01 +0000 Subject: [PATCH 4/6] test(refusal-envelope): count the enrollment gate's three refusals Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAnCuJoiB6P78EsvrL5 --- test/refusal-envelope.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/refusal-envelope.test.ts b/test/refusal-envelope.test.ts index 3c01096b7..ca124bcef 100644 --- a/test/refusal-envelope.test.ts +++ b/test/refusal-envelope.test.ts @@ -142,11 +142,13 @@ describe('every refusal names a code the platform will echo (#1075)', () => { // the approval-locked deal's line hold beside it; 26 until #2313 added the // standard price book's three refusals and the one-entry-per-product one; // 30 until #2285 added the conversion-marker refusal to - // `account_protection`. + // `account_protection`; 31 until #2344 added + // `campaign_member_enrollment_gate`'s three (a closed campaign, an + // opted-out lead, an opted-out contact). // The number is hand-maintained on purpose: a new refusal has to be a // deliberate edit here, so a guard that quietly stopped being swept cannot // hide behind a count that follows it. - expect(sites).toHaveLength(31); + expect(sites).toHaveLength(34); }); it('uses only members of the platform ErrorCode enum', () => { From 7575147fd0a0b8624cb44b1a9828102763e24dee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 23:36:57 +0000 Subject: [PATCH 5/6] chore(changeset): carry the claim's declaration lines Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAnCuJoiB6P78EsvrL5 --- .changeset/2344-campaign-member-direct-create-gates.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.changeset/2344-campaign-member-direct-create-gates.md b/.changeset/2344-campaign-member-direct-create-gates.md index baa0527ff..c24e66d73 100644 --- a/.changeset/2344-campaign-member-direct-create-gates.md +++ b/.changeset/2344-campaign-member-direct-create-gates.md @@ -2,6 +2,9 @@ "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. From 57773e16b76547222c40ac96c0a872765a2f84c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 23:41:03 +0000 Subject: [PATCH 6/6] docs(marketing): name the object-side gate beside the enroll flows, and the doors it covers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAnCuJoiB6P78EsvrL5 --- content/docs/marketing/campaign-members.mdx | 2 +- content/docs/marketing/campaign-members.zh-Hans.mdx | 2 +- content/docs/marketing/campaign-members.zh-Hant.mdx | 2 +- src/marketing/flows/campaign-member-enroll.flow.ts | 12 ++++++++++-- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/content/docs/marketing/campaign-members.mdx b/content/docs/marketing/campaign-members.mdx index 2df01c1b2..0a078e05d 100644 --- a/content/docs/marketing/campaign-members.mdx +++ b/content/docs/marketing/campaign-members.mdx @@ -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). diff --git a/content/docs/marketing/campaign-members.zh-Hans.mdx b/content/docs/marketing/campaign-members.zh-Hans.mdx index aecd79364..4c1046dd2 100644 --- a/content/docs/marketing/campaign-members.zh-Hans.mdx +++ b/content/docs/marketing/campaign-members.zh-Hans.mdx @@ -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 添加,都会被拒绝并显示同样的消息,什么都不会保存。这条消息目前在所有语言下都以英文显示。 一个人随着时间推移可以加入多个营销活动,但每个营销活动只能加入一次(每人每个营销活动一个成员身份)。 diff --git a/content/docs/marketing/campaign-members.zh-Hant.mdx b/content/docs/marketing/campaign-members.zh-Hant.mdx index 5e2920458..4063c6fb9 100644 --- a/content/docs/marketing/campaign-members.zh-Hant.mdx +++ b/content/docs/marketing/campaign-members.zh-Hant.mdx @@ -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 新增,都會被拒絕並顯示同樣的訊息,什麼都不會儲存。這則訊息目前在所有語言下都以英文顯示。 一個人隨著時間推移可以加入多個行銷活動,但每個行銷活動只能加入一次(每人每個行銷活動一個成員身分)。 diff --git a/src/marketing/flows/campaign-member-enroll.flow.ts b/src/marketing/flows/campaign-member-enroll.flow.ts index e2eb224e8..0bfeb94fa 100644 --- a/src/marketing/flows/campaign-member-enroll.flow.ts +++ b/src/marketing/flows/campaign-member-enroll.flow.ts @@ -10,8 +10,8 @@ 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; @@ -19,6 +19,14 @@ type Flow = Automation.Flow; * (`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