Skip to content

KAN-281: Restore phone-number display on the job-settings tab (main) - #438

Merged
corrin merged 31 commits into
mainfrom
KAN-281-Restore-phone-number-display-on-the-job-settings-tab
Jul 7, 2026
Merged

KAN-281: Restore phone-number display on the job-settings tab (main)#438
corrin merged 31 commits into
mainfrom
KAN-281-Restore-phone-number-display-on-the-job-settings-tab

Conversation

@corrin

@corrin corrin commented Jul 6, 2026

Copy link
Copy Markdown
Owner

KAN-281: https://docketworks.atlassian.net/browse/KAN-281

Same branch as #437 (the production hotfix), PR'd to main so the fix and the ADR 0029 production-branch workflow land on the integration line too — no back-merge needed later.

See #437 for the full description and verification.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added phone numbers throughout client and contact flows, including create/edit forms, lists, search results, and detail views.
    • Introduced a read-only Xero mode for safer E2E/testing and surfaced it in Xero status responses.
    • Updated deployment flows to use the production branch for releases and hotfix promotion.
  • Bug Fixes

    • Improved phone handling so primary numbers are selected, updated, or cleared consistently.
    • Added stronger validation and clearer error responses in client/contact updates.

corrin and others added 2 commits July 7, 2026 09:29
The 2026-07-06 release moved phones into ClientContactMethod but the
job-settings tab was never re-wired, so its Client Information card lost
the phone it used to show. Surface the client's and the selected
contact's primary phone as read-only text on payloads the tab already
loads (no new endpoint, display-only, no data changes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
main is the integration branch; production is what servers run.
Releases are promotion PRs main -> production; hotfixes cut from
production and back-merge to main. deploy.sh and instance.sh now
resolve origin/production by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds shared primary-phone resolution across client/contact models, surfaces phone fields through serializers, REST services, and frontend forms/tables, introduces a Xero read-only accounting provider gated by an XERO_READONLY setting with corresponding tests and E2E preflight checks, updates deploy scripts/docs to target the production branch (ADR 0029), and includes various unrelated test-infrastructure and typing fixes.

Changes

Client and Job Phone Fields

Layer / File(s) Summary
Phone contracts and helpers
apps/client/models.py, apps/client/serializers.py, apps/client/__init__.py, apps/client/services/__init__.py
Adds PRIMARY_PHONE_ORDERING, PhoneOwner, ClientContactMethod.primary_phone_annotation, set_primary_phone, phone-aware serializer fields/write paths, and refactors Client.save().
Phone annotations in services and views
apps/client/services/client_rest_service.py, apps/client/views/*, apps/job/services/workshop_pdf_service.py, apps/purchasing/services/supplier_search_service.py
Annotates client/contact/supplier/workshop querysets with derived phone values and threads phone updates through client create/update flows.
Frontend phone schema and UI
frontend/schema.yml, frontend/src/components/*, frontend/src/composables/*, frontend/src/pages/crm/clients/*
Adds phone/error_id to OpenAPI schemas and surfaces phone inputs/displays in contact and client modals, lookup composables, and CRM pages.
Backend phone tests
apps/client/tests/*, apps/job/tests/test_job_header_view.py
Adds tests for phone annotation, client/contact/job update flows, and REST endpoint contracts.

Estimated code review effort: 4 (Complex) | ~75 minutes

Xero Read-Only Mode

Layer / File(s) Summary
Read-only provider implementation
apps/workflow/accounting/xero/readonly_provider.py, apps/workflow/accounting/registry.py, apps/workflow/apps.py
Adds XeroReadOnlyProvider stubbing contact/document/PO/attachment/history writes and blocking full sync; registers it and routes to it when XERO_READONLY is enabled.
Settings, env config, and API surfacing
docketworks/settings.py, apps/workflow/serializers.py, apps/workflow/views/xero/*, .env.example, .env.precommit, scripts/server/templates/env-instance.template, frontend/schema.yml
Adds required XERO_READONLY setting, exposes it via ping/disconnect responses and schema, and adds client/staff guards in quote endpoints.
Read-only provider tests
apps/workflow/tests/test_xero_readonly_provider.py
Validates provider selection and stubbed write behavior with no real API access or AppError creation.
E2E preflight checks and hotfix docs
frontend/tests/scripts/global-setup.ts, docs/restore-prod-to-hotfix.md, CLAUDE.md, docs/development_session.md, .vscode/tasks.json, frontend/tests/purchasing/supplier-alias-search.spec.ts
Adds an E2E preflight check requiring xero_readonly, documents hotfix DB restore steps, and renames dev/hotfix environment tasks.

Estimated code review effort: 3 (Moderate) | ~40 minutes

Production Branch Deployment

Layer / File(s) Summary
Deploy scripts target production
scripts/server/deploy.sh, scripts/server/instance.sh
Resolves releases from origin/production instead of origin/main, adjusting related validation and comments.
Deployment docs and ADR
docs/adr/0029-servers-run-the-production-branch.md, docs/adr/README.md, docs/updating.md, apps/workflow/tests/test_xero_instance_templates.py
Adds ADR 0029 and updates runbook docs and tests to reflect the production-only deploy branch.

Estimated code review effort: 2 (Simple) | ~10 minutes

Test Infrastructure and Misc Fixes

Layer / File(s) Summary
Login session console-error allowance fixture
frontend/tests/fixtures/auth-console-errors.ts, frontend/tests/fixtures/auth.ts, frontend/src/utils/__tests__/authConsoleErrors.test.ts
Adds a time-bounded allowance for expected unauthenticated session-check console errors during login E2E flows.
Payroll date-range alignment tests
apps/accounting/tests/test_payroll_reconciliation_service.py, frontend/tests/reports/payroll-reconciliation.spec.ts
Adds backend unit tests for pay-week snapping/clamping and simplifies the E2E spec accordingly.
Cost line kind-change fix
frontend/src/components/shared/SmartCostLinesTable.vue, frontend/src/components/shared/__tests__/*
Avoids overwriting unit_rev before unit_cost is present when changing a line's kind.
E2E test timing and lookup fixes
frontend/tests/job/job-cost-entry-data.spec.ts, frontend/tests/kanban/kanban-drag-vanishing.spec.ts
Simplifies response-wait predicates and adjusts job-number lookup timing.
Typing and mypy baseline cleanup
apps/workflow/api/xero/*, apps/workflow/views/xero/xero_quote_manager.py, mypy-baseline.txt
Adds explicit type annotations and removes resolved mypy baseline diagnostics.

Estimated code review effort: 2 (Simple) | ~15 minutes

Possibly related PRs

  • corrin/docketworks#407: Both PRs modify deployment/instance bootstrapping scripts (scripts/server/deploy.sh, scripts/server/instance.sh), overlapping with this PR's production-branch deploy changes.
  • corrin/docketworks#421: Both PRs work on primary-phone resolution and phone-method behavior for clients/contacts, directly overlapping with this PR's phone annotation refactor.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description only links the Jira ticket and defers details to #437; it omits the required summary, changes, and checklist sections. Add the template sections: short description, Jira item, bullet changes, and checklist/verification details instead of referring readers to #437.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and relevant to the main user-facing change: restoring phone-number display on the job-settings tab.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch KAN-281-Restore-phone-number-display-on-the-job-settings-tab

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
scripts/server/deploy.sh (1)

43-61: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Update the deploy.sh template test to expect origin/production. apps/workflow/tests/test_xero_instance_templates.py:290-295 still asserts TARGET_REF="origin/main", which is now out of sync with scripts/server/deploy.sh and will leave the test failing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/server/deploy.sh` around lines 43 - 61, The deploy.sh template test
is still asserting the old default target ref, so update the test in
test_xero_instance_templates to expect TARGET_REF="origin/production" instead of
"origin/main". Keep the assertion aligned with the deploy.sh script’s TARGET_REF
default and any related template checks that reference the deploy script
behavior.

Source: Learnings

frontend/src/components/job/JobSettingsTab.vue (2)

1097-1128: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clear client_phone when changing the job client. confirmClientChange and the client-save success path leave localJobData.client_phone untouched, so the old number can keep showing under “Client Phone” after the client changes. Reset it immediately and copy serverJobDetail.client_phone back into local/header state on the save response.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/job/JobSettingsTab.vue` around lines 1097 - 1128, The
client-change flow in confirmClientChange and the client-save success path is
leaving client_phone stale, so clear localJobData.client_phone when the client
is changed and make sure the save response copies serverJobDetail.client_phone
back into both local state and the job header. Update the JobSettingsTab.vue
client-change logic and the success handler that patches job data so the
displayed “Client Phone” always reflects the current client.

1172-1218: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Refresh contactPhone when a client auto-assigns a contact frontend/src/components/job/JobSettingsTab.vue:1097-1218 — the client-change path and the same-contact early return update the name/display only, so the read-only phone field can stay blank after confirmClientChange clears it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/job/JobSettingsTab.vue` around lines 1172 - 1218, The
contact selection flow in handleContactSelected leaves contactPhone stale when
the client auto-assigns a contact or when the same contact is reselected, so
update that path to refresh the phone display too. In JobSettingsTab.vue, make
sure both the early return for an unchanged contact_id and the normal contact
update path assign contactPhone from the selected contact or fetched
updatedContact response, alongside contactDisplayValue and localJobData fields.
Ensure the confirmClientChange/autoset contact case restores the read-only phone
field instead of only the name.
🧹 Nitpick comments (4)
frontend/src/components/job/__tests__/JobSettingsTab.phone.test.ts (1)

1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Filename doesn't match the ComponentName.test.ts convention.

This spec is named JobSettingsTab.phone.test.ts rather than JobSettingsTab.test.ts. As per coding guidelines, "Place unit test specs in a colocated __tests__ folder with filenames matching ComponentName.test.ts". Consider merging these cases into the existing JobSettingsTab.test.ts (if one exists) or renaming to fit the convention.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/job/__tests__/JobSettingsTab.phone.test.ts` at line
1, The test file name does not follow the ComponentName.test.ts convention.
Rename or merge the phone-specific spec for JobSettingsTab so it lives alongside
the existing JobSettingsTab tests under the colocated __tests__ folder, and
ensure the final filename matches JobSettingsTab.test.ts; use the JobSettingsTab
test suite to keep the naming consistent with the component.

Source: Coding guidelines

apps/client/services/client_rest_service.py (1)

423-432: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate contact-serialization logic across get_job_contact and update_job_contact.

Both methods build an identical dict (id, name, email, position, is_primary, notes, phone) from a ClientContact. Adding phone here duplicates the same line in two places; a future field will need the same edit twice. Consider extracting a small _serialize_job_contact(contact) helper shared by both call sites.

♻️ Suggested helper
`@staticmethod`
def _serialize_job_contact(contact: ClientContact) -> Dict[str, Any]:
    return {
        "id": str(contact.id),
        "name": contact.name,
        "email": contact.email,
        "position": contact.position,
        "is_primary": contact.is_primary,
        "notes": contact.notes,
        "phone": contact.primary_phone_value(),
    }

Then call ClientRestService._serialize_job_contact(contact) in both get_job_contact and update_job_contact.

Also applies to: 499-507

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/client/services/client_rest_service.py` around lines 423 - 432,
`get_job_contact` and `update_job_contact` both duplicate the same
`ClientContact` serialization dict, including the new `phone` field. Extract the
shared mapping into a small helper on `ClientRestService` such as
`_serialize_job_contact(contact)` and have both methods call it instead of
rebuilding the dict inline. Keep the helper responsible for all fields (`id`,
`name`, `email`, `position`, `is_primary`, `notes`, `phone`) so future changes
only need one edit.
apps/client/models.py (1)

365-379: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider deduplicating with Client.primary_phone_value().

The new ClientContact.primary_phone_value() re-implements the same query/ordering logic that Client.primary_phone_value() already uses (per the docstring itself: "same ordering as every other primary-phone consumer"). Two independent copies of this rule risk silently diverging if one is ever tweaked without the other.

Consider extracting the shared logic into a small helper (e.g., a mixin, or a module-level function taking a contact_methods queryset) that both Client and ClientContact call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/client/models.py` around lines 365 - 379,
`ClientContact.primary_phone_value()` duplicates the same phone चयन/order logic
already implemented in `Client.primary_phone_value()`, so extract that shared
queryset-based lookup into a small helper and have both methods call it. Keep
the existing ordering semantics (`-is_primary`, `label`, `value`) in one place,
and update the two `primary_phone_value` methods to delegate to the shared
helper instead of re-implementing the query.
apps/job/serializers/job_serializer.py (1)

822-822: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate get_client_phone implementation.

Identical body to JobSerializer.get_client_phone (lines 205-208). Consider extracting a small shared helper (e.g. a mixin or module-level function) both serializers call, to avoid the two copies drifting apart later. Same N+1 caveat noted on the other occurrence applies here too.

♻️ Example extraction
+def get_client_phone_value(client) -> str:
+    """The client's primary phone number, or "" when there is no client."""
+    return client.primary_phone_value() if client else ""
+
+
 class JobSerializer(serializers.ModelSerializer):
     ...
     `@extend_schema_field`(serializers.CharField())
     def get_client_phone(self, obj) -> str:
-        """The client's primary phone number, or "" when it has none."""
-        return obj.client.primary_phone_value() if obj.client else ""
+        return get_client_phone_value(obj.client)

Also applies to: 837-849

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/job/serializers/job_serializer.py` at line 822, The
`JobSerializer.get_client_phone` logic is duplicated in the serializer method
fields, so extract the shared phone-formatting lookup into a common helper or
mixin that both serializer implementations call. Update the `JobSerializer` and
the other serializer method(s) that return `client_phone` to delegate to that
shared helper, keeping the existing behavior unchanged while avoiding drift
between copies.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/components/job/__tests__/JobSettingsTab.phone.test.ts`:
- Around line 32-47: The mocked useJobsStore in JobSettingsTab.phone.test.ts
skips the header-mapping logic, so add direct Pinia coverage for jobToHeader and
setHeader to exercise the new client_phone field. Extend the jobs store tests in
frontend/src/stores/__tests__/jobs.test.ts to verify client_phone is mapped into
the header object and persisted back through setHeader, using the existing
jobToHeader/setHeader symbols as the entry points.

---

Outside diff comments:
In `@frontend/src/components/job/JobSettingsTab.vue`:
- Around line 1097-1128: The client-change flow in confirmClientChange and the
client-save success path is leaving client_phone stale, so clear
localJobData.client_phone when the client is changed and make sure the save
response copies serverJobDetail.client_phone back into both local state and the
job header. Update the JobSettingsTab.vue client-change logic and the success
handler that patches job data so the displayed “Client Phone” always reflects
the current client.
- Around line 1172-1218: The contact selection flow in handleContactSelected
leaves contactPhone stale when the client auto-assigns a contact or when the
same contact is reselected, so update that path to refresh the phone display
too. In JobSettingsTab.vue, make sure both the early return for an unchanged
contact_id and the normal contact update path assign contactPhone from the
selected contact or fetched updatedContact response, alongside
contactDisplayValue and localJobData fields. Ensure the
confirmClientChange/autoset contact case restores the read-only phone field
instead of only the name.

In `@scripts/server/deploy.sh`:
- Around line 43-61: The deploy.sh template test is still asserting the old
default target ref, so update the test in test_xero_instance_templates to expect
TARGET_REF="origin/production" instead of "origin/main". Keep the assertion
aligned with the deploy.sh script’s TARGET_REF default and any related template
checks that reference the deploy script behavior.

---

Nitpick comments:
In `@apps/client/models.py`:
- Around line 365-379: `ClientContact.primary_phone_value()` duplicates the same
phone चयन/order logic already implemented in `Client.primary_phone_value()`, so
extract that shared queryset-based lookup into a small helper and have both
methods call it. Keep the existing ordering semantics (`-is_primary`, `label`,
`value`) in one place, and update the two `primary_phone_value` methods to
delegate to the shared helper instead of re-implementing the query.

In `@apps/client/services/client_rest_service.py`:
- Around line 423-432: `get_job_contact` and `update_job_contact` both duplicate
the same `ClientContact` serialization dict, including the new `phone` field.
Extract the shared mapping into a small helper on `ClientRestService` such as
`_serialize_job_contact(contact)` and have both methods call it instead of
rebuilding the dict inline. Keep the helper responsible for all fields (`id`,
`name`, `email`, `position`, `is_primary`, `notes`, `phone`) so future changes
only need one edit.

In `@apps/job/serializers/job_serializer.py`:
- Line 822: The `JobSerializer.get_client_phone` logic is duplicated in the
serializer method fields, so extract the shared phone-formatting lookup into a
common helper or mixin that both serializer implementations call. Update the
`JobSerializer` and the other serializer method(s) that return `client_phone` to
delegate to that shared helper, keeping the existing behavior unchanged while
avoiding drift between copies.

In `@frontend/src/components/job/__tests__/JobSettingsTab.phone.test.ts`:
- Line 1: The test file name does not follow the ComponentName.test.ts
convention. Rename or merge the phone-specific spec for JobSettingsTab so it
lives alongside the existing JobSettingsTab tests under the colocated __tests__
folder, and ensure the final filename matches JobSettingsTab.test.ts; use the
JobSettingsTab test suite to keep the naming consistent with the component.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d02f1d95-a6b7-4276-9d8b-09ffe3aebaaf

📥 Commits

Reviewing files that changed from the base of the PR and between 7715259 and 113eae7.

⛔ Files ignored due to path filters (1)
  • frontend/src/api/generated/api.ts is excluded by !**/generated/**
📒 Files selected for processing (15)
  • CLAUDE.md
  • apps/client/models.py
  • apps/client/serializers.py
  • apps/client/services/client_rest_service.py
  • apps/client/tests/test_contact_methods.py
  • apps/job/serializers/job_serializer.py
  • docs/adr/0029-servers-run-the-production-branch.md
  • docs/adr/README.md
  • docs/updating.md
  • frontend/schema.yml
  • frontend/src/components/job/JobSettingsTab.vue
  • frontend/src/components/job/__tests__/JobSettingsTab.phone.test.ts
  • frontend/src/stores/jobs.ts
  • scripts/server/deploy.sh
  • scripts/server/instance.sh

Comment thread frontend/src/components/job/__tests__/JobSettingsTab.phone.test.ts Outdated
corrin and others added 4 commits July 7, 2026 09:58
…ests

The guard tests pin the deploy scripts' default ref on purpose; ADR 0029
moves that pin from origin/main to origin/production.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uery

The KAN-281 phone-display fields called primary_phone_value() from
serializers, lazily querying contact_methods per request; nplusone
(strict in dev) turned that into a 500 on the job-settings tab.

Add ClientContactMethod.primary_phone_annotation as the single source
of the primary-phone Subquery (and PRIMARY_PHONE_ORDERING as the single
copy of the ordering rule), annotate every Job/contact-fetching call
site, and back client_phone with AnnotatedCharField so an unannotated
queryset crashes loudly instead of silently dropping the key. Migrate
the two pre-existing hand-written Subqueries (workshop PDF, supplier
search) to the shared helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Strict-mypy compliance for the tests added with the primary-phone
annotation fix: typed helpers, _MonkeyPatchedResponse return type for
the header GET helper, and module-level query-capture imports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…phone response-only

Annotate primary_phone from ClientContactMethod onto the clients list,
client-search hydration, client-create response, and the contact viewset
so the Phone column and job-contact phone display again. Split
JobContactBaseSerializer out so the response serializer carries the phone
while the update serializer never accepts one; regenerate schema and the
frontend API client. Surface contact-load failures on the job-settings tab
as toast + console.error instead of debugLog. Add regression tests for the
job-contact endpoint and the clients-list Phone column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
corrin and others added 2 commits July 7, 2026 12:49
…ethods migration

The ClientContactMethod migration deleted the phone UI without
replacement in four places. Restore each on the new data model:

- Clients list: Phone column, fed by primary_phone_annotation on the
  list/search/create-response querysets (ClientSearchResult.phone).
- Contact lists: per-contact phone on the client detail Contacts card,
  ContactSelectionModal, and the contact picker label, via an annotated
  ClientContactViewSet queryset and ClientContactSerializer.phone.
- Contact add/edit: the form's Phone input writes through
  set_primary_phone, which promotes/renumbers the owner's primary
  ClientContactMethod (ownership conflicts surface as 400) and enqueues
  a CRM call rematch.
- Client create: CreateClientModal's Phone input (create mode only;
  editing stays in PhoneNumberManager) creates the client's primary
  method inside the create transaction, so a conflict rolls back the
  client and skips the Xero push.

Blank phone input is a deliberate no-op: deleting numbers belongs to
PhoneNumberManager. Phone search is not restored - the old release
only ever searched names; its placeholder was aspirational.

Also type-clean the KAN-281 test files and sync one resolved
mypy-baseline entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three mandatory safety steps when refreshing this checkout's DB from
production: back up the prod DB, clear the Xero token, and set
accounting_provider to xero_readonly so the copy cannot write to the
real Xero organisation. Distinct from the anonymised dev/UAT restore
(restore-prod-to-nonprod.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (6)
apps/client/serializers.py (1)

52-54: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Enqueue the phone rematch on commit.
set_primary_phone() is also called from _create_client_in_xero() inside transaction.atomic(), so delay() can run before the phone rows commit and rematch against incomplete data.

Suggested change
-    numbers = [number for number in {old_number, method.normalized_value} if number]
-    rematch_phone_calls_task.delay(sorted(numbers))
+    numbers = [number for number in {old_number, method.normalized_value} if number]
+    transaction.on_commit(lambda: rematch_phone_calls_task.delay(sorted(numbers)))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/client/serializers.py` around lines 52 - 54, The phone rematch is being
queued immediately from set_primary_phone(), which can fire inside
transaction.atomic() before the phone records are committed; update the rematch
trigger so rematch_phone_calls_task.delay(...) runs only after a successful
commit. Use the set_primary_phone() flow in serializers.py as the entry point,
and move the enqueue logic to a transaction.on_commit callback (or equivalent
commit hook) so _create_client_in_xero() and other atomic callers only schedule
the task once the database state is durable.
apps/job/tests/test_job_rest_service.py (1)

73-115: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding a phoneless-client case.

The two tests here cover the "has a primary phone" path, mirroring the annotation-preference tests in test_contact_methods.py. Consider adding a case with no ClientContactMethod rows to assert client_phone == "", matching primary_phone_annotation's documented empty-string fallback and guarding against a future regression in these two service methods.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/job/tests/test_job_rest_service.py` around lines 73 - 115, Add a
negative test case for a client with no ClientContactMethod rows in
JobRestServiceClientPhoneTests to cover the empty-phone path. Reuse the existing
_job_with_client_phone pattern or add a helper for a phoneless client, then
assert that JobRestService.get_job_for_edit and JobRestService.get_job_summary
return job["client_phone"] == "" when no primary phone exists. This will align
the service tests with primary_phone_annotation’s empty-string fallback and
guard both methods against regressions.
apps/job/tests/test_job_header_view.py (1)

125-132: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Suppress the Ruff B018 false positive.

Static analysis flags Line 131 as a useless attribute access, but .data access here is intentional (it triggers serialization inside assertRaises to exercise the annotated-field guard). Per coding guidelines, add a # noqa with the specific code and justification to keep CI lint clean.

🔧 Suggested suppression
         with self.assertRaises(Exception) as ctx:
-            JobHeaderResponseSerializer(unannotated).data
+            JobHeaderResponseSerializer(unannotated).data  # noqa: B018 -- access triggers serialization to assert it raises
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/job/tests/test_job_header_view.py` around lines 125 - 132, The `.data`
access in `test_serializer_requires_annotated_instance` is intentional because
it triggers `JobHeaderResponseSerializer` serialization inside `assertRaises`,
so suppress the Ruff B018 false positive at that exact expression. Add a
targeted `# noqa` with the B018 code and a brief justification near the
`JobHeaderResponseSerializer(...).data` access so CI lint remains clean without
changing the test behavior.

Source: Linters/SAST tools

apps/client/tests/test_contact_methods.py (1)

381-391: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated _lazy_phone_queries helper across three test classes.

The same "no standalone client_clientcontactmethod SELECT" check is copy-pasted in GetJobContactPhoneTests, ClientListPhoneTests, and inlined again in ClientContactApiPhoneTests. Extracting it to a shared test mixin/utility would reduce drift risk if the convention changes.

♻️ Suggested consolidation
+def assert_no_lazy_phone_queries(captured: CaptureQueriesContext) -> None:
+    lazy = [
+        q["sql"]
+        for q in captured.captured_queries
+        if q["sql"].startswith('SELECT "client_clientcontactmethod"')
+    ]
+    assert lazy == [], f"Unexpected lazy phone queries: {lazy}"

Then replace each class-local _lazy_phone_queries/inline block with a call to the shared helper.

Also applies to: 441-446, 509-514

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/client/tests/test_contact_methods.py` around lines 381 - 391, The lazy
phone query check is duplicated in GetJobContactPhoneTests,
ClientListPhoneTests, and ClientContactApiPhoneTests; consolidate it into one
shared helper or mixin. Extract the existing _lazy_phone_queries logic into a
reusable test utility, then replace each class-local implementation and inline
block with calls to that shared helper. Keep the helper behavior identical by
preserving the current filter for top-level SELECTs on
client_clientcontactmethod.
frontend/src/components/CreateClientModal.vue (1)

336-341: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fine, but slightly awkward unused-var workaround.

The void _createOnlyPhone pattern to satisfy lint is a bit unusual; destructuring directly without binding an intermediate name would read more cleanly.

♻️ Alternative without the unused-var workaround
-      // Update existing client. ClientUpdateRequest has no phone field —
-      // client phone editing lives in PhoneNumberManager on the client detail page.
-      const { phone: _createOnlyPhone, ...updateFields } = cleanedData
-      void _createOnlyPhone
-      const updatePayload: ClientUpdateRequest = { ...updateFields }
+      // Update existing client. ClientUpdateRequest has no phone field —
+      // client phone editing lives in PhoneNumberManager on the client detail page.
+      const { phone, ...updateFields } = cleanedData
+      const updatePayload: ClientUpdateRequest = { ...updateFields }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/CreateClientModal.vue` around lines 336 - 341, The
client update branch in CreateClientModal.vue uses an awkward unused-variable
workaround for stripping phone from cleanedData. Refactor the props.editMode &&
props.clientId path so the phone field is removed without binding and voiding an
intermediate name, while still building the ClientUpdateRequest payload from
updateFields; keep the logic localized around the updatePayload construction.
frontend/src/composables/useContactManagement.ts (1)

57-85: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Fragile positional parsing of "name - phone - email" in displayValue.set().

Splitting on the literal ' - ' assumes none of name, phone, or email contain that exact substring. Extending this from 2 to 3 fields increases the chance of misassignment (e.g., a phone number formatted with spaced hyphens, or a name containing " - "), silently scrambling fields on round-trip.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/composables/useContactManagement.ts` around lines 57 - 85, The
displayValue setter in useContactManagement is using fragile positional parsing
for the combined contact string, which can misassign fields when a name, phone,
or email contains the separator. Update displayValue.get and displayValue.set to
avoid relying on splitting a single formatted string in useContactManagement;
instead parse the three fields more robustly or use a non-ambiguous structure so
selectedContact and newContactForm are populated correctly even when values
contain hyphens or the separator text.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/client/serializers.py`:
- Around line 52-54: The phone rematch is being queued immediately from
set_primary_phone(), which can fire inside transaction.atomic() before the phone
records are committed; update the rematch trigger so
rematch_phone_calls_task.delay(...) runs only after a successful commit. Use the
set_primary_phone() flow in serializers.py as the entry point, and move the
enqueue logic to a transaction.on_commit callback (or equivalent commit hook) so
_create_client_in_xero() and other atomic callers only schedule the task once
the database state is durable.

In `@apps/client/tests/test_contact_methods.py`:
- Around line 381-391: The lazy phone query check is duplicated in
GetJobContactPhoneTests, ClientListPhoneTests, and ClientContactApiPhoneTests;
consolidate it into one shared helper or mixin. Extract the existing
_lazy_phone_queries logic into a reusable test utility, then replace each
class-local implementation and inline block with calls to that shared helper.
Keep the helper behavior identical by preserving the current filter for
top-level SELECTs on client_clientcontactmethod.

In `@apps/job/tests/test_job_header_view.py`:
- Around line 125-132: The `.data` access in
`test_serializer_requires_annotated_instance` is intentional because it triggers
`JobHeaderResponseSerializer` serialization inside `assertRaises`, so suppress
the Ruff B018 false positive at that exact expression. Add a targeted `# noqa`
with the B018 code and a brief justification near the
`JobHeaderResponseSerializer(...).data` access so CI lint remains clean without
changing the test behavior.

In `@apps/job/tests/test_job_rest_service.py`:
- Around line 73-115: Add a negative test case for a client with no
ClientContactMethod rows in JobRestServiceClientPhoneTests to cover the
empty-phone path. Reuse the existing _job_with_client_phone pattern or add a
helper for a phoneless client, then assert that JobRestService.get_job_for_edit
and JobRestService.get_job_summary return job["client_phone"] == "" when no
primary phone exists. This will align the service tests with
primary_phone_annotation’s empty-string fallback and guard both methods against
regressions.

In `@frontend/src/components/CreateClientModal.vue`:
- Around line 336-341: The client update branch in CreateClientModal.vue uses an
awkward unused-variable workaround for stripping phone from cleanedData.
Refactor the props.editMode && props.clientId path so the phone field is removed
without binding and voiding an intermediate name, while still building the
ClientUpdateRequest payload from updateFields; keep the logic localized around
the updatePayload construction.

In `@frontend/src/composables/useContactManagement.ts`:
- Around line 57-85: The displayValue setter in useContactManagement is using
fragile positional parsing for the combined contact string, which can misassign
fields when a name, phone, or email contains the separator. Update
displayValue.get and displayValue.set to avoid relying on splitting a single
formatted string in useContactManagement; instead parse the three fields more
robustly or use a non-ambiguous structure so selectedContact and newContactForm
are populated correctly even when values contain hyphens or the separator text.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f9959497-5dd8-4b7a-9659-784257759277

📥 Commits

Reviewing files that changed from the base of the PR and between 113eae7 and 991db0d.

⛔ Files ignored due to path filters (1)
  • frontend/src/api/generated/api.ts is excluded by !**/generated/**
📒 Files selected for processing (30)
  • apps/client/__init__.py
  • apps/client/models.py
  • apps/client/serializers.py
  • apps/client/services/client_rest_service.py
  • apps/client/tests/test_client_invoice_summary.py
  • apps/client/tests/test_contact_methods.py
  • apps/client/tests/test_job_contact_view.py
  • apps/client/views/client_contact_viewset.py
  • apps/client/views/client_rest_views.py
  • apps/job/serializers/__init__.py
  • apps/job/serializers/job_serializer.py
  • apps/job/services/job_rest_service.py
  • apps/job/services/workshop_pdf_service.py
  • apps/job/tests/test_job_header_view.py
  • apps/job/tests/test_job_rest_service.py
  • apps/job/views/job_rest_views.py
  • apps/purchasing/services/supplier_search_service.py
  • apps/workflow/tests/test_xero_instance_templates.py
  • frontend/schema.yml
  • frontend/src/components/ContactSelectionModal.vue
  • frontend/src/components/CreateClientModal.vue
  • frontend/src/components/__tests__/ClientLookup.test.ts
  • frontend/src/components/job/JobSettingsTab.vue
  • frontend/src/composables/__tests__/useContactManagement.test.ts
  • frontend/src/composables/useClientLookup.ts
  • frontend/src/composables/useContactManagement.ts
  • frontend/src/pages/crm/clients/(index).vue
  • frontend/src/pages/crm/clients/[id].vue
  • frontend/tests/fixtures/auth.ts
  • mypy-baseline.txt
💤 Files with no reviewable changes (1)
  • mypy-baseline.txt
✅ Files skipped from review due to trivial changes (2)
  • frontend/src/components/tests/ClientLookup.test.ts
  • apps/client/init.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/src/components/job/JobSettingsTab.vue

corrin and others added 5 commits July 7, 2026 14:14
E2E runs against a prod-connected backend were writing real entities
into the Xero tenant: ~4 contacts, an invoice (with PDF attachment and
history note), and a quote per run, none of them ever cleaned up.

Add a required XERO_READONLY env var (process-scoped, so a live server
or celery worker sharing the DB is unaffected). When true, get_provider()
swaps the Xero backend for XeroReadOnlyProvider: every write logs a
warning and returns a well-formed fake result (fake UUIDs, INV-E2E-*/
QU-E2E-* numbers, _e2e_stub-marked raw_json) while reads, auth, and
token refresh inherit unchanged. run_full_sync is blanket-blocked
because it hides a stock push (sync_local_stock_to_xero).

