Dentolize · Churned Leads Rejoin Renewal — Walkthrough
On this pageTermsModelsHardcoded identifiersThe seventeen activity signalsScheduling reference

Glossary and Data Model

Terms

Lead (in this context) — a card representing one Dentolize customer on Dentolize's own internal sales board. Not the leads feature that clinics use for their own prospective patients. Same code, two uses; this PR concerns the internal one.

Company / tenant — one clinic customer's account. Company in the schema.

Main company — Dentolize's own tenant, MAIN_COMPANY_ID = 54176b70-1b7c-455c-b5a6-84863dbe4d3e. It holds the CRM records that mirror every customer.

Reference ID — the join key. Company.referenceId (a Float?) matches Patient.referenceId inside the main company. A company with no reference ID has no CRM card and is skipped by the job entirely.

Stage — a column on the board. LeadStage.

View / journey — a whole board (pipeline) containing stages. LeadView. Critically, the renewal guard compares views, not stages.

Renewal stage9dbf0d3f-ad02-45b8-8a3c-4b605435dde6, hardcoded. Where the job parks cards whose subscription is coming due.

Churned stage2397d5d6-0ecd-4796-9f8e-aa78db0a1a79. No longer referenced anywhere in the codebase after this PR. The stage row still exists in the database and users still see the column, but no code knows about it.

Score — count of how many of seventeen activity signals fired in the last five days. Range 0–17, stored clamped to 0–10.

Working / Working Low / Not Working — the three automation-owned tags. Thresholds are on the raw score: 0 → Not Working, 1–10 → Working Low, 11+ → Working.

Unused featuresCompany.unusedFeatures, an Int[] of codes 1–17 for modules with no activity in the window.

Scorecard note — the emoji-laden HTML note the job appends to a card each run, listing all seventeen signals with ✅ or 🔴.

Tier expiryCompany.tierExpiry, the subscription end date. Drives the renewal window and is copied onto the lead's due date every run.

MonthlyCompany.monthly. When true, the customer is on a monthly plan and is never auto-moved to Renewal.


Models

Line references are into packages/prisma/schema.prisma.

Company — line 64

FieldLineTypeRole here
managerName87String?written by the job from the CRM card's doctor
managerPhone88String?same; underscore stripped before writing
referenceId110Float?join key to the CRM patient
disabled125Boolean @default(false)now filtered in the query, not the loop
monthly126Boolean @default(false)monthly plans are never auto-moved
unusedFeatures139Int[]written every run
tierExpiry160DateTimenon-nullablerenewal window input

Note the mismatch: tierExpiry is non-nullable in the schema but the job guards it with company.tierExpiry && at companyLeadCron.js:84, as if it were optional.

Lead — line 5166

FieldLineRole
stageId5177required; the column the card is in
patientId5173String? @unique; the FK side of the patient↔lead link
order5183Int, required, no default; position within the column. Never selected by the cron — the root of the ordering defect
score5184Int?; written clamped to 10
stagedAt5217required; when it entered its current stage; used to compute timeline duration
journeyedAt5218DateTime?; when it last crossed into a different view
currentViewId5206denormalized cache of stage.viewId
initialViewId5204the view it first landed in; never overwritten
due5181DateTime?; normally now + stage.dueDays, but the cron overwrites it with tierExpiry
tags5179relation to PatientTag via LeadToPatientTag

Relevant indexes: @@index(score) :5234, @@index(stagedAt) :5235, @@index(journeyedAt) :5236, @@index([stageId, order]) :5251.

LeadStage — line 5108

FieldLineRole
name, color5110–5111display
totalLeads5112denormalized count and the allocator for the next card's order
order5113column position on the board
dueDays5114default SLA; ignored by the cron, which uses tierExpiry
viewId5116String? — the field the renewal guard actually compares

totalLeads being both a counter and an allocator is why counter drift causes duplicate positions on later inserts, not merely a wrong badge.

LeadView — line 5143

