Skip to content

fix(marketing): a campaign member who responded keeps the response when they unsubscribe (#2365) - #2372

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-1pb1zo
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-1pb1zo

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Irene · project thread
Fixes #2365

维护者速读

  • 改了什么:营销活动成员先「已响应」再「已退订」时,保留「已响应」标识和原响应时间;活动的「响应数量」改为统计曾经响应过的成员,退订不再拉低响应率。重置回「已发送」仍会清除。
  • 为什么改:Irene 10-29 对营销 D4 的裁决(「同意」决裁文档):退订不抹掉此人曾经响应过的事实。
  • 风险与回滚:低。只影响退订过的、曾响应的成员和这些活动的响应数;已有数据在下一次成员变动时按新口径重算。回滚即 revert 本 PR。
  • 先看:src/marketing/objects/campaign_member.hook.ts(清除条件改为「目标是已发送」)、src/marketing/objects/campaign.hook.ts(四份重算之一,num_responses 改数标识)、test/campaign-member-lifecycle.test.ts(新增 5 个用例)。
  • 两分钟验证:npx vitest run test/campaign-member-lifecycle.test.ts,预期 36 个全绿,其中「an unsubscribe keeps num_responses; a reset to sent lowers it」通过。
  • 需要你做的:无,席位落地。

What changes

  • (a) campaign_member_lifecycle (src/marketing/objects/campaign_member.hook.ts): the clear now fires only when the target status is sent and the member had a response (prior status responded/converted, or the stored has_responded). A move to unsubscribed keeps has_responded: true and the original response_date. Responded, then unsubscribed, then reset to sent still clears.
  • (b) The recompute, all four copies (campaign_metrics_refresh, campaign_member_metrics_refresh, campaign_lead_conversion_refresh, campaign_attribution_refresh): the member read takes has_responded instead of status, and num_responses counts has_responded === true. The four blocks stay character-identical (the existing identity test is green).
  • Seed: has_responded and the campaign's seeded num_responses derive from respondedDaysAgo. One existing unsubscribed member (Developer Content Marketing Push, lead Mira Costa) now carries a response 29 days ago, so the seed exercises the new rule; that campaign's seeded num_responses goes 4 to 5. The seed is insert-once, so existing databases keep their rows.
  • test/seed-consistency.test.ts: the old "track the responded status exactly" rule becomes: responded/converted carry the flag, sent never does, unsubscribed may be either, and response_date goes with the flag. A new case asserts the seed holds at least one responded-then-unsubscribed member. The metric no-op check counts the flag.
  • Docs: one line in the Marketing Manual (src/marketing/docs/crm_marketing_campaigns.md and .zh.md, the Number of Responses bullet). The docs-site pages this change made false are corrected in en, zh-Hans and zh-Hant: content/docs/marketing/campaigns*.mdx (Number of Responses) and content/docs/marketing/campaign-members*.mdx (the move-back sentence, the Has Responded row, the Responses formula, "computed live from member status").
  • Comment-only: campaign_member.object.ts (has_responded), campaign.actions.ts (mark_responded header), task.hook.ts (a cross-reference to the lifecycle clear).
  • Changeset: .changeset/campaign-response-kept-on-unsubscribe.md (patch).
  • Unchanged, per the card: the status options and who may unsubscribe.

Readers of num_responses / has_responded (git grep -n "num_responses\|has_responded" src/)

Reader Changes?
campaign.object.ts response_rate formula (num_responses / num_sent) No code change; now reads "ever responded"
campaign.view.ts list column num_responses No; displays the stored counter
four refresh hooks (above) Yes, the counted predicate
campaign_member_lifecycle Yes, the writer (a)
marketing.seed.ts derivation Yes, mirrors the hooks
campaign_member.object.ts, campaign.actions.ts Comment only
locale packs (src/sales/translations/*/objects.marketing.ts) No; field labels only
dashboards, datasets, reports, flows None reads either field

response_date readers (highlightFields, the member related-list columns on the campaign and on lead_detail.page.ts) are display only; an unsubscribed member who had responded now shows the date.

Tests

New in test/campaign-member-lifecycle.test.ts, run on the booted app:

  1. keeps the response when a member who responded unsubscribes (flag and the exact original date)
  2. a converted member who unsubscribes keeps the response too
  3. a member who never responded stays unflagged when they unsubscribe
  4. a reset to sent clears a response kept through an unsubscribe
  5. an unsubscribe keeps num_responses; a reset to sent lowers it (waits for the recompute's campaign write before reading "unchanged")

The existing "clears the summary when a member is reset out of a responded state" covers responded to sent.

Ablations (on-disk mutation, anchor counted, restored by git checkout HEAD, git diff HEAD empty after each):

Mutation Red
A: old clause back (any move out of responded clears) 1, 2, 5
B: all four recomputes count by status again 5
C: no clear at all existing reset case, 4, 5
D: clear keyed on prior status only 4

Acceptance notes

  • The seed change edits an existing demo row rather than adding one, so num_sent stays as it was everywhere.
  • Existing stored rows: a member already moved responded to unsubscribed before this ships has had its flag cleared; nothing here restores it (no data migration was asked for).

Verification

  • pnpm verify on 7e4d8d8: exit 0; validate passed, typecheck, lint, hygiene, build, tests 242 files / 4618 passed, 1 skipped.
  • Final head 06e7ee6 adds one comment line in task.hook.ts; re-ran pnpm typecheck (0), pnpm lint (0), and the two affected suites (79 passed).

🤖 Generated with Claude Code

https://claude.ai/code/session_01J8VVAoe2SeiEpzJxGKKf5e


Generated by Claude Code

…ount num_responses by has_responded (#2365)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8VVAoe2SeiEpzJxGKKf5e
…cs and changeset (#2365)

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

vercel Bot commented Oct 29, 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 29, 2026 2:35am UTC

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation 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 29, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

Verdict: ACCEPT · repo:hotcrm seat 1 · huangyiirene · session session_01J8VVAnPkgK6CWrJa6t7Fk5 · 2026-10-29T02:37Z · head 06e7ee6
Implemented-by: card thread session session_01J8VVAoe2SeiEpzJxGKKf5e (report 6486339306). Reviewed-by: the seat, against the 19-file diff and hotcrm origin/main 353dace.

  • Shape:
  • Ruling held (D4, keep the response record):
    • (a) campaign_member_lifecycle clears has_responded / response_date only when the target is sent. It reads the stored flag too, so responded → unsubscribed → sent clears. Unsubscribed → responded keeps the original date, because the stamp runs only when no date is stored.
    • (b) All four recompute copies count has_responded === true and read that field instead of status, so the identity test still holds.
    • The status options and who may unsubscribe are unchanged.
  • Seed: one member (Mira Costa) now responded and then unsubscribed. The seed's num_responses and has_responded derive from respondedDaysAgo. seed-consistency pins the new rule: responded/converted ⇒ flag, sent ⇒ no flag, a flag ⇒ a date.
  • Tests: five new cases: four lifecycle cases and one live num_responses case. The num_responses case first proves that the unsubscribe triggered a recompute before it asserts "unchanged". The report gives four ablations (A–D), each red and then restored.
  • Docs: the Marketing Manual line in en and zh; campaigns and campaign-members in en, zh-Hans and zh-Hant; a changeset.
  • Gates:
    • pnpm verify exits 0 at 7e4d8d8 (242 files, 4618 passed). 06e7ee6 adds one comment line.
    • CI on 06e7ee6: Typecheck and Build, Quality Checks, Check Changeset, Playwright, link-check and CodeQL are success. Build and Test is still running.
  • Landing: non-governed. In this stroke the seat marks the PR ready and turns on auto-merge.

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