/api/xero/ping/ now reports the flag and the E2E global-setup pre-flight
hard-fails unless the backend is in readonly mode. The unprefixed
"S&T Stainless Limited" supplier in supplier-alias-search.spec.ts gains
the [TEST] prefix so cleanup and safety checks can see it.

Deployments must add XERO_READONLY=False to their .env (the var is
required, matching the XERO_SYNC_PROJECTS convention); the instance
template includes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CodeRabbit flagged update_contact returning success with a None
external_id. The real provider's update path is an upsert (creates the
contact and assigns an ID when none exists), so mirror that instead of
failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI's cold mypy run caught errors a warm local cache had hidden. Fix
them at the contract level, no ignores added (three the branch had
introduced are removed; net ignore count is down five):

- Break the client->crm import cycle (lazy rematch-task import in
  apps/client/serializers.py); the cycle made mypy mis-infer a
  phone-annotated Client inside untouched xero/transforms.py.
- Type Client.save (keyword-only, matching ClientContact.save; dead
  _include_auto_now_update_field helper removed), validate_for_xero,
  get_final_client; declare client: Client in sync_clients.
- Contact write responses re-fetch through the primary_phone_annotation
  queryset instead of poking a synthetic attribute; the clients-list
  formatter takes WithAnnotations[Client, _ClientSummaryAnnotations].
