Skip to content

fix(marketing): a campaign member created by hand meets the closed-campaign and email-opt-out gates - #2351

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-cf12dw
Oct 28, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-cf12dw

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Irene · project thread
Fixes #2344

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:营销活动成员对象上新增一个保存前守卫 campaign_member_enrollment_gate:用户直接新建成员(活动详情页「营销活动成员」列表的「新建」、以用户身份导入、REST)时,若营销活动不是「规划中 / 进行中」或已不存在,拒绝(400);若线索或联系人已勾选「退订邮件」,拒绝(403)。提示语与 Add to Campaign 完全相同。
  • 为什么改:fix(marketing): Add to Campaign skips the gates Enroll Members enforces (completed campaign, email opt-out), and a member can hold both a Lead and a Contact #2336 只把两道门放进了 enroll 子流程,直接建成员绕过了子流程:实测在 0f17c6c 上,营销用户往 Completed 活动里给已退订线索建成员,REST 答 201,已结束活动的数字随之变化。
  • 风险与回滚:只加一个钩子与测试。系统写入(种子、系统导入、线索转化时的成员升级)照常通过;不改成员归属的编辑(标记已响应、标记退订)照常通过;把成员改到另一个活动或另一个人,按新建同样检查。回滚即还原本 PR。
  • 先看:src/marketing/objects/campaign_member.hook.ts 的 campaignMemberEnrollmentGate(两次读取与三处 refuse),test/campaign-member-direct-create-gates.test.ts 的第一个 describe(两个入口逐状态对拍)。
  • 两分钟验证:pnpm vitest run test/campaign-member-direct-create-gates.test.ts,预期 19 个用例全部通过;或在控制台打开 CPG-0007(Completed)→ Related → 营销活动成员「新建」→ 选任一线索 → 创建,预期对话框内显示「CPG-0007 - Developer Content Marketing Push is not open for enrollment…」且不保存。
  • 需要你做的:无,席位落地。

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

What changes

