Dentolize · Churned Leads Rejoin Renewal — Walkthrough
On this pageThe one test that gates the releaseEnvironment reality checkTest matrix — the churn changeTest matrix — the disabled-companies changeEdge cases worth explicit testsThe ordering defect — how to reproduce itNon-transactional moveWhat does not need testingRelated reading

For Quality

The one test that gates the release

Everything else on this page is secondary to this check.

Do the Churned and Renewal stages share a LeadView?

The move is guarded by companyLeadCron.js:82:

patient.lead.stage.viewId !== renewalStage.viewId

This compares views, not stages. So a churned lead only moves if its stage's viewId differs from the Renewal stage's viewId.

Run against the production (or a production-shaped) database:

SELECT id, name, "viewId"
FROM "LeadStage"
WHERE id IN (
  '9dbf0d3f-ad02-45b8-8a3c-4b605435dde6',  -- RENEWAL_STAGE_ID
  '2397d5d6-0ecd-4796-9f8e-aa78db0a1a79'   -- the removed CHURNED_STAGE_ID
);
ResultVerdict
Different non-null viewIdsThe PR does what its title says. Proceed.
Same viewIdChurned leads are scored and annotated but never move. The headline behaviour does not ship. Raise it.
Both viewId are NULLSame as above — null !== null is false, so the guard fails and nothing moves.
Renewal row missingThe job crashes. See the null-dereference test below.

Note the second UUID no longer appears anywhere in the codebase — it is recorded here only because this PR deleted it. Confirm the real Churned stage ID with the team rather than assuming this literal is still correct.


Environment reality check

The branch sandbox cannot exercise this feature. Document this before anyone reports a false pass.

We ran the job manually via Admin → Jobs on the sandbox. It completed in 7 ms with Total = 2 and no error — but it did no work at all, because:

  • The job only processes companies with a truthy referenceId (companyLeadCron.js:43).

Both seeded sandbox companies have an empty Reference ID — visible on the company detail screen in the Feature Tour.

  • Even if one had a referenceId, the patient lookup at :47 is scoped to the hardcoded

MAIN_COMPANY_ID, which does not exist in the sandbox seed.

  • And RENEWAL_STAGE_ID does not exist either, so renewalStage is null.

A 7 ms clean run is therefore the signature of a complete no-op, not a pass. Any test plan that treats "the job ran without error" as evidence is testing nothing.

To test this properly you need a seeded fixture with: a main company at the hardcoded UUID, a Renewal stage and a Churned stage at known UUIDs in known views, a company with a referenceId, and a patient in the main company with a matching referenceId and an attached lead.

Also note: the admin Edit Company form is broken in this build — submitting returns Variable "$referral" of required type "ID!" was not provided because the seeded company has no referral. That blocks setting referenceId or leadsEnabled through the UI. It is a pre-existing issue unrelated to this PR, but it will block your fixture setup.


Test matrix — the churn change

shouldMoveToRenewal requires all four of: different view, !monthly, truthy tierExpiry, expiry inside the window.

#Lead stagePlantierExpiryExpected
1Churned, different view from Renewalannualinside windowMoves to Renewal. The headline case.
2Churned, same view as Renewalannualinside windowScored, tagged, noted. Does not move.
3Churnedmonthlyinside windowScored and noted. Does not move.
4Churnedannualoutside windowScored and noted. Does not move.
5Churnedannualalready pastDepends only on the window test — a past date is "before" the cutoff, so it does move. Confirm this is intended.
6Already in Renewalannualinside windowScored and noted. Does not move (same view as itself).
7Any other stageannualinside windowMoves. Unchanged from before the PR.

Case 5 deserves attention. The condition is isBefore(cutoff) with no lower bound, so a subscription that expired two years ago satisfies it. Combined with the churn change, every long-dead churned account with an annual plan becomes eligible to move to Renewal on the first run. If the churned population is large, the first run could dump a substantial batch into Renewal. Verify whether that is intended; if not, it needs a lower bound.

Regression coverage — the pre-PR behaviour that must still hold

ScenarioExpected
Non-churned lead, all conditions metStill moves.
Company with no referenceIdSkipped entirely.
Company with referenceId but no matching patientSkipped.
Patient exists but has no leadManager name/phone still synced (:65-78 is above the guard); nothing else runs.
Disabled companyNot fetched at all.

