fix(campaign-member): 报名过市场活动的线索/联系人恢复可删除 —— 参与行随人级联删除 (#696) - #712
Merged
Conversation
… is deleted (#696) Anyone who had ever been enrolled in a campaign was permanently undeletable, through the API and the UI, with a 400 naming an object the caller had not touched: DELETE /api/v1/data/crm_lead/<id> → 400 "A campaign member must reference either a Lead or a Contact" "object":"crm_lead" The cause was a default nobody wrote down. `crm_campaign_member.crm_lead` and `.crm_contact` declared no `deleteBehavior`, so both resolved to `Field.lookup`'s spec default `set_null` — measured on 17.0.0-rc.2, the resolved field literally reads `"deleteBehavior":"set_null"`. Deleting the person made the engine's `cascadeDeleteRelations` pass clear that column, the row it had just edited instantly violated `lead_or_contact_required` (the rule the same object declares), and the whole delete rolled back. `enroll_leads` and the `campaign_enrollment` flow are ordinary parts of the marketing flow, so ordinary use reached it, and a GDPR "delete this person" request could not be served. Both party lookups now declare `deleteBehavior: 'cascade'`. A campaign member is a junction row whose whole meaning is "this person is enrolled in this campaign"; once the person is gone the row denotes nothing. `restrict` would have produced an accurate message but left the person undeletable until someone un-enrolled them by hand, and the impact fixed here is undeletable people, not confusing text. Cascade also removes the state that made this possible: no stored member row can now be manoeuvred into breaking its object's own rule. The campaign side is deliberately unchanged — `crm_campaign` is a required lookup, which the engine escalates from `set_null` to `restrict`, so a campaign with members still refuses to delete. That is correct there and is pinned so a copy-paste cannot start shredding campaign history. test/campaign-member-cascade.test.ts drives a real ObjectQL over InMemoryDriver (the sparse-row driver, so the merged record has absent keys) and pins: a lead enrolled in several campaigns deletes cleanly and takes its memberships with it, the same for a contact, memberships naming anybody else survive, the rule still rejects a member row that names nobody, and — written down rather than left to be discovered — a row naming both parties goes when either party goes. Reverse-verified: deleting the two declarations turns 6 of the 9 tests red with the issue's exact error string, while the three that do not depend on the change (the rule is still declared, the campaign lookup is still on the default, the rule still rejects an empty member) stay green. Refs #696 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHrPAGEgFDoHjphqYG4BMa
yinlianghui
marked this pull request as ready for review
August 5, 2026 14:28
This was referenced Aug 5, 2026
Merged
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.
Fixes #696
前提复核(先证伪,再动手)
issue 只是线索,先在
origin/main上把它说的机制原样跑了一遍。前提成立,且比 issue 描述得更精确一点:问题不在于谁写错了deleteBehavior,而在于根本没人写过它。Field.lookup的 spec 默认值就是set_null(@objectstack/spec@17.0.0-rc.2:deleteBehavior: z.enum(["set_null","cascade","restrict"]).optional().default("set_null"))。所以源码里一个字都没写,解析出来的字段却实打实地带着"deleteBehavior":"set_null"—— 这正是它在 review 里隐身的原因:缺失的 key 是看不见的。在真实 ObjectQL + InMemoryDriver 上实测(修复前):
即引擎的
cascadeDeleteRelations把crm_campaign_member.crm_lead置空 → 它刚改过的这一行立刻违反本对象自己声明的lead_or_contact_required→ 整个删除回滚。issue 描述的"置空后自我违规"机制逐字属实。顺带确认了两件容易误判的事:
has()守卫;置空后isBlank(null)依然为真,规则照样命中。这个删除在加守卫之前和之后都是失败的。__referentialFieldClear这个上下文标记只被plugin-security用来放行字段权限检查,不会跳过校验规则 —— 所以指望平台自己网开一面是没有的。修法:
deleteBehavior: 'cascade',元数据一行到位先查了 hotcrm 的元数据表达能力:
deleteBehavior是 lookup/masterDetail 上的一等 key,本仓已有先例(crm_contact.crm_account就写着deleteBehavior: 'cascade')。既然元数据表达得了,就不必下沉到 hook ——lead.hook.ts/contact.hook.ts一个字都没动,#693 的守卫文案改写完整地留给后续那一轮。选
cascade而不是restrict,三条轴上都成立:restrict能给出一句准确的报错,但人依然删不掉 —— 只是把"删不掉"换成"先手工退订"。而本 issue 的 impact 是人删不掉(GDPR"删除此人"必须能走通),不是文案难看。纯改文案不构成对本 issue 的关闭。??兜底、没有宽松解析)。声明即执行。campaign 那一侧故意不动:
crm_campaign是 required lookup,引擎会把 required 上的set_null默认自动升级成restrict,所以有成员的活动依旧拒绝删除 —— 那里这才是对的(活动的成员名单是它自己的历史记录,不是某个人的附属品)。这条也写成了断言,防止有人照着上面两行一路 copy-paste 把活动历史削掉。测试:
test/campaign-member-cascade.test.ts(9 项)跑真实 ObjectQL over
InMemoryDriver—— 挑这个 driver 的理由和object-validation-predicates.test.ts一致:它只存写过的列,交给校验器的合并记录带的是缺失的 key,正是当初触发规则的那种形状。deleteBehavior解析值为cascade(缺 key 在 review 里隐身,所以断言的是解析后的值,顺带能接住未来 spec 默认值翻转)enroll_leads只写crm_lead,lead_conversion不碰本对象,产品文档也写明"二者取一,不可兼有"),一旦 Campaign member lifecycle: support contacts, live metrics, and trim tracker fields that have no writer #597 之类的改动开始同时写两边,这条测试就是重新拍板的地方。反向验证(方向是先预测再跑的)
预测:删掉那两行声明 → 结构钉 + 四条删除路径转红;不依赖本次改动的三条(规则仍被声明、campaign lookup 仍是默认值、空 member 仍被拒)保持绿。实跑一致:
转红时打出来的正是 issue 里那句原文,说明测试钉住的确实是这个缺陷本身。
验证
平台依赖未动,仍钉在
@objectstack/*17.0.0-rc.2;无任何平台侧改动或绕行。顺带发现(已另开 issue,本 PR 不修)
crm_event_attendee是同一构造的同一缺陷:三个 party lookup(crm_contact/crm_lead/sys_user)同样吃set_null默认值,同一对象上同样挂着 severityerror的attendee_resolves。实测:且并非僵尸代码 ——
src/actions/global.actions.ts:339会真实写入这些行,且从不填external_name。已按越界即停的约定另开 #711,未在本 PR 触碰。另外顺带核过、确认不是缺陷的两处:
crm_task.related_to_required与crm_event.related_to_required也命中同样的set_null扫描,但它们 severity 是warning,不阻断写入。一处未动的小账:
content/docs/marketing/campaign-members.mdx尚未提到"删除一个人会一并移除其活动报名记录"这一新行为,本次按派单的文件边界没有改文档 —— 变更说明已写进 changeset,是否补一句文档留给维护者定。Generated by Claude Code