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 stage — 9dbf0d3f-ad02-45b8-8a3c-4b605435dde6, hardcoded. Where the job parks cards whose subscription is coming due.
Churned stage — 2397d5d6-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 features — Company.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 expiry — Company.tierExpiry, the subscription end date. Drives the renewal window and is copied onto the lead's due date every run.
Monthly — Company.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
| Field | Line | Type | Role here |
|---|---|---|---|
managerName | 87 | String? | written by the job from the CRM card's doctor |
managerPhone | 88 | String? | same; underscore stripped before writing |
referenceId | 110 | Float? | join key to the CRM patient |
disabled | 125 | Boolean @default(false) | now filtered in the query, not the loop |
monthly | 126 | Boolean @default(false) | monthly plans are never auto-moved |
unusedFeatures | 139 | Int[] | written every run |
tierExpiry | 160 | DateTime — non-nullable | renewal 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
| Field | Line | Role |
|---|---|---|
stageId | 5177 | required; the column the card is in |
patientId | 5173 | String? @unique; the FK side of the patient↔lead link |
order | 5183 | Int, required, no default; position within the column. Never selected by the cron — the root of the ordering defect |
score | 5184 | Int?; written clamped to 10 |
stagedAt | 5217 | required; when it entered its current stage; used to compute timeline duration |
journeyedAt | 5218 | DateTime?; when it last crossed into a different view |
currentViewId | 5206 | denormalized cache of stage.viewId |
initialViewId | 5204 | the view it first landed in; never overwritten |
due | 5181 | DateTime?; normally now + stage.dueDays, but the cron overwrites it with tierExpiry |
tags | 5179 | relation to PatientTag via LeadToPatientTag |
Relevant indexes: @@index(score) :5234, @@index(stagedAt) :5235, @@index(journeyedAt) :5236, @@index([stageId, order]) :5251.
LeadStage — line 5108
| Field | Line | Role |
|---|---|---|
name, color | 5110–5111 | display |
totalLeads | 5112 | denormalized count and the allocator for the next card's order |
order | 5113 | column position on the board |
dueDays | 5114 | default SLA; ignored by the cron, which uses tierExpiry |
viewId | 5116 | String? — 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.
| Field | Line | Value from the cron |
|---|---|---|
stageId | 5260 | source stage |
toStageId | 5262 | RENEWAL_STAGE_ID |
leadId | 5264 | the lead |
createdById | 5266 | required — MAIN_USER_ID, which is what distinguishes automated moves from human drags |
timeInSeconds | 5271 | required — 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
| Field | Line | Value from the cron |
|---|---|---|
details | 2488 | the HTML scorecard |
seen | 2492 | true — pre-marked read, so no unread badge |
chart | 2494 | hardcoded 'DENTAL' |
patientId | 2500 | the CRM patient |
leadStageId | 2506 | the source stage, not the destination |
createdById | 2512 | MAIN_USER_ID |
Patient — line 791
referenceId :857 (join key), doctorId :884 (source of manager name/phone), lead :924 (back-relation).
Hardcoded identifiers
| Constant | Value | Defined | Referenced elsewhere? |
|---|---|---|---|
MAIN_COMPANY_ID | 54176b70-1b7c-455c-b5a6-84863dbe4d3e | companyLeadCron.js:20 | Yes — 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_ID | 9ac8405d-97e5-4e10-8d55-98f519c4f9b7 | :21 | No |
RENEWAL_STAGE_ID | 9dbf0d3f-ad02-45b8-8a3c-4b605435dde6 | :22 | No |
NOT_WORKING_TAG_ID | 271bdd24-1fcd-45b7-8480-62e8d42c30c3 | :23 | No |
WORKING_TAG_ID | 21ee6e3e-d3f6-471d-ada1-41dc1a75e3a5 | :24 | No |
WORKING_LOW_TAG_ID | b42296ba-ee36-4e39-8170-e4829ccea660 | :25 | No |
CHURNED_STAGE_ID | 2397d5d6-0ecd-4796-9f8e-aa78db0a1a79 | deleted by this PR | No — 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.
| Code | Model | Scoped by |
|---|---|---|
| 1 | patient | companyId |
| 2 | appointment | companyId |
| 3 | operation | companyId |
| 4 | invoice | companyId |
| 5 | payment | companyId |
| 6 | transaction | branch.companyId |
| 7 | inventoryOrder | companyId |
| 8 | medicalValue | companyId |
| 9 | clinicalTest | patient.companyId |
| 10 | patientEncounter | companyId |
| 11 | labOrder | branch.companyId |
| 12 | communication | companyId |
| 13 | conversationMessage | companyId |
| 14 | systemChatMessage | companyId |
| 15 | patientReminder | companyId |
| 16 | lead | companyId |
| 17 | expense | branch.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
| Property | Value |
|---|---|
| Cron expression | 0 4 */3 * * — 04:00 every third day |
| Registered at | packages/server/src/cronJobs/cronJobs.js:104-115 |
| Timezone argument | '' — effectively server local time |
| Redis key | companyLeadCron (the job name doubles as the lock key) |
| Lock TTL | none |
| Entry process | packages/server/src/cron.index.js, via yarn start:cron or pm2:cron |
| Manual trigger | executeCronJob mutation, isAdmin, blocked when NODE_ENV=production |
| Status surface | getCronJobs 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.