Test matrix — the disabled-companies change

The processed set must be identical before and after. Company.disabled is Boolean @default(false) (schema.prisma:125), non-nullable, so where: { disabled: false } and !company.disabled select the same rows.

CheckExpected
Disabled company with referenceId and a live leadNot processed. No score change, no note, no move.
Total on the Jobs pageEquals the count of non-disabled companies. Verified on sandbox: Total = 2 for 2 non-disabled companies.
Enabled companyProcessed exactly as before.

The only behavioural delta is the total statistic. Confirm it dropped and that nobody reads it as a failure.


Edge cases worth explicit tests

Null Renewal stage. renewalStage is fetched with findUnique (:37-40) and dereferenced at :82 without a guard. Delete or rename the Renewal stage row and run the job: it should throw TypeError: Cannot read properties of null on the first company with a linked lead, and the catch at :295 should record it in the Error column. Confirm the error surfaces on the Jobs page rather than vanishing.

Mid-run failure. Force an exception partway through the company loop. Expect: every company after the failure point is skipped for that cycle, and the company being processed is left half-written (counters moved, card not). Because ordering is tierExpiry: 'desc', the most urgent renewals are processed last and are the first casualties.

Stuck lock. Kill the cron process mid-run, then wait for the next scheduled tick. Expect the job to never run again — the redis key has no TTL and there is no finally block. Only a cron-process restart clears it (cron.index.js deletes all job keys on boot). Confirm this behaviour so it is documented rather than discovered.

Score boundaries. The tag is chosen from the raw 0–17 count while the stored score is capped at 10 (:233, :242).

Raw scoreStoredTag
00Not Working
11Working Low
1010Working Low
1110Working
1710Working

Assert the 10→11 boundary specifically: both display 10 / 10 but carry different tags.

Tag preservation. A lead with manually applied tags plus an automation tag must keep the manual ones and have only the automation tag replaced (:239-244).

Five-day window overlap. The job runs every 3 days but looks back 5. A single event should be counted in two consecutive runs without side effects, and a clinic going silent should take two runs to reach Not Working.

Renewal window boundaries. The cutoff is dayjs().add(26, 'days').subtract(1, 'seconds').endOf('month'). Test on the 1st, the 5th and the 28th of a month — the effective lead time ranges from ~26 to ~57 days. Specifically test a run where now + 26d lands exactly at midnight on the 1st; the subtract(1, 'seconds') exists to keep that from rounding up into the following month.


The ordering defect — how to reproduce it

This is pre-existing but this PR makes it fire far more often, so it belongs in the test plan.

At :207-212:

where: { stageId: patient.lead.stageId, order: { gte: patient.lead.order } }

order is not in the lead's select list (:49-62), so patient.lead.order is undefined. Prisma drops undefined filter keys, leaving { stageId } — so every lead in the source stage is decremented.

Repro: put five leads in a stage with orders 1–5. Trigger an automated move of the lead at order 3. Expected-correct result: remaining orders 1, 2, 3, 4. Actual: 0, 1, 2, 3 — the two cards above the moved one were shifted when they should not have been, and order 0 now exists.

Run it repeatedly and orders drift negative; Lead.order is a plain Int (schema.prisma:5183) with no constraint. Compare against the interactive path, moveLead.js:183-186, which uses gt and a correctly-loaded order.

Because Churned is now a source stage and is typically the largest column, one churned move rewrites every churned card's position.


Non-transactional move

:192-247 performs six writes with no prisma.$transaction, unlike moveLead.js:129. Inject a failure after the totalLeads increment (:193) but before the lead.update (:230) and confirm the counter is left inflated with no matching card, and that nothing repairs it.

This matters beyond a wrong badge: LeadStage.totalLeads is the allocator for the next card's order in the create paths (addNewLead.js:40-41, 93), so a desynced counter produces duplicate order values on later inserts.


What does not need testing

  • Any clinic-facing screen or permission — no customer-facing code path is touched.
  • The manager name/phone sync (:65-78) — above the changed guard, unaffected.
  • The seventeen probes themselves — unchanged by this PR, though they now run for more

companies.

  • GraphQL schema, permissions, migrations — none changed.