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

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:

  1. 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.

  1. 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.

  1. 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):

ConstantValueUsed at
MAIN_COMPANY_ID54176b70-…dbe4d3e:48 — which tenant holds the CRM
MAIN_USER_ID9ac8405d-…19c4f9b7:221, :274 — the "author" of automated timeline entries and notes
RENEWAL_STAGE_ID9dbf0d3f-…5435dde6:38, :194, :220, :245
NOT_WORKING_TAG_ID271bdd24-…d42c30c3:242
WORKING_TAG_ID21ee6e3e-…1a75e3a5:242
WORKING_LOW_TAG_IDb42296ba-…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, although tierExpiry is 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:

WriteLineNotes
company.update manager name/phone:71-77outside the patient.lead guard — always runs when a doctor is attached
company.update unusedFeatures:163-186array of codes 1–17 for each unused module
leadStage.update Renewal totalLeads++:193-196only when moving
leadStage.update source totalLeads--:202-205only when moving
lead.updateMany source order--:207-212only when moving — see the defect note below
stageTimeline.create:216-227audit row, createdById: MAIN_USER_ID
lead.update:230-247score, tags, due, stage, view
note.create:249-280the 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.