- XERO_READONLY read via os.environ (required var, crash if absent);
  XeroQuoteManager.__init__ typed; quote create/delete views guard
  job.client is None and narrow request.user to Staff.
- Readonly-provider test stubs _attach_workshop_pdf via patch.object.

mypy-baseline shrinks by 18 resolved entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
apps/workflow/views/xero/xero_view.py (1)

962-971: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Minor duplication in xero_disconnect response payload.

Both branches build the identical {"connected": False, "xero_readonly": settings.XERO_READONLY} dict.

♻️ Optional dedup
+    disconnected_payload = {"connected": False, "xero_readonly": settings.XERO_READONLY}
     try:
         cache.delete("xero_tenant_id")
         try:
             active = get_active_app()
         except NoActiveXeroApp:
             logger.info("xero_disconnect: no active XeroApp; nothing to do")
-            return Response(
-                {"connected": False, "xero_readonly": settings.XERO_READONLY},
-                status=status.HTTP_200_OK,
-            )
+            return Response(disconnected_payload, status=status.HTTP_200_OK)
         wipe_tokens_and_quota(active)
         logger.info(f"Disconnected XeroApp {active.id} ({active.label})")
-        return Response(
-            {"connected": False, "xero_readonly": settings.XERO_READONLY},
-            status=status.HTTP_200_OK,
-        )
+        return Response(disconnected_payload, status=status.HTTP_200_OK)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/workflow/views/xero/xero_view.py` around lines 962 - 971, The
xero_disconnect response payload is duplicated across both branches with the
same connected/xero_readonly dict, so factor that shared Response body into a
single reusable value in xero_disconnect and return it from both the inactive
and active paths. Use the existing xero_disconnect flow, the
wipe_tokens_and_quota call, and the logger.info line as anchors while keeping
the response content identical.
apps/client/models.py (1)

248-261: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicate primary_phone_value() logic between Client and ClientContact.

Both methods filter/order/first on contact_methods identically. Could extract a small shared helper (e.g., a mixin method or standalone function taking the contact_methods manager) to avoid drift if the ordering logic changes later.

♻️ Possible consolidation
+def _primary_phone_value(contact_methods) -> str:
+    method = (
+        contact_methods.filter(method_type=ClientContactMethod.MethodType.PHONE)
+        .order_by(*PRIMARY_PHONE_ORDERING)
+        .first()
+    )
+    return method.value if method else ""

Then both Client.primary_phone_value and ClientContact.primary_phone_value call _primary_phone_value(self.contact_methods).

Also applies to: 364-377

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/client/models.py` around lines 248 - 261, The `primary_phone_value()`
implementation in both `Client` and `ClientContact` is duplicated, so extract
the shared filter/order/first lookup into a small helper or mixin method that
accepts the `contact_methods` manager and returns the primary phone value.
Update both `Client.primary_phone_value` and `ClientContact.primary_phone_value`
to delegate to that shared helper so the ordering logic stays in one place and
won’t drift over time.
.env.example (1)