The pipeline container. stages LeadStage[] :5146. Unique on [companyId, name] :5159.

StageTimeline — line 5255

The audit row written on every move.

FieldLineValue from the cron
stageId5260source stage
toStageId5262RENEWAL_STAGE_ID
leadId5264the lead
createdById5266required — MAIN_USER_ID, which is what distinguishes automated moves from human drags
timeInSeconds5271required — dayjs().diff(patient.lead.stagedAt, 'seconds')

PatientTag — line 5323

There is no separate lead-tag model; Lead.tags points here via LeadToPatientTag (:5179 ↔ :5335). Unique on [companyId, name] :5348.

Note — line 2486

FieldLineValue from the cron
details2488the HTML scorecard
seen2492true — pre-marked read, so no unread badge
chart2494hardcoded 'DENTAL'
patientId2500the CRM patient
leadStageId2506the source stage, not the destination
createdById2512MAIN_USER_ID

Patient — line 791

referenceId :857 (join key), doctorId :884 (source of manager name/phone), lead :924 (back-relation).


Hardcoded identifiers

ConstantValueDefinedReferenced elsewhere?
MAIN_COMPANY_ID54176b70-1b7c-455c-b5a6-84863dbe4d3ecompanyLeadCron.js:20Yes — also at server/src/utils/variables.js:45, getMonthlySubscriptionsForDentolize.js:17, clinic-web/src/variables.js:60, clinic-mobile/src/utils/variables.js:48. The cron redeclares the literal rather than importing the exported constant.
MAIN_USER_ID9ac8405d-97e5-4e10-8d55-98f519c4f9b7:21No
RENEWAL_STAGE_ID9dbf0d3f-ad02-45b8-8a3c-4b605435dde6:22No
NOT_WORKING_TAG_ID271bdd24-1fcd-45b7-8480-62e8d42c30c3:23No
WORKING_TAG_ID21ee6e3e-d3f6-471d-ada1-41dc1a75e3a5:24No
WORKING_LOW_TAG_IDb42296ba-ee36-4e39-8170-e4829ccea660:25No
CHURNED_STAGE_ID2397d5d6-0ecd-4796-9f8e-aa78db0a1a79deleted by this PRNo — zero hits repository-wide

None of these stages or tags are created by a seed file or migration. They are production rows pinned as literals in source.


The seventeen activity signals

Codes as written into Company.unusedFeatures.

CodeModelScoped by
1patientcompanyId
2appointmentcompanyId
3operationcompanyId
4invoicecompanyId
5paymentcompanyId
6transactionbranch.companyId
7inventoryOrdercompanyId
8medicalValuecompanyId
9clinicalTestpatient.companyId
10patientEncountercompanyId
11labOrderbranch.companyId
12communicationcompanyId
13conversationMessagecompanyId
14systemChatMessagecompanyId
15patientRemindercompanyId
16leadcompanyId
17expensebranch.companyId

All use createdAt: { gt: now - 5 days }. Beware: the scorecard note prints these in a different order from the code numbering — expenses appears sixth in the note but is code 17.


Scheduling reference

PropertyValue
Cron expression0 4 */3 * * — 04:00 every third day
Registered atpackages/server/src/cronJobs/cronJobs.js:104-115
Timezone argument'' — effectively server local time
Redis keycompanyLeadCron (the job name doubles as the lock key)
Lock TTLnone
Entry processpackages/server/src/cron.index.js, via yarn start:cron or pm2:cron
Manual triggerexecuteCronJob mutation, isAdmin, blocked when NODE_ENV=production
Status surfacegetCronJobs query → Admin → Jobs, /ZROYKuKEVCvQykPlS4kP/jobs

The eight sibling jobs: expensesCron (monthly), messagesCron (hourly), treasuryCron (hourly), pendingMessageCron (every 5s), appointmentsCron (every 5 min), tasksCron (hourly), mergerCron (daily 01:30), patientPointsCron (daily 02:30). All nine share the same no-TTL redis lock pattern.