Skip to main content

Branch Cleanup (users on deleted branches)

Audience: developers & AI agents · Scope: the adminbcbranchcleanup admin page and the account-disable mechanism · Last reviewed: 2026-07-31

TL;DR — The branch sync soft-deletes branches that drop out of the Rep Master sheet (deleted = 1) but keeps them while users are still assigned (custom_2) — and thereafter skips them on every import, so their RSM data is frozen and the follow-up nudges for those users' quotes route to a former RSM. The Branch Cleanup admin page (menu: right under Branches) lists all real users on soft-deleted branches, grouped per branch, and offers two independent actions: reassign the checked users to an active branch (the one that actually fixes it), and disable/enable a user's website account at the Joomla user level (#__users.block, active sessions killed). Once a deleted branch has no users left, the next branch import hard-deletes it.

The full cycle: Branch Cleanup (move everyone off) → Branches → Import (branch disappears).

The problem this solves

How a branch gets stranded

ConfigboxControllerAdminbcbranches::syncBranches() reads the Rep Master sheet and reconciles #__configbox_external_branches by branch code:

  1. Sheet rows whose Include in Branch List flag isn't true are skipped entirely during extraction (adminbcbranches.phpgetExtractedData()), so they never reach the DB write.
  2. Any existing branch not covered by the surviving rows is marked deleted = 1 (soft delete).
  3. Soft-deleted branches are then hard-deleted only if no user references them — the delete is WHERE deleted = '1' AND id NOT IN (<branch ids in use>), where "in use" means #__configbox_users.custom_2 (non-temporary users). Nobody is left branchless.

So a branch with users on it survives as deleted = 1 indefinitely. And because step 1 skipped its sheet row, it is never updated again — it is invisible to every future import. Whatever rsm_email / rsm_name it held on the day it dropped out, it keeps forever.

Why that's not cosmetic

rsm_name was added by migration 0.5.18 after these rows had stopped syncing, so it defaults to '' and is never backfilled → blank RSM names. But the more damaging half is rsm_email, which holds a stale but non-empty value: the Quote Follow-Up nudge routing reads it and mails the RSM who used to own that agency. It fails silently and stays failing.

Measured on live data (2026-07-31): 411 of 2,865 rows in the nudge sheet had a blank rsm_name; 369 of those traced to 14 soft-deleted branches whose rsm_email also disagreed with Rep Master (DB said kane@/alex@/ivan@/deena@ where the sheet now assigns krista@/meg@/corinne@/dale@). The remaining 42 are users with no branch at all — rsm_email falls back to pipe@betacalco.com and rsm_name is blank by design (see nudge-cadence).

The only way out is to move the users, since editing the sheet cannot reach a skipped branch. The Branches screen has always warned about these branches (getDeletedBranchesStillUsed()); this page is the worklist that clears them.

Artifacts

All under data/customization/:

FileRole
controllers/adminbcbranchcleanup.phpdisplay, getAjaxList (table refresh), reassignUsers, setAccountBlock — every task checks isAuthorized() (the front-end entry point dispatches any controller task, so admin tasks must self-guard)
models/adminbcbranchcleanup.phpThe worklist query (users × deleted branches × groups × #__users × quote counts from #__configbox_external_user_quotes), reassignUsers() (validates the target branch is active), setPlatformUserBlock()
views/adminbcbranchcleanup/View + tmpl/default.php (page shell) and tmpl/table.php (per-branch groups, re-rendered via getAjaxList)
assets/javascript/adminbcbranchcleanup.js (+ .min.js)AMD module configbox/custom/adminbcbranchcleanup — collapse/expand, the "hide disabled" filter, group check-all, reassign, two-step confirm for disable (no native confirm())
assets/css/custom.css (+ .min.css)Page styling — the collapsed-group disclosure, toolbar, and the overflow-x on .group-body that keeps the action column reachable
templates/adminmainmenu/extra_menu_items.phpThe "Branch Cleanup" menu entry

The two operations

They are independent switches on a user: which branch they belong to, and whether they can log in. Only the first one clears the branch — disabling an account leaves the user (and therefore the branch) exactly where it was.

1. Reassign — reassignUsers(int[] $userIds, int $branchId)

UPDATE `#__configbox_users` SET `custom_2` = <branchId>
WHERE `id` IN (<userIds>) AND `is_temporary` = '0'

Guarded before the write: the target must exist and be active (WHERE id = … AND deleted = '0'), otherwise the model throws and the task returns an error — so a user can never be moved from one dead branch onto another. Ids are intval-mapped and empties filtered before they reach the IN list. Returns the affected-row count, which the controller echoes back as the feedback message.

This is the same column the Customers screen's bulk-assign widget writes (ConfigboxControllerAdminbcbranches::assignCustomers()); that one also allows unassigning (custom_2 = NULL), which this page deliberately does not — a branchless user is the other failure mode we're trying to avoid.

2. Disable / enable — setPlatformUserBlock(int $userId, bool $blocked)

Resolves the ConfigBox user's platform_user_id, then:

UPDATE `#__users` SET `block` = '1'|'0' WHERE `id` = <platformUserId>
DELETE FROM `#__session` WHERE `userid` = <platformUserId> -- on block only

It's a Joomla-level block, so it applies to every login surface, front end included; killing the sessions means an already-signed-in user doesn't keep working until their cookie expires. A user with no platform_user_id throws ("no website login account") — and the button isn't rendered for them in the first place.

This intentionally bypasses Joomla's user plugins: no notification mail, no onUserAfterSave events, no password-reset flow. It's an admin kill-switch, not an account-lifecycle workflow. If you ever need the full Joomla semantics, do it in Users → Manage instead.

UI notes worth knowing

  • Groups render collapsed. With 30+ deleted branches the expanded page is unusable. Collapse state (and the "hide disabled" filter) is re-applied after every AJAX re-render — refreshTable() captures the expanded branch ids, then restores them in injectHtml's callback. The toolbar itself sits outside .table-wrapper, so its own state survives the swap for free.
  • Chosen is initialised lazily, on first expand. A select inside a collapsed container has no measurable width, and Chosen would bake that in and render a zero-width control. ensureChosen() guards with a chosen-ready class so re-expanding doesn't double-initialise.
  • Filter state is class-based (filtered-out / group-hidden), never .hide()/.show() + :visible. This is a fixed bug worth not reintroducing: jQuery's :visible is false when any ancestor is display:none, and groups are collapsed by default — so a :visible row count was 0 for every collapsed group. Switching the filter on hid every group, and switching it off could not bring them back (a hidden group can never report visible rows again). Everything that asks "which rows are actionable" now goes through getShownRows(), which is independent of collapse state.
  • The summary line is filtered too. applyDisabledFilter() totals the shown rows and the still-populated groups into .summary-user-count / .summary-branch-count. Without it the page kept announcing "449 user(s) on 32 deleted branch(es)" above a filtered list of ten branches, which reads as a filter that half-worked.
  • The disabled filter also unchecks what it hides, so a filtered-out user can never be swept into a reassignment invisibly; reassign and check-all both scope to getShownRows(), and syncCheckAllBoxes() keeps each header box consistent with the rows under it.
  • has-active marks branches with users who can still log in (has platform_user_id, not blocked) — counted in the template, rendered as the amber accent plus a "N still active" badge. It is a server-rendered fact about the branch, so it does not move with the filter.
  • Each row links out twice: to the ConfigBox customer record (admincustomers&task=edit) and to the Joomla account (com_users&task=user.edit, omitted when there's no platform_user_id). Both target="_blank" — this is a worklist, and losing your place in it to follow a link is worse than a new tab.
  • The nth-child wrap rules in custom.css are column-index-bound — adding or removing a column in tmpl/table.php means updating them (they are commented with the current order).
  • E2E: tests/specs/backend/branch-cleanup.spec.ts covers all of the above (read-only — it never triggers Reassign or Disable, which mutate real user rows). It waits for view-processed on the view before interacting: the behaviour is bound by an AMD module that loads after DOMContentLoaded, so clicking earlier silently does nothing.
  • Reassignment is the same custom_2 update as the Customers screen's bulk-assign widget (ConfigboxControllerAdminbcbranches::assignCustomers()), which this page's per-group flow complements — the widget lives in the admincustomers template override (templates/admincustomers/default.php), which wraps the stock Kenedo listing template rather than copying it (a full stale copy is what broke the page's filters after the ConfigBox 3.5.0 upgrade).
  • Operator how-to: admin-guide/quotes-sales/branch-cleanup.md
  • The sync and soft-delete mechanics: google-sheets/consumers.md and admin-guide/quotes-sales/manage-branches.md