[PRD] Qontak CDP | Customer Health Score — Phase 1: Rule-Based Scoring Engine (MVP)
Evolves: [PRD] Customer Signal Label (page 51135449719) — the label-only Hot/Warm/Cold discovery. That doc explicitly deferred numeric 0–100 scoring as "too complex for MVP"; this PRD delivers the numeric score with the label derived from configurable thresholds, so the health score subsumes the signal label. Grounded: validated against
contact-service(CDP),qontak.com(CRM),qontak-customer-fe, and the chat stack — see Appendix A. Benchmarked against Zoho Zia, HubSpot, Salesforce, and Gainsight — see Appendix B. Aligned + repo-grounded (v1.4): metrics aligned across Customer Health Score · Customer Segmentation · CEBE — Field Attributes reuse the sharedsegment_engineoperator logic per field type; use-case signals (Engagement/Conversion/Support/CSAT) come from their real systems of record — CRM (qontak.com) + omnichannel (hub-chat), some already in CDP — ingested via a CDP consumer or the CEBE metrics layer (CEBE is a path, not the source).
HEADER BLOCK
| Field | Value |
|---|---|
| PM | Zhelia Alifa |
| PRD Version | 1.7 |
| Status | DRAFT |
| PRD Type | NEW |
| Epic | TF-3498 |
| Squad | CDP Squad |
| RFC Link | N/A — RFC to follow this PRD |
| Figma Master | TBD — score config builder + score display |
| Anchor | No — standalone CDP feature |
| Labels | epic:qontak-cdp | module:customers | feature:customer-health-score |
| Last Updated | 2026-07-08 |
Table of Contents
- HEADER BLOCK
- 1. One-liner + Problem
- 2. What Happens If We Don't Build This
- 3. Target Users + Persona Context
- 4. Non-Goals
- Scope Changes
- 5. Constraints
- 6. New Features
- 7. API & Webhook Behavior
- 8. System Flow + User Stories + ACs
- 9. Rollout
- 10. Observability
- 11. Success Metrics
- 12. Launch Plan & Stage Gates
- 13. Dependencies
- 14. Key Decisions + Alternatives Rejected
- 15. Open Questions
- Appendix A — Grounded Code References
- Appendix B — Competitive Benchmark
- PRD CHANGELOG
1. One-liner + Problem
One-liner: A configurable 0–100 Customer Health Score for CDP contacts, so Sales/Marketing/CS prioritise by one explainable number instead of gut feel.
Problem: CDP today is a data viewer, not an intelligence layer. A contact's profile, conversations, deals, tickets, and CSAT all exist in Qontak, but there is no single measure of how healthy / high-intent / at-risk a customer is. As a result:
- Sales follow-up is inconsistent and driven by intuition (which contact to call first?).
- Marketing campaign targeting is broad and inefficient (no way to segment by health).
- CS misses at-risk customers until churn is visible in lost deals or complaints.
- There is no numeric foundation for downstream automation, re-engagement triggers, or next-best-action.
The earlier Customer Signal Label effort validated the need but shipped only a coarse Hot/Warm/Cold label. Clients asked for a transparent, tunable score they can configure to their own definition of "healthy" — not a fixed 3-bucket label and not a black-box ML score.
2. What Happens If We Don't Build This
- CDP stays a passive data viewer — the "intelligence layer" positioning in the CDP roadmap does not materialise, capping CDP adoption and stickiness (the initiative's north-star metric).
- Teams keep prioritising customers by manual judgement, so follow-up stays inconsistent and high-intent customers keep slipping through.
- No numeric substrate exists for the next CDP bets — segmentation by health, health-triggered broadcasts, re-engagement automation, next-best-action — each of which assumes a score.
- Competitors (Zoho Zia, HubSpot, Salesforce, Gainsight — Appendix B) already ship customer/health scoring; Qontak CDP remains behind on a table-stakes CRM-intelligence capability.
3. Target Users + Persona Context
| Persona | Role | Goal | Pain (today) | Workaround |
|---|---|---|---|---|
| Primary — CDP Admin / Ops | Configures CDP for the company | Encode the company's definition of a "healthy" customer once, so the whole team works from one consistent score | No scoring exists; can only build static segments with no notion of degree/priority | Build many manual segments + written playbooks that quickly go stale |
| Secondary — Score consumer (Sales · Marketing · CS) | Works contacts day-to-day — Sales prioritises follow-up, Marketing targets campaigns, CS retains accounts | See at a glance which contacts to act on first (call, target, or save) | No ranking — Sales scans raw fields/deals per contact, Marketing has no health signal to segment on, CS only sees at-risk after churn/complaint | Personal spreadsheets + gut feel; broad low-reply blasts; reactive manual account reviews |
4. Non-Goals
- No machine-learning / predictive model in MVP. The score is a transparent rule-based computation the admin fully configures — not an AI-trained predictor like Zoho Zia (which requires training data). AI/ML scoring is a possible later phase.
- No real-time (per-event) recalculation — the score is recomputed on a daily schedule. Sub-daily/real-time recompute is out of scope for MVP.
- No more than one active health-score configuration per client in MVP (a client cannot run several parallel scoring schemes).
- NPS is out of MVP scope — satisfaction is CSAT-only. Per-customer CSAT is the sole satisfaction
signal for MVP (grounded: CRM
csatspercrm_person). Per-customer NPS is not modelled in MVP — the CRM only has per-agent NPS today (users.last_nps_submission) — and can be revisited in a later phase (see OQ-1 / D-5). - No per-metric custom formula language — clients pick from the curated metric catalog (§6.2) and set point values / thresholds; they cannot write arbitrary expressions.
- No historical back-scoring / score history timeline in MVP — only the current score + last-computed timestamp are stored (trend history is a later phase).
- No automation/trigger actions off the score (broadcast, re-engagement, next-best-action) in MVP — the score is produced and displayed; acting on it is downstream scope.
- Score is not a per-agent or per-company-account rollup — it is scoped to a single CDP contact.
Scope Changes
Engineering surfaces this PRD touches (controlled vocab). Kept in sync with the scope_changes frontmatter above.
- Backend —
contact-service: net-newhealth_score(int) +health_score_components+health_score_label+health_score_computed_aton the contact doc; a company-scopedhealth_score_configcollection (components on/off, per-metric rules, thresholds, labels — modelled on the existingCustomerSegmentconfig); a daily recompute job reusing thegocraft/workcron pattern; a per-contact signal-aggregation step (counts/sums/averages) reusing thesegment_engineoperator layer. - Backend —
qontak.com: expose per-contact aggregate signals the CDP score needs that are not fully derivable from CDP activity logs today — won-deal count + deal-size sum + lost-deal count, ticket count/open/priority, CSAT average, call count (see §7 + D-4). - Frontend —
qontak-customer-fe: score-config admin UI (component on/off, metric picker ≤3/component, thresholds, labels), Customer Index score column + label filter, Customer Detail score breakdown (per-component contribution + contributing rules). - Data — daily per-contact aggregation of conversation / WhatsApp-call / campaign / deal / ticket / CSAT signals into the score.
- Design — Figma for the config builder and the score display (currently TBD).
5. Constraints
| Constraint | Value |
|---|---|
| Feature flag | cdp_customer_health_score_enabled | default: OFF — enabled per company by Ops. |
| Plan / tier | CDP-enabled plans — Growth and Enterprise (same tier as other CDP intelligence features). Not Starter. |
| Platform | Config + display on web (qontak-customer-fe). Score is computed server-side in contact-service; mobile read-only display is out of scope for MVP (score field will be returned by the contact API, so mobile can render later with no backend change). |
| Score range & structure | Final score is an integer 0–100. Default template = 5 components, each capped at 20. A client may turn components on/off; when N components are active, each active component's cap auto-redistributes to round(100 / N) (remainder → first component; rounding rule is OQ-4). The client may also manually edit the per-component caps, as long as active caps still total exactly 100. |
| Metrics per component | Maximum 3 active metrics per component (MVP complexity guard). Metrics are chosen from the curated catalog (§6.2); each metric is a rule condition → points (points may be positive or negative). |
| Component score | component_score = clamp( Σ points of matched rules, 0, component_cap ) — a component can never go below 0 or above its cap. |
| Final score | health_score = Σ component_score over active components (already ≤ Σ caps = 100). |
| Labels & thresholds | Client defines threshold bands → label. Qontak ships defaults: 75–100 = Healthy · 50–74 = Neutral · 0–49 = At-risk. Bands and label text are fully editable (label names, colours); bands must be contiguous and cover 0–100 with no gaps/overlaps. |
| Active configs | Exactly one active health_score_config per company in MVP (additional configs may be saved as draft/archived but only one is active). |
| Recompute cadence | Daily batch recompute (scheduler), plus a manual "recompute now" trigger for admins. Not real-time. |
| Performance | Daily recompute ≤ 4 hours for the largest tenant; batched contact updates (bulk write). Customer Index must render the stored score with no added latency (score is a stored field, not computed at read time). Config save ≤ 2s. |
| Data availability (repo-grounded) | Field Attributes → shared segment_engine over Postgres customer_datamart (operators per field type — §6.2), reachable now (default + custom). Use-case signals come from their systems of record: conversation recency/frequency + campaign delivered/read are reachable in CDP now (activity_log); deals / tickets / CSAT / WA-calls live in the CRM (qontak.com) per crm_person; campaign reply/rate/type + WA-call activity live in the omnichannel backend (hub-chat); orders via external_activity_log (now, if a Loyalty/POS integration posts them). The CRM + omnichannel signals are not ingested into CDP today → delivered via a new/extended CDP consumer or the CEBE metrics layer (§13; D-10). NPS is out of MVP scope (OQ-1). Store split: attributes + the label filter live in customer_datamart; the contact doc is MongoDB — store-of-record is OQ-8. |
| Explainability | The score must be explainable — the Customer Detail view shows each component's contribution and which rules matched. No black-box output. |
5.1 Data Lifecycle
| Artifact | Retention | Visibility |
|---|---|---|
health_score + component breakdown + label on the contact | Live — overwritten each daily recompute (no history kept in MVP; trend history is a later phase, OQ-7) | Same visibility as the contact (respects contact permission / team-owner scope) |
health_score_config (active) | Persistent until edited/archived | CDP Admin only |
health_score_config (draft / archived versions) | Retained for audit/rollback of a scoring scheme | CDP Admin only |
| Recompute job run log (per company) | 90 days | Internal / CDP Squad |
| Per-contact signal aggregates (intermediate) | Transient within a job run; not persisted beyond the computed component scores | Internal |
6. New Features
Three net-new CDP surfaces + one backend engine, plus a read-only score display on the Inbox customer-profile panel — web inbox (hub-chat) and chat mobile app (cross-team surfaces; see CHS-S08 & §13). No net-new contact fields are user-editable — the
score is system-computed. The score renders inside the customer-profile section, so its visibility is inherited from that section's view permission — a user without profile-view permission sees the section hidden and the score with it (CHS-S08; no separate health-score permission). Once computed, the score is also consumed across CDP: as a numeric segment condition (CHS-S09), on the MAG public API — get-all + single customer (CHS-S10), as a filter in the direct Select Contact menu (CHS-S11), and via the S2S customer-details API for other services (CHS-S12).
Component tree:
Customer Health Score (CDP)
├── Health Score Config (Admin) — company-scoped, 1 active
│ ├── Component toggles (on/off; 5 default) + auto cap redistribution (client-editable caps)
│ ├── Per-component metric picker (≤3 metrics from the curated catalog §6.2)
│ │ └── each metric = condition (operator + value) → points (+/-)
│ ├── Threshold bands → labels (default Healthy/Neutral/At-risk; editable)
│ └── Save / Activate / "Recompute now"
├── Scoring Engine (backend, contact-service)
│ ├── SignalAggregator — per-contact daily aggregates (fields via segment_engine; deals/tickets/CSAT/calls from CRM; campaign/calls from omnichannel; some via CDP consumers today)
│ ├── ScoreEvaluator — applies config rules (reuses segment_engine operators) → component scores → total → label
│ └── DailyRecomputeJob — gocraft/work cron; bulk-writes health_score onto contacts
├── Customer Index — new "Health Score" column (score + label chip) + filter by label
└── Customer Detail — score + label + breakdown (per-component contribution + which rules matched)
UI states (all three surfaces):
| Surface | Loading | Empty | Error | Success |
|---|---|---|---|---|
| Config builder | Skeleton form | No config yet → "Start from default template" CTA | Inline validation (bands overlap/gap; >3 metrics; caps ≠ 100) blocks save | Config saved + "active"; "Recompute now" enabled |
| Customer Index column | Column skeleton | Score not yet computed → "—" (neutral placeholder) | Column hidden, list still renders (fail-silent) | Score + coloured label chip; filter by label works |
| Customer Detail breakdown | Panel skeleton | "Health score not yet computed for this contact" | "Couldn't load score breakdown" (contact still renders) | Score, label, 5 component bars, matched-rule explanation |
📊 UI State — Config Builder & Score Display
stateDiagram-v2
[*] --> NoConfig
NoConfig --> Editing: "Start from default template"
Editing --> ValidationError: save fails (caps≠100 / >3 metrics / band gap-overlap)
ValidationError --> Editing: fix highlighted field
Editing --> ActiveConfig: save + activate (valid)
ActiveConfig --> Editing: edit config
ActiveConfig --> Recomputing: daily schedule OR "Recompute now"
Recomputing --> Scored: bulk-write score + label + computed_at
Recomputing --> HeldOnShift: >40% label-band shift (distribution guard §9.2)
HeldOnShift --> ActiveConfig: admin confirms OR revert to prior version
Scored --> Recomputing: next run / config change
Scored --> [*]
note right of Scored
Contact surfaces: Index column + label chip,
Detail breakdown, + Inbox customer-profile panel (web + chat mobile). Not-yet-scored shows "—".
end note
6.1 Scoring Model
Definitions
- A component is one of the five signal groups. The default template enables all five; a
client can disable any (min 1 active). Active components split 100 points evenly by default:
cap = round(100 / active_count)(remainder → first component; OQ-4) — but the client may edit the per-component caps manually, as long as active caps still total exactly 100. - A metric is one rule inside a component:
condition (field/operator/value) → points. Points can be positive (adds health) or negative (subtracts). Max 3 metrics per component. - Component score =
clamp( Σ matched-rule points, 0, cap ). - Health score =
Σ component_scoreacross active components → integer 0–100. - Label = the band the health score falls into (default 75–100 Healthy / 50–74 Neutral / 0–49 At-risk; editable).
Worked example (default template, all 5 components, cap 20 each):
| Component | Active metrics (example) | Matched | Raw | Capped |
|---|---|---|---|---|
| Field Attributes | phone/email/username is_not_empty (+10); Job title contains "Manager/Director" (+5); Source ∈ {Referral, Website} (+5) | all three | +20 | 20 |
| Engagement | Last convo ≤7d (+8); ≥3 convos/30d (+7); campaign reply rate ≥20% (+5) | all three | +20 | 20 |
| Conversion | Won deals ≥1 (+10); won deal rate ≥50% (+5); recent won ≤90d (+5) | all three | +20 | 20 |
| Support | No open ticket (+10); issues resolved ≥80% (+10) | both | +20 | 20 |
| Satisfaction (CSAT) | Avg CSAT ≥4/5 (+12); recent CSAT ≤90d (+8) | both | +20 | 20 |
| Total | 100 → Healthy |
Recompute & change semantics: editing the active config or clicking "Recompute now" re-scores all contacts on the next job run; the score reflects the config live at compute time. When components are toggled, caps auto-redistribute to total 100 (and the client may edit caps manually) — a contact's absolute score can therefore shift after a config change (this is expected; flagged in Observability + Rollback, §9/§10).
6.2 Metric Catalog (curated — repo-grounded)
Qontak ships a default template (the ★ metrics below are pre-selected). Clients swap in other catalog metrics (max 3 active per component) and set the point values. Each metric is grounded in its real system of record — the operator engine for attributes, and the CRM / omnichannel stack for the use-case signals — not a single abstract source.
🔗 Alignment + source model (read first). The Health Score composes what the CDP already has and reuses the same substrates as Customer Segmentation + CEBE, but the data comes from the real systems of record (grounded in Appendix A):
- Field Attributes (Component 1) → the
segment_engineoperator logic per field type, for default and custom fields — the same operator set the Customer Segmentation — Basic Attributes PRD defines. One operator engine, reused (D-11).- Use-case components read per-customer signals from their systems of record: CRM (
qontak.com) for deals / tickets / CSAT / calls (percrm_person—person.rb,csat.rb,call_recordings), and the omnichannel / WhatsApp backend behindhub-chatfor campaign delivery/reply/type + WA-call activity. Some are already ingested in CDP today (conversation room events, campaign delivered/read, orders viaexternal_activity_log); the rest need an ingestion path (a CDP consumer or the CEBE metrics layer, §13). CEBE is an ingestion path, not the source — the source is the CRM / omnichannel data above (D-10).
Source legend (grounded — Appendix A): ✅ CDP now (already in contact-service) · 🗄️ CRM qontak.com (per crm_person) · 💬 omnichannel / hub-chat (WA / campaign backend) · ⚙️ shared segment_engine · ⏳ needs ingestion (exists at source, not yet in CDP — via a CDP consumer or CEBE, §13).
Component 1 — Field Attributes (default & custom fields) (static profile / firmographic fit — evaluated by the shared segment_engine operators, per field type; ⚙️ ✅ reachable now over Postgres customer_datamart, segment.go:34–52). The client picks a field, and the operators available are driven by that field's type — the same reusable set as Customer Segmentation (default & custom alike) — then sets the example rule → points:
| Field type | Operators (reusable — Segmentation ⇄ Health Score) | Example rule → points |
|---|---|---|
Text (single_line_text / multi_line_text / url) | is · is_not · contains · does_not_contain · starts_with · ends_with · is_empty · is_not_empty | Job title contains "Manager/Director/C-" → +5; email is_not_empty → +3 (profile completeness) |
Dropdown / multi_select | is · is_not · contains · does_not_contain · contains_any · contains_all · is_empty · is_not_empty | ★ Customer Type is "VIP" → +10; ★ Lifecycle Status is "Active Customer" → +5; Source contains_any {Referral, Website} → +3 |
| Number (+ currency / income tier) | equals · not_equals · greater_than · less_than · between · is_empty · is_not_empty | Avg annual income greater_than X → +5; company size between a…b → +N |
| Date | on · not_on · before · after · between · is_empty · is_not_empty | Customer-since before [date] → +5 (tenure); last profile update after [date] → +N |
| Boolean | is · is_not | Is-loyalty-member is true → +5; marketing opt-in is true → +3 |
| File / signature / GPS | is_empty · is_not_empty only | ID / KTP document is_not_empty → +3 (verified profile) |
★ default-template presets: Customer Type, Lifecycle Status, and a profile-completeness rule.
Component 2 — Engagement (interaction recency, frequency & campaign responsiveness — every metric carries a time window)
| Metric | Operator (+ window) | Example rule → points | Source |
|---|---|---|---|
| ★ Recent conversation | last-activity ≤ N days | last conversation ≤ 7d → +8 | ✅ CDP now (activity_log room, room_consumer.go) · 💬 hub-chat rooms (last_message_at) |
| ★ Conversation frequency | count ≥ N in window | ≥ 3 conversations in 30d → +7 | ✅ CDP now (activity_log room events) · 💬 hub-chat rooms per qontak_customer_id |
| ★ Campaign reply rate | ratio ≥ % in window | replies / delivered ≥ 20% in 90d → +5 | 💬 hub-chat (message_status_count + inbound is_campaign) ⏳ |
| Campaign delivered rate | ratio ≥ % in window | delivered / sent ≥ 90% in 90d → +N | 💬 hub-chat (message_status_count) · ✅ delivered/read in CDP now ⏳ |
| Number of campaign replies | count ≥ N in window | ≥ 2 campaign replies in 90d → +N | 💬 hub-chat (inbound is_campaign) ⏳ |
| Last campaign delivered | last-delivered ≤ N days | delivered ≤ 30d → +N | ✅ CDP now (activity_log campaign delivered/read) |
| Last campaign reply | last-reply ≤ N days | reply ≤ 30d → +N | 💬 hub-chat (inbound is_campaign) ⏳ |
| Campaign reply type | type is {broadcast / utility} | reply to a utility template → +N (vs broadcast) | 💬 hub-chat (message_template.category MARKETING / UTILITY / AUTH) ⏳ |
| WhatsApp call — count | count ≥ N in window | ≥ 1 call in 90d → +N | 🗄️ CRM call_recordings (crm_person_id) · 💬 hub-chat WA-call ⏳ |
| WhatsApp call — recency | last-call ≤ N days | last call ≤ 30d → +N | 🗄️ CRM call_recordings.call_time ⏳ |
| WhatsApp call — duration | avg / sum ≥ seconds | avg call duration ≥ 60s in 90d → +N | 🗄️ CRM call_recordings.duration · 💬 hub-chat callDuration ⏳ |
| WhatsApp call — direction | direction is {inbound / outbound} | ≥ 1 inbound (customer-initiated) call in 90d → +N | 🗄️ CRM call_type · 💬 hub-chat direction (USER / BUSINESS_INITIATED) ⏳ |
(Inactivity penalty removed — the recency metrics already capture disengagement.)
Component 3 — Conversion (revenue intent — CRM deals per crm_person + commerce orders via external_activity_log)
| Metric | Operator (+ window) | Example rule → points | Source |
|---|---|---|---|
| ★ Won deals | count ≥ N | won deals ≥ 1 → +10 | 🗄️ CRM (crm_deals × crm_stages.win_probability = 1, person.rb:59–63) ⏳ |
| ★ Won deal rate | ratio ≥ % | won ÷ all associated deals ≥ 50% → +5 | 🗄️ CRM (won count ÷ crm_people_deals total) ⏳ |
| ★ Total spend (LTV) / won value | sum ≥ amount | Σ won deal size ≥ Rp50m → +5 | 🗄️ CRM (crm_deals.size, compute_deal_aggregates_usd, person.rb:742–791) ⏳ |
| Recent won deal | last-won ≤ N days | won ≤ 90d → +5 | 🗄️ CRM (crm_deals won updated_at) ⏳ |
| Commerce — total purchases (order count) | count ≥ N in window | ≥ 2 orders in 180d → +N | ✅ CDP now if Loyalty/POS integrated (external_activity_log order_confirmed / payment_confirmed) |
| Commerce — total purchase amount | sum ≥ amount | Σ order amount ≥ X → +N | ✅ external_activity_log Attributes.amount (if integrated) |
| Commerce — last purchase (recency) | last-purchase ≤ N days | order / payment ≤ 30d → +N | ✅ external_activity_log order / payment timestamp |
| Commerce — avg order value | avg ≥ amount | total amount ÷ orders ≥ X → +N | ✅ external_activity_log (amount ÷ order count) |
(Lost-deal penalty removed per PM feedback. Commerce metrics require a Loyalty/POS integration posting to external_activity_log; without it, deal value proxies spend — OQ-2.)
Component 4 — Support Interaction (support friction from the customer's point of view — CRM tickets per crm_person; a healthy customer has few / no unresolved issues)
| Metric | Operator (+ window) | Example rule → points | Source |
|---|---|---|---|
| ★ No open ticket | has-open is false | no currently-open ticket → +10 | 🗄️ CRM (tickets × ticket_stages.win_probability < 1, people_tickets) ⏳ |
| ★ Recent complaint | last-ticket ≤ N days | ticket opened ≤ 7d → −5 | 🗄️ CRM (tickets.created_at recency) ⏳ |
| ★ Issues get resolved | resolved-ratio ≥ % | resolved ÷ total ≥ 80% → +N (the customer's issues do get closed) | 🗄️ CRM (win_probability = 1 ÷ total) ⏳ |
| Ticket volume | count > N in window | > 5 tickets in 90d → −3 (recurring friction) | 🗄️ CRM (people_tickets count) ⏳ |
Component 5 — Satisfaction (CSAT) (CSAT only for MVP — per-customer csats; NPS is out of MVP scope, see §4 / OQ-1)
| Metric | Operator (+ window) | Example rule → points | Source |
|---|---|---|---|
| ★ High average CSAT | avg ≥ threshold | avg CSAT ≥ 4/5 → +12 | 🗄️ CRM (csats.option_value avg per crm_person, csat.rb) ⏳ |
| ★ Low average CSAT | avg ≤ threshold | avg CSAT ≤ 2/5 → −8 | 🗄️ CRM (csats avg) ⏳ |
| ★ Recent CSAT response | last-response ≤ N days | CSAT response ≤ 90d → +8 | 🗄️ CRM (csats.selected_at) ⏳ |
| CSAT participation | count ≥ N in window | ≥ 1 CSAT submitted in 180d → +N | 🗄️ CRM (csats count where selected) ⏳ |
★ = default-template preset. Source legend: ✅ CDP now · 🗄️ CRM
qontak.com(percrm_person) · 💬 omnichannel /hub-chat· ⚙️ sharedsegment_engine· ⏳ needs ingestion into CDP (via a CDP consumer or the CEBE metrics layer — §13). Systems of record are grounded in Appendix A; CEBE is one standardized ingestion path, not the data source (D-10).
7. API & Webhook Behavior
Server-side enforcement/computation lives in contact-service. Written in plain language;
HTTP/JSON contracts are for the RFC.
| # | Behavior | Entity Affected | Triggered By | Expected Behavior | Failure Behavior |
|---|---|---|---|---|---|
| 1 | Save health-score config | health_score_config (company-scoped) | Admin saves in the config builder | Validate: components ≥1 active, caps total 100, ≤3 metrics/component, bands contiguous & cover 0–100, each metric references a catalog metric available for the company's data. Persist; mark one active. | Validation fail → 422 with the offending rule highlighted; previous active config unchanged. |
| 2 | Activate config | health_score_config | Admin clicks Activate | Set chosen config active, demote the prior active to archived (only one active per company). | Conflict (another activate in-flight) → 409; retry. |
| 3 | Daily recompute (all contacts) | contact.health_score + breakdown + label | Scheduler (daily) per company with flag ON | For each contact: aggregate signals (behaviour #5), apply active config, compute component scores → total → label; bulk-write onto contacts; stamp health_score_computed_at. Emit run summary. | Aggregation source (CRM signals) unavailable → skip that signal, compute with available components, log health_score_partial_source; job continues. Whole-job failure → alert + last good scores retained. |
| 4 | Recompute now (manual) | same as #3 | Admin clicks "Recompute now" | Enqueue an immediate recompute for the company (same path as daily). Rate-limited (e.g. ≤1 / 10 min). | Rate-limited → inline "recompute already running / try later". |
| 5 | Aggregate per-contact signals | in-memory (per contact) | recompute job | Assemble the values the active metrics need from their systems of record: Field attributes via segment_engine over customer_datamart (CDP, now); conversation recency/frequency + campaign delivered/read via activity_log (CDP, now); deals won/lost/rate/value (CRM crm_deals × crm_people_deals × crm_stages), tickets open/priority/recency (CRM people_tickets × ticket_stages/ticket_priority), CSAT avg/recency (CRM csats), WA calls count/recency/duration (CRM call_recordings), campaign reply/rate/type (omnichannel/hub-chat) — the CRM + omnichannel signals arrive via a CDP consumer or the CEBE metrics layer (§13). No NPS in MVP. | A source (or a not-yet-ingested signal) is unavailable → that component's affected metrics score 0 for that contact; log health_score_signal_missing with source; do not fail the contact. |
| 6 | Read score on contact | contact read APIs | Customer Index / Detail load | Contact list + detail responses include health_score, health_score_label, and (detail only) health_score_components breakdown. Index filter by label maps to a stored-field query. | Score not yet computed → fields null → UI shows "—" / "not yet computed" (behaviour, not error). |
| 7 | Score changed event (optional, for downstream) | contact.health_score | recompute writes a materially changed score/label | Emit a cdp_health_score_changed event (contact id, old/new label, score) for future automation consumers. MVP: emitted but no consumer acts on it (Non-Goal 7). | Event bus down → dropped; not retried in MVP (score is still persisted on the contact). |
| 8 | Expose score on the MAG public API | contact (public list + single) | Consumer calls MAG Get all customers / Get single customer | Include health_score, health_score_label, health_score_computed_at in the customer payload (read passthrough from the stored fields). Additive/backward-compatible; feature-gated per company. | Flag OFF or not yet computed → fields null; never an error (CHS-S10). |
| 9 | Expose score on the S2S customer-details API | contact (internal S2S) | Another service calls S2S Get customer details | Include the same three health_score* fields (read passthrough) so other services read the score without re-deriving it. | Flag OFF or not computed → null (CHS-S12). |
| 10 | Filter / segment by score | contact query (Index filter · Direct Select Contact menu · Segment rule) | User filters the Index / picks recipients in the Select Contact menu / builds a segment | Health-score numeric condition (0–100) evaluated by segment_engine using the Number operator set (equals/not_equals/greater_than/less_than/between/is_empty/is_not_empty) — or the derived label as a dropdown — mapped to a stored-field query over the score store-of-record (OQ-8). | Flag OFF → the field/filter is not offered; is_empty matches not-yet-scored (CHS-S09/S11). |
8. System Flow + User Stories + ACs
8.1 System Flow
- CDP Admin opens Settings → Customer Health Score (flag
cdp_customer_health_score_enabledON, Growth/Enterprise). - If no config exists, admin starts from the default template (5 components, ★ preset metrics, default bands).
- Admin toggles components on/off (min 1). System redistributes caps so active caps total 100.
- Per active component, admin picks up to 3 metrics from the catalog (§6.2) and sets each rule's points; the catalog disables metrics whose source signal is not yet ingested into CDP for the company (e.g. deals/tickets/CSAT/WA-calls before their CRM/omnichannel ingestion lands), labelling each with its source; metrics whose signal is live (attributes, conversation, campaign delivered/read) are selectable. NPS is not offered (out of MVP).
- Admin edits threshold bands + labels (default Healthy/Neutral/At-risk); system validates bands are contiguous and cover 0–100.
- Admin saves + activates (exactly one active config; prior active → archived).
- Daily scheduler (and manual "Recompute now") runs the recompute job for the company.
- For each contact, SignalAggregator assembles field values (via
segment_engine) + use-case signals from their systems of record (CRM deals/tickets/CSAT/calls; omnichannel campaign; CDPactivity_logfor conversation + campaign-delivered) — ingested via a CDP consumer or CEBE. - ScoreEvaluator applies the active config: matched rules → component raw →
clamp(0, cap)→ sum → health_score; maps to a label via bands. - Job bulk-writes
health_score,health_score_components,health_score_label,health_score_computed_atonto each contact; emits a run summary. - Sales/Marketing/CS open Customer Index → see the score column + label chip, filter by label.
- They open Customer Detail → see the score, label, and per-component breakdown (which rules matched).
- Failure branch: if a signal source (e.g. CRM deal aggregate) is unavailable, the affected metrics score 0 for that contact,
health_score_signal_missingis logged, and the rest of the score still computes (no contact is skipped; last good score retained on total job failure).
📊 System Flow — Customer Health Score
sequenceDiagram
participant Admin as CDP Admin
participant Cfg as Health Score Config (contact-service)
participant Sched as Daily Scheduler (gocraft/work)
participant Agg as SignalAggregator
participant Src as Sources (segment_engine fields · CRM deals/tickets/CSAT/calls · omnichannel campaign · CDP activity_log)
participant Eval as ScoreEvaluator
participant C as Contact docs
participant User as Sales / Marketing / CS
Admin->>Cfg: save config (components on/off, metrics ≤3, bands, labels)
Cfg-->>Admin: validate (caps=100, bands cover 0-100) → active
Sched->>Agg: run recompute (company, flag ON)
loop each contact
Agg->>Src: fetch field values + CRM/omnichannel signals (via consumer/CEBE)
alt source unavailable
Src-->>Agg: timeout / signal not yet ingested
Agg->>Agg: affected metrics = 0; log health_score_signal_missing
else ok
Src-->>Agg: counts / sums / averages
end
Agg->>Eval: aggregates + active config
Eval->>Eval: rules → component clamp(0,cap) → sum → label
Eval->>C: bulk-write score + breakdown + label + computed_at
end
User->>C: open Customer Index / Detail
C-->>User: score + label chip + breakdown
8.2 User Stories
| User Story | Importance | Mockup | Technical Notes | Acceptance Criteria |
|---|---|---|---|---|
| [CHS-S01] — Configure components & metrics As a CDP Admin, I want to enable/disable score components and pick the metrics inside each, so that the health score reflects my company's definition of a healthy customer. | Must Have | Figma: TBD — config builder | Data Fields: • components[] (each: key, enabled, cap) — 5 defaults• metrics[] per component (≤3; each: catalog_metric_id, operator, value, points)• operators reuse segment_engine (is/greater_than/between/before/last-N-days)Before-After Behavior: Before: no scoring config exists (only static segments). After: a company-scoped health_score_config (modelled on CustomerSegment) stores component toggles + per-metric rules. | — Happy Path — • AC-1: Given the flag is ON and no config exists, when the admin opens the builder, then the default template loads (5 components enabled, ★ preset metrics, cap 20 each). • AC-2: Given the builder, when the admin disables a component, then remaining active caps redistribute so active caps total exactly 100. • AC-3: Given a component, when the admin adds metrics, then no more than 3 metrics can be active for that component (the add control disables at 3). • AC-4: Given a metric, when the admin sets a condition + points, then positive and negative point values are both accepted. • AC-5: Given a metric whose source signal is not yet ingested into CDP for the company (e.g. deals/tickets/CSAT/WA-calls before their CRM/omnichannel ingestion lands), when the catalog is shown, then that metric is disabled and labelled with its source/availability (e.g. "available when CRM deal ingestion ships"); metrics whose signal is live (attributes, conversation, campaign delivered) are selectable. — Error / Unhappy Path — • ERR-1: Given a save, when active caps do not total 100 or a component has >3 metrics, then save is blocked with an inline message; nothing persists. • ERR-2: Given all components disabled, when the admin tries to save, then save is blocked ("at least one component required"). — Permission Model — • CAN: CDP Admin / Owner. • CANNOT: Sales/Marketing/CS agents (read-only consumers of the score). • Unauthorized: Settings entry not rendered; direct URL → 403. — UI States — • Loading: form skeleton. • Empty: "Start from default template" CTA. • Error: inline validation on the offending field. • Success: config saved (draft) toast. |
| [CHS-S02] — Configure threshold bands & custom labels As a CDP Admin, I want to define score bands and their labels, so that the numeric score maps to language my team uses. | Must Have | Figma: TBD — bands editor | Data Fields: • bands[] (each: min, max, label, colour)Before-After Behavior: Before: none. After: bands map score→label; Qontak ships defaults 75–100 Healthy / 50–74 Neutral / 0–49 At-risk, all editable. | — Happy Path — • AC-1: Given the bands editor, when it loads, then the three default bands (Healthy/Neutral/At-risk) are pre-filled and editable (label text + colour + min/max). • AC-2: Given custom bands, when the admin saves, then the bands must be contiguous and cover 0–100 with no gaps or overlaps, else save is blocked. • AC-3: Given a saved band set, when a contact scores within a band, then that contact's health_score_label = that band's label.— Error / Unhappy Path — • ERR-1: Given overlapping (e.g. 40–60 and 55–80) or gapped bands, when the admin saves, then save is blocked with the conflict highlighted. — Permission Model — • CAN: CDP Admin / Owner. • CANNOT: agents. — UI States — • Loading: skeleton. • Empty: defaults shown. • Error: overlap/gap highlight. • Success: bands saved. |
| [CHS-S03] — Daily automatic scoring of every contact As a CDP user, I want the system to automatically compute each contact's health score daily, so that priorities stay current without manual work. | Must Have | Figma: N/A — backend job | Data Fields: • writes health_score (int 0–100), health_score_components (per-component sub-scores), health_score_label, health_score_computed_at• reuses gocraft/work cron (worker_service.go PeriodicallyEnqueue)Before-After Behavior: Before: no score exists on contacts. After: a daily job aggregates signals, applies the active config, and bulk-writes a score+label onto every contact. | — Happy Path — • AC-1: Given an active config and flag ON, when the daily job runs, then every contact for the company gets health_score = Σ clamped component scores (0–100) and a health_score_label from the bands.• AC-2: Given a component's matched-rule points exceed its cap, when scored, then that component score is clamped to its cap (never exceeds cap, never below 0). • AC-3: Given the job completes, when it finishes, then health_score_computed_at is stamped and a run summary (contacts scored, label distribution, duration) is emitted.• AC-4: Given the largest tenant, when the job runs, then it completes within the ≤4h performance budget (bulk writes). — Error / Unhappy Path — • ERR-1: Given a signal source (e.g. CRM deal aggregate) is unavailable for a contact, when scoring, then the affected metrics score 0, health_score_signal_missing is logged with the source, and the contact is still scored on remaining components (not skipped).• ERR-2: Given the whole job fails, when it aborts, then contacts retain their last good score (no partial wipe) and an alert fires. — Permission Model — • CAN: system (scheduler) + Admin ("Recompute now"). • CANNOT: agents cannot trigger recompute. — UI States — N/A (backend). Admin sees last-run status + timestamp in the config page. |
| [CHS-S04] — See health score on Customer Index As a Sales / Marketing / CS user, I want to see each contact's health score and label in the Customer Index and filter by label, so that I can prioritise at scale. | Must Have | Figma: TBD — index column + filter | Data Fields: • reads stored health_score, health_score_label (no compute at read time)Before-After Behavior: Before: index shows raw fields only. After: a "Health Score" column (score + coloured label chip) + a label filter. | — Happy Path — • AC-1: Given computed scores, when the user opens the Customer Index, then a Health Score column shows the score + label chip per contact, sorted/rendered from the stored field (no read-time latency). • AC-2: Given the label filter, when the user filters by "At-risk", then only contacts with that label are listed. • AC-3: Given a contact not yet scored, when the index renders, then its cell shows "—" (not an error). — Error / Unhappy Path — • ERR-1: Given the score field fails to load, when the index renders, then the column is hidden and the list still renders (fail-silent). — Permission Model — • CAN: any CDP user who can view contacts (respects team-owner / permission scope). • CANNOT: users without contact-view permission. — UI States — • Loading: column skeleton. • Empty: "—". • Error: column hidden. • Success: score + chip + filter. |
| [CHS-S05] — Understand why (score breakdown on Customer Detail) As a CS / Sales user, I want to see how a contact's score was built, so that I trust it and know what to act on. | Must Have | Figma: TBD — detail breakdown | Data Fields: • reads health_score_components (per-component sub-score + matched rules) + labelBefore-After Behavior: Before: none. After: Customer Detail shows the total score, label, five component contributions, and which rules matched. | — Happy Path — • AC-1: Given a scored contact, when the user opens Customer Detail, then the total score + label + each active component's contribution (e.g. "Engagement 20/20") are shown. • AC-2: Given the breakdown, when expanded, then the matched rules are listed in plain language (e.g. "Last conversation ≤7 days: +8"). • AC-3: Given a disabled component, when the breakdown renders, then it is not shown (only active components appear). — Error / Unhappy Path — • ERR-1: Given the breakdown fails to load, when the detail renders, then a "couldn't load score breakdown" note shows and the rest of the contact still renders. • ERR-2: Given a not-yet-scored contact, when detail opens, then "Health score not yet computed" is shown. — Permission Model — • CAN: any user who can view the contact. • CANNOT: others. — UI States — • Loading: panel skeleton. • Empty: "not yet computed". • Error: inline note. • Success: full breakdown. |
| [CHS-S06] — Recompute after a config change As a CDP Admin, I want changing the config (or clicking "Recompute now") to re-score contacts, so that a tuning change takes effect without waiting a day. | Should Have | Figma: TBD — recompute action | Data Fields: • "Recompute now" trigger (rate-limited ≤1/10min) • uses the config live at compute time Before-After Behavior: Before: scores would only change on the next daily run. After: an activate/"Recompute now" enqueues an immediate recompute for the company. | — Happy Path — • AC-1: Given an activated config change, when the admin clicks "Recompute now", then an immediate recompute enqueues and, on completion, contact scores reflect the new config. • AC-2: Given components were re-toggled (caps redistributed), when recompute runs, then absolute scores may shift and the new label distribution is reflected everywhere (index + detail). — Error / Unhappy Path — • ERR-1: Given a recompute is already running, when the admin clicks again within the window, then it is rate-limited with "recompute already running". — Permission Model — • CAN: CDP Admin. • CANNOT: agents. — UI States — • Loading: "recomputing…" status. • Success: "last computed |
| [CHS-S07-NEG] — Scoring guard rails (Guard Rail — from Non-Goals) As the system, I must enforce the MVP scope boundaries so the config stays simple and grounded. | Guard Rail | — | — | • NEG-1: Given the config builder, when the admin tries to activate a second config, then only one config can be active per company (the prior active is archived) — never two active (Non-Goal 3).• NEG-2: Given the catalog, when an NPS metric is requested, then no per-customer NPS metric is offered — NPS is out of MVP scope (satisfaction = CSAT only; Non-Goal 4 / OQ-1 / D-5). • NEG-3: Given a company whose deal-signal ingestion has not yet landed in CDP, when building Conversion, then deal metrics are disabled with a source/availability label until CRM deal ingestion (or CEBE) ships; commerce is optional via external_activity_log (OQ-2 / D-6).• NEG-4: Given a component, when a 4th metric is added, then it is blocked (max 3, Constraint). • NEG-5: Given the flag is OFF, when a user opens the Customer Index, then no Health Score column/filter appears and no recompute runs. |
| [CHS-S08] — View Health Score on the Inbox customer-profile panel (web + chat mobile)
As an Inbox agent, I want to see the customer's health score on the customer-profile panel while I'm in a conversation — on both the web inbox and the chat mobile app — so that I can gauge account health without leaving the Inbox. | Should Have | Figma: TBD — Inbox customer-profile panel (web + chat mobile) score chip | Cross-team display surface (additive). CDP exposes health_score / health_score_label / health_score_computed_at on the contact read-API (contact/base.go). The web inbox (hub-chat Nuxt) and the chat mobile app render a read-only score chip + coloured label on the customer-profile panel — those surfaces are owned by Omnichannel / Mobile (see §13); CDP only serves the fields. No new contact field, no new permission key. The score sits inside the customer-profile section, so its visibility is inherited from that section's view permission — no separate health-score gate; if the section is hidden, the score is hidden with it. | — Happy Path —
• AC-1: Given a contact with a computed health score, when an agent opens that customer's profile panel in the web Inbox, then the score value + coloured label chip + last-computed timestamp are shown (read-only).
• AC-2: Given the same contact, when an agent opens the customer profile in the chat mobile app, then the same score + label + timestamp are shown.
— Empty / Error —
• AC-3: Given a contact with no score computed yet, then the panel shows the "—" neutral placeholder (consistent with the CDP Index/Detail).
• ERR-1: Given the CDP read-API is unavailable, then the score field fails silently (the profile panel still renders) — no blocking error.
— Permission —
• AC-4: Given an agent without the customer-profile view permission, when they open the conversation, then the whole customer-profile section is hidden and the health score is not shown — visibility is inherited from the section, with no separate health-score permission and no partial reveal. |
| [CHS-S09] — Group customers by Health Score in Segmentation
As a CDP marketer / admin, I want to add a Customer Health Score condition when building a segment, so that I can group customers by how healthy they are (e.g. score ≥ 80, or at-risk) and target them in a campaign. | Should Have | Figma: TBD — Segment rule builder → new "Customer Health Score" field | Segmentation consumes the score as a field. Add health_score to the segment Rule Builder as a numeric field (0–100). Operators reuse the shared segment_engine Number operator set exactly — equals · not_equals · greater_than · less_than · between · is_empty · is_not_empty (§6.2; the same set the Customer Segmentation PRD defines — D-11); is_empty = not-yet-scored. Optionally also expose the derived label as a dropdown field (Healthy / Neutral / At-risk). Evaluated by the same segment_engine over the score's store-of-record (OQ-8). Jointly owned with the Customer Segmentation initiative. | — Happy Path —
• AC-1: Given the segment rule builder, when the user adds a condition, then Customer Health Score appears as a selectable numeric field.
• AC-2: Given the health-score field, when the user opens the operator list, then exactly the numeric operators are offered (equals/not_equals/greater_than/less_than/between/is_empty/is_not_empty) — no text/date operators.
• AC-3: Given health_score greater_than 80, when the segment is previewed/saved, then only customers whose current score > 80 match.
• AC-4: Given health_score between 40 and 60, when evaluated, then only customers in that band match.
— Empty —
• AC-5: Given health_score is_empty, when evaluated, then only not-yet-scored customers match (score null).
— Guard —
• NEG-1: Given the health-score flag is OFF for the company, then the health-score field is not offered in the rule builder. |
| [CHS-S10] — Health Score returned by the MAG public API (list + single customer)
As an API consumer / integrator, I want the Get all customers and Get single customer MAG endpoints to include the customer's health score, so that external systems can read it without a separate call. | Should Have | — (API only) | Public-API read passthrough. The MAG "Get all customers" (list) and "Get single customer" responses include health_score, health_score_label, and health_score_computed_at (read-only; sourced from the same stored contact fields as the CDP read-API, contact/base.go). No new endpoint — the existing customer payloads gain the fields; versioned per MAG's contract. Additive, backward-compatible; null when not yet computed. | — Happy Path —
• AC-1: Given a scored customer, when a consumer calls Get all customers on MAG, then each customer object includes health_score + health_score_label + health_score_computed_at.
• AC-2: Given a scored customer, when a consumer calls Get single customer, then the same three fields are present.
— Empty —
• AC-3: Given a customer not yet scored, then the three fields are returned as null (not omitted, not an error).
— Guard —
• NEG-1: Given the health-score flag is OFF for the company, then the fields are returned null (feature-gated). |
| [CHS-S11] — Filter contacts by Health Score in the Direct Select Contact menu
As a campaign / broadcast user, I want to filter contacts by health score in the direct Select Contact menu, so that I can pick recipients by account health when composing. | Should Have | Figma: TBD — Select Contact menu → Health Score filter | Consumes the stored score as a filter. The direct Select Contact recipient picker gains a health-score filter — by numeric range/operator (the same segment_engine Number set) and/or by label (Healthy / Neutral / At-risk). Maps to a stored-field query over the score's store-of-record (OQ-8), like the Customer Index label filter (behaviour #6). Read-only consumption; no new field. | — Happy Path —
• AC-1: Given the Select Contact menu, when the user opens filters, then a Health Score filter is available.
• AC-2: Given the user filters health_score greater_than 70 (or label = Healthy), when applied, then only matching contacts are listed as selectable recipients.
— Empty —
• AC-3: Given no contact matches, then the list shows the empty state (no error).
— Guard —
• NEG-1: Given the health-score flag is OFF, then the Health Score filter is not shown in the Select Contact menu. |
| [CHS-S12] — Health Score exposed via the S2S customer-details API
As another internal service, I want to read a customer's health score through the S2S Get customer details API, so that my service can use it without duplicating the scoring logic. | Should Have | — (API only) | Service-to-service read passthrough. The internal S2S "Get customer details" response includes health_score, health_score_label, health_score_computed_at (read-only, from the stored contact fields). Same store-of-record as the read-API / MAG; additive to the existing S2S payload; null when not computed; feature-gated per company. | — Happy Path —
• AC-1: Given a scored customer, when another service calls S2S Get customer details, then the response includes health_score + health_score_label + health_score_computed_at.
— Empty —
• AC-2: Given a customer not yet scored, then the three fields are null (not an error).
— Guard —
• NEG-1: Given the health-score flag is OFF for the company, then the fields are null. |
Dependencies: CHS-S01/S02 → health_score_config collection + config UI; CHS-S03 → daily recompute job + SignalAggregator + per-contact metrics (§13); CHS-S04/S05 → contact read API returns stored score/breakdown + FE column/detail; CHS-S06 → manual recompute trigger; CHS-S08 → Inbox display surfaces (web + mobile), cross-team (§13); CHS-S09 → Segmentation numeric condition; CHS-S10 → MAG public API passthrough; CHS-S11 → Direct Select Contact filter; CHS-S12 → S2S customer-details passthrough (all §13). All scoring stories depend on the active config from CHS-S01/S02; all consumption stories (S09–S12) depend on the stored health_score* fields from CHS-S03/S04.
9. Rollout
| Field | Detail |
|---|---|
| Flag | cdp_customer_health_score_enabled | default: OFF — per-company enable by Ops. |
| Stage 0 | Schema additive: health_score* fields on contact + health_score_config collection + read-API passthrough. No behaviour change (fields null). |
| Stage 1 — Internal QA | 1–2 synthetic tenants. Verify config validation (caps=100, ≤3 metrics, bands), recompute correctness (component clamp, label mapping), signal aggregation per source, performance. |
| Stage 2 — Closed Beta | 3–5 CS/Sales-heavy tenants with the default template. Validate the default template resonates; tune preset metrics/points; confirm score explainability lands with users. |
| Stage 3 — GA | Progressive per-tenant enable (Growth + Enterprise). |
9.1 Migration Transition Window
- Before first recompute: contacts have null
health_score→ index shows "—", detail shows "not yet computed". No error state; the feature is simply "pending". - After first recompute: scores populate; the column/breakdown activate.
- Config change / component re-toggle: because caps redistribute to total 100, a contact's absolute score can shift after a change. The next recompute (or "Recompute now") re-scores consistently; the admin is warned "changing components will re-score all contacts".
9.2 Semantic Regression Rollback
The score is a computed output that can look valid but be worse (e.g. a bad config change skews everyone to "At-risk", or a source starts returning wrong aggregates). Rollback mechanism:
- Config-level: each
health_score_configversion is retained; revert to the prior active version (one click) and recompute. This is the primary rollback. - Feature-level flag:
cdp_customer_health_score_enabledOFF hides the score everywhere and pauses recompute (scores retained but not shown). - Trigger threshold: if a recompute shifts >40% of contacts into a different label band vs the previous run, the job flags a
health_score_distribution_shiftalert and holds the write pending admin confirmation (auto-revert to last good scores until acknowledged) — prevents a bad config silently re-labelling the whole base.
10. Observability
| Event | Trigger | Properties | Alert |
|---|---|---|---|
health_score_config_saved | Admin saves/activates a config | company_sso_id, active_components, metric_count, bands | — |
health_score_recompute_completed | Recompute job finishes | company_sso_id, contacts_scored, label_distribution, duration_ms, trigger (daily/manual) | duration > 4h → CDP on-call |
health_score_signal_missing | A signal source unavailable for a contact | company_sso_id, contact_id, source (deals/tickets/csat/calls/convo/campaign) | > 5% of contacts in a run → notify CDP Squad (source degraded) |
health_score_partial_source | Whole source unavailable for a run | company_sso_id, source | any → CDP Squad |
health_score_distribution_shift | >40% of contacts change label band vs prior run | company_sso_id, shift_pct, from/to distribution | any → hold write + notify PM + Admin (rollback guard, §9.2) |
cdp_health_score_changed | Materially changed score/label written (optional, per contact — sampled) | company_sso_id, contact_id, old_label, new_label | — (future automation hook) |
Dashboard owner: CDP Squad. Cadence: daily review for the first 2 weeks of Beta, then weekly for the first month post-GA. Investigate immediately on any health_score_distribution_shift or recompute duration breach.
11. Success Metrics
| Metric | Definition | Baseline | Target |
|---|---|---|---|
| ⭐ CDP stickiness / adoption (North Star) | % of eligible (Growth/Enterprise) tenants with an active health-score config within 60 days of GA | 0 (feature doesn't exist) | ≥ 30% within 30 days of GA |
| Feature engagement | % of active-config tenants whose users use the label filter on the Customer Index weekly | N/A | ≥ 50% of active-config tenants |
| Explainability / trust | % of surveyed beta users who agree "I understand why a customer got its score" | N/A | ≥ 80% |
| Config health | % of active configs that keep the default template vs customise (signals template fit) | N/A | Track (no target — informs template tuning) |
| Recompute reliability | Recompute runs completing within SLA without partial-source alerts | N/A | ≥ 99% of daily runs within 4h; < 1% partial-source |
| Prioritisation impact (secondary) | Correlation of "Healthy" label with subsequent won deals (validation sample) | N/A | Positive correlation demonstrated in beta review |
12. Launch Plan & Stage Gates
| Stage | Audience | Duration | Success Gate | Owner |
|---|---|---|---|---|
| Stage 0 — Schema & config | Engineering | 1–2 sprints | health_score* fields + health_score_config + read-API passthrough live; flag wired; recompute job in staging; zero behaviour change. | CDP Eng |
| Stage 1 — Internal QA | 1–2 synthetic tenants | 1 week | Config validation, component clamp + label mapping correct; each signal source aggregates correctly; recompute within SLA; rollback (config revert + distribution-shift hold) verified. | QA |
| Stage 2 — Closed Beta | 3–5 tenants | 2–3 weeks | Default template validated with real users; explainability ≥80%; no distribution-shift incidents from product bugs; preset metrics tuned. | PM + CS |
| Stage 3 — GA | Progressive Growth/Enterprise | Ongoing | Adoption tracking on; recompute reliability ≥99%; no open high-priority bugs. | PM + Ops |
13. Dependencies
| Dependency | Owner | Deliverable | Blocking? |
|---|---|---|---|
health_score_config collection + config CRUD | CDP Backend | Company-scoped config (components, metrics, bands, one active) — modelled on CustomerSegment (segment/segment.go). | YES |
health_score* fields on the contact doc + read-API passthrough | CDP Backend | health_score, health_score_components, health_score_label, health_score_computed_at (contact/base.go). | YES |
| Daily recompute job | CDP Backend | gocraft/work cron (reuse worker_service.go PeriodicallyEnqueue + BackfillChatContact pattern) + manual "recompute now". | YES |
| Field-attribute rule evaluation — shared operator engine | CDP Backend | Reuse the segment_engine operator logic per field type (text / dropdown / number / date / boolean — segment.go:34–52) — the same set the Customer Segmentation — Basic Attributes PRD defines, for default and custom fields (D-11). One engine serves both Segmentation and Health Score. | YES |
| Per-customer use-case signals — ingestion from CRM + omnichannel | CDP Backend + CRM + Omnichannel (+ optionally CEBE) | Deals / tickets / CSAT / WA-calls are grounded in the CRM (qontak.com) per crm_person (person.rb, csat.rb, call_recordings); campaign reply/rate/type + WA-call activity in the omnichannel backend (hub-chat). Conversation recency + campaign delivered/read are already in CDP (activity_log). The not-yet-ingested signals need a new/extended CDP consumer (today's Kafka consumers carry only status enums — crm_event_payload.go:10–16) or the CEBE metrics layer as the standardized path (§6.2; D-10). | YES (partially reachable now) |
Score store-of-record + segment_engine reconciliation | CDP Backend | Decide where health_score/label live and which store the Index label-filter queries: the Postgres customer_datamart (where segment_engine + segmentation already run — engine.go:19,320) vs the MongoDB contact doc (contact/base.go:50+). The two are separate stores; picking wrong forces a rewrite of the recompute writer or the Index filter. See OQ-8. | YES |
| Config builder + Index column/filter + Detail breakdown | CDP FE (qontak-customer-fe) | Config UI, /customers score column + label filter (pattern like existing filterByRoute), detail breakdown panel. | YES |
| Inbox (web) customer-profile panel — score display | Omnichannel / hub-chat (FE) | Render the read-only health_score chip + label + computed_at on the customer-profile panel in the web inbox, from the CDP contact read-API (CHS-S08). | Not blocking the engine; gates the web-inbox display surface only |
| Chat mobile app — customer-profile score display | Mobile (chat app) | Render the same score chip + label on the mobile inbox customer-profile panel, from the CDP contact read-API (CHS-S08). | Not blocking the engine; gates the mobile display surface only |
| Segmentation — Health Score condition in the Rule Builder | CDP FE (qontak-customer-fe) + shared segment_engine | Add health_score as a numeric field (+ optional label dropdown) to the segment Rule Builder, numeric operator set per D-11; evaluated by the same engine over the score store-of-record (OQ-8). Jointly owned with Customer Segmentation. | Not blocking the engine; gates the segmentation consumption (CHS-S09) |
| MAG public API — score passthrough | CDP Backend | Add health_score* to the MAG Get all / Get single customer payloads (read-only, feature-gated). | Not blocking the engine; gates the public-API consumption (CHS-S10) |
| Direct Select Contact — health-score filter | CDP FE + Backend | Health-score numeric/label filter in the recipient Select Contact menu, mapped to a stored-field query. | Not blocking the engine; gates the recipient-filter consumption (CHS-S11) |
| S2S customer-details — score passthrough | CDP Backend | Add health_score* to the internal S2S Get customer details payload (read-only, feature-gated). | Not blocking the engine; gates the S2S consumption (CHS-S12) |
| Commerce (purchase) metrics (optional) | CDP + Integrations | Orders/payments reach CDP now via external_activity_log where a Loyalty/POS integration posts them (amount in attributes); otherwise CRM deal value proxies spend. A dedicated LTV/AOV/purchase-freq feed can later come via CEBE Conversion (OQ-2). | NO (optional) |
| NPS-per-customer (out of MVP) | — | Not modelled in MVP — the CRM has only per-agent NPS (users.last_nps_submission); per-customer NPS is a later-phase addition (OQ-1). | NO (out of scope) |
| Figma designs | Design | Config builder + score display. | YES (Stage 1) |
📊 Dependency Graph
graph LR
F[Customer Health Score] -->|BLOCKING| CFG[health_score_config collection + CRUD]
F -->|BLOCKING| FLD[health_score* fields on contact + read-API passthrough]
F -->|BLOCKING| JOB[Daily recompute job - gocraft/work cron]
F -->|BLOCKING - shared with Segmentation| EVAL[Field attributes: segment_engine per fieldType over customer_datamart - now]
F -->|BLOCKING| SIG[Per-customer use-case signals: deals/tickets/CSAT/calls + campaign]
F -->|BLOCKING| STORE[Score store-of-record: customer_datamart vs Mongo contact doc]
F -->|BLOCKING| FE[customer-fe: config UI + Index column/filter + Detail breakdown]
F -->|Stage 1| FIG[Figma designs]
CRM[CRM qontak.com: deals / tickets / CSAT / call_recordings per crm_person] -->|ingest via consumer or CEBE| SIG
OMNI[omnichannel / hub-chat: campaign reply/type + WA-call] -->|ingest via consumer or CEBE| SIG
CDPNOW[CDP now: activity_log conversation + campaign delivered/read; external_activity_log orders] --> SIG
COM[Commerce optional: external_activity_log now / CEBE Conversion later] -.-> SIG
14. Key Decisions + Alternatives Rejected
14a — Decisions Made
Decisions dated 2026-07-08, grounded in the codebase review (Appendix A) + benchmark (Appendix B).
| ID | Decision | Rationale (grounded) |
|---|---|---|
| D-1 | Numeric 0–100 score with a derived label, not a fixed Hot/Warm/Cold label. | The Signal Label PRD deferred numeric scoring; clients want a tunable score. The label is just a band over the score, so numeric subsumes the label with more expressiveness. |
| D-2 | Rule-based, admin-configured — NOT ML. | Zoho Zia (Appendix B) is powerful but requires training data (per the benchmark board) and is a black box. A transparent rule engine works day-1, is explainable, and matches the Signal Label decision to avoid ML complexity for MVP. |
| D-3 | 5 default components, each cap 20; components on/off with cap auto-redistribution to 100; ≤3 metrics/component. | Matches the requested model and mirrors Gainsight/HubSpot weighted-measure scorecards. The ≤3-metrics cap and fixed component structure keep MVP complexity bounded. |
| D-4 | Reuse segment_engine operators + CustomerSegment config shape + the gocraft/work cron mechanism. | segment_engine (engine.go) implements the exact operators the Field-Attribute rules need (over Postgres customer_datamart); CustomerSegment (segment/segment.go:74–89) already models a company-scoped rule set with LastEvaluatedAt/RecalcErrorCount; gocraft/work cron exists (worker_service.go:13,49–52,74). Grounding corrections: (1) there is no existing scheduled all-contact evaluator to clone — the daily recompute job is net-new (only the mechanism is reused); (2) segment_engine queries Postgres customer_datamart, while the contact doc is Mongo — the score's store-of-record is an open decision (OQ-8); (3) the use-case component signals are sourced from their systems of record — CRM (deals/tickets/CSAT/calls) + omnichannel (campaign/WA-call) — ingested via a new/extended CDP consumer or the CEBE metrics layer, not a bespoke one-off aggregate (D-10/OQ-5). |
| D-5 | Satisfaction = CSAT only for MVP; NPS out of scope. | Per-customer CSAT is grounded in the CRM (csats avg/recency per crm_person, csat.rb). Per-customer NPS does not exist in the CRM today (only per-agent users.last_nps_submission), so NPS is dropped from the MVP rather than modelled speculatively — revisit in a later phase (OQ-1). |
| D-6 | Conversion from CRM deals (incl. won deal rate) + elaborated commerce. | Won count / won deal rate (won ÷ associated) / won value / LTV / recent-won grounded per crm_person in the CRM (crm_deals × crm_stages.win_probability × crm_people_deals; compute_deal_aggregates_usd, person.rb:742–791). Commerce — total purchases, total purchase amount, last purchase, avg order value — from external_activity_log order/payment events (order_confirmed/payment_confirmed, amount in Attributes) where a Loyalty/POS integration posts them; else deal value proxies spend (OQ-2). Lost-deal penalty dropped per PM feedback (a low win-rate already reflects it). |
| D-7 | Score stored on the contact (not computed at read). | Customer Index must render at scale with no added latency; a stored field + daily bulk recompute meets the performance constraint. |
| D-8 | One active config per company for MVP. | Keeps the mental model and UI simple; multiple parallel schemes add config/version complexity without validated demand. |
| D-9 | Distribution-shift guard on recompute (>40% band change holds the write). | A scoring output can regress silently; the guard prevents a bad config from re-labelling the whole base unnoticed (semantic-regression rollback, §9.2). |
| D-10 | Reuse shared substrates; source from systems of record; ingest via consumer or CEBE. Field Attributes → segment_engine over customer_datamart (shared with Segmentation). Use-case signals → their real systems of record: CRM (deals/tickets/CSAT/calls per crm_person) + omnichannel/hub-chat (campaign reply/type, WA-call); conversation + campaign-delivered already in CDP. | Grounded in Appendix A. The not-yet-ingested signals need a new/extended CDP consumer (today's Kafka consumers carry only status enums) or the CEBE metrics layer as the standardized aggregate path — CEBE is an ingestion option, not the data source (the earlier "everything via CEBE" framing was corrected here). Still aligns with CEBE (which names Health Score a downstream consumer) and Segmentation (shared operator engine); it just doesn't pretend CEBE is where the data originates. |
| D-11 | Field-attribute operators reuse the shared per-field-type operator logic — the Customer Segmentation "Operator Logic by Default/Custom Field" set = segment_engine (segment.go:34–52), for default and custom fields alike. | One operator vocabulary across Segmentation + Health Score config; no divergent operator sets to maintain; a client configuring a segment and a health-score rule sees the same operators per field type (§6.2 table). |
14b — Alternatives Rejected
| Alternative | Why Rejected |
|---|---|
| Keep label-only (Hot/Warm/Cold) | Too coarse; clients want a tunable score and explainability. Label is retained as a derived band of the numeric score. |
| ML/AI predictive score (à la Zoho Zia) | Requires training data + is a black box; fails the explainability need and the MVP-complexity bar. Candidate for a later phase. |
| Real-time (per-event) recompute | High infra cost; daily batch meets the prioritisation use case. Real-time is a later phase. |
| Free-form formula language for metrics | Unbounded complexity + validation/security burden; a curated catalog (≤3/component) is safer and easier to reason about. |
| Compute score at read time | Would add latency to the Customer Index at scale; violates the performance constraint. Stored field + batch recompute chosen. |
| Multiple active configs per company | Config/version/UX complexity with no validated MVP demand; one active config chosen. |
| Build a bespoke one-off CRM→CDP aggregate path in this PRD | Diverges from the shared CDP data plumbing; instead ingest each signal from its system of record via a new/extended CDP consumer or the CEBE metrics layer (D-10) — reuses existing infrastructure and stays aligned with Segmentation + CEBE. |
| Build on a separate CS tool (Gainsight-style) integration | The signals (convo, deals, tickets, CSAT) already live in Qontak; building natively avoids an external dependency and keeps the score where the data is. |
15. Open Questions
| # | Type | Question | Mitigation / Default | Owner | Deadline |
|---|---|---|---|---|---|
| OQ-1 | Decision | NPS-per-customer is out of MVP scope. The CRM has only per-agent NPS (users.last_nps_submission), no per-customer NPS. MVP satisfaction = CSAT only (CRM csats). Should per-customer NPS be built later (own capture, or via CEBE Communication)? | Ship CSAT-only for MVP (D-5); revisit NPS post-MVP. | PM | Post-MVP |
| OQ-2 | Decision | Commerce (purchase) metrics — total purchases, total purchase amount, last purchase, avg order value — come from external_activity_log order/payment events where a Loyalty/POS integration posts them; otherwise CRM deal value proxies spend. A dedicated feed can come later via CEBE Conversion. Which tenants have the integration, and is commerce in the default template? | Include commerce metrics when the integration is present (else hidden); off by default in the template. | PM | Pre-RFC |
| OQ-3 | Risk | Campaign-reply linkage. WhatsApp-broadcast logs have no direct crm_person_id (phone/bsuid linkage); email delivery_stats is polymorphic. Reply/open aggregation per contact may be partial (CEBE Campaign will standardise this). | Use email reply + conversation recency as primary engagement; treat WA-broadcast reply as best-effort until CEBE Campaign firms up linkage. | CDP Eng + CEBE | Pre-RFC |
| OQ-4 | Decision | Cap redistribution rounding. With N active components, 100/N isn't always integer (e.g. 3 → 33/33/34). Confirm the remainder rule (proposed: remainder → first component; caps always total 100). | Default: remainder to the first component; document in the builder. | PM + Eng | Pre-RFC |
| OQ-5 | Decision | Ingestion path for the CRM + omnichannel signals. The signals are grounded (CRM per crm_person; omnichannel/hub-chat) but are not in CDP today (the Kafka consumers carry only status enums — crm_event_payload.go:10–16). Deliver via new/extended CDP consumers or the CEBE metrics layer? | Prefer CEBE where it already standardises the metric; extend a direct CDP consumer where CEBE hasn't shipped that module. Confirm per-signal in the RFC. Field Attributes + conversation + campaign-delivered are buildable now. | CDP + CEBE | Pre-RFC |
| OQ-8 | Decision | Score store-of-record. segment_engine runs on Postgres customer_datamart; the contact doc is MongoDB. Where do health_score/label live, and which store does the Index label-filter query? | Proposed: store on customer_datamart (co-located with segmentation + the Index filter), mirror onto the contact API response. Confirm in RFC. | CDP Eng | Pre-RFC |
| OQ-6 | Assumption | Team-owner / permission scope. Score visibility should respect existing contact permission + team-owner scoping (the Team Owner initiative). Assumed: score inherits contact visibility. | Confirm with the Team Owner/permission model; no separate score-level permission in MVP. | PM | Pre-RFC |
| OQ-7 | Open Question | Score history / trend. MVP stores only the current score. Do users need a trend (score over time)? | Deferred; a status/ trend store is a later phase if demand appears. | PM | Post-beta |
Appendix A — Grounded Code References
contact-service (CDP · Go / MongoDB) — the home of the score
- Contact model + custom fields:
internal/app/repository/contact/base.go:49–110(contact doc),:199–210(CustomField: Key, Value, FieldType, Datatype, ValueID). Field types: single_line_text, text_area, dropdown_select, number, date, upload, multiple_select, url, gps, signature. → Field Attributes component source (✅). - Field-properties catalog:
internal/app/repository/field_properties/base.go:352–382(Name, FieldType, Datatype, Dropdown options, per-type validation; 15 default system fields incl. status, source, job_title, avg_annual_income, address). → catalog of selectable fields. - Rule/operator engine (reuse):
internal/pkg/segment_engine/engine.go—IRuleEngine.BuildQuery(interface.go:15); operatorsis / is_not / contains / greater_than / less_than / between / on / before / after / is_empty …; field categories System/Default/Custom; type-aware casting; SQL-injection allowlist. → metric-rule evaluation substrate (✅ reuse) — the same engine Customer Segmentation uses (D-11). Note (grounding): it builds SQLWHEREagainst the Postgrescustomer_datamarttable (engine.go:19,320; JSONBdefault_fields/custom_fields), not the Mongo contact doc → store-split (OQ-8). - Config precedent (reuse shape):
internal/app/repository/segment/segment.go—CustomerSegment(company-scoped RuleSet → RuleGroup → RuleCondition,Statusactive/archived,LastEvaluatedAt,RecalcErrorCount). →health_score_configmodelled on this. - Scheduler (reuse):
internal/worker/worker_service.go:13–98—gocraft/workpool +PeriodicallyEnqueue(cron, job); example croninternal/app/cron/backfill_chat_contact.go. → daily recompute job. - Activity ingestion:
internal/app/repository/activity_log/base.go:17–34(category, customer_id, timestamp, associated_type,Attributes interface{}); Kafka consumers present ininternal/app/kafka_consumer/:deal_consumer.go,ticket_consumer.go,campaign_consumer.go,room_consumer.go,task_consumer.go,company_consumer.go,field_consumer.go,loyalty_member_enrollment.go,marketing_opt_in.go,workflow_approval_consumer.go. → conversation (room) + campaign events present and aggregatable; but no per-contact numeric aggregates stored (it's an event log — CEBE is the metrics layer over these). - ⚠️ Missing data paths (grounding — today's state; resolved by CRM/omnichannel ingestion): No
csatconsumer and nocall/call_recordingconsumer exist incontact-service(grep returns none) → CSAT + calls are not in CDP directly today. The deal/ticket consumers only accept statuseslinked/unlinked/resolved(deal_consumer.go:12,ticket_consumer.go:12), and the CRM event payloadCrmChangeData={id, name, qontak_customer_id, user_id, status}only (crm_event_payload.go:10–16) → deal value/size, won-vs-lost, stage, and ticket priority are not carried into CDP directly.CreateActivityLogForDealstoresCategory+AssociatedName, no amount (activity_log.go:351,423–425). This is why Conversion, Support, and Satisfaction signals are ingested from their systems of record (CRM + omnichannel) via a new/extended CDP consumer or the CEBE metrics layer (D-10/OQ-5/§13) — not re-aggregated ad-hoc insidecontact-service. - Commerce/loyalty events:
internal/app/repository/external_activity_log/model.go:13–51— source external/internal, categorytransaction, actionsorder_confirmed/payment_confirmed(amount inAttributes). → commerce reaches CDP now via a Loyalty/POSexternal_activity_log; a dedicated LTV/AOV/purchase-freq feed can later come via CEBE Conversion (OQ-2/D-6). - Config precedent (company settings):
internal/app/repository/webhook_config/base.go:16–26(company-scoped config CRUD). - No existing per-contact score field anywhere →
health_score*is net-new oncontact/base.go.
qontak.com (CRM · Rails) — system of record for deals / tickets / CSAT / calls
These signals live in the CRM, per
crm_person, and are fully per-customer aggregable (helpers cited below) — but are NOT reachable from CDP directly today (no consumer / not in the CRM→CDP event payload — see the contact-service "Missing data paths" note above). They are ingested into CDP via a new/extended CDP consumer or the CEBE metrics layer (§13, D-10/OQ-5). CEBE is an ingestion path, not where the data originates.
- Deals (Conversion — CRM system of record; ingest via consumer/CEBE):
app/models/crm/deal.rb—crm_deals.size(value, multi-currency),crm_stage_id → Crm::Stage.win_probability(1 = won, 0 = lost). Per-person viacrm_people_dealsjoin (person.rb:114–115); helpersCrm::Person#get_won_deals(person.rb:59–63),#compute_deal_aggregates_usd(person.rb:742–791). → won count, won rate (won ÷ associated), won-value sum / LTV, lost count. - Tickets (Support — CRM system of record; ingest via consumer/CEBE):
app/models/crm/ticket.rb+ticket_stages.win_probability(=1 → resolved) +ticket_priority(Low/Medium/High/Critical); per-person viapeople_ticketsjoin (person.rb:128–129,ticket.rb:74). → open count, has-open (boolean), high-priority open, ticket volume, last-opened recency, aging-unresolved, resolved ratio — all customer-POV. - CSAT (Satisfaction — CRM system of record; ingest via consumer/CEBE):
app/models/csat.rb(Crm::Person has_many :csats,person.rb:152) +db/migrate/20210428041644_create_csat.rb—csats.crm_person_id,option_value(numeric),selected,selected_at. Avg per person:Csat.where(crm_person_id:, selected: true).average(:option_value). → avg CSAT + recency + count. CSAT is the only satisfaction signal in MVP. - NPS (out of MVP): in
qontak.comtoday onlyusers.last_nps_submission/nps_countexist — per-agent, not per-customer. There is no per-customer NPS, so NPS is dropped from the MVP (satisfaction = CSAT only). → OQ-1/D-5. - WA / voice calls (Engagement — CRM
call_recordings+ omnichannel; ingest via consumer/CEBE):app/models/call_recording.rb(belongs_to :contact, foreign_key: crm_person_id, line 10) + migrations —crm_person_id,call_time,started_at,duration,call_type. → call count / recency / duration / direction per contact. Also surfaced in the omnichannel WA-call backend (see hub-chat below). - Campaign (Engagement — email in CRM; WA in omnichannel): email
campaigns+delivery_stats(polymorphicreference_object; scopes for opened/replied); WhatsApp broadcast delivery/reply lives in the omnichannel backend (see hub-chat below). → email reply derivable in CRM; WA broadcast delivered/read already in CDP, reply/type via omnichannel (OQ-3). - Commerce (optional — not in CRM): no purchase/order ledger in qontak.com (only
Crm::DealsTransaction, deal metadata). Commerce reaches CDP viaexternal_activity_log(a Loyalty/POS integration) now, or a later CEBE Conversion feed; else CRM deal value proxies spend. → OQ-2/D-6.
qontak-customer-fe (Nuxt) — display surfaces
- Customer Index list +
filterByRoutescoping pattern → score column + label filter hook point. - Customer Detail page → breakdown panel hook point.
- Contact type extended with
health_score*fields once the API returns them.
Omnichannel / hub-chat — conversation, campaign & WA-call signals
(hub-chat is the Nuxt frontend; the signals below are served by the omnichannel/WhatsApp backend it consumes — grounded via the hub-chat type contracts.)
- Conversation / room: room events flow into CDP as
activity_logcategoryroom(viaroom_consumer.go) → conversation count + recency reachable now (✅). Rooms are keyed to a customer viaqontak_customer_id(hub-chatRoomDetail), withlast_message_at/last_activity_atfor recency. - Campaign / broadcast: the WA/omnichannel backend holds
message_status_count {sent, delivered, read, failed, pending},RecipientMessage.status,messages_broadcast_idkeyed bycontact_phone_number/contact_id, andmessage_template.category(MARKETING / UTILITY / AUTHENTICATION) → delivered rate, reply rate, # replies, last delivered / last reply, reply type broadcast-vs-utility. CDP today ingests only campaigndelivered/read(campaign_consumer.go) — reply + type need an extended consumer or CEBE Campaign (OQ-3/OQ-5). - WhatsApp calls: WA-call activity carries
contactId,callDuration,finalStatus,startDialTime, anddirection(USER_INITIATED = inbound / BUSINESS_INITIATED = outbound) → call count / recency / duration / direction. Not in CDP today (mirrors CRMcall_recordings).
Appendix B — Competitive Benchmark
Source: the uploaded benchmark board (Zoho Zia, HubSpot, Salesforce, Gainsight) + public product docs. The board's own outline confirms the competitor set and Zia's multi-score model.
| Product | Scoring model | Components / measures | Configurability | Take-away for Qontak |
|---|---|---|---|---|
| Zoho CRM (Zia Scores) | AI/ML-predicted, multi-score | Customer Health score (satisfaction, usage patterns), Engagement score (module/record activity), Follow-up score (timeliness, relevance, impact), Conversion score (journey-based: Leads/Deals). Also field-attribute signals on the record. | Predefined score types; requires training data; shown as a % gauge + level ("Need improvement / Excellent"). | Validates the multi-component model (Qontak's 5 map onto Health/Engagement/Follow-up/Conversion + field attributes). But Zia is a black box needing training data → Qontak differentiator = transparent, rule-based, works day-1. |
| HubSpot | Customer Health score in the Customer Success workspace | Health score built from properties/usage; segment/health views. | Property-driven scoring; workspace surfaces health. | Confirms health-score is a CS workspace staple; reinforces the Customer Index column + filter and detail breakdown UX. |
| Salesforce | Health/scoring gauges (Einstein + custom) | Gauge-style health + scoring on the account/contact. | Config + Einstein options. | Validates the gauge + label display and the score-on-record pattern. |
| Gainsight | Scorecards (the CS gold standard) | Weighted measures grouped into an overall health score with coloured labels (e.g. red/yellow/green); each measure weighted. | Fully configurable measures + weights + colour scheme. | Directly validates Qontak's model: weighted components + capped contribution + configurable coloured label bands. Qontak's edge: it lives in the same platform as the conversations/deals/tickets/CSAT, so no separate CS tool/integration is needed. |
Positioning (why Qontak's approach wins for MVP):
- Transparent & rule-based — every point is explainable on the Customer Detail breakdown (vs Zia's black-box ML).
- Works day-1 — no ML training data required; the default 5-component template ships preset.
- Native to the data — signals (convo, calls, deals, tickets, CSAT) already live in Qontak; no external CS-tool integration (vs standalone Gainsight/Totango).
- Configurable like Gainsight — components on/off, curated metrics (≤3 each), custom label bands — without Gainsight's cost/complexity.
PRD CHANGELOG
| Version | Date | By | Section | Type | Summary |
|---|---|---|---|---|---|
| 1.7 | 2026-07-15 | Permission emphasis + downstream consumers | §6, §7, §8.2 (CHS-S08/S09/S10/S11/S12), §13 | ADDED | (1) Permission emphasis — the score renders inside the customer-profile section; visibility is inherited from that section's view permission (no separate health-score gate; section hidden → score hidden). Reinforced in the §6 intro + CHS-S08 (new AC-4). (2) Downstream consumers — four new Should Have stories + §7 behaviours #8–#10 + four §13 dependencies: CHS-S09 segment by health score (numeric segment_engine Number operators, per D-11; optional label dropdown); CHS-S10 health_score* on the MAG public API (get-all + single customer); CHS-S11 health-score filter in the direct Select Contact menu; CHS-S12 health_score* via the S2S customer-details API. All read-only passthroughs of the stored fields; feature-gated; null when not computed. Mirrors Confluence page 51135449719 (v1.7). |
| 1.6 | 2026-07-09 | Inbox display scope | §6, §8.2 (CHS-S08), §13 | ADDED | New scope: the Customer Health Score is displayed (read-only) on the Inbox customer-profile panel — web inbox (hub-chat) and chat mobile app. Added story CHS-S08, a §6 display-surface note, and two §13 cross-team dependencies (Omnichannel/hub-chat FE + Mobile chat). CDP serves the health_score* fields via the contact read-API; the Inbox/mobile surfaces render them — no new field, no new permission key. Mirrors Confluence page 51135449719 (v1.6). |
| 1.5 | 2026-07-09 | Metric refinements (PM feedback) | §6.1 (worked example), §6.2 (Components 3 & 4), §14 (D-6), §15 (OQ-2) | UPDATED | Worked example now shows a fully-healthy customer on the default template — every component fills its cap of 20 → total 100 Healthy (Field Attributes uses phone / email / username is_not_empty + job title + source; no VIP / Active rows). Conversion: removed Lost-deal penalty; elaborated commerce into total purchases, total purchase amount, last purchase, avg order value (via external_activity_log). Support: removed Aging-unresolved-ticket + Open-high-priority-ticket (kept No-open-ticket, Recent-complaint, Issues-get-resolved, Ticket-volume). D-6 + OQ-2 updated accordingly. |
| 1.4 | 2026-07-09 | Metric-catalog rework (PM feedback) | §5, §6 (tree/model/constraints), §6.2 (full rewrite), §7, §8, §13 (+graph), §14 (D-5/6/10), §15 (OQ-1/2/5), Appendix A | UPDATED | Reworked the metric catalog per PM feedback, and re-grounded every source against the real repos (contact-service, qontak.com CRM, hub-chat omnichannel) instead of blanket-CEBE. (1) Caps are auto-redistributed and client-editable (§5/§6.1). (2) Field Attributes table restructured: the per-field-type operator table now lives inside Component 1, and each field type carries its own example rule → points (default + custom). (3) Engagement elaborated — campaign delivered rate / reply rate / # replies / last delivered / last reply / reply type (broadcast vs utility) + WA-call count/recency/duration/direction, all with time windows; inactivity penalty removed. (4) Conversion — added won deal rate (won ÷ associated) alongside won count / LTV / lost-penalty; commerce optional. (5) Support — dropped low-resolution-rate / SLA / repeat-issue; rebuilt around customer-POV metrics (open/aging/high-priority/volume/recency/resolved-ratio). (6) Satisfaction = CSAT only; NPS + negative-experience-trend removed (NPS out of MVP — CRM has only per-agent NPS). (7) Sources re-grounded: attributes via segment_engine (now); conversation + campaign-delivered in CDP now; deals/tickets/CSAT/WA-calls = CRM qontak.com per crm_person (person.rb/csat.rb/call_recordings); campaign reply/type + WA-call = omnichannel hub-chat; ingested via a CDP consumer or CEBE — CEBE is one ingestion path, not the data source (D-10). OQ-1 (NPS out), OQ-2 (commerce optional), OQ-5 (ingestion path) reopened as decisions; bumped to v1.4. |
| 1.3 | 2026-07-08 | Align to CEBE + Segmentation | §4, §5, §6.2, §7, §8, §13 (+graph), §14 (D-4/5/6 + D-10/11), §15 (OQ-1/2/5 resolved), Appendix A, frontmatter (related) | UPDATED | Cross-initiative alignment pass (all sections reconciled to remove the pre-alignment "P0 gap / NPS-doesn't-exist / commerce-proposal-only" framing — §4 Non-Goal 4, §7 behavior #5, §8.1 step 4, CHS-S01 AC-5, S07 NEG-2/3, the §13 dependency graph nodes, and Appendix A grounding notes now all point at CEBE phasing). (1) Field Attributes now explicitly reuse the shared segment_engine operator logic per field type — the same set the Customer Segmentation — Basic Attributes PRD defines ("Operator Logic by Default/Custom Field"), for default and custom fields; added the operator-by-field-type table (§6.2) + D-11. (2) Use-case components (Engagement/Conversion/Support/Satisfaction) retagged to source from CEBE per-customer metrics (CEBE ANCHOR §6–7 names Health Score a downstream consumer) — added D-10 + a §6.2 alignment callout. (3) This resolves the earlier P0 (OQ-5 CRM→CDP aggregate path = CEBE) and un-defers NPS (CEBE Avg NPS, Q4 2026, OQ-1) and commerce (CEBE Conversion LTV/AOV, Q1 2027, OQ-2) — all three OQs marked ✅ Resolved. (4) Availability now phased to CEBE §8 (Comms/Ticket Q4 2026; Deals/Commerce/Loyalty Q1 2027; attributes + convo/campaign now). §13 deps + graph reframed to CEBE + shared operator engine; §5 data-availability row rewritten; related: → CEBE + Segmentation. No change to the 5-component scoring model. |
| 1.2 | 2026-07-08 | score-prd follow-ups | §1 (one-liner), §3 (personas), §6 (UI-state diagram), §13 (dependency graph) | UPDATED | Applied the top score-prd improvement items: trimmed the one-liner to ≤25 words; merged the three secondary personas (Sales/Marketing/CS) into one "Score consumer" persona (1–3 rule); added a UI-state stateDiagram for the config-builder/score lifecycle (§6) and a Dependency Graph (§13) surfacing the P0 CRM→CDP aggregate path + store-of-record edges. No scope/model change. |
| 1.1 | 2026-07-08 | ground-prd pass | §5, §6.2, §13, §14 (D-4), §15 (OQ-5/OQ-8), Appendix A | UPDATED | Grounded fixes (P0/P1) from the ground-prd codebase pass. Corrected the over-claim that deals/tickets/CSAT/calls are "available today": grounding confirmed contact-service has no CSAT/call consumer and the CRM→CDP event payload carries only {id,name,qontak_customer_id,user_id,status} (crm_event_payload.go:10–16) — so Conversion (deal value/won-lost/stage), Support (ticket priority/stage), Satisfaction (CSAT), and the WA-call metric are not reachable from CDP today. Escalated OQ-5 to a P0 blocker and added it as the top §13 dependency (CRM→CDP aggregate path gates 3 of 5 components). Added OQ-8 + a §13 dependency for the store-of-record split (segment_engine runs on Postgres customer_datamart, contact doc is Mongo). Corrected D-4 (recompute job is net-new; only the cron mechanism is reused). Added a ⚠️ Grounding reality callout to §6.2 and expanded Appendix A with the consumer-absence + payload-shape + customer_datamart evidence. No scope/model change — only accuracy of what's buildable-now vs blocked. |
| 1.0 | 2026-07-08 | Zhelia Alifa (drafted w/ AI, grounded) | All | CREATED | First grounded PRD for Customer Health Score — evolves the label-only Customer Signal Label discovery (page 51135449719) into a numeric 0–100 rule-based score with a derived, client-configurable label. Defines the 5-component model (Field Attributes · Engagement · Conversion · Support · Satisfaction/CSAT), each capped at 20 with components on/off + cap auto-redistribution and ≤3 metrics/component; a curated, grounded metric catalog (§6.2) with a default template; daily recompute (reusing gocraft/work + segment_engine + CustomerSegment config shape); config/index/detail UI; distribution-shift rollback guard. Grounded against contact-service, qontak.com, qontak-customer-fe, chat stack (Appendix A) and benchmarked vs Zoho Zia / HubSpot / Salesforce / Gainsight (Appendix B). MVP decisions: CSAT-only satisfaction (NPS deferred, OQ-1); commerce metrics proposal-only (OQ-2); one active config/company. |