PBJ CRM Suite 7.0.0
PBJ CRM and its five modules now carry one equal version. What changed in 7.0.0, why, and what upgraders need to know: the unified inbox, the ownership ladder, Convert+, ticket merge with undo, PBJ Events, Money owed to you, three database migrations, and 8,362 lines of legacy removed.
PBJ CRM Suite 7.0.0 is out. Six plugins — PBJ CRM and its five modules — now carry one equal version number, and every module requires PBJ CRM 7.0.0. This is the largest single release the suite has had: twenty-five worked items over three days, one shared services contract, three database migrations, and 8,362 lines of dead code taken out of the shipped trees.
This post is the engineering record, not a brochure. Each section says what changed and why, and the last two sections say plainly what is not proven and what you have to do to upgrade safely.
Read this first if you are upgrading
Update PBJ CRM first, then the modules. Every module — Helpdesk, Invoicing, Events (Dispatch), Vault (Archive) and RingCentral — now declares a minimum CRM of 7.0.0. Before this release those floors were five different numbers (Helpdesk 1.44.0, Invoicing 1.29.0, Dispatch 1.42.0, Archive 1.44.0, RingCentral 1.48.1); they are all 7.0.0 now.
A module installed against an older CRM does not half-work. It refuses to load, registers nothing at all, and draws exactly one admin notice telling you why. That was proved by removal: with PBJ CRM deactivated, each of the five modules produced one notice and nothing else — no portal assets, no module registration, no partially wired screens. If you update a module first and see that notice, update the CRM and the notice goes away.
The database migrations run once, on load, and stamp only after a verifier has looked. Three of them ship in 7.0.0:
- PBJ CRM stays at database 25. Nothing new; 25 arrived earlier in this train with the attached-agents table.
- PBJ Helpdesk goes 13 → 14. The reversible-merge undo ledger is created by the ordinary upgrade path instead of on first use.
- PBJ Invoicing goes 6 → 7. Two no-op columns are dropped from the invoices table. This is the first subtractive migration this plugin has ever run, and it is described in full below.
Why one version number
The suite had drifted into six independent version lines — CRM 1.77.x, Helpdesk 2.58.x, Invoicing 1.44.x, Dispatch 1.34.x, Archive 1.18.x, RingCentral 1.12.x — each with its own minimum-CRM floor, and every module carrying method_exists() probes so it could survive whichever CRM it happened to find. That is defensible when modules ship independently. It stopped being defensible the moment the CRM started publishing shared user-interface machinery that the modules declare into rather than merely call.
7.0.0 draws a line. One version, one floor, one contract. PBJ_CRM_Services::VERSION moves from 23 to 24, and contract 24 publishes the five hub seams this train built — rail children, the portal shell configuration, customer autofill, bulk selection, and the module registry. At the 7.0.0 floor a module can call all five without probing for them first, because the floor guarantees they are there.
The unified inbox and the side rail
The rail used to carry an abstract Channels group. It is gone. In its place the rail lists your actual inboxes, by their own names — an email inbox by its address, a phone line by its number, with the section name underneath as a muted second line so a collapsed rail hides both. The CRM owns the vocabulary, the labels and the order; a module supplies only its rows.
Open an inbox and it grows contextual children: Tickets · Filed · Spam · Bin, with live counts, and only under the inbox that is actually open. Zero counts are omitted rather than drawn as a zero. Every child hash is contained inside its own section’s address space, so a pile cannot be opened under the wrong inbox.
Inside the list, selection now behaves the way selection behaves everywhere else:
- Plain click opens the row and clears any ticks.
- ⌘/Ctrl-click toggles one row’s tick and sets the anchor.
- Shift-click ticks the range from the anchor.
- On a phone, a 500 ms long press enters selection mode and taps toggle from there.
The bulk bar underneath offers the intersection of what every ticked row supports — tick three emails and you get the email verbs; add a task to the selection and the bar shrinks to what both kinds can do. The CRM owns the verb words and their order (read, unread, close, assign, ticket, file, spam, bin, delete); a module that declares an unfamiliar verb keeps its own label.
There is no core “read” table. Marking a row read asks the module that owns it, and a module with no answer leaves the row exactly as it was — which is why RingCentral deliberately declares no read handler: read status there belongs to the provider, not to us.
One performance fix rode along. Every module registration was re-routing the whole application, so a cold load rendered every screen about twenty-eight times — twenty-eight inbox requests in twenty-two milliseconds. Registration now coalesces onto a microtask: 28 became 7.
Quick add: one menu, one shell, one set of forms
Three items in this train rebuilt the fastest path in the product — adding something.
One popup shell. A single suiteDialog() configuration — badge, title, optional subtitle and toggle, body, primary, cancel, bound-only keyboard hints — now drives every dialog in the suite. One implementation owns layering, the focus trap, Escape, outside-click and focus return. The last two hand-built windows (the customer chooser and the whose-day picker) were converted, so there is no longer a second modal stack anywhere. Under a phone width the same configuration becomes a full-height sheet with Back, a one-line title and a 44 px primary, without the caller knowing.
One quick-add menu, declared not hardcoded. Modules declare a create row, a group (primary or other) and an optional one-character shortcut; the core owns ordering, layout, the divider, and the shortcut bindings, and it reserves K and N for its own Task and Note rows. Duplicate keys are refused once and the first declaration wins. Today that reads Task K, Note N, Contact, Company, Ticket T, Event E, Invoice I, Estimate, Vault entry — and it is the live registry, refreshed on every menu open, so deactivating Helpdesk removes Ticket from the menu without a core edit.
One set of forms. Every registered type now renders inside that shell: Person and Business with an in-shell toggle, Task with its step repeater, Note, Ticket, Event, and the Vault’s encrypted entry form. Adding work from a person or business reuses the same form with the record preselected and attached on save. Invoice and Estimate deliberately keep their full editors — a quick-add popup is the wrong shape for a document with line items.
Portal search: one ranked list, with totals that are true
Search used to return results grouped by provider, with each group’s count being whatever that group had after clipping. That is a subtly dishonest answer: the number you saw was a property of the page size, not of your data.
Now the core collects candidates from the CRM and every permitted module, normalises each provider’s score into three bands — exact reference 100, title prefix 70, body contains 40 — applies a clamped ±5 provider tie-break and nothing more, sorts by score then recency, and clips once, after the merge. The response carries one items list plus the true pre-clip totals per provider, per kind, and overall.
The result is one interleaved list — contact, ticket, event, invoice, file, phone conversation — with type chips and the matched field shown, and a total that means what it says. Every provider’s direct count was checked against the merged response and they agree; the same scope clauses that filter the rows also filter the counts, so an employee’s total is their total and not the business’s.
The Vault stays out of global search on purpose. It has its own search, behind its own unlock.
Convert+
Turning one record into another used to be a per-module affair: each plugin wired its own conversion choices, its own close behaviour, and its own follow-up. The CRM now owns a normalised, first-wins registry of conversion sources and targets, plus the settings that enable them, choose a default, and decide what carries across.
Task, ticket, event and phone conversation/call are sources; ticket, event, task, invoice, estimate and note are targets, each created by the module that owns it. A successful conversion stores the source reference, writes provenance and paired history notes on both sides, optionally carries origin, history, files, pinned work, customer and notification settings, and then closes the source. A failure or a duplicate leaves the source open — the close is the last thing that happens, not the first.
The record header now shows exactly two actions for open convertible work: Close and Convert+. An ordinary close never enters the conversion dialog. Utilities moved under More. The integration matrix ran 277 checks and 24 real conversions across four source kinds and six targets, including settings, exact-key refusal, duplicate 409, native close, provenance, notes, real file and task pins, the notification ledger and the ownership ladder below.
Who owns a record: the ownership ladder and attached agents
This is the change with the widest blast radius, so it is worth reading even if you skip everything else.
Every work record — task, ticket, event — has one owner (its existing assignee) and any number of attached agents, stored in one CRM-owned table rather than a per-module invention. Attached staff can read and collaborate on conversation and checklist surfaces without gaining arbitrary edit rights, and one core clause widens every module’s list and single-record scope, so “who can see this” is answered in one place.
Terminal acts — completing a task, closing a ticket, finishing an event, and every conversion outcome — are gated server-side with a named refusal, pbj_crm_owner_required.
The gate is a ladder, not a wall. The first version of this was owner-only, and that was wrong: the ladder is the owner, the owner’s manager, a CRM owner, and a WordPress administrator. It is answered from the same rule the staff picker uses, so the list of people you can hand something to and the list of people who can close it can never disagree. A manager’s reach is their own group at or below their rank; an owner’s and an admin’s is everyone; an employee’s is themselves, which passes only as the owner. An unowned record is actionable from the manager rung up.
Hand over writes the owner column, keeps the former owner attached as a collaborator, writes the module’s own timeline note, and sends the ordinary notification cascade. When somebody who is not the owner hands a record over, the dialog says so in as many words: the current owner stays attached.
An attached agent who is not the owner sees the terminal controls removed and a struck-through line explaining why, rather than a button that fails when pressed.
Tasks, redesigned
The Tasks surface was rebuilt as a list and a detail pair, on the existing task model, scopes and routes — no data moved.
The list carries Open, Late, Assigned to me and Done chips with live counts, checklist-aware search, due-date order, per-viewer “hide done steps”, a visual-only completion box, progress, and a dashed New task card. The detail carries an inline title, the fact chips, Mark done / Move date / Hand over / Properties, Enter-to-add steps, atomic ticks, drag-ordered steps that persist, per-step removal, Check all visible, a progress donut and newest-first history. Where estimated-work data is missing it is omitted rather than invented.
A second pass added Properties and a manager-only Manage screen: group and team-load management, bulk operations with explicit per-row results, one-copy-per-assignee creation, reusable CRM-owned task templates, and shell-confirmed danger actions. The bulk route never reports a silent partial: a run that requested four rows, changed three and refused one out-of-scope row says exactly that, with the refusal reason per row.
A third pass gave phones dedicated task and ticket work screens with a route-backed properties sheet, dismissible by grab handle, backdrop or Escape, and a 44 px horizontally scrolling filter row.
Tickets adopt the same shape
Rather than copy the task work format into Helpdesk, the CRM publishes it: workList and workProperties are core renderers, and Helpdesk supplies ticket declarations only. Tickets got list, detail, properties and Manage from the same code that draws tasks, plus the shared People row, the hand-over dialog and the Convert+ registry.
One measurable side effect: the ticket list’s payload dropped from 40 queries to 21 for 20 rows, after the per-ticket to-do lookup was batched.
Merging tickets, with a real undo
The CRM’s contact-merge sheet was already the right shape, so tickets use it rather than getting a second one. It now accepts 2 to 21 records through the shared pickers, and Helpdesk supplies every ticket-specific piece: carried-item counts, field conflicts, cross-customer confirmation, closed-loser pointers, survivor notes, and a 30-day ledger-backed undo.
Two things about the undo ledger are worth stating. First, it used to be created the first time somebody merged, which meant an existing site paid for a SHOW TABLES on every request and only got the table when it was already needed. In 7.0.0 it arrives with the update, through an ordinary forward migration branch and a flag-guarded repair branch, both of which stamp the version only after a verifier has confirmed the table exists.
Second, a disclosure was found and closed on the way to this release. A merged ticket’s merged_into pointer was returned to every viewer with no scope check — so a client, or an employee outside the survivor’s group, whose own ticket had been merged away was handed the survivor’s ticket number and a working deep link to a ticket they cannot open. It is now answered per viewer: out of reach it returns an empty pointer, the same answer as a ticket that was never merged. The same fact had a second carrier — the merge system note prints the counterpart ticket’s number in prose — and that note is redacted at the same projection point, with the survivor and loser ids stripped from the emitted event metadata. The stored row is untouched; only the projection changed.
PBJ Events
Dispatch is called PBJ Events on screen now. The rail says EVENTS, the vocabulary says event throughout the shared role, theme, deletion, delivery, daily-count, bulk, calendar and identity surfaces, and #/events resolves.
This is a display rename and nothing more, deliberately: the visit and appointment transport kinds, the visits and dispatch-agenda keys and the #/visits route are unchanged, so nothing integrating against them breaks. The product slug remains pbj-dispatch.
The Vault unlocks in place
Opening a protected value used to take you out of the screen you were on. It now opens the CRM’s shared dialog from ordinary lists, selected readers, Vault detail, search and pinned profile surfaces — without changing the route. A locked value shows the Vault’s existing passphrase form inside the same popup, which then transitions through the existing unlock and per-field reveal routes and offers Copy, an idle countdown and Lock now.
The security boundary did not move. Revealed markup is cleared on close and on lock. A decrypt failure is labelled Unreadable rather than rendered blank, so a broken value cannot be mistaken for an empty one. No PHP, schema, crypto, key, recovery or grant behaviour changed at all, and a viewer below the record type’s minimum still gets a 403 from the server, so no protected-value control can render for them in the first place.
Money owed to you, and the document lists rebuilt underneath it
Invoicing gained an accounts-receivable screen — Money owed to you — and the interesting part is where it lives. Rather than draw a one-off report, the shapes it needed were built into the CRM as reusable primitives: tone on a card row, a status dot, a meter bar, a KPI card row, a workspace overview, a ledger row and a ledger reader head. Invoicing declares data into them.
The core chooses the presentation from the data: a row that carries a breakdown gets the ledger row. No module sets a flag saying “draw me as a ledger”.
The invoice, estimate and recurring lists were then rebuilt as the second consumer of exactly those primitives, which is the only honest test of whether a shape is reusable. Doing that turned up three things the core still owed and now has: a declared tone that wins over derived urgency (one card was painting its status badge one colour and its selected edge another from the same fact), a customer block on a reading pane drawn with the same item card every other record uses, and grouped actions that join a consecutive run into one segmented control.
The headline figure re-scopes with the list. On the same data an administrator and the CRM owner read $3,246.90 across ten invoices and seven customers; a manager reads $300.00 across two; an employee reads their own single row. An employee does not read the business’s book.
Payment chasing is one setting, and Invoicing’s first subtractive migration
Every invoice used to carry its own Payment chasing checkboxes — reminders and late fees, per document. They had not decided anything for some time: automatic reminders and late fees are site settings, and the site settings were already the only thing any reader consulted. The per-invoice controls were removed, and a PUT that still names either key is refused with a named error, pbj_inv_automation_site_setting, rather than silently accepted and ignored.
7.0.0 finishes the job in the schema. Database 6 → 7 drops the reminders_enabled and late_fees_enabled columns. Nothing changes on screen and no invoice data moves — both columns were NOT NULL DEFAULT 0 and nothing named them.
The migration is written the careful way, because dropping a column is the one thing a plugin cannot undo for you:
- One column per statement. A combined
ALTERwould let a refusal on the second undo the first. - It verifies before it stamps. The version is written only after both columns are confirmed gone.
- It heals a half-finished drop on a later request, through a repair branch inside the early return.
It was proved three ways on a real database before it shipped: a genuine stored-6 site upgrading (nine invoice rows and both money totals unchanged), the repair path healing an induced half-landed drop on a site already stamped 7, and a fresh activation that never receives the columns at all. On pbj.tech’s MySQL after the release push, the verifier ran SHOW COLUMNS for both names, both returned empty, and the version stamped 7.
The contact and business record on a phone
The customer record on a phone is now the same hierarchy on a person and on a business: identity and actions and facts, then Open work, Activity, Employees or Works at, About, More details, and Anything else. The header carries the record-kind accent, initials, a PERSON or BUSINESS chip, reference and tag chips, and fixed Email, Address and Marketing rows.
Missing data stays visible and struck through. That is deliberate and it is the rule across the suite: a control that is absent because the module is not installed, or because you may not, is gone; a control that is absent because this record has no phone number is drawn and struck through, because that line is what tells somebody to add one.
Existing module-declared work and activity cards and core relationship nodes are counted and folded on this screen. No module markup changed to make it happen.
What the live field test corrected
An earlier build of this train went to pbj.tech so it could be used rather than merely tested, and using it found six shared-shape defects that no gate had caught. All six were repaired in the CRM core, not patched per screen:
- Module rail children were being dropped in the rail slot handoff, so Tickets never showed its contextual children even though it declared them.
- The footer Manage mode mounted on Tasks but not on Tickets; one declared payload now mounts both, and an employee without the permission gets neither the route nor the toggle.
- State filter chips were drawn without counts, or with counts from a different query. One core chip row now renders each provider’s vocabulary, count, active state and URL — Tasks reading 5/3/2/3/0/5 and Tickets 36/6/1/2/14, both matching direct SQL.
- There were several checkboxes. One 18×18 tokenised checkbox now covers every visible checkbox — core, direct and module-declared, quick-add and bulk selection included — in both light and dark.
- Hovering an inbox route previewed it. That is now click or keyboard only.
- Fact-pair spacing was inconsistent between invoice, ticket and task headers.
Two later corrections came from the same source. The phone contact record was suppressing the published Show as client action while the company record at the same width drew it and opened the projection correctly — one capability with two answers, which is exactly the bug shape this train exists to remove. And #/reports, a core router route with no module capability to read, now declares the same manager-and-up refusal its own report panels already carried, so an employee gets a typed card instead of an index with nothing in it.
8,362 lines of legacy removed
With the floors raised and the seams published, a large amount of compatibility code stopped being reachable. It was enumerated first — one row per candidate with file and line, what it was for, why it is unreachable at 7.0.0, and what breaks if that reasoning is wrong — and then deleted from an approved list, never from a filter re-derived at deletion time.
The totals: 8,362 lines off the six shipped trees — RingCentral 1,948, CRM 5,086, Helpdesk 1,050, Vault 241, Events 45, and Invoicing −8 (negative, because the database-7 migration adds 121 lines of verifier, drop, stamp and repair). That is 54 JavaScript functions, 75 PHP declarations, 2 whole files and 212 CSS rules.
Three things about how it was done are worth recording, because they are the parts that could have gone wrong:
- Every span was re-derived by name at deletion time. The line numbers in the enumeration were already stale after the version bump and not one of them was used.
- A whole family was put back. Fifty-eight calendar CSS selectors read as dead to a static reader because they do not exist as literals anywhere — they are composed at runtime from identifiers the module declares. Forty-six of the fifty-eight were caught rendering in a browser; all fifty-eight were restored.
- A family of 796 guards was not touched, because the approval covered a count rather than a list, and the family is provably not uniform — at least one of those guards carries a comment explaining that it is guarded on purpose.
The check that matters most on a deletion pass of this size is call resolution: every method call, every callback array, every dynamically assembled handler name still resolving to something that exists. It reported zero unresolved sites across all six plugins, identical to the pre-deletion baseline. The suite’s own dynamic dispatch is the reason that check exists: 67 handle_* and 4 walkthrough_* methods have no literal reference anywhere in the codebase and are all live, because four named sites build their callback names by concatenation. A naive “no call sites, delete it” sweep would have taken 67 working admin handlers with it.
Translations
All six translation templates were regenerated at 7.0.0 with zero warnings: CRM 2,092 strings, Helpdesk 1,118, Invoicing 543, Vault 454, Events 274, RingCentral 127. The first run produced three warnings — a missing translator comment on a CRM placeholder, one message id carrying two different translator comments in the Vault, and a missing comment on a Vault plural — and all three were fixed at source and the run repeated, rather than accepted.
What is not proven
Stated plainly, because a release note that only lists successes is not a record:
- Everything here was built and proved on WordPress Studio, which is SQLite and PHP 8.4, and then exercised on pbj.tech, which is MariaDB and PHP 8.1.34. Both database engines have now run the three migrations, but no other production stack has.
- The Invoicing column drop is the migration that most needed a real MySQL run and got one. If a host refuses the
ALTER, the repair branch retries it on a later request; a refused drop leaves two unused columns behind, which is untidy rather than broken. - Several per-role proofs in this train were made server-side, by acting as each role against the real routes with real data, rather than by logging in as five different people in a browser. Where a browser was used it was used as an administrator.
- The runtime interface gate that guards this suite ran full and green — 250 cells, 816 visual variants, 7,994 assertions, zero unexpected failures — but it is a harness, and a harness proves what it was told to look at.
Upgrading
Update PBJ CRM to 7.0.0 first, then update the modules. The modules will not load against an older CRM and will tell you so with a single notice. The migrations run themselves on the next load and stamp only after verifying their own work; there is nothing to click.
7.0.0 is a new major version for the whole suite. How it reaches your site depends on your licence — if your WordPress update screen does not offer it, get in touch and we will sort it out.
See all PBJ WordPress plugins · PBJ CRM changelog · Helpdesk · Invoicing · Events · Vault · RingCentral