Dentolize · Churned Leads Rejoin Renewal — Walkthrough
On this pageBusiness viewTechnical view

Usage Scoring, Tags and Activity Notes

This machinery is not new in this PR. It matters here because the PR extends it to churned leads, who had been frozen out of it entirely.

Business view

Every three days the automation asks a simple question about each customer: in the last five days, did anything happen in this clinic's account?

It checks seventeen different kinds of activity — did they add a patient, book an appointment, record a procedure, raise an invoice, take a payment, log an expense, order inventory, run a lab order, send a message, and so on. Each one that shows activity counts one point.

That count becomes three things on the customer's card:

A score out of ten. Shown on the lead card as n / 10, coloured green above 7, red below 3, amber in between.

A colour tag. One of three:

TagWhen
Not Workingzero of the seventeen showed activity
Working Lowbetween one and ten showed activity
Workingeleven or more showed activity

A written scorecard note, appended to the card's history, listing every one of the seventeen with a ✅ or 🔴, headed by an arrow showing whether the score went up (❇️), down (🔻) or held steady (🟡) since the previous run.

Separately, the list of modules the clinic isn't using is written onto the account record as Unused Features, which the admin team can filter the company list by.

For churned customers, all four of these froze the day the card was moved to Churned. After this PR they update again — which means a churned customer who quietly came back to life will now visibly turn from Not Working to Working Low, and their scorecard note will carry a green up-arrow. That signal did not exist before.

A wrinkle worth knowing

The Working tag is harder to earn than it looks. It requires eleven or more of the seventeen signals — a clinic that is genuinely, healthily active across patients, appointments, invoices and payments but not using lab orders, inventory, loyalty or the messaging modules will sit on Working Low indefinitely. Treat Working Low as "alive", not as "at risk". See the technical view for exactly why.


Technical view

The five-day window

const createdAtDate = dayjs().subtract(5, 'day').toDate()

companyLeadCron.js:26-28

The job runs every three days but looks back five, so the windows overlap by two days. An event is therefore counted in up to two consecutive runs. Since the output is a boolean "did anything happen", not a sum, overlap is harmless — but it does mean a clinic that goes silent takes up to two runs (≈6 days) to fall to Not Working.

The seventeen probes

companyLeadCron.js:92-142. Each is an independent findFirst with createdAt: { gt: createdAtDate } — seventeen separate round trips per company, per run, sequentially awaited.

#ModelScope predicateLine
1patientcompanyId:92
2appointmentcompanyId:95
3operationcompanyId:98
4invoicecompanyId:101
5paymentcompanyId:104
6transactionbranch.companyId:107
7inventoryOrdercompanyId:110
8medicalValuecompanyId:113
9clinicalTestpatient.companyId:116
10patientEncountercompanyId:119
11labOrderbranch.companyId:122
12communicationcompanyId:125
13conversationMessagecompanyId:128
14systemChatMessagecompanyId:131
15patientRemindercompanyId:134
16leadcompanyId:137
17expensebranch.companyId:140

Three of them (6, 11, 17) join through branch, and one (9) through patient. Those are the relational shapes of the models, not an inconsistency.

Note that #14, systemChatMessage, counts support-chat traffic — a clinic that only ever talks to support scores a point for "usage". And #16 counts the clinic's own leads, so a clinic using the CRM feature scores there.

Score and clamping

const score = (usingPatient ? 1 : 0) + … + (usingExpenses ? 1 : 0)   // :144-161, range 0–17

Written to the lead as:

score: Math.min(score, 10)   // :233

Lead.score is Int? at schema.prisma:5184, indexed at :5234. The card renders it as (n / 10) with colour thresholds at >7 and <3 (packages/clinic-web/src/components/dashboard/leads/LeadsBoard/LeadsCard.js:67-76).

The tag threshold

score > 10 ? WORKING_TAG_ID : !score ? NOT_WORKING_TAG_ID : WORKING_LOW_TAG_ID

companyLeadCron.js:242

This tests the raw score (0–17), not the clamped one. So:

  • score === 0Not Working
  • 1 ≤ score ≤ 10Working Low
  • score ≥ 11Working

Because the stored value is clamped to 10, every lead tagged Working displays exactly 10 / 10, and so does every lead scoring exactly 10 that is tagged Working Low. The tag and the displayed score cannot be reconciled by looking at the card — two cards both reading 10 / 10 can carry different tags. That is pre-existing behaviour, not introduced here, but this PR pushes a new population of leads through it.

Tags are applied by set-replacement that preserves unrelated tags (:239-244):

tags: {
  set: [
    ...patient.lead.tags.map(s => s.id).filter(id => !allTags.includes(id)),
    <the one computed tag>
  ].map(id => ({ id }))
}

allTags (:30) is the three automation-owned tag IDs. Any manually applied tag survives; the previous automation tag is stripped and replaced. Lead.tags points at PatientTag (schema.prisma:5179:5323) — there is no separate lead-tag model.

unusedFeatures

unusedFeatures: [ usingPatient ? null : 1, …, usingExpenses ? null : 17 ].filter(s => !!s)

companyLeadCron.js:163-186

Codes are 1-based positional indices matching the probe table above, written to Company.unusedFeatures (Int[], schema.prisma:139). Because the filter is !!s, a code of 0 would be dropped — which is why the codes start at 1 rather than 0. Surfaced in the admin UI at packages/clinic-web/src/components/admin/companies/Companies.js:527-533 (column and filter), Company.js:224 (detail), and adminHelpers.js:38 (the label list).

The scorecard note

companyLeadCron.js:249-280. Creates a Note (schema.prisma:2486) with:

  • details — an HTML string of <strong> runs, one per probe, with ✅ or 🔴, headed by the

five-day date range and a trend emoji.

  • createdById: MAIN_USER_ID — the system user, so these are distinguishable from human notes.
  • seen: true — pre-marked read, so they do not raise an unread badge.
  • chart: 'DENTAL' — hardcoded (Note.chart is ChartTypes @default(DENTAL), :2494).
  • patientId: patient.id
  • leadStageId: patient.lead.stageId

That last field is the source stage, not the destination: patient.lead is the object read at :47-63, and although lead.update at :230 has already changed the row, the in-memory patient.lead.stageId still holds the pre-move value. So when a lead is moved to Renewal, its scorecard note is filed against the stage it came from. Likewise the trend arrow compares against patient.lead.score (:251, :254), the pre-update value — which is correct, and is the reason the comparison still works despite running after the write.

The order of the two probe lists differs, incidentally: the score and unusedFeatures arrays run …payment, transaction, inventoryOrder…, while the note text runs …payments, expenses, transactions, inventory orders… — expenses is printed sixth but numbered seventeenth. Cosmetic, but it will confuse anyone cross-referencing a note against an unusedFeatures code list.