The Company Lead Cron, End to End
Business view
Dentolize sells software to dental clinics. To keep track of those clinics as customers — rather than as tenants — the company dogfoods its own CRM: each paying clinic exists a second time, inside Dentolize's own tenant, as a patient record with a lead attached to it. The lead is the card on a sales board.
Every three days, a background job wakes up and walks that board. For each clinic it asks three questions:
- Who do we talk to? Copy the current manager's name and phone from the CRM record
onto the clinic's account record, so support and billing see the right contact.
- Are they actually using it? Look at seventeen kinds of activity over the last five
days, count how many showed signs of life, and turn that into a score out of ten, a colour-coded tag, and a written scorecard note on the card.
- Is money about to run out? If the subscription expires within roughly the next
month and the customer is on an annual plan, drag the card into the Renewal column so a human follows up.
Nobody clicks anything to make this happen. It is the reason a renewal card appears on the board "by itself" a few weeks before a subscription lapses.
This PR changes step 2 and 3 to also apply to customers already parked in the Churned column, and tidies how step 0 (which companies do we even look at) is written.
Technical view
File: packages/server/src/cronJobs/companies/companyLeadCron.js:8 Registration: packages/server/src/cronJobs/cronJobs.js:104-115 Schedule: '0 4 */3 * *' — 04:00 every third day. Timezone: passed as '' (cronJobs.js:111), same as every other job in the file, so it effectively runs in server local time.
Entry point and locking
The cron process is packages/server/src/cron.index.js, started via yarn start:cron (packages/server/package.json:8) or pm2:cron. On boot it deletes every job's redis key, then starts all nine jobs.
Each job uses the same hand-rolled redis mutex (companyLeadCron.js:12-18):
const cachedRedisJob = await redisClient.get(name)
if (cachedRedisJob && JSON.parse(cachedRedisJob).running) return
await redisClient.set(name, JSON.stringify({ running: true }))
There is no TTL and no finally block. If the process is killed mid-run the key stays {running: true} and the job never runs again until the cron process restarts. That is a pre-existing property of all nine jobs, not something this PR touches.
On completion (companyLeadCron.js:285-294) it writes back:
{ running: false, total: companies.length, started, ended: new Date(), error: null }
and on failure (:296) the same shape with total: 0 and error: e.message. The whole body is wrapped in one try/catch, so a single company that throws aborts the entire run — every company after it in the ordering is skipped until the next scheduled tick.
Hardcoded identity
The job is hardwired to Dentolize's own tenant (companyLeadCron.js:20-25):
| Constant | Value | Used at |
|---|---|---|
MAIN_COMPANY_ID | 54176b70-…dbe4d3e | :48 — which tenant holds the CRM |
MAIN_USER_ID | 9ac8405d-…19c4f9b7 | :221, :274 — the "author" of automated timeline entries and notes |
RENEWAL_STAGE_ID | 9dbf0d3f-…5435dde6 | :38, :194, :220, :245 |
NOT_WORKING_TAG_ID | 271bdd24-…d42c30c3 | :242 |
WORKING_TAG_ID | 21ee6e3e-…1a75e3a5 | :242 |
WORKING_LOW_TAG_ID | b42296ba-…9ccea660 | :242 |
MAIN_COMPANY_ID also exists as a proper exported constant at packages/server/src/utils/variables.js:45, but this file does not use it — it redeclares the literal. RENEWAL_STAGE_ID and the three tag IDs appear nowhere else in the repository.
The loop
companies = company.findMany({ orderBy: { tierExpiry: 'desc' }, where: { disabled: false } }) :32-35
renewalStage = leadStage.findUnique({ where: { id: RENEWAL_STAGE_ID }, select: { viewId } }) :37-40
for each company:
if (!company.referenceId) skip :43
patient = patient.findFirst({ companyId: MAIN_COMPANY_ID, referenceId: company.referenceId }) :47-63
if patient has a doctor whose name/phone differ → write them onto the company :65-78
if (patient && patient.lead): :80
compute shouldMoveToRenewal :81-90
run 17 activity probes over the last 5 days :92-142
score = count of probes that hit :144-161
company.update({ unusedFeatures: [codes for probes that missed] }) :163-186
if shouldMoveToRenewal → perform the stage move :192-228
lead.update({ score, order, journeyedAt, stagedAt, currentViewId, due, tags, stageId }) :230-247
note.create({ …emoji scorecard… }) :249-280
company.referenceId (schema.prisma:110) is the join key: a Float? on the tenant record that matches Patient.referenceId (schema.prisma:857) inside the main tenant. A company with no referenceId has never been linked to a CRM card and is skipped outright.
The renewal condition
const shouldMoveToRenewal =
patient.lead.stage.viewId !== renewalStage.viewId &&
!company.monthly &&
company.tierExpiry &&
dayjs(company.tierExpiry).isBefore(
dayjs().add(26, 'days').subtract(1, 'seconds').endOf('month')
)
companyLeadCron.js:81-90
All four must hold:
stage.viewId !== renewalStage.viewId— the lead is not already in the same view
(pipeline) as the Renewal stage. Note this compares views, not stages. This is the clause that determines whether the PR's headline behaviour actually happens; see Change 1.
!company.monthly(schema.prisma:126) — annual plans only. Monthly subscribers are
never auto-moved.
company.tierExpiry— truthiness guard, althoughtierExpiryis declared
non-nullable at schema.prisma:160, so in practice this is always true.
- The date window. Take now, add 26 days, subtract a second, then round up to the end of
that month. Any expiry before that instant qualifies.
The window is therefore not a fixed 26 days — it stretches to the end of the month the 26-day mark lands in. Running on the 5th of a month gives ≈26 days of lead time; running on the 21st gives ≈41 days; the theoretical range is roughly 26–57 days. The subtract(1, 'seconds') exists to stop now + 26d landing exactly on midnight of the 1st and rounding up to the end of the following month.
What gets written
Per qualifying company, in this order and not inside a transaction:
| Write | Line | Notes |
|---|---|---|
company.update manager name/phone | :71-77 | outside the patient.lead guard — always runs when a doctor is attached |
company.update unusedFeatures | :163-186 | array of codes 1–17 for each unused module |
leadStage.update Renewal totalLeads++ | :193-196 | only when moving |
leadStage.update source totalLeads-- | :202-205 | only when moving |
lead.updateMany source order-- | :207-212 | only when moving — see the defect note below |
stageTimeline.create | :216-227 | audit row, createdById: MAIN_USER_ID |
lead.update | :230-247 | score, tags, due, stage, view |
note.create | :249-280 | the emoji scorecard |
Six or seven separate awaits with no prisma.$transaction. The interactive equivalent, packages/server/src/resolvers/mutations/actions/leads/moveLead.js:129, does wrap the same work in a transaction. A mid-sequence failure here leaves LeadStage.totalLeads desynced from reality. Details in Stage Move Mechanics.
Manual execution
packages/server/src/resolvers/mutations/actions/cronJobs/executeCronJob.js:35 exposes the job as an admin mutation, guarded by isAdmin (packages/server/src/permissions/permissions.js:4071) and hard-blocked when NODE_ENV === 'production' (executeCronJob.js:18). The admin UI for it is at /ZROYKuKEVCvQykPlS4kP/jobs — see the Feature Tour.