Change 1 — Churned Leads Rejoin Renewal
This is the change the PR is named after.
Business view
The old behaviour
Dentolize's customer board has a Churned column. A card lands there when someone on the team decides an account is lost — cancelled, gone quiet, stopped paying, whatever the reason.
Once a card was in that column, the automation stopped looking at it. Completely. Not "moved it less often" — it was skipped before any of the work happened:
- Its usage score was never recalculated, so the number on the card froze at whatever it was
the day it was marked churned.
- Its colour tag (
Working/Working Low/Not Working) froze too. - The clinic's Unused Features list on the account record stopped being refreshed.
- No scorecard note was ever added again, so the card's history simply stopped.
- And it was never moved to Renewal, no matter what its subscription was doing.
Why that was a problem
"Churned" in this system is a human judgement recorded as a column, not a fact derived from billing. The two drift apart constantly:
- A customer marked churned in March whose annual subscription runs to November is still a
paying customer for eight more months — and still a renewal opportunity.
- A customer marked churned after a quiet quarter who then quietly started using the product
again showed no sign of it, because the score that would have revealed the revival was never recalculated.
- A customer marked churned by mistake stayed invisible forever. There was no automated
path back out of that column.
The Churned column was a roach motel: cards checked in, and the automation never looked at them again.
The new behaviour
The exclusion is deleted. A churned lead is now treated exactly like any other lead:
- It gets a fresh usage score every three days.
- Its tag updates, so a revived customer visibly turns from
Not WorkingtoWorking Low. - The clinic's Unused Features list stays current.
- It gets a scorecard note each run, with an up-arrow or down-arrow showing whether
engagement improved or declined since last time.
- And if its subscription is inside the renewal window and it is on an annual plan, it moves
to Renewal — putting a written-off account back in front of a human at exactly the moment there is money on the table.
The important caveat
Whether that last point actually happens depends on how the board is configured, not on this code. The full explanation is in the technical view below, but in plain terms: the automation refuses to move a card that is already in the same pipeline as the Renewal column. If Churned and Renewal are two columns of the same pipeline, churned cards still will not move — they will just start getting scored and annotated again. If Churned lives in a separate pipeline, they will move.
Nothing in the codebase records which of those two is true. It needs to be checked against production data before anyone promises the headline behaviour. See For Quality.
Technical view
The diff
packages/server/src/cronJobs/companies/companyLeadCron.js
- const CHURNED_STAGE_ID = '2397d5d6-0ecd-4796-9f8e-aa78db0a1a79'
- if (patient && patient.lead && patient.lead.stageId !== CHURNED_STAGE_ID) {
+ if (patient && patient.lead) {
Two lines. The guard is at companyLeadCron.js:80.
Blast radius of that one guard
The removed condition gated the entire body from :80 to :281. Everything below was unreachable for a churned lead:
| Work unit | Lines | Was skipped for churned leads |
|---|---|---|
shouldMoveToRenewal computation | :81-90 | yes |
| 17 activity probes | :92-142 | yes |
score computation | :144-161 | yes |
company.update({ unusedFeatures }) | :163-186 | yes |
| Renewal stage move | :192-228 | yes |
lead.update (score, tags, due, stage) | :230-247 | yes |
note.create scorecard | :249-280 | yes |
Note what was not gated: the manager name/phone sync at :65-78 sits above the guard, so churned companies always had their contact details kept in sync. Only the scoring and lifecycle half was dark.
This is why the change is larger than "churned leads can now move to Renewal". It is "churned leads re-enter the entire scoring pipeline". The unusedFeatures refresh in particular is a quiet win — that field drives an admin filter (packages/clinic-web/src/components/admin/companies/Companies.js:527-533) that was silently stale for every churned account.
The viewId versus stageId problem
The move is gated by companyLeadCron.js:82:
patient.lead.stage.viewId !== renewalStage.viewId
renewalStage is fetched at :37-40 selecting only viewId. patient.lead.stage is selected at :57 as { id: true, viewId: true }. So the comparison is view to view, not stage to stage.
LeadView (packages/prisma/schema.prisma:5143) is the pipeline/board container; LeadStage (:5108) is a column within it, carrying a nullable viewId (:5116).
Three cases:
- Churned is in a different view from Renewal.
viewIddiffers → the guard passes →
churned leads move to Renewal. This is the behaviour the PR title promises.
- Churned is in the same view as Renewal.
viewIdmatches → the guard fails → churned
leads are scored, tagged and annotated, but never moved. The PR title is not delivered, though the rest of the change still is.
- Either stage has
viewId = null.null !== nullisfalsein JavaScript only when
both are null — actually null !== null evaluates to false, so two null views compare as "same view" and the guard fails. A null Renewal view would block every lead in a null-view stage from moving.
Case 3 is worth stating precisely because it is easy to get backwards: if renewalStage.viewId is null and the lead's stage viewId is also null, the expression null !== null is false, so shouldMoveToRenewal is false and nothing moves.
The repository cannot resolve which case is live. Lead stages are per-company records created through the UI (packages/server/src/resolvers/mutations/actions/leads/addNewLeadStage.js:6), not seeded — there is no seed file or migration in packages/prisma/migrations that creates a stage named "Renewal" or "Churned". The stage IDs in this file are production data pinned as literals.
CHURNED_STAGE_ID is now dead
A full working-tree grep for 2397d5d6-0ecd-4796-9f8e-aa78db0a1a79 returns zero hits after this change. A case-insensitive grep for the token churned across all .js, .graphql and .prisma files also returns zero hits.
The stage row presumably still exists in the database and on the board — users will still see a Churned column — but the application code no longer has any concept of churn. If a future change needs to treat churned accounts specially again, that UUID will have to be reintroduced from scratch.
Interaction with the rest of the move
Once a churned lead does qualify, it goes through the same move path as any other lead (:192-228), including a StageTimeline row recording stageId: <churned> → toStageId: <renewal> with createdById: MAIN_USER_ID. That gives a clean audit trail: the timeline will show the automated churn→renewal transitions distinctly from human drags, because the author is the system user.
It also inherits the move path's known defects, which this PR makes more likely to fire because Churned is typically a large column. See Stage Move Mechanics and Known Defects.