One guard on the object, for the doors that skip the flows. campaign_member_enrollment_gate (beforeInsert, beforeUpdate, priority 100) on crm_campaign_member:

  • returns at once on a write with no ctx.user (this repo's system-write signal): the seed, an import run as system, and campaign_lead_conversion_refresh;
  • on an update, returns unless the write MOVES the member (a changed crm_campaign, crm_lead or crm_contact; a key re-sent unchanged by the edit form moves nothing);
  • reads the campaign as the caller (display_title, status) and refuses anything but planning / in_progress, an id naming no campaign included: VALIDATION_FAILED / 400;
  • then reads the Lead or Contact as the caller and refuses email_opt_out === true: FORBIDDEN / 403.

The three sentences are the enroll flows' own English sentences, rendered the same way (display_title or "The chosen campaign"; first and last name; full_name or "This contact").

Why a hook (the smallest mechanism that holds on 18.2.0). A validations[] rule sees only the member's own columns, and both gates read another record. There is no before-save flow trigger, so the flows' end refusals cannot run at the insert. So a lowered before* hook, which pnpm lint (os lint --strict) accepts as lowerable; the built artifact carries it as a body.source.

Codes, by the house envelope (src/sales/objects/_refusal.ts). A closed campaign is invalid_value (VALIDATION_FAILED / 400): the campaign lookup's own lookupFilters already declare only open campaigns as values of this field, so a closed one is a submitted value the object's rules reject. An opted-out person is prohibited (FORBIDDEN / 403): a compliance flag that forbids the write outright, the code task_do_not_call_guard and event_do_not_call_guard give Do Not Call.

The flows' own insert is not exempted, though it can be told apart. Measured: a flow data node's insert carries ctx.provenance.flowRunId and ctx.user; a REST create carries ctx.user and no provenance; a seed carries session.isSystem and no user. The guard does not skip on flowRunId: the flow has checked the same two things a moment before, so the check answers the same and the insert passes (the #2336 and #2167 suites stay green), while an exemption would let the next flow that writes a member skip both gates.

Moving a member is covered by the same guard. An edit that changes the campaign, Lead or Contact is checked on the record it would become. Mark Responded on a Completed campaign's member and Unsubscribed on an opted-out person's member still save.

Two copies, one test. The open statuses, the === true reading and the sentences exist as CEL and templates in campaign-member-enroll.flow.ts and as JS in the hook. A lowered body cannot import and a flow edge cannot call a hook, so they cannot share a source; test/campaign-member-direct-create-gates.test.ts holds them together (Tests below).

Tests

New test/campaign-member-direct-create-gates.test.ts (19 cases), through @objectstack/verify on the shipped app as a marketing_user:

  • Parity, both doors, every status. For each crm_campaign.status option read off the shipped metadata (planning, in_progress, completed, aborted) and each side: Add to Campaign (the flow door) and POST /data/crm_campaign_member (the direct door; the panel's New sends this request). Same verdict; when refused, 400 VALIDATION_FAILED with userMessage equal to the flow's refusalMessage, and no row. A status added later is driven through both without an edit.
  • Opted-out lead and contact on an open campaign: the flow refuses; the direct door answers 403 FORBIDDEN with the flow's refusalMessage, no row. A closed campaign is reported before an opted-out person, as the flows order them.
  • Controls: a system write (seed) of a Completed campaign's member naming an opted-out lead is stored; Responded on a Completed campaign's member with the form re-sending campaign and lead answers 200; Unsubscribed on an opted-out lead's member answers 200.
  • Moving edits: to a Completed campaign 400 VALIDATION_FAILED, to an opted-out lead 403 FORBIDDEN, the stored member unchanged.
  • The lowered body through the real QuickJS runner (runShippedHook): SandboxError with VALIDATION_FAILED / 400 and FORBIDDEN / 403; open + opted-in passes; a write with no user passes.

Also: test/runtime-coverage.test.ts lists the new file; test/refusal-envelope.test.ts's hand-kept count goes 32 → 35 for the three new refuse sites (merged with #2342's 31 → 32).

Ablation 1: guard off

Committed implementation, then if (!ctx.user?.id) return; → if (!ctx.user?.id || true) return; in the gate only (anchor count 1, marker 0 → 1), restored with git checkout HEAD --, proven by marker 0, git diff HEAD empty and the blob hash equal to HEAD's.

MUTANT_EXIT=1
  × lead side, completed campaign / aborted campaign
  × contact side, completed campaign / aborted campaign
  × lead side, opted out · × contact side, opted out
  × a closed campaign is named before an opted-out person
  × moving a member to a Completed campaign · × moving a member to an opted-out lead
  × refuses a closed campaign and an opted-out person (lowered body)
  Tests  10 failed | 9 passed (19)
restored: marker=0 diff_lines=0 hash_ok=yes

The 9 that stay green are the open-status parity cases, the controls and the pass-through case.

Ablation 2: drift in the flow copy

CAMPAIGN_OPEN in campaign-member-enroll.flow.ts widened to admit completed, the hook untouched. Restored the same way.

MUTANT_EXIT=1
  × lead side, completed campaign: the same verdict, in the same words
  × contact side, completed campaign: the same verdict, in the same words
  Tests  2 failed | 17 passed (19)
restored: marker=0 diff_lines=0 hash_ok=yes

A first try put a block comment inside the CEL source as the marker; that made the flow unregistrable ("Flow 'campaign_lead_member_enroll' not found"), so it measured nothing and was discarded.

Verification

After merging origin/main a31d91a, at 10f30f1: pnpm validate, pnpm typecheck, pnpm lint, pnpm hygiene, pnpm hygiene:tokens, pnpm build all exit 0, and the full suite:

 Test Files  233 passed (233)
      Tests  4509 passed | 1 skipped (4510)

The head 7575147 adds only the two declaration lines to the changeset.

Premise, re-measured at `0f17c6c` before editing

POST /api/v1/data/crm_campaign_member as a marketing_user: Completed campaign + opted-out lead → 201; Completed + opted-in → 201; In Progress + opted-out → 201. After the change, the same three answer 400, 400 and 403.

Browser re-check of the panel's New (shipped artifact, lowered body, dev admin, fresh seed)

Served from dist/objectstack.json with objectstack dev -a, so the QuickJS body ran, not the in-process handler.

  • CPG-0007 (Completed) → Related → Campaign Members → New, Lead = Lena Fischer → Create: the dialog stays open with "CPG-0007 - Developer Content Marketing Push is not open for enrollment, or no longer exists: only a Planning or In Progress campaign takes members. Nobody was enrolled." (POST answered 400 VALIDATION_FAILED.)
  • CPG-0001 (In Progress) → New, Lead = Leo Vance (opted out): "Leo Vance has opted out of email, so the lead was not added to the campaign." (403 FORBIDDEN.)
  • CPG-0001 → New, Lead = Lena Fischer: saved, toast Campaign Member "CM-00052" created.
  • zh-CN locale, CPG-0007 → New: the form is Chinese, the refusal is the English sentence (see Acceptance notes).

Screenshots: project files issue-2344/ (01-completed-campaign-new, 02-optedout-lead-new, 03-open-optedin-control, 04-zh-CN-completed-campaign-new).

Acceptance notes

  • Mechanism assumption 1, measured: the flow insert CAN be told apart (ctx.provenance.flowRunId), and is deliberately not exempted; reason above.
  • Mechanism assumption 2, measured: holds. The panel's New shows the refusal inside its dialog and keeps the form open.
  • Translations: not reusable by this mechanism. The flows' messages are translated in the packs under each enroll flow's refusals; 18.2.0's translation schema has keys for object validation messages (_validations) and flow refusals, and none for a hook's refusal. So the direct door answers in English in every locale (measured in zh-CN). The changeset says so. Reported to the seat as a platform gap; no copy of the translations is inlined in the hook.
  • Not changed, reported: campaign-member-enroll.flow.ts's header still says every enrollment door reaches the insert only through the two flows, and content/docs/marketing/campaign-members (en, zh-Hans, zh-Hant) names only Enroll Members and Add to Campaign as holding the two rules. Both files are outside this claim's file surface; a one-sentence edit each would cite the object guard.
  • Existing data. Members already stored are untouched; an edit that does not move a member passes the guard without a read.

Screenshots

See the browser re-check above; files are in project files issue-2344/.


Generated by Claude Code

…losed 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8VVAnCuJoiB6P78EsvrL5
…' verdicts and words

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8VVAnCuJoiB6P78EsvrL5
…t gates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8VVAnCuJoiB6P78EsvrL5
…-cf12dw

# Conflicts:
#	test/refusal-envelope.test.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8VVAnCuJoiB6P78EsvrL5
@vercel

vercel Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
hotcrm Ignored Ignored Oct 27, 2026 11:41pm UTC

Request Review

@github-actions github-actions Bot added ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces backend Server-side behaviour — hooks, flows, actions labels Oct 27, 2026
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Oct 27, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

Verdict: ACCEPT · repo:hotcrm seat 1 · huangyiirene · session session_01J8VVAnPkgK6CWrJa6t7Fk5 · 2026-10-28T00:03Z · head 6dfd723
Implemented-by: card thread session session_01J8VVAnCuJoiB6P78EsvrL5 (report 6456298779 on #2344; round 1 in the seat thread) · Reviewed-by: the seat, against the 9-file diff and hotcrm origin/main b8332aa

  • Shape:
    • Draft, base main, assignee huangyiirene. The body reads Fixes #2344.
    • No governed path is touched.
    • The surface was extended in round 1, at the seat's word, to the enroll-flow header and the campaign-members docs (en, zh-Hans, zh-Hant). Both would otherwise have stated something false once this landed.
    • GitHub reports the PR clean against b8332aa.
  • Ruling held:
    • campaign_member_enrollment_gate (beforeInsert / beforeUpdate) refuses a user's direct create of a campaign member, or a move of one, for two reasons, with the flows' own English sentences:
      • a campaign that is not Planning or In Progress, or that no longer exists: VALIDATION_FAILED / 400, because the lookup's lookupFilters already rule that value out;
      • a Lead or Contact with Email Opt Out stored true: FORBIDDEN / 403, the code task_do_not_call_guard gives its phone-side twin.
    • System writes, which carry no ctx.user, pass. An edit that moves no member also passes, for example Mark Responded on a member of a Completed campaign.
    • The flows' own insert is deliberately not exempted by ctx.provenance.flowRunId. The flow has already checked the same two things and answers the same way, and an exemption would reopen the hole for the next flow that writes a member. The seat agrees.
    • The two copies (flow CEL and hook JS) cannot share a source. test/campaign-member-direct-create-gates.test.ts drives both doors over every campaign status and both opt-out states, and fails if their verdicts or sentences part.
  • Mechanism assumptions:
    • 1: the flow insert can be told apart by its provenance. It is not exempted, for the reason above.
    • 2: the panel's New shows both refusals in the browser.
  • Tests:
    • The new file passes 19/19.
    • Ablations: with the guard off, 10 tests go red; with flow drift, 2 go red.
    • The refusal-envelope count is 35 on the merged tree (main's 32 + 3).
    • runtime-coverage lists the new hook.
  • Gates:
    • pnpm verify exit 0 at 6dfd723: 236 files, 4541 passed, 1 skipped.
    • All 10 check runs on 6dfd723 are success.
  • Out of scope, deduped by the seat: a hook's refusal has no translation key on 18.2.0, so zh-CN users read these sentences in English. objectstack#26170 already tracks this and is open, so nothing new is filed. The docs now say the message is English for now.
  • Landing: non-governed. The seat flips the PR to ready and arms auto-merge in this stroke.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Server-side behaviour — hooks, flows, actions ci/cd CI plumbing and the verification pipeline documentation Improvements or additions to documentation metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

2 participants