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

Stage Move Mechanics and Known Defects

Business view

When a card moves from one column of the board to another, more happens than the card changing place. The board keeps a running count of how many cards are in each column, and each card remembers its position within its column so the order you arranged them in survives a page refresh. A move has to update all of that, plus write an audit entry saying who moved it, from where, to where, and how long it sat in the old column.

Dentolize has two pieces of code that do this. One is the interactive one that runs when a person drags a card. The other is the automated one inside the renewal job. They were written separately and they do not behave the same way.

The automated one has three rough edges. None of them are introduced by this PR — they have been there as long as the job has. But this PR sends a new and potentially large population of cards down that path for the first time, so it makes them more likely to bite:

  1. Positions in the old column get scrambled. When a card leaves a column, every

remaining card in that column is shifted up one position — including the ones that were above it and should not have moved. Over repeated runs the manual ordering of the source column degrades. Because Churned is usually the biggest column on the board, and it is now a source column for the first time, this is the most consequential of the three.

  1. The move is not all-or-nothing. It is six separate database writes with no transaction

around them. If the process dies between the third and the fourth, the column counters end up disagreeing with the actual number of cards, and nothing repairs that automatically.

  1. The card lands at the bottom of Renewal, always. It is appended rather than inserted,

which is reasonable for an automated arrival, but it is a different rule from the interactive drag.

The practical advice: after this ships, watch the ordering of the Churned column and the card counts on the column headers. If counts start drifting from reality, item 2 is the cause; if manual ordering in Churned keeps resetting, item 1 is the cause.


Technical view

The two implementations

InteractiveAutomated
Fileresolvers/mutations/actions/leads/moveLead.jscronJobs/companies/companyLeadCron.js:192-247
Transactionyes — moveLead.js:129no
Source reorder predicateorder: { gt: leadDetails.order }:183-186order: { gte: patient.lead.order }:207-212
Target reordershifts target rows >= newOrder:177-180none; appends
journeyedAtonly on view change — :190-192unconditional — :199
lastInteractionupdated — :218not touched
Round-robin assignmentyes — :39, :166no
duenow + stage.dueDays:215company.tierExpiry:238

Defect 1 — order is never selected

The lead is read at companyLeadCron.js:47-63 with this select:

lead: {
  select: {
    id: true,
    stageId: true,
    score: true,
    stagedAt: true,
    stage: { select: { id: true, viewId: true } },
    tags: { select: { id: true } }
  }
}

order is not in the list. It is then used at :207-212:

await prisma.lead.updateMany({
  where: { stageId: patient.lead.stageId, order: { gte: patient.lead.order } },
  data: { order: { decrement: 1 } }
})

patient.lead.order is undefined. Prisma drops filter keys whose value is undefined, so the where collapses to { stageId: patient.lead.stageId } and every lead in the source stage is decremented, not just those at or below the departing card.

Consequences:

  • Cards above the departing card in the source column lose a position they should have kept.
  • After enough runs, order values in a heavily-used source stage drift negative. Lead.order

is Int (schema.prisma:5183) with no non-negative constraint, so nothing stops it.

  • @@index([stageId, order]) (:5251) stays valid; this is a data-correctness problem, not an

integrity one. The board simply renders in a wrong order.

The gte is independently wrong even if order were selected — moveLead.js:184 uses gt, because the departing card's own row is being reassigned anyway and should not be counted twice. Fixing the select alone would swap one off-by-one for another.

This PR's contribution: it does not touch this code. It changes who reaches it. Before, the Churned stage could never be a source stage for an automated move. Now it can, and it is typically the largest column on the board, so a single churned-lead move rewrites the order of every other churned card.

Defect 2 — no transaction

companyLeadCron.js:192-247 performs, in sequence and each individually awaited:

  1. leadStage.update — Renewal totalLeads increment (:193)
  2. leadStage.update — source totalLeads decrement (:202)
  3. lead.updateMany — source reorder (:207)
  4. stageTimeline.create — audit row (:216)
  5. lead.update — the card itself (:230)
  6. note.create — scorecard (:249)

moveLead.js:129 wraps the equivalent sequence in prisma.$transaction. Here there is none. A failure at step 2 leaves Renewal's totalLeads inflated with no corresponding card; a failure at step 5 leaves both counters moved but the card still in the old stage.

LeadStage.totalLeads (schema.prisma:5112) is not merely a display counter — it is the allocator for the next card's order in the create paths (addNewLead.js:40-41, 93; addNewLeadFromQr.js:73-74, 96) and drives round-robin assignment in moveLead.js:39. A desynced counter therefore causes duplicate order values on subsequent inserts, not just a wrong badge.

There is no reconciliation job. Nothing recomputes totalLeads from count(leads).

Compounding this: the entire company loop sits inside one try/catch (:11, :295). An exception during company #40's move aborts the run for companies #41 onward, leaving #40 in exactly the half-written state above.

Defect 3 — append-only target placement

newStage = await prisma.leadStage.update({
  where: { id: RENEWAL_STAGE_ID },
  data: { totalLeads: { increment: 1 } }
})
order = newStage.totalLeads

companyLeadCron.js:193-198

prisma.update returns the post-update row, so order becomes the new count — appending to the bottom of Renewal. No target-side rows are shifted. This matches the create-path convention (addNewLead.js:93) rather than the drag convention, and is defensible for an automated arrival. It is listed here for completeness rather than as a bug.

Other divergences

journeyedAt is set unconditionally (:199). Lead.journeyedAt (schema.prisma:5218) means "when this lead crossed into a different view", and moveLead.js:190-192 gates it on oldStage.viewId !== newStage.viewId. The cron sets it on every renewal move. In practice the shouldMoveToRenewal guard at :82 already requires the views to differ, so the two agree — but only by coincidence of the guard, not by construction. If the guard were ever relaxed to compare stage IDs instead of view IDs, journeyedAt would start recording same-view moves.

due is overwritten with tierExpiry (:238). Everywhere else Lead.due is now + stage.dueDays (moveLead.js:55, addNewLead.js:99). Here it is the subscription expiry date, and — note — this is written on every run, not only on moves. The card's due date is thus continuously reset to the renewal date. That is intentional and useful: the clock icon on the card (LeadsCard.js:47-51, red when overdue) becomes a countdown to subscription expiry.

stagedAt and order are undefined on non-move runs. At :230-247 the lead.update always fires, but order, journeyedAt and stagedAt are only assigned inside the if (shouldMoveToRenewal) block (:188-190 declares them, :198-200 assigns). On a non-move run they are undefined, which Prisma treats as "leave alone". Correct, but it relies on the undefined-means-skip semantics rather than stating it.

currentViewId is set to newStage.viewId on a move and undefined otherwise (:237), keeping the denormalized view cache (schema.prisma:5206) in sync the same way the interactive paths do.

A crash risk worth noting

renewalStage is fetched at :37-40 with findUnique, which returns null when the row is absent. It is then dereferenced at :82 as renewalStage.viewId with no null guard. If RENEWAL_STAGE_ID is ever missing from the database — a restored-from-backup environment, a fresh deployment, a sandbox — the job throws TypeError on the first company that has a linked lead, and the catch at :295 records it as the run error.

This is exactly what makes the job a silent no-op in the branch sandbox: the seeded companies have no referenceId, so the loop never reaches :80 and the null renewalStage is never dereferenced. See For Quality.