47-48: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reorder key alphabetically per dotenv-linter.

Static analysis flags XERO_READONLY as out of order relative to XERO_SYNC_PROJECTS.

Suggested reorder
 XERO_DEFAULT_USER_ID=YOUR_XERO_USER_ID_FOR_TIME_ENTRIES
+# Suppress all Xero writes for this process (E2E/test backends only; reads stay live)
+XERO_READONLY=False
 XERO_SYNC_PROJECTS=False
-# Suppress all Xero writes for this process (E2E/test backends only; reads stay live)
-XERO_READONLY=False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.example around lines 47 - 48, The dotenv-linter warning is about key
ordering in the environment example. Reorder the Xero-related entries in the
.env.example section so XERO_READONLY appears in its alphabetical position
relative to XERO_SYNC_PROJECTS, keeping the existing comments and values
unchanged.

Source: Linters/SAST tools

apps/workflow/tests/test_xero_readonly_provider.py (1)

32-33: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unused helper _no_api.

_no_api isn't called anywhere in this file — all side_effect= usages pass _API_FORBIDDEN directly. Consider removing it, or if intentionally kept for future use, annotate as NoReturn instead of Any.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/workflow/tests/test_xero_readonly_provider.py` around lines 32 - 33,
Unused helper _no_api is defined but never referenced in the test file, since
the side_effect setup uses _API_FORBIDDEN directly. Remove _no_api if it is not
needed, or if you want to keep it for future use, update its signature in the
test module to use NoReturn instead of Any so the intent is explicit.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/client/services/__init__.py`:
- Line 9: The exported symbols in __init__.py were hand-edited; regenerate the
module using scripts/update_init.py instead of directly changing the
import/__all__ in __init__.py. Review ClientRestService and
_ClientSummaryAnnotations in client_rest_service to decide whether the
underscore-prefixed annotation should remain internal and be removed from
__all__, or be renamed if it is intended to be part of the public API.

---

Nitpick comments:
In @.env.example:
- Around line 47-48: The dotenv-linter warning is about key ordering in the
environment example. Reorder the Xero-related entries in the .env.example
section so XERO_READONLY appears in its alphabetical position relative to
XERO_SYNC_PROJECTS, keeping the existing comments and values unchanged.

In `@apps/client/models.py`:
- Around line 248-261: The `primary_phone_value()` implementation in both
`Client` and `ClientContact` is duplicated, so extract the shared
filter/order/first lookup into a small helper or mixin method that accepts the
`contact_methods` manager and returns the primary phone value. Update both
`Client.primary_phone_value` and `ClientContact.primary_phone_value` to delegate
to that shared helper so the ordering logic stays in one place and won’t drift
over time.

In `@apps/workflow/tests/test_xero_readonly_provider.py`:
- Around line 32-33: Unused helper _no_api is defined but never referenced in
the test file, since the side_effect setup uses _API_FORBIDDEN directly. Remove
_no_api if it is not needed, or if you want to keep it for future use, update
its signature in the test module to use NoReturn instead of Any so the intent is
explicit.

In `@apps/workflow/views/xero/xero_view.py`:
- Around line 962-971: The xero_disconnect response payload is duplicated across
both branches with the same connected/xero_readonly dict, so factor that shared
Response body into a single reusable value in xero_disconnect and return it from
both the inactive and active paths. Use the existing xero_disconnect flow, the
wipe_tokens_and_quota call, and the logger.info line as anchors while keeping
the response content identical.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ea0e73f7-43c3-40c3-b304-83dcc4cede86

📥 Commits

Reviewing files that changed from the base of the PR and between 991db0d and 1553dd8.

⛔ Files ignored due to path filters (1)
  • frontend/src/api/generated/api.ts is excluded by !**/generated/**
📒 Files selected for processing (24)
  • .env.example
  • CLAUDE.md
  • apps/client/models.py
  • apps/client/serializers.py
  • apps/client/services/__init__.py
  • apps/client/services/client_rest_service.py
  • apps/client/tests/test_contact_methods.py
  • apps/workflow/accounting/registry.py
  • apps/workflow/accounting/xero/readonly_provider.py
  • apps/workflow/api/xero/reprocess_xero.py
  • apps/workflow/api/xero/transforms.py
  • apps/workflow/apps.py
  • apps/workflow/serializers.py
  • apps/workflow/tests/test_xero_readonly_provider.py
  • apps/workflow/views/xero/xero_quote_manager.py
  • apps/workflow/views/xero/xero_view.py
  • docketworks/settings.py
  • docs/development_session.md
  • docs/restore-prod-to-hotfix.md
  • frontend/schema.yml
  • frontend/tests/purchasing/supplier-alias-search.spec.ts
  • frontend/tests/scripts/global-setup.ts
  • mypy-baseline.txt
  • scripts/server/templates/env-instance.template
💤 Files with no reviewable changes (1)
  • mypy-baseline.txt
✅ Files skipped from review due to trivial changes (4)
  • apps/workflow/api/xero/transforms.py
  • frontend/tests/purchasing/supplier-alias-search.spec.ts
  • apps/workflow/api/xero/reprocess_xero.py
  • docs/development_session.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • apps/client/services/client_rest_service.py
  • apps/client/serializers.py
  • frontend/schema.yml
  • apps/client/tests/test_contact_methods.py

Comment thread apps/client/services/__init__.py Outdated
if apps.ready:
from .client_merge_service import reassign_client_fk_records
from .client_rest_service import ClientRestService
from .client_rest_service import ClientRestService, _ClientSummaryAnnotations

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Regenerate __init__.py instead of hand-editing.

This file is autogenerated. Per repo convention, additions/removals of exported symbols should be applied by running python scripts/update_init.py, not by hand-editing the import/__all__ lists directly.

Separately, _ClientSummaryAnnotations is underscore-prefixed (private convention) yet is now exported in __all__, making it part of the public surface — worth reconsidering whether it should be renamed without the leading underscore if it's genuinely meant to be public, or kept out of __all__ if it's meant to stay internal.

Also applies to: 38-38

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/client/services/__init__.py` at line 9, The exported symbols in
__init__.py were hand-edited; regenerate the module using scripts/update_init.py
instead of directly changing the import/__all__ in __init__.py. Review
ClientRestService and _ClientSummaryAnnotations in client_rest_service to decide
whether the underscore-prefixed annotation should remain internal and be removed
from __all__, or be renamed if it is intended to be part of the public API.

