From f63d2888de2a2a8fcac3ee0fcfd57f3fc6a55a13 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 02:22:24 +0000 Subject: [PATCH 1/3] fix(marketing): keep a member's response when they unsubscribe, and count num_responses by has_responded (#2365) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAoe2SeiEpzJxGKKf5e --- src/marketing/actions/campaign.actions.ts | 5 +- src/marketing/data/marketing.seed.ts | 29 ++++---- src/marketing/objects/campaign.hook.ts | 4 +- src/marketing/objects/campaign_member.hook.ts | 25 ++++--- .../objects/campaign_member.object.ts | 11 ++-- .../objects/lead.campaign-metrics.hook.ts | 4 +- .../opportunity.campaign-metrics.hook.ts | 4 +- test/campaign-member-lifecycle.test.ts | 66 +++++++++++++++++++ test/seed-consistency.test.ts | 30 ++++++--- 9 files changed, 137 insertions(+), 41 deletions(-) diff --git a/src/marketing/actions/campaign.actions.ts b/src/marketing/actions/campaign.actions.ts index 8d5d2cedb..61a796fcd 100644 --- a/src/marketing/actions/campaign.actions.ts +++ b/src/marketing/actions/campaign.actions.ts @@ -41,8 +41,9 @@ export const EnrollLeadsAction: Action = { * * The writer for the `responded` status, on the member row rather than on the * campaign: a rep hears back from one person, and one membership changes. - * `num_responses` and `response_rate` are computed off that status, so an app - * with no writer for it reports a structural zero on every campaign. + * `num_responses` and `response_rate` count the `has_responded` flag that + * status stamps, so an app with no writer for it reports a structural zero on + * every campaign. * * A declarative update (`operation: 'update'`), written AS THE CALLER (#2167): * the member's own `allowEdit` decides who may record a response, exactly as diff --git a/src/marketing/data/marketing.seed.ts b/src/marketing/data/marketing.seed.ts index 3ddfd8e9e..c1c09c5d9 100644 --- a/src/marketing/data/marketing.seed.ts +++ b/src/marketing/data/marketing.seed.ts @@ -46,7 +46,11 @@ type CampaignMemberSpec = { readonly contact?: string; readonly status: 'sent' | 'responded' | 'unsubscribed'; readonly addedDaysAgo: number; - /** Days ago the member responded. Required by `responded`, absent otherwise. */ + /** + * Days ago the member responded. Required by `responded`, absent on `sent`, + * and optional on `unsubscribed`: a member who responded and later + * unsubscribed keeps the response (#2365). + */ readonly respondedDaysAgo?: number; }; @@ -79,7 +83,9 @@ const CAMPAIGN_MEMBERS: Record = { 'Developer Content Marketing Push': [ { lead: 'lena.fischer@graniteinsurance.example.com', status: 'responded', addedDaysAgo: 33, respondedDaysAgo: 27 }, { lead: 'kai.watanabe@coralreefhotels.example.com', status: 'sent', addedDaysAgo: 33 }, - { lead: 'mira.costa@atlasconstruction.example.com', status: 'unsubscribed', addedDaysAgo: 33 }, + // Responded, then asked to stop hearing from you: still one of the + // campaign's responses (#2365). + { lead: 'mira.costa@atlasconstruction.example.com', status: 'unsubscribed', addedDaysAgo: 33, respondedDaysAgo: 29 }, { lead: 'pia.anand@citrinefinance.example.com', status: 'responded', addedDaysAgo: 32, respondedDaysAgo: 24 }, { lead: 'marco.ricci@auroratravel.example.com', status: 'sent', addedDaysAgo: 32 }, { lead: 'lisa.t@cloudfirst.example.com', status: 'responded', addedDaysAgo: 30, respondedDaysAgo: 21 }, @@ -154,11 +160,10 @@ const campaignMetrics = (campaignName: string) => { return { // "Total members enrolled" — campaign.hook.ts's single definition of num_sent. num_sent: members.length, - // `responded` or `converted`, as in `computeCampaignMetrics`. The seed set - // holds no `converted` member (see the note on CAMPAIGN_MEMBERS), so the - // two predicates coincide on this data — spelled the narrow way because - // widening it here would suggest a seeded state that does not exist. - num_responses: members.filter((m) => m.status === 'responded').length, + // Members who ever responded — the hooks count `has_responded`, which the + // member rows below derive from `respondedDaysAgo` — so an unsubscribed + // member who had responded still counts (#2365). + num_responses: members.filter((m) => m.respondedDaysAgo !== undefined).length, num_leads: leadKeys.size, num_converted_leads: convertedLeads, num_opportunities: attributed.length, @@ -311,10 +316,12 @@ const memberRecords = ( ...(kind === 'lead' ? { crm_lead: m.lead } : { crm_contact: m.contact }), status: m.status, added_date: celDaysAgo(m.addedDaysAgo), - // `has_responded` / `response_date` track the `responded` status - // exactly — a responded member with no response date is a shape the - // response-tracking surfaces cannot render. - has_responded: m.status === 'responded', + // `has_responded` / `response_date` record that the person responded: + // set on every `responded` member, never on a `sent` one, and kept on + // an `unsubscribed` member who had responded (#2365). A responded + // member with no response date is a shape the response-tracking + // surfaces cannot render. + has_responded: m.respondedDaysAgo !== undefined, ...(m.respondedDaysAgo !== undefined ? { response_date: celDaysAgo(m.respondedDaysAgo) } : {}), })), ); diff --git a/src/marketing/objects/campaign.hook.ts b/src/marketing/objects/campaign.hook.ts index 65e8bdaf5..d3a9d388c 100644 --- a/src/marketing/objects/campaign.hook.ts +++ b/src/marketing/objects/campaign.hook.ts @@ -149,7 +149,7 @@ const campaignMetricsRefresh: Hook = { for (const id of [campaignId]) { // ── recompute ── identical in all four refresh hooks; see the header. const memberRows = await api.object('crm_campaign_member').find({ - where: { crm_campaign: id }, fields: ['crm_lead', 'status'], top: 5000, + where: { crm_campaign: id }, fields: ['crm_lead', 'has_responded'], top: 5000, }); const leadIds = Array.from(new Set( memberRows.map((r) => (typeof r.crm_lead === 'string' ? r.crm_lead : '')).filter(Boolean), @@ -168,7 +168,7 @@ const campaignMetricsRefresh: Hook = { await api.object('crm_campaign').update({ id, num_sent: memberRows.length, - num_responses: memberRows.filter((r) => r.status === 'responded' || r.status === 'converted').length, + num_responses: memberRows.filter((r) => r.has_responded === true).length, num_leads: leadIds.length, num_converted_leads: convertedLeads, num_opportunities: numOpportunities, diff --git a/src/marketing/objects/campaign_member.hook.ts b/src/marketing/objects/campaign_member.hook.ts index 7f04b8959..1f38b3310 100644 --- a/src/marketing/objects/campaign_member.hook.ts +++ b/src/marketing/objects/campaign_member.hook.ts @@ -209,7 +209,9 @@ const campaignMemberEnrollmentGate: Hook = { * * `converted` counts as responded too: a member cannot convert without having * responded first, and `has_responded` is the "did this person ever engage" - * flag the response surfaces filter on. + * flag the response surfaces filter on. Because it means EVER, a responded + * member who later unsubscribes keeps it (#2365); only a reset to `sent` + * clears it, and the campaign's `num_responses` counts this flag. */ const campaignMemberLifecycle: Hook = { name: 'campaign_member_lifecycle', @@ -251,12 +253,17 @@ const campaignMemberLifecycle: Hook = { return; } - // Moving BACK out of a responded state (a mis-click corrected, or a member - // reset to `sent` for a re-send) clears the summary with it. Leaving - // `has_responded: true` behind on a `sent` member is the same disagreement - // in the other direction, and it inflates nothing visible — which is - // exactly why it would survive review. - if (wasResponded && typeof input.status === 'string') { + // Moving BACK to `sent` (a mis-click corrected, or a member reset for a + // re-send) clears the summary with it: `sent` means "never engaged", and + // `has_responded: true` left on a `sent` member is the same disagreement in + // the other direction. A move to `unsubscribed` is NOT a move back (#2365, + // ruled D4): the person did respond, and opting out afterwards does not + // undo that, so the flag and the original `response_date` stay — and the + // campaign's `num_responses`, which counts the flag, does not drop on every + // unsubscribe. A member that responded, unsubscribed and is then reset to + // `sent` is cleared too, hence the check on the stored flag. + const hadResponse = wasResponded || previous?.has_responded === true; + if (status === 'sent' && hadResponse && typeof input.status === 'string') { input.has_responded = false; input.response_date = null; return; @@ -388,7 +395,7 @@ const campaignMemberMetricsRefresh: Hook = { for (const id of campaignIds) { // ── recompute ── identical in all four refresh hooks; see the header. const memberRows = await api.object('crm_campaign_member').find({ - where: { crm_campaign: id }, fields: ['crm_lead', 'status'], top: 5000, + where: { crm_campaign: id }, fields: ['crm_lead', 'has_responded'], top: 5000, }); const leadIds = Array.from(new Set( memberRows.map((r) => (typeof r.crm_lead === 'string' ? r.crm_lead : '')).filter(Boolean), @@ -407,7 +414,7 @@ const campaignMemberMetricsRefresh: Hook = { await api.object('crm_campaign').update({ id, num_sent: memberRows.length, - num_responses: memberRows.filter((r) => r.status === 'responded' || r.status === 'converted').length, + num_responses: memberRows.filter((r) => r.has_responded === true).length, num_leads: leadIds.length, num_converted_leads: convertedLeads, num_opportunities: numOpportunities, diff --git a/src/marketing/objects/campaign_member.object.ts b/src/marketing/objects/campaign_member.object.ts index 2e74cd1e9..7ccebffdf 100644 --- a/src/marketing/objects/campaign_member.object.ts +++ b/src/marketing/objects/campaign_member.object.ts @@ -270,11 +270,12 @@ export const CampaignMember = ObjectSchema.create({ group: 'response', }), - // Kept in lockstep with `status == "responded"` by - // `campaign_member_lifecycle` (beforeInsert/beforeUpdate) — a boolean that - // disagrees with the status it summarises is the shape the response - // surfaces cannot render, and it is what `test/seed-consistency.test.ts` - // already pins on the seed side. + // "Did this person ever respond", kept by `campaign_member_lifecycle` + // (beforeInsert/beforeUpdate): set on `responded` / `converted`, cleared + // on a reset to `sent`, and KEPT when a member who responded unsubscribes + // (#2365) — the campaign's `num_responses` counts this flag, so an opt-out + // does not erase a response. `test/seed-consistency.test.ts` pins the same + // rule on the seed side. has_responded: Field.boolean({ label: 'Has Responded', defaultValue: false, diff --git a/src/sales/objects/lead.campaign-metrics.hook.ts b/src/sales/objects/lead.campaign-metrics.hook.ts index 83b8cf88a..f2eee569e 100644 --- a/src/sales/objects/lead.campaign-metrics.hook.ts +++ b/src/sales/objects/lead.campaign-metrics.hook.ts @@ -101,7 +101,7 @@ const campaignLeadConversionRefresh: Hook = { for (const id of campaignIds) { // ── recompute ── identical in all four refresh hooks; see the header. const memberRows = await api.object('crm_campaign_member').find({ - where: { crm_campaign: id }, fields: ['crm_lead', 'status'], top: 5000, + where: { crm_campaign: id }, fields: ['crm_lead', 'has_responded'], top: 5000, }); const leadIds = Array.from(new Set( memberRows.map((r) => (typeof r.crm_lead === 'string' ? r.crm_lead : '')).filter(Boolean), @@ -120,7 +120,7 @@ const campaignLeadConversionRefresh: Hook = { await api.object('crm_campaign').update({ id, num_sent: memberRows.length, - num_responses: memberRows.filter((r) => r.status === 'responded' || r.status === 'converted').length, + num_responses: memberRows.filter((r) => r.has_responded === true).length, num_leads: leadIds.length, num_converted_leads: convertedLeads, num_opportunities: numOpportunities, diff --git a/src/sales/objects/opportunity.campaign-metrics.hook.ts b/src/sales/objects/opportunity.campaign-metrics.hook.ts index 752f81d4b..0a4087b89 100644 --- a/src/sales/objects/opportunity.campaign-metrics.hook.ts +++ b/src/sales/objects/opportunity.campaign-metrics.hook.ts @@ -79,7 +79,7 @@ const campaignAttributionRefresh: Hook = { for (const id of campaignIds) { // ── recompute ── identical in all four refresh hooks; see the header. const memberRows = await api.object('crm_campaign_member').find({ - where: { crm_campaign: id }, fields: ['crm_lead', 'status'], top: 5000, + where: { crm_campaign: id }, fields: ['crm_lead', 'has_responded'], top: 5000, }); const leadIds = Array.from(new Set( memberRows.map((r) => (typeof r.crm_lead === 'string' ? r.crm_lead : '')).filter(Boolean), @@ -98,7 +98,7 @@ const campaignAttributionRefresh: Hook = { await api.object('crm_campaign').update({ id, num_sent: memberRows.length, - num_responses: memberRows.filter((r) => r.status === 'responded' || r.status === 'converted').length, + num_responses: memberRows.filter((r) => r.has_responded === true).length, num_leads: leadIds.length, num_converted_leads: convertedLeads, num_opportunities: numOpportunities, diff --git a/test/campaign-member-lifecycle.test.ts b/test/campaign-member-lifecycle.test.ts index c52425b86..3536c7384 100644 --- a/test/campaign-member-lifecycle.test.ts +++ b/test/campaign-member-lifecycle.test.ts @@ -190,6 +190,50 @@ describe('campaign_member_lifecycle', () => { expect(row.response_date ?? null).toBeNull(); }); + /** + * #2365, ruled (marketing D4): an unsubscribe does not erase the fact that + * the person responded. Before the ruling, any move out of `responded` + * cleared both fields, `unsubscribed` included. + */ + it('keeps the response when a member who responded unsubscribes', async () => { + const m = await member(); + await editMember(m.id, { status: 'responded' }); + const answered = await stored('crm_campaign_member', m.id); + expect(typeof answered.response_date, 'the response was stamped first').toBe('string'); + await editMember(m.id, { status: 'unsubscribed' }); + const row = await stored('crm_campaign_member', m.id); + expect(row.status).toBe('unsubscribed'); + expect(row.has_responded, 'the person did respond').toBe(true); + expect(row.response_date, 'the original stamp, not a new one and not a cleared one').toBe(answered.response_date); + }); + + it('a converted member who unsubscribes keeps the response too', async () => { + const m = await member(); + await editMember(m.id, { status: 'converted' }); + await editMember(m.id, { status: 'unsubscribed' }); + expect((await stored('crm_campaign_member', m.id)).has_responded).toBe(true); + }); + + it('a member who never responded stays unflagged when they unsubscribe', async () => { + const m = await member(); + await editMember(m.id, { status: 'unsubscribed' }); + const row = await stored('crm_campaign_member', m.id); + expect(row.has_responded).toBe(false); + expect(row.response_date ?? null).toBeNull(); + }); + + it('a reset to sent clears a response kept through an unsubscribe', async () => { + // `sent` means "never engaged", whatever status the member left: the + // stored flag, not only the prior status, decides whether to clear. + const m = await member(); + await editMember(m.id, { status: 'responded' }); + await editMember(m.id, { status: 'unsubscribed' }); + await editMember(m.id, { status: 'sent' }); + const row = await stored('crm_campaign_member', m.id); + expect(row.has_responded).toBe(false); + expect(row.response_date ?? null).toBeNull(); + }); + it('defaults has_responded on a fresh enrollment', async () => { const row = await stored('crm_campaign_member', (await member()).id); expect(row.has_responded).toBe(false); @@ -433,6 +477,28 @@ describe('campaign_member_metrics_refresh — LIVE, not at completion', () => { expect(row.num_sent).toBe(2); }); + /** + * #2365: `num_responses` counts `has_responded`, not the status, so the + * response rate is "ever responded". Counted by status, an unsubscribe of a + * responded member took the response away with it. + */ + it('an unsubscribe keeps num_responses; a reset to sent lowers it', async () => { + const { campaign, m1, m2 } = await live(); + await editMember(m1.id, { status: 'responded' }); + await editMember(m2.id, { status: 'responded' }); + await settlesAt(campaign.id, { num_responses: 2 }); + // "Unchanged" is only evidence once the recompute has run: wait for the + // engine to receive the campaign write this unsubscribe triggers. + const engine = await engineWritesDuring(() => editMember(m1.id, { status: 'unsubscribed' })); + expect( + engine.of('crm_campaign', 'update').filter((w) => (w.args[1] as Rec).id === campaign.id), + 'the unsubscribe did not trigger a recompute, so this case would prove nothing', + ).not.toHaveLength(0); + expect((await stored('crm_campaign', campaign.id)).num_responses).toBe(2); + await editMember(m2.id, { status: 'sent' }); + await settlesAt(campaign.id, { num_responses: 1, num_sent: 2 }); + }); + it('removing a member decrements it, again with no status transition', async () => { const { campaign, m2 } = await live(); await verify.hooks.run('crm_campaign_member', 'delete', { id: m2.id }, { as: admin }); diff --git a/test/seed-consistency.test.ts b/test/seed-consistency.test.ts index 013dcf9d2..e78d74244 100644 --- a/test/seed-consistency.test.ts +++ b/test/seed-consistency.test.ts @@ -329,7 +329,9 @@ describe('the campaign metric refresh would be a no-op over the seeds', () => { const won = attributed.filter((o) => String(o.stage) === 'closed_won'); const expected: Record = { num_sent: enrolled.length, - num_responses: enrolled.filter((m) => String(m.status) === 'responded').length, + // The hooks count the flag, not the status (#2365): an unsubscribed + // member who had responded is still a response. + num_responses: enrolled.filter((m) => m.has_responded === true).length, num_leads: leadIds.size, num_converted_leads: convertedLeads, num_opportunities: attributed.length, @@ -416,23 +418,35 @@ describe('campaign members only use lifecycle values a writer produces (#597)', expect(bad, `fields with no writer: ${[...new Set(bad)].join(', ')}`).toEqual([]); }); - it('has_responded and response_date track the responded status exactly', () => { + // `campaign_member_lifecycle`'s rule since #2365 (marketing D4): responded + // and converted carry the flag, `sent` never does, and `unsubscribed` may be + // either — a person who responded and then opted out keeps the response. + it('has_responded follows the status, and response_date goes with has_responded', () => { const problems: string[] = []; for (const m of members) { - const responded = String(m.status) === 'responded'; - if (Boolean(m.has_responded) !== responded) { - problems.push(`${String(m.crm_campaign)}: has_responded ${String(m.has_responded)} for status ${String(m.status)}`); + const status = String(m.status); + const flagged = m.has_responded === true; + if ((status === 'responded' || status === 'converted') && !flagged) { + problems.push(`${String(m.crm_campaign)}: ${status} member without has_responded`); } - if (responded && m.response_date == null) { + if (status === 'sent' && flagged) { + problems.push(`${String(m.crm_campaign)}: has_responded on a sent member`); + } + if (flagged && m.response_date == null) { problems.push(`${String(m.crm_campaign)}: responded member without a response_date`); } - if (!responded && m.response_date != null) { - problems.push(`${String(m.crm_campaign)}: response_date on a ${String(m.status)} member`); + if (!flagged && m.response_date != null) { + problems.push(`${String(m.crm_campaign)}: response_date on a ${status} member that never responded`); } } expect(problems, problems.join('\n')).toEqual([]); }); + it('seeds an unsubscribed member who had responded, so the rule is exercised', () => { + const kept = members.filter((m) => String(m.status) === 'unsubscribed' && m.has_responded === true); + expect(kept.length, 'no seeded member responded and then unsubscribed').toBeGreaterThan(0); + }); + it('every member carries an added_date (the enrollment stamp)', () => { expect(members.filter((m) => m.added_date == null)).toEqual([]); }); From 7e4d8d8589930552ff6a2de0bcee4471a21efa34 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 02:24:35 +0000 Subject: [PATCH 2/3] =?UTF-8?q?docs(marketing):=20a=20response=20survives?= =?UTF-8?q?=20an=20unsubscribe=20=E2=80=94=20manual,=20site=20docs=20and?= =?UTF-8?q?=20changeset=20(#2365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAoe2SeiEpzJxGKKf5e --- .changeset/campaign-response-kept-on-unsubscribe.md | 5 +++++ content/docs/marketing/campaign-members.mdx | 8 ++++---- content/docs/marketing/campaign-members.zh-Hans.mdx | 8 ++++---- content/docs/marketing/campaign-members.zh-Hant.mdx | 8 ++++---- content/docs/marketing/campaigns.mdx | 2 +- content/docs/marketing/campaigns.zh-Hans.mdx | 2 +- content/docs/marketing/campaigns.zh-Hant.mdx | 2 +- src/marketing/docs/crm_marketing_campaigns.md | 5 +++-- src/marketing/docs/crm_marketing_campaigns.zh.md | 2 +- 9 files changed, 24 insertions(+), 18 deletions(-) create mode 100644 .changeset/campaign-response-kept-on-unsubscribe.md diff --git a/.changeset/campaign-response-kept-on-unsubscribe.md b/.changeset/campaign-response-kept-on-unsubscribe.md new file mode 100644 index 000000000..1ef257f70 --- /dev/null +++ b/.changeset/campaign-response-kept-on-unsubscribe.md @@ -0,0 +1,5 @@ +--- +"hotcrm": patch +--- + +**A campaign member who responded and then unsubscribes still counts as a response.** Before, moving a member from *Responded* or *Converted* to *Unsubscribed* cleared **Has Responded** and **Response Date**, and the campaign's **Number of Responses** counted members by status, so every unsubscribe lowered the campaign's **Response Rate %**. Now the member keeps **Has Responded** and the original **Response Date** when they unsubscribe, and **Number of Responses** counts members who ever responded (**Has Responded**), so the response rate is "ever responded". Resetting a member to *Sent* still clears both. The demo data now includes one member who responded and later unsubscribed. The Marketing Manual and the Campaigns and Campaign Members docs say so in English and Chinese. diff --git a/content/docs/marketing/campaign-members.mdx b/content/docs/marketing/campaign-members.mdx index 0a078e05d..4d0c48d26 100644 --- a/content/docs/marketing/campaign-members.mdx +++ b/content/docs/marketing/campaign-members.mdx @@ -32,7 +32,7 @@ The member status is the response lifecycle, and every value on it has something | 🟣 **Converted** | Their lead went on to become a customer | Automatic, when that lead is converted | | 🔴 **Unsubscribed** | They asked to stop hearing from you | A user who may edit campaign members, on the member record | -These four are not a ladder that only climbs. Most members stay at *Sent*; one who answers moves to *Responded*, and a member still at *Sent* or *Responded* whose lead is later converted is promoted to *Converted* for you. Moving a member back — a mis-click corrected, or a reset to *Sent* for a re-send — clears the response stamp with it. An *Unsubscribed* member is never promoted by that conversion sweep: an opt-out is not overwritten by a marketing outcome. +These four are not a ladder that only climbs. Most members stay at *Sent*; one who answers moves to *Responded*, and a member still at *Sent* or *Responded* whose lead is later converted is promoted to *Converted* for you. Moving a member back — a mis-click corrected, or a reset to *Sent* for a re-send — clears the response stamp with it. Unsubscribing a member who had responded does not: the response stays on the member and still counts as one of the campaign's responses. An *Unsubscribed* member is never promoted by that conversion sweep: an opt-out is not overwritten by a marketing outcome. ## Why both lead and contact? @@ -56,7 +56,7 @@ If a lead is later converted to a contact, the existing campaign memberships sta | Added Date | When the person was enrolled — stamped automatically the moment the membership is saved, whichever of the three ways above created it, and read-only afterwards | | Status | Where the person got to (the four above) | | Response Date | When they responded, stamped the first time they reach a responded status | -| Has Responded | The yes/no shortcut, kept in step with *Status* for you — *Converted* counts as responded | +| Has Responded | Whether the person ever responded, kept for you — *Converted* counts as responded, a reset to *Sent* clears it, and a member who responded keeps it after unsubscribing | ## What is tracked, and what is not @@ -75,7 +75,7 @@ The campaign detail page rolls up its members: ``` Sent = every member of the campaign -Responses = members at Responded or Converted +Responses = members who ever responded (Has Responded), unsubscribed ones included Response rate = Responses ÷ Sent, as a percentage ``` @@ -90,7 +90,7 @@ On the campaign detail page, under the **Related** tab, in a panel titled **Camp Two things readers arrive expecting, and where they really are: - There is no *Members* tab. Campaign membership is one panel among the campaign's other related records on the shared *Related* tab. -- There are no saved member list views — not *All Members*, *Responded*, *Unsubscribed* or *Converted to Opportunity*. Campaign Member ships no list views at all, and a related-list panel would not read them if it did: the panel's columns are curated on the campaign link itself, never from a view. Nothing ships that narrows the panel to one status, and no report ships over Campaign Member either, so "everyone who responded" is a number on the campaign rather than a list you can open: see [How the metrics roll up](#how-the-metrics-roll-up), where the response rate is computed live from member status. +- There are no saved member list views — not *All Members*, *Responded*, *Unsubscribed* or *Converted to Opportunity*. Campaign Member ships no list views at all, and a related-list panel would not read them if it did: the panel's columns are curated on the campaign link itself, never from a view. Nothing ships that narrows the panel to one status, and no report ships over Campaign Member either, so "everyone who responded" is a number on the campaign rather than a list you can open: see [How the metrics roll up](#how-the-metrics-roll-up), where the response rate is computed live from the members' responses. ## Tips for marketers diff --git a/content/docs/marketing/campaign-members.zh-Hans.mdx b/content/docs/marketing/campaign-members.zh-Hans.mdx index 4c1046dd2..dcf3002ce 100644 --- a/content/docs/marketing/campaign-members.zh-Hans.mdx +++ b/content/docs/marketing/campaign-members.zh-Hans.mdx @@ -32,7 +32,7 @@ description: 加入每个营销活动的线索和联系人,以及如何跟踪 | 🟣 **已转化** | 其线索后来成为了客户 | 该线索转化时自动写入 | | 🔴 **已退订** | 他们要求不再收到联系 | 可以编辑营销活动成员的用户,在成员记录上设置 | -这四个取值并不是一部只升不降的阶梯。大多数成员停在*已发送*;作出回应的移到*已响应*;仍处于*已发送*或*已响应*、其线索后来转化的成员会被自动提升为*已转化*。把成员改回去——纠正一次误点,或为了重发而重置回*已发送*——会连同响应时间戳一起清除。*已退订*的成员永远不会被那次转化扫描提升:退订不会被一个营销结果覆盖掉。 +这四个取值并不是一部只升不降的阶梯。大多数成员停在*已发送*;作出回应的移到*已响应*;仍处于*已发送*或*已响应*、其线索后来转化的成员会被自动提升为*已转化*。把成员改回去——纠正一次误点,或为了重发而重置回*已发送*——会连同响应时间戳一起清除。把一个已经响应过的成员设为*已退订*则不会:响应留在成员上,仍算作营销活动的一次响应。*已退订*的成员永远不会被那次转化扫描提升:退订不会被一个营销结果覆盖掉。 ## 为什么同时支持线索和联系人? @@ -56,7 +56,7 @@ description: 加入每个营销活动的线索和联系人,以及如何跟踪 | Added Date | 该人加入的时间——无论通过上面三种方式中的哪一种加入,都在保存成员记录的那一刻自动写入,之后只读 | | Status | 该人走到了哪一步(见上文四种状态) | | Response Date | 他们响应的时间,在首次进入已响应状态时打上 | -| Has Responded | 是/否的快捷标识,由系统与*状态*保持一致——*已转化*同样算作已响应 | +| Has Responded | 此人是否曾经响应过,由系统维护——*已转化*同样算作已响应;重置回*已发送*会清除它;响应过的成员退订后仍保留它 | ## 什么被跟踪,什么没有 @@ -75,7 +75,7 @@ description: 加入每个营销活动的线索和联系人,以及如何跟踪 ``` Sent = 该营销活动的全部成员 -Responses = 处于 Responded 或 Converted 的成员 +Responses = 曾经响应过的成员(Has Responded),含后来退订的 Response rate = Responses ÷ Sent,以百分比表示 ``` @@ -90,7 +90,7 @@ Response rate = Responses ÷ Sent,以百分比表示 读者常带着两个预期过来,它们真正的落点是: - **没有 *Members* 选项卡。** 成员面板只是活动在共用的 *Related* 选项卡上的关联记录面板之一。 -- **没有任何成员列表视图** —— *All Members*、*Responded*、*Unsubscribed*、*Converted to Opportunity* 都不存在。Campaign Member 对象一个列表视图都没有;即便有,关联列表面板也不读子对象的列表视图:面板的列是在活动关联字段上直接定好的,从来不来自视图。产品里没有任何东西能把这个面板按状态筛选,也没有面向 Campaign Member 的报表,所以「所有响应过的人」是活动上的一个**数字**而不是一份可以打开的名单——见上文《指标如何汇总》一节,那里的响应率由成员状态实时算出。 +- **没有任何成员列表视图** —— *All Members*、*Responded*、*Unsubscribed*、*Converted to Opportunity* 都不存在。Campaign Member 对象一个列表视图都没有;即便有,关联列表面板也不读子对象的列表视图:面板的列是在活动关联字段上直接定好的,从来不来自视图。产品里没有任何东西能把这个面板按状态筛选,也没有面向 Campaign Member 的报表,所以「所有响应过的人」是活动上的一个**数字**而不是一份可以打开的名单——见上文《指标如何汇总》一节,那里的响应率由成员的响应实时算出。 ## 给营销人员的提示 diff --git a/content/docs/marketing/campaign-members.zh-Hant.mdx b/content/docs/marketing/campaign-members.zh-Hant.mdx index 4063c6fb9..495450538 100644 --- a/content/docs/marketing/campaign-members.zh-Hant.mdx +++ b/content/docs/marketing/campaign-members.zh-Hant.mdx @@ -34,7 +34,7 @@ description: 加入每個行銷活動的潛在客戶和聯絡人,以及如何 | 🟣 **已轉化** | 其潛在客戶後來成為了客戶 | 該潛在客戶轉化時自動寫入 | | 🔴 **已退訂** | 他們要求不再收到聯絡 | 可以編輯行銷活動成員的使用者,在成員記錄上設定 | -這四個取值並不是一部只升不降的階梯。大多數成員停在*已發送*;作出回應的移到*已回應*;仍處於*已發送*或*已回應*、其潛在客戶後來轉化的成員會被自動提升為*已轉化*。把成員改回去——修正一次誤點,或為了重寄而重設回*已發送*——會連同回應時間戳一起清除。*已退訂*的成員永遠不會被那次轉化掃描提升:退訂不會被一個行銷結果覆蓋掉。 +這四個取值並不是一部只升不降的階梯。大多數成員停在*已發送*;作出回應的移到*已回應*;仍處於*已發送*或*已回應*、其潛在客戶後來轉化的成員會被自動提升為*已轉化*。把成員改回去——修正一次誤點,或為了重寄而重設回*已發送*——會連同回應時間戳一起清除。把一個已經回應過的成員設為*已退訂*則不會:回應留在成員上,仍算作行銷活動的一次回應。*已退訂*的成員永遠不會被那次轉化掃描提升:退訂不會被一個行銷結果覆蓋掉。 ## 為什麼同時支援潛在客戶和聯絡人? @@ -58,7 +58,7 @@ description: 加入每個行銷活動的潛在客戶和聯絡人,以及如何 | Added Date | 該人加入的時間——無論透過上面三種方式中的哪一種加入,都在儲存成員記錄的那一刻自動寫入,之後唯讀 | | Status | 該人走到了哪一步(見上文四種狀態) | | Response Date | 他們回應的時間,在首次進入已回應狀態時打上 | -| Has Responded | 是/否的快捷標識,由系統與*狀態*保持一致——*已轉化*同樣算作已回應 | +| Has Responded | 此人是否曾經回應過,由系統維護——*已轉化*同樣算作已回應;重設回*已發送*會清除它;回應過的成員退訂後仍保留它 | ## 什麼被追蹤,什麼沒有 @@ -77,7 +77,7 @@ description: 加入每個行銷活動的潛在客戶和聯絡人,以及如何 ``` Sent = 該行銷活動的全部成員 -Responses = 處於 Responded 或 Converted 的成員 +Responses = 曾經回應過的成員(Has Responded),含後來退訂的 Response rate = Responses ÷ Sent,以百分比表示 ``` @@ -92,7 +92,7 @@ Response rate = Responses ÷ Sent,以百分比表示 讀者常帶著兩個預期過來,它們真正的落點是: - **沒有 *Members* 索引標籤。** 成員面板只是活動在共用的 *Related* 索引標籤上的關聯記錄面板之一。 -- **沒有任何成員清單檢視** —— *All Members*、*Responded*、*Unsubscribed*、*Converted to Opportunity* 都不存在。Campaign Member 物件一個清單檢視都沒有;即便有,關聯清單面板也不讀子物件的清單檢視:面板的欄位是在活動關聯欄位上直接定好的,從來不來自檢視。產品裡沒有任何東西能把這個面板按狀態篩選,也沒有面向 Campaign Member 的報表,所以「所有回應過的人」是活動上的一個**數字**而不是一份可以開啟的名單——見上文《指標如何彙總》一節,那裡的回應率由成員狀態即時算出。 +- **沒有任何成員清單檢視** —— *All Members*、*Responded*、*Unsubscribed*、*Converted to Opportunity* 都不存在。Campaign Member 物件一個清單檢視都沒有;即便有,關聯清單面板也不讀子物件的清單檢視:面板的欄位是在活動關聯欄位上直接定好的,從來不來自檢視。產品裡沒有任何東西能把這個面板按狀態篩選,也沒有面向 Campaign Member 的報表,所以「所有回應過的人」是活動上的一個**數字**而不是一份可以開啟的名單——見上文《指標如何彙總》一節,那裡的回應率由成員的回應即時算出。 ## 給行銷人員的提示 diff --git a/content/docs/marketing/campaigns.mdx b/content/docs/marketing/campaigns.mdx index a0d0026c4..1fe38a6a1 100644 --- a/content/docs/marketing/campaigns.mdx +++ b/content/docs/marketing/campaigns.mdx @@ -83,7 +83,7 @@ For real-time campaigns (e.g., a webinar with a registration form), you can also The numbers live on the campaign record itself — the counters and the response rate in *Performance*, the money and ROI in *Budget & ROI*. Every one of them is recomputed as members are enrolled or answer, as their leads convert, and as opportunities are attributed to the campaign: - **Number Sent** — every member enrolled in the campaign, whatever their status. -- **Number of Responses** — members at *Responded* **or** *Converted*: a member whose lead converted answered too, and the counter says so. +- **Number of Responses** — members who ever responded: those at *Responded* **or** *Converted* (a member whose lead converted answered too), and a member who responded and later became *Unsubscribed* — an opt-out does not take the response back. - **Response Rate %** — responses ÷ number sent, as a percentage. It is the only rate the campaign carries. - **Number of Leads** — the distinct leads among those members. - **Converted Leads** — how many of those leads have since been converted. diff --git a/content/docs/marketing/campaigns.zh-Hans.mdx b/content/docs/marketing/campaigns.zh-Hans.mdx index 24f996431..da441e568 100644 --- a/content/docs/marketing/campaigns.zh-Hans.mdx +++ b/content/docs/marketing/campaigns.zh-Hans.mdx @@ -83,7 +83,7 @@ description: 规划、启动并衡量市场营销举措——电子邮件、网 这些数字就在营销活动记录本身上——各项计数与响应率在 *Performance* 部分,金额与 ROI 在 *Budget & ROI* 部分。它们会随着成员加入、成员响应、其线索转化、商机归因到该营销活动而重新计算: - **Number Sent** — 加入该营销活动的全部成员,无论其状态为何。 -- **Number of Responses** — 状态为*已响应* **或** *已转化*的成员:线索已转化的成员同样作出过响应,这个计数把他们算了进来。 +- **Number of Responses** — 曾经作出响应的成员:状态为*已响应* **或** *已转化*的成员(线索已转化的成员同样作出过响应),以及先响应、后来变为*已退订*的成员——退订不会把那次响应收回。 - **Response Rate %** — 响应数 ÷ 发送数量的百分比。这是营销活动上唯一的一个“率”。 - **Number of Leads** — 这些成员中不重复的线索数。 - **Converted Leads** — 其中后来完成了转化的线索数。 diff --git a/content/docs/marketing/campaigns.zh-Hant.mdx b/content/docs/marketing/campaigns.zh-Hant.mdx index 1a5c8a8f5..ab8f548bf 100644 --- a/content/docs/marketing/campaigns.zh-Hant.mdx +++ b/content/docs/marketing/campaigns.zh-Hant.mdx @@ -85,7 +85,7 @@ description: 規劃、啟動並衡量行銷舉措——電子郵件、網路研 這些數字就在行銷活動記錄本身上——各項計數與回應率在 *Performance* 部分,金額與 ROI 在 *Budget & ROI* 部分。它們會隨著成員加入、成員回應、其潛在客戶轉化、商機歸因到該行銷活動而重新計算: - **Number Sent** — 加入該行銷活動的全部成員,無論其狀態為何。 -- **Number of Responses** — 狀態為*已回應* **或** *已轉化*的成員:潛在客戶已轉化的成員同樣作出過回應,這個計數把他們算了進來。 +- **Number of Responses** — 曾經作出回應的成員:狀態為*已回應* **或** *已轉化*的成員(潛在客戶已轉化的成員同樣作出過回應),以及先回應、後來變為*已退訂*的成員——退訂不會把那次回應收回。 - **Response Rate %** — 回應數 ÷ 傳送數量的百分比。這是行銷活動上唯一的一個「率」。 - **Number of Leads** — 這些成員中不重複的潛在客戶數。 - **Converted Leads** — 其中後來完成了轉化的潛在客戶數。 diff --git a/src/marketing/docs/crm_marketing_campaigns.md b/src/marketing/docs/crm_marketing_campaigns.md index ba3de35fa..003abbf3f 100644 --- a/src/marketing/docs/crm_marketing_campaigns.md +++ b/src/marketing/docs/crm_marketing_campaigns.md @@ -61,8 +61,9 @@ are added, answer or leave, as their leads convert, and as deals name the campaign. A running campaign shows real figures; you do not wait for it to end. - **Number Sent** — every member of the campaign, whatever their status. -- **Number of Responses** — members at **Responded** or **Converted**: a member - whose lead converted answered too. +- **Number of Responses** — members who responded, whatever their status now: + **Responded** and **Converted** members, and a member who responded and later + became **Unsubscribed** still counts as a response. - **Response Rate %** — responses as a share of the number sent. - **Number of Leads** — the different leads among the members. - **Converted Leads** — how many of those leads have been converted. diff --git a/src/marketing/docs/crm_marketing_campaigns.zh.md b/src/marketing/docs/crm_marketing_campaigns.zh.md index 3a0804585..b8b51e04d 100644 --- a/src/marketing/docs/crm_marketing_campaigns.zh.md +++ b/src/marketing/docs/crm_marketing_campaigns.zh.md @@ -40,7 +40,7 @@ HotCRM 不跟踪打开、点击或退信。它能观察到的是一个人作出 这些数字就在营销活动记录上,并且随时保持最新:成员加入、回应或移除,其线索转化,以及商机关联到该活动时都会重算。正在进行的活动显示的就是真实数字,不必等它结束。 - **发送数量**——活动的全部成员,不论状态。 -- **响应数量**——处于**已响应**或**已转化**的成员:线索已转化的成员也算作回应过。 +- **响应数量**——作出过回应的成员,不论现在处于什么状态:**已响应**和**已转化**的成员都算,先回应、后来变为**已退订**的成员也仍算作一次响应。 - **响应率(%)**——响应数量占发送数量的比例。 - **线索数量**——成员中不同线索的个数。 - **已转化线索数**——这些线索中已转化的个数。 From 06e7ee606e09c7bbb9fcc6a10044ac8abe5640e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 02:34:08 +0000 Subject: [PATCH 3/3] docs(tasks): the member lifecycle clears response_date on a reset to sent (#2365) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8VVAoe2SeiEpzJxGKKf5e --- src/sales/objects/task.hook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sales/objects/task.hook.ts b/src/sales/objects/task.hook.ts index f1a5be215..7ff74e409 100644 --- a/src/sales/objects/task.hook.ts +++ b/src/sales/objects/task.hook.ts @@ -106,7 +106,7 @@ const taskValidation: Hook = { // back to an open status kept its Completed Date, and the field is // readonly, so nobody could clear it by hand: an open task read as // finished on the date it had been closed. Same shape as the member - // lifecycle hook clearing `response_date` on leaving a responded state. + // lifecycle hook clearing `response_date` on a reset to `sent`. // The key is hook-written, so the readonly strip leaves it alone. if ( previous?.status === 'completed' &&