Repository navigation
fix(marketing): a campaign member created by hand meets the closed-campaign and email-opt-out gates - #2351
Merged
Conversation
…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
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
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
…nd the doors it covers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8VVAnCuJoiB6P78EsvrL5
Contributor
Author
席位核对Verdict: ACCEPT ·
Generated by Claude Code |
This was referenced Oct 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requested by Irene · project thread
Fixes #2344
Clause-②: no
Metadata-protocol: none
维护者速读
campaign_member_enrollment_gate:用户直接新建成员(活动详情页「营销活动成员」列表的「新建」、以用户身份导入、REST)时,若营销活动不是「规划中 / 进行中」或已不存在,拒绝(400);若线索或联系人已勾选「退订邮件」,拒绝(403)。提示语与 Add to Campaign 完全相同。0f17c6c上,营销用户往 Completed 活动里给已退订线索建成员,REST 答 201,已结束活动的数字随之变化。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
What changes
One guard on the object, for the doors that skip the flows.
campaign_member_enrollment_gate(beforeInsert,beforeUpdate, priority 100) oncrm_campaign_member:ctx.user(this repo's system-write signal): the seed, an import run as system, andcampaign_lead_conversion_refresh;crm_campaign,crm_leadorcrm_contact; a key re-sent unchanged by the edit form moves nothing);display_title,status) and refuses anything butplanning/in_progress, an id naming no campaign included:VALIDATION_FAILED/ 400;email_opt_out === true:FORBIDDEN/ 403.The three sentences are the enroll flows' own English sentences, rendered the same way (
display_titleor "The chosen campaign"; first and last name;full_nameor "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'endrefusals cannot run at the insert. So a loweredbefore*hook, whichpnpm lint(os lint --strict) accepts as lowerable; the built artifact carries it as abody.source.Codes, by the house envelope (
src/sales/objects/_refusal.ts). A closed campaign isinvalid_value(VALIDATION_FAILED/ 400): the campaign lookup's ownlookupFiltersalready 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 isprohibited(FORBIDDEN/ 403): a compliance flag that forbids the write outright, the codetask_do_not_call_guardandevent_do_not_call_guardgive 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.flowRunIdandctx.user; a REST create carriesctx.userand no provenance; a seed carriessession.isSystemand no user. The guard does not skip onflowRunId: 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
=== truereading and the sentences exist as CEL and templates incampaign-member-enroll.flow.tsand 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.tsholds them together (Tests below).Tests
New
test/campaign-member-direct-create-gates.test.ts(19 cases), through@objectstack/verifyon the shipped app as amarketing_user:crm_campaign.statusoption read off the shipped metadata (planning, in_progress, completed, aborted) and each side: Add to Campaign (the flow door) andPOST /data/crm_campaign_member(the direct door; the panel's New sends this request). Same verdict; when refused,400 VALIDATION_FAILEDwithuserMessageequal to the flow'srefusalMessage, and no row. A status added later is driven through both without an edit.403 FORBIDDENwith the flow'srefusalMessage, no row. A closed campaign is reported before an opted-out person, as the flows order them.400 VALIDATION_FAILED, to an opted-out lead403 FORBIDDEN, the stored member unchanged.runShippedHook):SandboxErrorwithVALIDATION_FAILED/ 400 andFORBIDDEN/ 403; open + opted-in passes; a write with no user passes.Also:
test/runtime-coverage.test.tslists the new file;test/refusal-envelope.test.ts's hand-kept count goes 32 → 35 for the three newrefusesites (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 withgit checkout HEAD --, proven by marker 0,git diff HEADempty and the blob hash equal to HEAD's.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_OPENincampaign-member-enroll.flow.tswidened to admitcompleted, the hook untouched. Restored the same way.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/maina31d91a, at10f30f1:pnpm validate,pnpm typecheck,pnpm lint,pnpm hygiene,pnpm hygiene:tokens,pnpm buildall exit 0, and the full suite:The head
7575147adds only the two declaration lines to the changeset.Premise, re-measured at `0f17c6c` before editing
POST /api/v1/data/crm_campaign_memberas amarketing_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.jsonwithobjectstack dev -a, so the QuickJS body ran, not the in-process handler.VALIDATION_FAILED.)FORBIDDEN.)Campaign Member "CM-00052" created.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
ctx.provenance.flowRunId), and is deliberately not exempted; reason above.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.campaign-member-enroll.flow.ts's header still says every enrollment door reaches the insert only through the two flows, andcontent/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.Screenshots
See the browser re-check above; files are in project files
issue-2344/.Generated by Claude Code