Source: Path instructions

corrin and others added 4 commits July 7, 2026 16:16
XERO_READONLY became a required setting; CI's .env.precommit did not
define it, so settings import crashed (ImproperlyConfigured), taking
down the backend test, type-check, and pre-commit jobs in one stroke.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
.env.precommit (1)

37-37: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Minor: key ordering per dotenv-linter.

dotenv-linter flags that XERO_READONLY should be placed before XERO_REDIRECT_URI alphabetically. Purely cosmetic, but easy to fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.precommit at line 37, The dotenv key order in the environment config is
out of alphabetical sequence, and `XERO_READONLY` should be placed before
`XERO_REDIRECT_URI` to satisfy dotenv-linter. Update the ordering in the env
file so the `XERO_*` entries remain alphabetically sorted, using the existing
variable names as the reference for placement.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/utils/__tests__/authConsoleErrors.test.ts`:
- Around line 1-8: The test file is importing auth-console-errors through a
parent-path reference instead of staying on the `@-alias` path. Update the imports
used by authConsoleErrors.test to resolve
createLoginSessionCheckConsoleAllowance, isUnauthenticatedSessionCheckResponse,
LOGIN_ME_PATH, UNAUTHENTICATED_SESSION_CHECK_CONSOLE_ERROR, and
CapturedBrowserError via a src-local fixture location or a small local test
wrapper. Keep the fixture reachable from frontend/src without using ../../../
traversal.

---

Nitpick comments:
In @.env.precommit:
- Line 37: The dotenv key order in the environment config is out of alphabetical
sequence, and `XERO_READONLY` should be placed before `XERO_REDIRECT_URI` to
satisfy dotenv-linter. Update the ordering in the env file so the `XERO_*`
entries remain alphabetically sorted, using the existing variable names as the
reference for placement.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3c5ae51f-aa15-4882-8ed9-5de83e941469

📥 Commits

Reviewing files that changed from the base of the PR and between 1553dd8 and 86e9fdb.

📒 Files selected for processing (6)
  • .env.precommit
  • .vscode/tasks.json
  • docs/restore-prod-to-hotfix.md
  • frontend/src/utils/__tests__/authConsoleErrors.test.ts
  • frontend/tests/fixtures/auth-console-errors.ts
  • frontend/tests/fixtures/auth.ts
✅ Files skipped from review due to trivial changes (2)
  • .vscode/tasks.json
  • docs/restore-prod-to-hotfix.md

Comment thread frontend/src/utils/__tests__/authConsoleErrors.test.ts Outdated
corrin and others added 7 commits July 7, 2026 17:39
Typing a description into the phantom cost-line row infers kind 'adjust'
and recomputed unit_rev immediately, but the row has no unit_cost yet, so
apply() threw an uncaught 'Missing cost line unit_cost' pageerror on every
free-text entry. Harmless to users (unit_rev derives on unit_cost entry)
but the new E2E console-error gate rightly fails the suite on it.

Skip the premature derivation when unit_cost is absent; derivation still
happens in the onUpdate:unit_cost handler once the user enters a value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
get_aligned_date_range snaps to Monday/Sunday week boundaries and clamps
the start to CompanyDefaults.xero_payroll_start_date. The E2E payroll
spec used to hardcode the snapped date, which only held where the clamp
field was unset; the alignment oracle now lives here with controlled
CompanyDefaults instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hardcoded 2025-03-31 expectation only holds when
CompanyDefaults.xero_payroll_start_date is unset; instances with Xero
payroll history clamp the range start to it (correct behavior, verified
by apps/accounting/tests/test_payroll_reconciliation_service.py). The
E2E test keeps its real job: range in via the UI, report renders real
data over live HTTP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hotfix in 9b49a99 added read-only Client Phone / Contact Phone
fields to the Job Settings tab on the false premise that the tab used
to show a phone; the reference production UI never did. Revert the
added template blocks and their supporting frontend state, then remove
every backend field/annotation/serializer key that existed only to
feed them (JobSerializer.client_phone, JobHeaderResponseSerializer
.client_phone, AnnotatedCharField, the job/contact-fetch
.annotate() calls, JobContactResponseSerializer.phone, and the now-dead
ClientContact.primary_phone_value() helper), per ADR 0017. Client
list/search phone and the CRM/workshop-PDF phone paths are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review finding on 549523c: deleting test_job_contact_view.py wholesale
left JobContactRestView.get/.put and ClientRestService.get_job_contact
with no end-to-end coverage (routing, permissions, response-serializer
validation). Restore the file trimmed of phone assertions and payload
keys: GET returns the job's contact, PUT reassigns and echoes the new
contact, and an unrecognized field in the PUT body (the removed phone
key) is ignored rather than written or echoed back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cost-entry: drop the explicit 15s timeouts on the five mutation
waitForResponse helpers so they inherit the 30s actionTimeout; one
response exceeding 15s under prod-size load was the only failure mode.

Kanban search-then-drag: read the shared job's number from its job page
instead of asserting the card is visible on the unfiltered board first —
columns cap at 200 jobs and sibling specs demote the shared job, so on a
prod-size board the card can be legitimately absent. The post-search and
post-drag assertions (the actual regression guard) are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
corrin and others added 6 commits July 7, 2026 21:30
Task 2 of the ClientContactMethod phone-restore work (counterpart to
Task 1's job-settings revert): the Edit Client modal lost phone editing
when the 2026-07-06 migration moved phone off Client onto
ClientContactMethod. Restore it on the new model:

- ClientUpdateSerializer/ClientDetailResponseSerializer gain `phone`;
  update_client() applies non-blank phone via set_primary_phone() inside
  the same local-update transaction (a conflict rolls back the whole
  update, matching create's behaviour), and refetches with the
  primary_phone_annotation afterwards -- which also fixes update_client's
  return value missing the with_invoice_summary() aggregates
  _format_client_detail requires (previously an untested, silently-broken
  path). ValueError now explicitly passes through update_client's
  exception handling so validation failures (incl. phone ownership
  conflicts) surface as 400s instead of being swallowed into 500s by
  persist_and_raise.
- get_client_by_id annotates its queryset with primary_phone_annotation
  so client detail reads carry phone too.
- Regenerated schema.yml/api.ts for the new phone fields.
- CreateClientModal.vue: phone input now always shown (create and edit),
  sent on update, and populated/read back through the new contract --
  template now matches the old-prod reference exactly; JobSettingsTab.vue
  regains the two lines feeding the modal's clientData.phone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ename

Self-review catch on 00b2335: the previous commit renamed
_ClientSummaryAnnotations to _ClientPhoneAnnotations but left one
docstring pointing at the old name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The revert's test rewrite dropped the only test exercising the header
fetch for a job with client=None; the serializer's nullable client and
contact fields were left unguarded. Shell jobs exist in production.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
schema.yml gained error_id in 1b1afad but the generated zod client was
not regenerated, so the field would be stripped from parsed error bodies
(ADR 0021 makes the generated client the only frontend API surface).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
apps/client/serializers.py (2)

88-117: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Wrap ClientContactSerializer.create()/update() in a transaction. set_primary_phone() can raise serializers.ValidationError after the contact row has already been written, and there’s no request-level ATOMIC_REQUESTS or local transaction.atomic() here. A conflicting phone can leave a persisted contact or partial field update behind.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/client/serializers.py` around lines 88 - 117, Wrap
ClientContactSerializer.create() and ClientContactSerializer.update() in a
transaction so the contact write and set_primary_phone() behave atomically. The
issue is that _apply_phone() can raise serializers.ValidationError after
super().create() or super().update() has already persisted the ClientContact,
leaving partial data behind. Fix this by using transaction.atomic() around the
create/update flow (or around the super() call plus _apply_phone()) and keep the
error handling in _apply_phone() unchanged.

