Skip to content

fix(campaign-member): 报名过市场活动的线索/联系人恢复可删除 —— 参与行随人级联删除 (#696) - #712

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-696-campaign-member-cascade
Aug 5, 2026
Merged

fix(campaign-member): 报名过市场活动的线索/联系人恢复可删除 —— 参与行随人级联删除 (#696)#712
yinlianghui merged 1 commit into
mainfrom
claude/issue-696-campaign-member-cascade

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #696

前提复核(先证伪,再动手)

issue 只是线索,先在 origin/main 上把它说的机制原样跑了一遍。前提成立,且比 issue 描述得更精确一点:问题不在于谁写错了 deleteBehavior,而在于根本没人写过它

Field.lookup 的 spec 默认值就是 set_null@objectstack/spec@17.0.0-rc.2deleteBehavior: z.enum(["set_null","cascade","restrict"]).optional().default("set_null"))。所以源码里一个字都没写,解析出来的字段却实打实地带着 "deleteBehavior":"set_null" —— 这正是它在 review 里隐身的原因:缺失的 key 是看不见的

在真实 ObjectQL + InMemoryDriver 上实测(修复前):

crm_lead field:    {... "reference":"crm_lead",    "deleteBehavior":"set_null" ...}
crm_contact field: {... "reference":"crm_contact", "deleteBehavior":"set_null" ...}

DELETE lead error: A campaign member must reference either a Lead or a Contact
members after:     [ …那行 member,原封不动… ]
lead after:        [ …那条 lead,还在… ]

即引擎的 cascadeDeleteRelationscrm_campaign_member.crm_lead 置空 → 它刚改过的这一行立刻违反本对象自己声明的 lead_or_contact_required → 整个删除回滚。issue 描述的"置空后自我违规"机制逐字属实

顺带确认了两件容易误判的事:

  • 提示里点名的 commit 0f72853fix(validations): make every validation predicate total so rules stop being silently skipped #634,把校验谓词做成 total)不是诱因。它只是给谓词加了 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,三条轴上都成立:

  • 真实业务需求:campaign_member 是一张 junction 行,全部语义就是"这个人报名了这场活动"。人没了,这行什么也不指。restrict 能给出一句准确的报错,但人依然删不掉 —— 只是把"删不掉"换成"先手工退订"。而本 issue 的 impact 是人删不掉(GDPR"删除此人"必须能走通),不是文案难看。纯改文案不构成对本 issue 的关闭。
  • 长期架构:级联之后,不存在任何可达状态能让一行已存储的 member 违反本对象自己的规则。规则从"声明了但可被引擎自己推翻"变成结构上恒真。
  • 让 AI 写的元数据不容易出错:这是声明式的一行,不是消费端的容忍(没有 ?? 兜底、没有宽松解析)。声明即执行。

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,正是当初触发规则的那种形状。

  • 结构钉:两个 party lookup 的 deleteBehavior 解析值为 cascade(缺 key 在 review 里隐身,所以断言的是解析后的值,顺带能接住未来 spec 默认值翻转)
  • 报名了多场活动的 lead 干净删除,其 member 行随之消失
  • contact 同样
  • 指向别人的 member 行(另一种 party 类型 / 同类型的另一条记录)全部幸存
  • 规则本身没被架空:直接插入一行谁也不指的 member,依旧被拒
  • 一行同时指向 lead 和 contact 的记录,任一方被删都会消失 —— 这是级联在两个 lookup 上的唯一语义代价,实测下来写进测试而不是留给下一个读代码的人踩。今天不可达(enrollment flow / enroll_leads 只写 crm_leadlead_conversion 不碰本对象,产品文档也写明"二者取一,不可兼有"),一旦 Campaign member lifecycle: support contacts, live metrics, and trim tracker fields that have no writer #597 之类的改动开始同时写两边,这条测试就是重新拍板的地方。

反向验证(方向是先预测再跑的)

预测:删掉那两行声明 → 结构钉 + 四条删除路径转红;不依赖本次改动的三条(规则仍被声明、campaign lookup 仍是默认值、空 member 仍被拒)保持绿。实跑一致:

× crm_lead cascades          AssertionError: expected 'set_null' to be 'cascade'
× crm_contact cascades       AssertionError: expected 'set_null' to be 'cascade'
× deletes a lead enrolled in several campaigns …
    promise rejected "ValidationError: A campaign member must r…" instead of resolving
× deletes an enrolled contact the same way          (同上)
× leaves every membership that named somebody else alone
    ValidationError: A campaign member must reference either a Lead or a Contact
× removes a both-parties row on either delete
    AssertionError: expected [ { …(9) } ] to have a length of +0 but got 1
Tests  6 failed | 3 passed (9)

转红时打出来的正是 issue 里那句原文,说明测试钉住的确实是这个缺陷本身。

验证

pnpm test        56 files / 1328 passed | 1 skipped
pnpm typecheck   clean
pnpm validate    ✓ Validation passed (1014ms)   —— 余下告警为既有项
pnpm lint        13 warning(s) / 14 suggestion(s)(全部既有)
pnpm hygiene     ✓ source hygiene clean
pnpm build       ✓ Build complete

平台依赖未动,仍钉在 @objectstack/* 17.0.0-rc.2;无任何平台侧改动或绕行。

顺带发现(已另开 issue,本 PR 不修)

crm_event_attendee同一构造的同一缺陷:三个 party lookup(crm_contact / crm_lead / sys_user)同样吃 set_null 默认值,同一对象上同样挂着 severity errorattendee_resolves。实测:

EVENT_ATTENDEE delete-lead: An attendee must point at a Contact, a Lead, a User, or name an external guest
EVENT_ATTENDEE lead survives? true

且并非僵尸代码 —— src/actions/global.actions.ts:339 会真实写入这些行,且从不填 external_name。已按越界即停的约定另开 #711,未在本 PR 触碰。

另外顺带核过、确认不是缺陷的两处:crm_task.related_to_requiredcrm_event.related_to_required 也命中同样的 set_null 扫描,但它们 severity 是 warning,不阻断写入。

一处未动的小账:content/docs/marketing/campaign-members.mdx 尚未提到"删除一个人会一并移除其活动报名记录"这一新行为,本次按派单的文件边界没有改文档 —— 变更说明已写进 changeset,是否补一句文档留给维护者定。


Generated by Claude Code

… 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

报名过市场活动的线索/联系人无法删除:级联把 campaign_member 的引用置空,随即违反它自己的校验规则

2 participants