Branch Cleanup (users on deleted branches)
Audience: developers & AI agents · Scope: the
adminbcbranchcleanupadmin 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:
- Sheet rows whose
Include in Branch Listflag isn'ttrueare skipped entirely during extraction (adminbcbranches.php→getExtractedData()), so they never reach the DB write. - Any existing branch not covered by the surviving rows is marked
deleted = 1(soft delete). - 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/:
| File | Role |
|---|---|
controllers/adminbcbranchcleanup.php | display, 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.php | The 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.php | The "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 ininjectHtml'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
selectinside a collapsed container has no measurable width, and Chosen would bake that in and render a zero-width control.ensureChosen()guards with achosen-readyclass 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:visibleis false when any ancestor isdisplay:none, and groups are collapsed by default — so a:visiblerow 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 throughgetShownRows(), 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(), andsyncCheckAllBoxes()keeps each header box consistent with the rows under it. has-activemarks branches with users who can still log in (hasplatform_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 noplatform_user_id). Bothtarget="_blank"— this is a worklist, and losing your place in it to follow a link is worse than a new tab. - The
nth-childwrap rules in custom.css are column-index-bound — adding or removing a column intmpl/table.phpmeans updating them (they are commented with the current order). - E2E:
tests/specs/backend/branch-cleanup.spec.tscovers all of the above (read-only — it never triggers Reassign or Disable, which mutate real user rows). It waits forview-processedon the view before interacting: the behaviour is bound by an AMD module that loads afterDOMContentLoaded, so clicking earlier silently does nothing.
Related
- Reassignment is the same
custom_2update as the Customers screen's bulk-assign widget (ConfigboxControllerAdminbcbranches::assignCustomers()), which this page's per-group flow complements — the widget lives in theadmincustomerstemplate 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