17-61: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject non-digit phone input before calling set_primary_phone ClientContactMethod.normalize_phone() returns "" for strings with no digits, and save() raises ValueError for that case. _apply_phone() only maps DjangoValidationError, so inputs like "abc" will surface as a 500 instead of a validation error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/client/serializers.py` around lines 17 - 61, Non-digit phone values can
still reach set_primary_phone and fail later with a ValueError because
ClientContactMethod.normalize_phone() returns an empty string. Update the
serializer path that calls set_primary_phone, especially _apply_phone, to
validate the incoming phone value has digits before delegating and raise a
DjangoValidationError instead of letting the save() error escape. Use
set_primary_phone and ClientContactMethod.normalize_phone as the key touchpoints
when adding this early rejection.
apps/client/services/client_rest_service.py (1)

297-401: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Wrap update_client in the same error-persistence guard as the sibling methods. Unexpected DB failures and phone-ownership ValueErrors can escape without being written to AppError, breaking the class’s error-handling pattern.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/client/services/client_rest_service.py` around lines 297 - 401, Wrap
ClientRestService.update_client in the same AppError persistence guard used by
the sibling update methods so unexpected DB exceptions and phone ownership
ValueError paths are captured instead of escaping. Use the existing
error-handling pattern around the main body of update_client, including the
local update branch and the _update_client_in_xero call, and make sure any
caught exception is written to AppError before re-raising or returning.
🧹 Nitpick comments (2)
frontend/tests/kanban/kanban-drag-vanishing.spec.ts (1)

