Skip to content

Release: first-class People and Company links (KAN-278), person archive, job-create fixes - #466

Merged
corrin merged 94 commits into
productionfrom
main
Jul 16, 2026
Merged

Release: first-class People and Company links (KAN-278), person archive, job-create fixes#466
corrin merged 94 commits into
productionfrom
main

Conversation

@corrin

@corrin corrin commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Release of main to production — 87 commits, first full release since the KAN-281 hotfix on 2026-07-07. The KAN-281 hotfix that lives only on production (#437) is already in main via #438, so nothing is lost; main merges into production cleanly.

Tickets: KAN-278, KAN-281

Headline: first-class People and Company links (KAN-278)

See ADR 0030. The CRM used to treat a company contact as a row owned by one company, mixing human identity (name, email) with relationship-at-company (position, is_primary, Xero import key). This release splits them:

  • Person owns identity, CompanyPersonLink owns the relationship. Jobs and phone call records point at Person. Contact methods are owned by exactly one Company or one Person; phone sharing is allowed only when all owners trace to a common company (unchanged legacy rows are grandfathered until edited).
  • client app/model renamed to company across backend, migrations, generated API client, and frontend. Legacy CRM bookmarks route to the not-found page (ADR 0017 — no redirect shims).
  • Person deduplication and merge tooling, with a reviewed-duplicate cleanup dataset refreshed against current production drift.
  • First-class people management UI — people directory, person detail, edit/delete from the Select-Person modal.

Person archive

Departed people can be retired without breaking history. An explicit archive endpoint plus an include_archived directory filter, is_active on summaries, an Archived badge, and restore. An earlier attempt that auto-archived on last-link removal was reverted — it broke link restore; archiving is now explicit, and adding or restoring a company link un-archives.

Job create/edit fixes (#465)

  • Job-create errors are no longer masked as timeouts.
  • Duplicate submit is blocked when create succeeds but navigation fails; resolved router.push failures are handled.
  • Display-only person_name is no longer sent in the job delta; the redundant job-person fetch that 404s after a company change is gone.
  • A stale people-list fetch could clobber a newer search result — fixed.

Xero cleanup

  • Dropped the dead XeroApp.tenant_id column (ADR 0017) and the dead connections fallback in get_tenant_id (ADR 0015).

Ops, docs, and dependencies

  • Production backup hardening: run from the active release, exclude private config, restore rehearsal fixes.
  • scripts/pull_prod_files.sh to restore instance files to the hotfix checkout; docs/restore-prod-to-hotfix.md updated.
  • New ADR 0028 (type annotations are data contracts) and ADR 0030.
  • E2E: optimizeDeps pin for lazily-discovered heavy deps; teardown no longer reads the dropped XeroApp.tenant_id column; hardened Xero safety checks.
  • Batched Dependabot updates (2026-07-14), plus ws and @typescript-eslint/parser bumps.

Deploy notes

21 new migrations, including the clientcompany app-label rename and the Person backfill (company.00040008).

The app-label rename needs one-time DB surgery — relabelling django_migrations, content types, and client_* tables. deploy.sh runs manage.py relabel_client_app before migrate automatically, so no manual step is required. The command is idempotent (it keys off rows still recorded under the old label) and deploy aborts the instance with services stopped if it fails. It is marked TEMPORARY: remove the command and its deploy hook once every production instance has cut over and produced a verified company-schema backup.

Take a backup before deploying — the data migrations rewrite contact rows into Person / CompanyPersonLink and apply the reviewed duplicate cleanup.

corrin and others added 30 commits July 6, 2026 22:03
All instances (prod/UAT/demo) are past the squash and have the baseline
rows recorded in their ledgers, so the replaces lists are dead weight
(the follow-up noted in PR #434). Removing them now makes the upcoming
client->company app-label surgery unambiguous: no stale ("client", ...)
entries for the migration loader to resolve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ated API client)

Pure structural rename - no behavioral change, zero row-value changes:
- apps/client -> apps/company (app label 'company'), model Client -> Company
- FK/field renames: Job.client, Invoice/Bill/CreditNote/Quote.client,
  PhoneCallRecord.client -> company; CompanyDefaults.shop_client ->
  shop_company, test_client_name -> test_company_name
- ClientContact/ClientContactMethod/Supplier class names unchanged (Stage B)
- Index/constraint renames (company_name_fts_idx, company_name_trgm_idx,
  crm_phone_company_call_idx, unique_company_* etc.)
- relabel_client_app surgery command: pre-migrate ledger relabel (deletes
  the 23 historic ghost rows, keeps 0001_baseline), content-type relabel,
  client_* -> company_* table renames; wired into deploy.sh before migrate
- Rename migrations on top of the squashed baselines: proxy Delete/Create
  dance around RenameModel (contenttypes trap), Remove-constraint ->
  RenameField -> Add-constraint ordering (Q()-condition trap)
- API surface: api/clients/ -> api/companies/, client_id/client_name ->
  company_*; schema.yml + generated api.ts regenerated (hand-written
  frontend swept in the follow-up commit on this branch)
- Persisted values deliberately unchanged (Step 3): telemetry domain
  'client', JobEvent 'client_changed', kanban 'client_contains',
  data-quality cross_client/effective_client_id, crm client_match param

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hand-written frontend sweep matching the regenerated API client:
- api.clients_* -> api.companies_*, schemas.Client* -> schemas.Company*,
  client_id/client_name -> company_id/company_name
- File renames: CompanyLookup.vue, CreateCompanyModal.vue,
  CompanyDropdown.vue, InlineEditCompany.vue, useCompanyLookup.ts,
  companyService.ts, companyStore.ts, company-wrapper.ts
- Route /crm/clients -> /crm/companies (typed-router regenerated)
- data-automation-id values + Playwright specs updated in lockstep
- Kept: src/api/client.ts (HTTP client), schemas.ClientContact* (backend
  class names unchanged until Stage B), Xero OAuth client_id, and the
  Step-3 persisted values (domain: 'client', source: 'client_lookup',
  client_match, cross_client/effective_client_id)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lay-on-the-job-settings-tab

KAN-281: Restore phone-number display on the job-settings tab (main)
…mpany-rename

# Conflicts:
#	apps/company/__init__.py
#	apps/company/models.py
#	apps/company/serializers.py
#	apps/company/services/__init__.py
#	apps/company/services/company_rest_service.py
#	apps/company/tests/test_company_invoice_summary.py
#	apps/company/tests/test_contact_methods.py
#	apps/company/tests/test_job_contact_view.py
#	apps/company/views/company_rest_views.py
#	apps/company/views/contact_viewset.py
#	apps/job/services/workshop_pdf_service.py
#	apps/purchasing/services/supplier_search_service.py
#	apps/workflow/api/xero/reprocess_xero.py
#	apps/workflow/views/xero/xero_quote_manager.py
#	apps/workflow/views/xero/xero_view.py
#	frontend/schema.yml
#	frontend/src/components/CreateCompanyModal.vue
#	frontend/src/components/job/JobSettingsTab.vue
#	frontend/src/composables/useCompanyLookup.ts
#	mypy-baseline.txt
KAN-278 Step 2: pure structural rename Client → Company
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
KAN-278 feat: people and companies first-class
fix: finish KAN-278 terminology cleanup
corrin and others added 27 commits July 16, 2026 14:49
create.vue's submit catch swallowed every create-or-navigate failure by
stashing form state to localStorage and calling window.location.reload().
The reload aborted the in-flight navigation and dropped the user on a blank
create page with no error surfaced — a real failure presenting as an E2E
timeout (see crm/phone-call-job-link flake). The four diagnostic statements
after the reload were dead code.

Split submit into two phases:
- create: on failure, toast extractErrorMessage(error) and let the user
  retry (no reload, no permanent-disable state).
- navigate: the job already exists, so a router.push rejection is reported
  as "created but the page did not open", never as a creation failure.

Drop the localStorage round-trip and hasCreationError flag (the flag
permanently disabled the submit button with no reload to reset it). onMounted
no longer restores from localStorage — without a reload the in-memory form
state already persists, so it just resets to a clean form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Changing a job's company cleared the linked person and the autosave delta
included person_name, which the backend correctly rejects with 400
"Unsupported field 'person_name' in delta payload" — person_name is derived
from job.person.name (read_only) and has no writable target. The rejected
save meant waitForAutosave never resolved, surfacing as a 120s E2E timeout
(edit-job-settings › change company) rather than a visible error.

person_name legitimately blanks the displayed person via the optimistic
patch. The bug is only that the two wire-payload builders forwarded it to the
delta. Fix each at source:
- useJobHeaderAutosave saveAdapter: drop person_name from allowedHeaderKeys
  and the company-change re-add; only person_id (the writable field) clears
  the person. Remove the now-dead person_name before-value branch.
- JobSettingsTab saveAdapter: skip display-only keys when building the
  partial payload.

The optimistic display path is untouched, and the backend's strict rejection
remains the loud safety net for any future regression. Adds a regression test
asserting a company-change save omits person_name from the wire.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Once jobService.createJob returns 201 the job exists. If the subsequent
router.push rejects, the catch reset isSubmitting=false; with the form
untouched canSubmit stayed true, so the submit button re-enabled and a second
click created a duplicate job.

Latch a jobCreated guard on creation success and add it to the submit button's
disable binding, so re-submit is blocked regardless of navigation outcome. The
create-failure resets are untouched (retry there is correct); the guard latches
on success only, so it never blocks a legitimate retry.

Addresses CodeRabbit review on PR #465.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
router.push resolves with a NavigationFailure (aborted/cancelled/duplicated)
rather than rejecting, so the catch-only handling missed those cases: the flow
fell through with isSubmitting stuck true and no error toast — a navigation
failure masquerading as a hung spinner.

Inspect the resolved result with isNavigationFailure() and route it, alongside
the existing catch for thrown errors, through one reportNavFailure handler
(log, toast, reset isSubmitting). Genuine success still falls through to unmount.

Addresses CodeRabbit review on PR #465.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ange

JobSettingsTab's load watcher called GET /api/companies/jobs/{id}/person/
unconditionally, but consumed only .id/.name — both already present on the
JobHeaderResponse it was reacting to (person_id/person_name). When a company
change clears the person, that endpoint 404s (it raises when job.person is
None); Chromium auto-logs the failed load, tripping the E2E rule-30 guard even
though the app catches the 404. The fetch was vestigial (predates the header
carrying person fields).

Read person_id/person_name straight from the header instead, matching the
save/retry paths. No request -> no 404 -> the read-side 404 fallback goes too.

Also completes the useJobsStore mock (patchHeader) in the labourRate and urgent
JobSettingsTab tests; the person block previously swallowed the missing-mock
error inside its try/catch, which this change removes.

Retiring the endpoint itself is a tracked follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
loadPeople() applied every response unconditionally, so concurrent calls
raced: creating a person triggers an unfiltered refetch, and searching
triggers a filtered one. When the slower unfiltered response resolved last it
overwrote the filtered search result, hiding the just-created person — a
timing-dependent flake (E2E crm/people › link lifecycle).

Add a monotonic request token so only the most recently started loadPeople
applies its response; stale responses are dropped regardless of resolution
order. Adds a regression test resolving the two calls out of order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace direct property mutation of localJobData/serverBaseline with spread
replacements, per the frontend immutable-update convention (rules 8-9).
personDisplayValue stays a direct primitive assignment.

Addresses CodeRabbit review on PR #465.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…odal

The first-class people cutover replaced ContactSelectionModal with
PersonSelectionModal and silently dropped the per-person Edit (pencil) and
Delete (trash) hover actions, the delete-confirmation overlay, the edit-mode
form, and the composable's edit/delete methods — a regression vs production.

Restore them, adapting production's copy (contact->person, client->company):
- usePersonManagement: startEditPerson/cancelEdit/updatePerson (change-detected
  across the identity, company-link, and phone contact-method endpoints) plus
  deletePerson (unlink, surfacing the backend 400 phone-conflict detail).
- PersonSelectionModal: Edit/Delete hover buttons, delete-confirm overlay, amber
  editing state, edit-mode title/label; new props/emits + automation ids.
- PersonSelector: wire emits, prod-aligned toasts.
- person_service.remove_company_link: archive the Person (is_active=False) when
  its last active company link is removed, so no searchable orphans remain.

Also institutionalise the gate that would have caught this: a "No feature
removal" rule in CLAUDE.md requiring a Feature Parity Inventory before any
rewrite/cutover, plus a docs/plans/_template.md scaffold.

Tests: backend sole-company-archive case; modal edit/delete unit tests; e2e
edit + delete flows on the job-settings person selector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…estore

The archive-on-last-link cascade in 913cd92 broke the existing person
link-lifecycle. Once a sole-company person's link was removed the Person was
archived (is_active=False), but every person/link endpoint does
get_object_or_404(Person, is_active=True) — so the person could no longer be
viewed or their link restored (people.spec.ts restore step 404'd).

The new model deliberately supports active, company-less people
(test_directory_includes_person_without_company), so link removal must leave
the person active and restorable. Remove the cascade; the delete button
unlinks only, which is exact production parity. Orphan cleanup, if wanted,
belongs in a separate explicit archive action, not tied to link removal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Archiving driven by links (last-link removal archives; restore/add un-archives),
archived people stay viewable so the restore-link lifecycle keeps working, plus
an explicit PersonDetail archive action and a directory show-archived filter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nine TDD tasks: backend archive/un-archive on link removal/add, relaxed
is_active gates, explicit archive endpoint, directory include_archived filter,
client regen, directory + PersonDetail UI, and an e2e archive→restore spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ract

PersonSummary/PersonDetail are response-only serializers and always emit
is_active, but ModelSerializer inferred required=False from the model's
BooleanField(default=True), so the schema marked it optional and the generated
zod rendered `is_active: z.boolean().optional()`. The type lied about the
contract (ADR 0028), and `!person.is_active` on an absent field would badge
every row as archived.

Declare is_active explicitly on PersonSummarySerializer so the schema lists it
as required; PersonDetailSerializer inherits it. The generated client now has
`is_active: z.boolean()`.

Drops the `=== false` workaround in the directory badge, and fixes the
directory test fixture that omitted is_active (not a valid PersonSummary).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an "Archived" badge and Archive-person action to the person
detail page, and makes restoreLink refresh the full person (not just
company links) so the badge clears after un-archiving via link
restore.
Address CodeRabbit review on #464.

The managers are service objects, not the HTTP boundary: the view is, and
it already converts AlreadyLoggedException into a 500 carrying error_id.
Their generic arms persisted then returned an error dict, so that path was
unreachable. Re-raise instead (ADR 0001), which also fixes real bugs:

- Unexpected server errors were reported to the frontend as HTTP 400: the
  manager set "status": 500 but the view ignores it and hardcodes 400.
- The dict path dropped error_id, which ADR 0013 requires.

Expected business failures still return success: False dicts -> 4xx. Only
unexpected exceptions raise.

Also fixes, found while verifying:

- delete_document on both managers had no AlreadyLoggedException arm at all,
  so its bare except re-persisted an already-persisted failure. That is the
  double-persistence ADR 0001 exists to prevent. CodeRabbit missed this.
- xero_quote_manager.delete_document returned "messages" as a list of dicts,
  but XeroDocumentSuccessResponseSerializer declares ListField(CharField());
  DRF rejects a dict, so that path raised on serialization. Fixed the
  producer to match the contract (ADR 0015).
- Four tests never configured xero_sales_branding_theme_id and so failed
  against the mandatory theme guard this PR added.
- xero_view: chain the fallback AssertionError with `from exc` (Ruff B904).

ADR 0001 gains an explicit chain-termination clause: the silence about where
the chain ends is what made this review ambiguous.

Views now pass message= to _build_xero_error_payload so the underlying
exception text survives the move to the raise path (ADR 0013).

Typing: create_document/delete_document now return a named XeroDocumentResponse
TypedDict rather than being unannotated (ADR 0028). It is what surfaced the
messages contract bug. mypy-baseline shrinks by 8.

Frontend: the branding-theme E2E asserted the selector was enabled before the
no-themes skip could run, but an empty theme list is exactly when the control
stays disabled by design, so the test timed out instead of skipping. Wait for
the load to settle, fail on the error marker, skip on the empty marker, then
assert enabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes four regressions found in final review of the person-archive feature:

- PersonContactMethodsView.get/post 404'd on archived people, which broke
  the PersonDetail page's Promise.all load and hid the restore-link button.
  Drop the is_active filter to match PersonDetailView.
- classify_phone_ownership skipped archived people entirely, so a phone
  conflict with an archived owner surfaced as an unrecoverable "belongs to
  <empty>" company conflict instead of offering to restore the link.
- removeLink refetched only companyLinks, leaving person.value stale so the
  Archived badge and Archive button never updated after removing the last
  link. Mirror restoreLink and reload the whole person.
- archivePerson used an ad hoc error message instead of extractErrorMessage,
  inconsistent with its sibling handlers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…wnership

The draft put contact-methods gating and phone-ownership for archived people
out of scope. Both were load-bearing and are now fixed (d4e4d78): an archived
person was unreachable (PersonDetail loads contact-methods in the same
Promise.all, so its 404 collapsed the page and hid the restore-link button),
and classify_phone_ownership skipping archived people regressed the
"Restore company link" recovery path into an unrecoverable dead end.

Record what remains genuinely out of scope: reuse of an archived person's
number by a different person.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(jobs)+feat(crm): job create/edit failures, restore dropped person edit/delete, and person archive
SOLO_CACHE is None under tests (settings.py:892, settings_test.py:25), so
django-solo's get_solo() reads straight through to the DB and clear_cache()
short-circuits on `if cache_name:`. There is no cache to leak between tests,
so registering clear_cache as a cleanup guards nothing and implies a hazard
that cannot occur.

Matches the existing convention in test_xero_branding_themes.py and
test_xero_quota_floor.py: clear_cache() in setUp, no cleanup registered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 19142750-a50c-405e-8920-bffb9bef0f20

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch main

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…heme

fix: apply Xero sales branding theme to quotes and invoices
@corrin
corrin merged commit 3ba6b75 into production Jul 16, 2026
10 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant