Skip to content

KAN-281: Restore phone-number display on the job-settings tab - #437

Merged
corrin merged 32 commits into
productionfrom
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#437
corrin merged 32 commits into
productionfrom
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

The 2026-07-06 release moved phone numbers into ClientContactMethod; the job-settings tab's Client Information card was never re-wired and lost the phone it used to show. Data was always intact — this is a display-only fix, no migrations, no writes.

Changes

  • Primary-phone Subquery annotationClientContactMethod.primary_phone_annotation(owner=..., outer_ref=...) is the single source of the primary-phone ordering rule (-is_primary, label, value). All Job-fetching call sites annotate client_phone/contact_phone (header view, get_job_for_edit, get_job_summary, get_job_contact, update_job_contact); the serializers read the annotation via AnnotatedCharField and crash loudly if a call site forgets to annotate. The original per-object primary_phone_value() approach tripped the nplusone guard (dev raises NPlusOneError; prod would silently run extra queries per row) — the two pre-existing hand-written Subquery duplicates (workshop_pdf_service, supplier_search_service) are migrated to the shared helper in the same PR (ADR 0017).
  • Job-contact response serializer no longer drops phoneJobContactRestView round-trips payloads through Serializer(data=...).is_valid() before returning .data, and DRF silently drops read_only=True fields from data-bound validation. Declaring phone read-only therefore removed it from the GET/PUT responses entirely: the generated Zod client (which requires phone: string) rejected the response, the tab showed "Failed to load contact information", and the Contact Person phone stayed blank. Fixed by splitting JobContactBaseSerializer: the response serializer declares a writable-for-validation phone, the update serializer has no phone field at all, so the write path still can't accept one.
  • Contract breaches surfaceJobSettingsTab.vue reports a failed contact load with console.error + toast instead of debugLog (frontend rule 30: no silent failures).
  • Regenerated OpenAPI schema + frontend client (phone is no longer marked readOnly).
  • ADR 0029: servers deploy the production branch; main is integration. deploy.sh/instance.sh now default to origin/production; runbook + CLAUDE.md updated.

Verification

  • apps/client/tests/test_job_contact_view.py (new): view-level GET/PUT tests pinning that the response carries the contact's primary phone — this is the test that would have caught the read-only drop.
  • apps/job/tests/test_job_header_view.py (new): phone ordering, empty-phone and no-client cases, exact query counts (N+1 guard), ETag round-trip, and the crash-loudly contract for unannotated jobs.
  • Existing contact-method, workshop-PDF, and supplier-search suites pass unchanged (helper migration is behavior-preserving).
  • Live verification on the MSM instance: job-settings tab loads with no error toast, Client Information and Contact Person both show their phone numbers, and an autosave edit round-trips with phones intact.

Status

Pending commit (working tree, not yet pushed): the JobContactBaseSerializer split, the schema/gen:api regeneration for the readOnly removal, the JobSettingsTab.vue toast, and apps/client/tests/test_job_contact_view.py. Delete this section once they land. Full task breakdown with per-task tests: docs/plans/can-you-check-the-lazy-hickey.md and the KAN-281 ticket.

Same branch is also PR'd to main (#438) so the fix and workflow change exist on both lines.

🤖 Generated with Claude Code

corrin and others added 4 commits July 6, 2026 20:39
chore: batch Dependabot dependency updates
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

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: 0424daac-5e06-416d-b20e-8e28992086b8

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 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.

corrin and others added 24 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>
…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>
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>
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>
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>
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>
corrin and others added 4 commits July 7, 2026 21:46
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>
@corrin
corrin merged commit 584ff0d into production Jul 7, 2026
8 of 9 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