---
name: evolution-addressbook-account-history
description: How contact employment/account history is recorded and rendered in the addressbook CRM
metadata: 
  node_type: memory
  type: project
  originSessionId: 43d3fbf5-ea2e-4b6a-9f9e-1ccdacfa9218
---

Contact "employment history" (which account a contact belonged to over time) is recorded in a dedicated table `addressbook_account_history` (one row per association span; `end_date IS NULL` = current). The legacy `cmoveaccount` handler in `addressbooksave.php` used to just overwrite `addressbook.clientid` destructively with no trail — now it closes the open span and opens a new one on each move. The move modal lets the user (a) backdate the change via `movedate` (clamped ≤ today; used for both end_date of old span and start_date of new span, guarded so an end never precedes its start), and (b) create a new client on the fly via `newclientname`/abn/telephone/email — handler INSERTs a `contacts` row (`is_client=1`) and uses the return of `DB::INSERT` (which is `lastInsertId()` on success, an `errorInfo` array on failure — guard with `is_numeric()`) as the new `clientid`. JS `abToggleNewClient(showNew)` swaps the existing-picker vs new-client panels and blanks the inactive side so only one intent posts. `addressbookmeta` could NOT hold this because it's a UNIQUE(addressbookid,src,srcid) current-state table, not a dated sequence.

The first timeline node ("Contact created") is anchored by `addressbook.created_date` (added, default current_timestamp; existing rows backfilled NULL → "date unknown"). Backfill seeds each contact's current account with start_date = earliest known activity (first opp/quote/job date) via `migrations/addressbook_account_history.sql`.

Rendered in `addressbookedit.inc`: compact CSS timeline (`.ab-emp-timeline`) in the Overview Employment card + detailed version with durations on the History tab. Both build from `$abTimeline` (newest-first).

A moved contact is flagged wherever they surface attached to an object: the shared `addressbookdiv.inc` (used by siteedit/jobedit/opportunityedit/leadedit etc.) adds a "Moved account" badge when a linked contact's current `addressbook.clientid` no longer matches the object's account (`$objectClientid` = the `$contactid` it computes per page); excluded for `src=="client"`. In `clientedit.inc`, a "Former Contacts" section (right after the `addressbookdiv.inc` include) queries `addressbook_account_history` for spans where `clientid = this account AND end_date IS NOT NULL`, joins `addressbook`+`contacts` to show where they went; contacts whose `end_date` is within 3 months show inline ("recently departed"), older ones hidden behind a "Show inactive" checkbox (`toggleFormerInactive()`). Wrapped in try/catch so tenants without the migration degrade gracefully.

Site links survive a move non-destructively: `addressbookmeta(src='site')` rows are NOT deleted on `cmoveaccount`. Instead the Sites tab flags any linked site whose owning `sites.clientid` differs from the contact's current `$c['clientid']` (and isn't 0/unassigned) with a "Previous account" badge + `table-warning` row — computed at render time, no schema change. This is why the linkedSites query selects `s.clientid as siteClientid`.

Opportunity/quote person pickers break on a move: `opportunities.contactid` and `quotes.contact_pid` store an `addressbook.id`, but the person dropdown is server-rendered filtered to the object's account (`opportunityedit.inc` line 14 `selectQuery`→objects `$r->id`; `quotedit.inc` line 321 `getFieldArray`→arrays `$r['id']`). After a move the stored person isn't in the list → picker falls back to None/"Add New Contact" → save writes contactid=0 (silent data loss; for quotes it also propagates to invoices via `quoteditsave.php` INSERT...SELECT). Fix (no schema/save change): if the stored id isn't in `$contacts`, `DB::SELECT` the addressbook row by id into `$movedContact`, prepend a pre-selected `<option>` carrying the real id badged "(moved account)", and show an `alert-warning` banner. The preserved option keeps the real id so the existing save path persists it. NB selectFilter's `filtercontact` endpoint queries `contacts` (account table) keyed by clientid — it's the ACCOUNT picker, not the person picker. Jobs have no person-id column (only `contact` name string) so are unaffected.

Audit verdict (verified, not assumed): the on-the-fly new-client `INSERT INTO contacts` is SAFE despite omitting many NOT NULL columns — `contacts` is MyISAM and the DB sets no strict sql_mode (`library/db.php` only SET NAMES), and legacy `clientaddsave.php` inserts an even smaller column set successfully; do NOT "fix" it by adding all columns. The addressbooksave.php handlers gate only on `checkSession()` (no `mod_*`) — that's the codebase norm for form-post save handlers (peer `opportunityeditsave.php` is the same), NOT a regression; the `mod_*` rule in [[evolution_ajax_save_auth_gate]] is about data-returning AJAX/report endpoints. The unescaped `$row->name`/`data-*` XSS in `addressbookdiv.inc` and the plain `<option>` loops in opportunityedit/quotedit are all PRE-EXISTING (only the `$movedContact` option I added is htmlspecialchars'd). `clientedit` Former-Contacts try/catch genuinely works (PDO ERRMODE_EXCEPTION). Fixed in this pass: `persEdit.inc editPerson()` used to set `.value=obj.csiteid` (never-existed prop, dead code) on the "remove from object" checkbox — replaced with `.checked=false` reset so a prior tick can't carry to a different contact.

**Why:** User wanted a chronological, aesthetic employment journey (created → Company A → Company B). **How to apply:** any new account-reassignment path must write to this table, not just update clientid. Relates to [[evolution_permission_system]] only loosely; core CRM page is addressbookedit.