206-207: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Avoid networkidle — known Playwright anti-pattern for flaky waits.

Playwright's docs explicitly mark networkidle as discouraged: "'networkidle' - DISCOURAGED wait until there are no network connections for at least 500 ms. Don't use this method for testing, rely on web assertions to assess readiness instead." Kanban boards commonly have background polling/websocket updates, so this wait could hang or add unpredictable latency — the opposite of what this flakiness-fix PR intends.

The subsequent expect(getVisibleJobCard(...)).toBeVisible(...) already provides a proper web-first synchronization point; the networkidle wait looks unnecessary.

♻️ Suggested fix
     await page.goto('/kanban')
-    await page.waitForLoadState('networkidle')
+    await expect(page.getByPlaceholder('Search jobs...')).toBeVisible()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/tests/kanban/kanban-drag-vanishing.spec.ts` around lines 206 - 207,
The Kanban Playwright test is using the discouraged
page.waitForLoadState('networkidle') after page.goto('/kanban'), which can be
flaky or hang when the board has ongoing background network activity. Remove
that wait from the kanban-drag-vanishing spec and rely on the existing web
assertion with getVisibleJobCard(...) / expect(...).toBeVisible() as the
readiness signal instead.
apps/client/tests/test_contact_methods.py (1)

397-533: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Solid coverage, but missing a create-time conflict/rollback test.

This class thoroughly covers list/create/update/promote/blank/conflict scenarios for contact phone, but (unlike ClientCreatePhoneTests.test_create_with_conflicting_phone_rolls_back_client for Client) there's no test asserting that a create request with a conflicting phone leaves no orphan ClientContact behind. See the related atomicity concern raised on apps/client/serializers.py (ClientContactSerializer.create()/_apply_phone()); a test here would catch that class of bug once the underlying behavior is confirmed/fixed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/client/tests/test_contact_methods.py` around lines 397 - 533, Add a
create-time rollback coverage test in ClientContactApiPhoneTests to mirror the
existing client phone atomicity case: create a second Client with a conflicting
phone, POST to ClientContactApiPhoneTests.URL with that phone, and assert the
response is 400 and no ClientContact or ClientContactMethod was created for the
attempted contact. Use the existing _contact helper and
ClientContactSerializer.create()/ _apply_phone behavior as the target area to
verify the create path remains atomic when a phone conflict occurs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/accounting/tests/test_payroll_reconciliation_service.py`:
- Line 44: The comment in test_payroll_reconciliation_service.py uses an
ambiguous en dash character that Ruff flags, so replace the `–` in the date
range text with a regular hyphen for consistent ASCII punctuation. Update the
inline comment near the payroll reconciliation test to use standard hyphen-minus
characters only, keeping the meaning the same.

---

Outside diff comments:
In `@apps/client/serializers.py`:
- Around line 88-117: Wrap ClientContactSerializer.create() and
ClientContactSerializer.update() in a transaction so the contact write and
set_primary_phone() behave atomically. The issue is that _apply_phone() can
raise serializers.ValidationError after super().create() or super().update() has
already persisted the ClientContact, leaving partial data behind. Fix this by
using transaction.atomic() around the create/update flow (or around the super()
call plus _apply_phone()) and keep the error handling in _apply_phone()
unchanged.
- Around line 17-61: Non-digit phone values can still reach set_primary_phone
and fail later with a ValueError because ClientContactMethod.normalize_phone()
returns an empty string. Update the serializer path that calls
set_primary_phone, especially _apply_phone, to validate the incoming phone value
has digits before delegating and raise a DjangoValidationError instead of
letting the save() error escape. Use set_primary_phone and
ClientContactMethod.normalize_phone as the key touchpoints when adding this
early rejection.

In `@apps/client/services/client_rest_service.py`:
- Around line 297-401: Wrap ClientRestService.update_client in the same AppError
persistence guard used by the sibling update methods so unexpected DB exceptions
and phone ownership ValueError paths are captured instead of escaping. Use the
existing error-handling pattern around the main body of update_client, including
the local update branch and the _update_client_in_xero call, and make sure any
caught exception is written to AppError before re-raising or returning.

---

Nitpick comments:
In `@apps/client/tests/test_contact_methods.py`:
- Around line 397-533: Add a create-time rollback coverage test in
ClientContactApiPhoneTests to mirror the existing client phone atomicity case:
create a second Client with a conflicting phone, POST to
ClientContactApiPhoneTests.URL with that phone, and assert the response is 400
and no ClientContact or ClientContactMethod was created for the attempted
contact. Use the existing _contact helper and ClientContactSerializer.create()/
_apply_phone behavior as the target area to verify the create path remains
atomic when a phone conflict occurs.

In `@frontend/tests/kanban/kanban-drag-vanishing.spec.ts`:
- Around line 206-207: The Kanban Playwright test is using the discouraged
page.waitForLoadState('networkidle') after page.goto('/kanban'), which can be
flaky or hang when the board has ongoing background network activity. Remove
that wait from the kanban-drag-vanishing spec and rely on the existing web
assertion with getVisibleJobCard(...) / expect(...).toBeVisible() as the
readiness signal instead.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fa4b9d91-e2dd-4849-bf50-d89949e95cf8

📥 Commits

Reviewing files that changed from the base of the PR and between 86e9fdb and 933ef55.

⛔ Files ignored due to path filters (1)
  • frontend/src/api/generated/api.ts is excluded by !**/generated/**
📒 Files selected for processing (18)
  • apps/accounting/tests/test_payroll_reconciliation_service.py
  • apps/client/models.py
  • apps/client/serializers.py
  • apps/client/services/__init__.py
  • apps/client/services/client_rest_service.py
  • apps/client/tests/test_contact_methods.py
  • apps/client/tests/test_job_contact_view.py
  • apps/job/tests/test_job_header_view.py
  • docs/restore-prod-to-hotfix.md
  • frontend/schema.yml
  • frontend/src/components/CreateClientModal.vue
  • frontend/src/components/__tests__/CreateClientModal.test.ts
  • frontend/src/components/job/JobSettingsTab.vue
  • frontend/src/components/shared/SmartCostLinesTable.vue
  • frontend/src/components/shared/__tests__/SmartCostLinesTable.kindInference.test.ts
  • frontend/tests/job/job-cost-entry-data.spec.ts
  • frontend/tests/kanban/kanban-drag-vanishing.spec.ts
  • frontend/tests/reports/payroll-reconciliation.spec.ts
💤 Files with no reviewable changes (1)
  • apps/client/models.py
✅ Files skipped from review due to trivial changes (1)
  • docs/restore-prod-to-hotfix.md

Comment thread apps/accounting/tests/test_payroll_reconciliation_service.py Outdated
@corrin
corrin merged commit 1b30134 into main Jul 7, 2026
9 checks passed
@corrin
corrin deleted the KAN-281-Restore-phone-number-display-on-the-job-settings-tab branch July 7, 2026 18:56
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