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:
| Tag | When |
|---|---|
Not Working | zero of the seventeen showed activity |
Working Low | between one and ten showed activity |
Working | eleven 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.
| # | Model | Scope predicate | Line |
|---|---|---|---|
| 1 | patient | companyId | :92 |
| 2 | appointment | companyId | :95 |
| 3 | operation | companyId | :98 |
| 4 | invoice | companyId | :101 |
| 5 | payment | companyId | :104 |
| 6 | transaction | branch.companyId | :107 |
| 7 | inventoryOrder | companyId | :110 |
| 8 | medicalValue | companyId | :113 |
| 9 | clinicalTest | patient.companyId | :116 |
| 10 | patientEncounter | companyId | :119 |
| 11 | labOrder | branch.companyId | :122 |
| 12 | communication | companyId | :125 |
| 13 | conversationMessage | companyId | :128 |
| 14 | systemChatMessage | companyId | :131 |
| 15 | patientReminder | companyId | :134 |
| 16 | lead | companyId | :137 |
| 17 | expense | branch.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 === 0→Not Working1 ≤ score ≤ 10→Working Lowscore ≥ 11→Working
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.chartisChartTypes @default(DENTAL),:2494).patientId: patient.idleadStageId: 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.