fix(jobs)+feat(crm): job create/edit failures, restore dropped person edit/delete, and person archive - #465
Conversation
create.vue's submit catch swallowed every create-or-navigate failure by stashing form state to localStorage and calling window.location.reload(). The reload aborted the in-flight navigation and dropped the user on a blank create page with no error surfaced — a real failure presenting as an E2E timeout (see crm/phone-call-job-link flake). The four diagnostic statements after the reload were dead code. Split submit into two phases: - create: on failure, toast extractErrorMessage(error) and let the user retry (no reload, no permanent-disable state). - navigate: the job already exists, so a router.push rejection is reported as "created but the page did not open", never as a creation failure. Drop the localStorage round-trip and hasCreationError flag (the flag permanently disabled the submit button with no reload to reset it). onMounted no longer restores from localStorage — without a reload the in-memory form state already persists, so it just resets to a clean form. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Changing a job's company cleared the linked person and the autosave delta included person_name, which the backend correctly rejects with 400 "Unsupported field 'person_name' in delta payload" — person_name is derived from job.person.name (read_only) and has no writable target. The rejected save meant waitForAutosave never resolved, surfacing as a 120s E2E timeout (edit-job-settings › change company) rather than a visible error. person_name legitimately blanks the displayed person via the optimistic patch. The bug is only that the two wire-payload builders forwarded it to the delta. Fix each at source: - useJobHeaderAutosave saveAdapter: drop person_name from allowedHeaderKeys and the company-change re-add; only person_id (the writable field) clears the person. Remove the now-dead person_name before-value branch. - JobSettingsTab saveAdapter: skip display-only keys when building the partial payload. The optimistic display path is untouched, and the backend's strict rejection remains the loud safety net for any future regression. Adds a regression test asserting a company-change save omits person_name from the wire. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThe changes exclude ChangesJob header autosave filtering
Job creation submission flow
People request ordering
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
frontend/src/components/job/JobSettingsTab.vue (1)
1286-1289: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueAvoid allocating a
Seton every save.Since
person_nameis currently the only display-only field, you can avoid allocating aSeton every save by checking the key directly. If you prefer to keep theSetfor future extensibility, consider defining it outside ofsaveAdapterso it is only allocated once.♻️ Proposed refactor
- const DISPLAY_ONLY_JOB_FIELDS = new Set(['person_name']) const partialPayload: Record<string, unknown> = {} for (const [k, v] of Object.entries(patch)) { - if (DISPLAY_ONLY_JOB_FIELDS.has(k)) continue + if (k === 'person_name') continue partialPayload[k] = normalise(k, v) }🤖 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 1286 - 1289, Update saveAdapter’s display-only field filtering to avoid creating DISPLAY_ONLY_JOB_FIELDS on every save: directly skip the person_name key, or move the constant Set outside saveAdapter so it is allocated once while preserving the existing filtering behavior.
🤖 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/pages/jobs/create.vue`:
- Around line 481-501: Prevent duplicate submissions after jobService.createJob
succeeds by introducing or reusing a creation-success guard in the create-job
submission flow. Update the navigation-failure handling around router.push so
resetting isSubmitting does not make canSubmit true or allow the untouched form
to be submitted again; preserve the existing error toast and navigation behavior
while blocking retries regardless of navigation outcome.
- Around line 488-501: Update the navigation flow around router.push in the job
creation handler to inspect its resolved result with isNavigationFailure().
Treat resolved navigation failures like caught errors by logging the failure,
showing the existing toast, and resetting isSubmitting; retain the success path
for successful navigation and the existing catch handling for unexpected errors.
---
Nitpick comments:
In `@frontend/src/components/job/JobSettingsTab.vue`:
- Around line 1286-1289: Update saveAdapter’s display-only field filtering to
avoid creating DISPLAY_ONLY_JOB_FIELDS on every save: directly skip the
person_name key, or move the constant Set outside saveAdapter so it is allocated
once while preserving the existing filtering behavior.
🪄 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: 0d8a8496-bee1-484f-8f80-087513cebf03
📒 Files selected for processing (4)
frontend/src/components/job/JobSettingsTab.vuefrontend/src/components/job/__tests__/JobSettingsTab.companyChange.test.tsfrontend/src/composables/useJobHeaderAutosave.tsfrontend/src/pages/jobs/create.vue
💤 Files with no reviewable changes (1)
- frontend/src/composables/useJobHeaderAutosave.ts
Once jobService.createJob returns 201 the job exists. If the subsequent router.push rejects, the catch reset isSubmitting=false; with the form untouched canSubmit stayed true, so the submit button re-enabled and a second click created a duplicate job. Latch a jobCreated guard on creation success and add it to the submit button's disable binding, so re-submit is blocked regardless of navigation outcome. The create-failure resets are untouched (retry there is correct); the guard latches on success only, so it never blocks a legitimate retry. Addresses CodeRabbit review on PR #465. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
router.push resolves with a NavigationFailure (aborted/cancelled/duplicated) rather than rejecting, so the catch-only handling missed those cases: the flow fell through with isSubmitting stuck true and no error toast — a navigation failure masquerading as a hung spinner. Inspect the resolved result with isNavigationFailure() and route it, alongside the existing catch for thrown errors, through one reportNavFailure handler (log, toast, reset isSubmitting). Genuine success still falls through to unmount. Addresses CodeRabbit review on PR #465. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ange
JobSettingsTab's load watcher called GET /api/companies/jobs/{id}/person/
unconditionally, but consumed only .id/.name — both already present on the
JobHeaderResponse it was reacting to (person_id/person_name). When a company
change clears the person, that endpoint 404s (it raises when job.person is
None); Chromium auto-logs the failed load, tripping the E2E rule-30 guard even
though the app catches the 404. The fetch was vestigial (predates the header
carrying person fields).
Read person_id/person_name straight from the header instead, matching the
save/retry paths. No request -> no 404 -> the read-side 404 fallback goes too.
Also completes the useJobsStore mock (patchHeader) in the labourRate and urgent
JobSettingsTab tests; the person block previously swallowed the missing-mock
error inside its try/catch, which this change removes.
Retiring the endpoint itself is a tracked follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
loadPeople() applied every response unconditionally, so concurrent calls raced: creating a person triggers an unfiltered refetch, and searching triggers a filtered one. When the slower unfiltered response resolved last it overwrote the filtered search result, hiding the just-created person — a timing-dependent flake (E2E crm/people › link lifecycle). Add a monotonic request token so only the most recently started loadPeople applies its response; stale responses are dropped regardless of resolution order. Adds a regression test resolving the two calls out of order. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
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 (1)
frontend/src/components/job/JobSettingsTab.vue (1)
1255-1260: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winReconcile
person_nameafter removing it from the payload.Filtering makes the later
touchedKeys.includes('person_name')branches unreachable. Person clears and company-change responses can therefore leaveserverBaselineandjobsStorewith the old name.When
person_idorcompany_idis touched, reconcile both person fields fromserverJobDetailand include both inheaderPatch; when clearing without response data, explicitly clear the name too.🤖 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 1255 - 1260, Update the patch reconciliation flow around DISPLAY_ONLY_JOB_FIELDS and touchedKeys so person_id or company_id changes derive both person_id and person_name from serverJobDetail and add both fields to headerPatch. When person_id is cleared without response data, explicitly clear person_name as well, ensuring serverBaseline and jobsStore receive the reconciled values despite person_name being excluded from partialPayload.
🤖 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/JobSettingsTab.vue`:
- Around line 848-852: In the hydration logic around localJobData and
serverBaseline, replace direct property mutations with immutable object
replacements while preserving the existing person_id and person_name fallback
values; update each ref’s value by spreading its current object and overriding
those fields, leaving personDisplayValue unchanged.
---
Outside diff comments:
In `@frontend/src/components/job/JobSettingsTab.vue`:
- Around line 1255-1260: Update the patch reconciliation flow around
DISPLAY_ONLY_JOB_FIELDS and touchedKeys so person_id or company_id changes
derive both person_id and person_name from serverJobDetail and add both fields
to headerPatch. When person_id is cleared without response data, explicitly
clear person_name as well, ensuring serverBaseline and jobsStore receive the
reconciled values despite person_name being excluded from partialPayload.
🪄 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: 98590c6d-3bcc-462b-8be2-0cd4a62a63ba
📒 Files selected for processing (6)
frontend/src/components/job/JobSettingsTab.vuefrontend/src/components/job/__tests__/JobSettingsTab.labourRate.test.tsfrontend/src/components/job/__tests__/JobSettingsTab.urgent.test.tsfrontend/src/pages/crm/people/(index).vuefrontend/src/pages/crm/people/__tests__/people-directory.test.tsfrontend/src/pages/jobs/create.vue
🚧 Files skipped from review as they are similar to previous changes (1)
- frontend/src/pages/jobs/create.vue
Replace direct property mutation of localJobData/serverBaseline with spread replacements, per the frontend immutable-update convention (rules 8-9). personDisplayValue stays a direct primitive assignment. Addresses CodeRabbit review on PR #465. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…odal The first-class people cutover replaced ContactSelectionModal with PersonSelectionModal and silently dropped the per-person Edit (pencil) and Delete (trash) hover actions, the delete-confirmation overlay, the edit-mode form, and the composable's edit/delete methods — a regression vs production. Restore them, adapting production's copy (contact->person, client->company): - usePersonManagement: startEditPerson/cancelEdit/updatePerson (change-detected across the identity, company-link, and phone contact-method endpoints) plus deletePerson (unlink, surfacing the backend 400 phone-conflict detail). - PersonSelectionModal: Edit/Delete hover buttons, delete-confirm overlay, amber editing state, edit-mode title/label; new props/emits + automation ids. - PersonSelector: wire emits, prod-aligned toasts. - person_service.remove_company_link: archive the Person (is_active=False) when its last active company link is removed, so no searchable orphans remain. Also institutionalise the gate that would have caught this: a "No feature removal" rule in CLAUDE.md requiring a Feature Parity Inventory before any rewrite/cutover, plus a docs/plans/_template.md scaffold. Tests: backend sole-company-archive case; modal edit/delete unit tests; e2e edit + delete flows on the job-settings person selector. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…estore The archive-on-last-link cascade in 913cd92 broke the existing person link-lifecycle. Once a sole-company person's link was removed the Person was archived (is_active=False), but every person/link endpoint does get_object_or_404(Person, is_active=True) — so the person could no longer be viewed or their link restored (people.spec.ts restore step 404'd). The new model deliberately supports active, company-less people (test_directory_includes_person_without_company), so link removal must leave the person active and restorable. Remove the cascade; the delete button unlinks only, which is exact production parity. Orphan cleanup, if wanted, belongs in a separate explicit archive action, not tied to link removal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Archiving driven by links (last-link removal archives; restore/add un-archives), archived people stay viewable so the restore-link lifecycle keeps working, plus an explicit PersonDetail archive action and a directory show-archived filter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nine TDD tasks: backend archive/un-archive on link removal/add, relaxed is_active gates, explicit archive endpoint, directory include_archived filter, client regen, directory + PersonDetail UI, and an e2e archive→restore spec. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ract PersonSummary/PersonDetail are response-only serializers and always emit is_active, but ModelSerializer inferred required=False from the model's BooleanField(default=True), so the schema marked it optional and the generated zod rendered `is_active: z.boolean().optional()`. The type lied about the contract (ADR 0028), and `!person.is_active` on an absent field would badge every row as archived. Declare is_active explicitly on PersonSummarySerializer so the schema lists it as required; PersonDetailSerializer inherits it. The generated client now has `is_active: z.boolean()`. Drops the `=== false` workaround in the directory badge, and fixes the directory test fixture that omitted is_active (not a valid PersonSummary). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an "Archived" badge and Archive-person action to the person detail page, and makes restoreLink refresh the full person (not just company links) so the badge clears after un-archiving via link restore.
Fixes four regressions found in final review of the person-archive feature: - PersonContactMethodsView.get/post 404'd on archived people, which broke the PersonDetail page's Promise.all load and hid the restore-link button. Drop the is_active filter to match PersonDetailView. - classify_phone_ownership skipped archived people entirely, so a phone conflict with an archived owner surfaced as an unrecoverable "belongs to <empty>" company conflict instead of offering to restore the link. - removeLink refetched only companyLinks, leaving person.value stale so the Archived badge and Archive button never updated after removing the last link. Mirror restoreLink and reload the whole person. - archivePerson used an ad hoc error message instead of extractErrorMessage, inconsistent with its sibling handlers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…wnership The draft put contact-methods gating and phone-ownership for archived people out of scope. Both were load-bearing and are now fixed (d4e4d78): an archived person was unreachable (PersonDetail loads contact-methods in the same Promise.all, so its 404 collapsed the page and hid the restore-link button), and classify_phone_ownership skipping archived people regressed the "Restore company link" recovery path into an unrecoverable dead end. Record what remains genuinely out of scope: reuse of an archived person's number by a different person. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four bodies of work on one branch. They're causally linked rather than merely bundled: fixing the E2E-masked job failures (1–2) meant running the suite, which surfaced a dropped feature (3), and restoring that properly turned into the person archive feature (4).
Suggested review order. §1–2 are small, self-contained, and already described below. §3 is a regression fix. §4 is the substantial one — start with the design spec, then the implementation plan.
1. Job create —
create.vueswallowed create/navigate errorshandleSubmit's catch stashed form state tolocalStorageand calledwindow.location.reload(), aborting the in-flight navigation and dropping the user on a blank create page with no error surfaced (the four statements after the reload were dead code). Split submit into two honest phases:toast.error(extractErrorMessage(error)), user can retry (no reload, no permanent-disable flag).router.pushrejection reports "Job #N created but the page did not open", never as a creation failure.Drops the
localStorageround-trip and thehasCreationErrorflag (which permanently disabled the submit button with no reload left to reset it).2. Job change-company — display-only
person_nameleaked into the deltaChanging a job's company cleared the linked person and the autosave delta included
person_name, which the backend correctly rejects with 400Unsupported field 'person_name' in delta payload— it's derived fromjob.person.name(read_only) and has no writable target. The rejected save meantwaitForAutosavenever resolved → 120s timeout inedit-job-settings › change company.person_namelegitimately blanks the displayed person via the optimistic patch; only the two wire-payload builders wrongly forwarded it. Fixed each at source:useJobHeaderAutosavesaveAdapter — removed fromallowedHeaderKeysand the company-change re-add; onlyperson_id(the writable field) clears the person.JobSettingsTabsaveAdapter — skips display-only keys when building the partial payload.The backend's strict rejection (ADR 0004/0015/0017) remains the loud safety net; a regression test asserts a company-change save omits
person_namefrom the wire.3. Restore person Edit/Delete dropped by the people cutover (regression)
The "first-class people" cutover (
5bf012f3) replacedContactSelectionModal.vuewithPersonSelectionModal.vueand silently dropped two of the three per-person hover actions. Production offers Select · Edit (pencil) · Delete (trash) plus a delete-confirmation overlay; this branch's modal offered Select only. Also lost: the edit-mode form, the primary-person warning, and the Update/Delete toasts.All restored, with user-facing copy taken verbatim from production (only
contact→person,client→company; no invented phrasing):usePersonManagement—startEditPerson/cancelEdit/updatePerson/deletePerson.updatePersonis change-detected and fans out across three endpoints, because the new model splits what production wrote in one call: identity (people_partial_update), company-link fields (people_company_links_update), and phone — now aContactMethodrow rather than a scalar (list → patch/create).PersonSelectionModal— Edit/Delete buttons, delete-confirm overlay, amber editing state, edit-mode title/submit label, stable automation ids.PersonSelector— wired emits, prod-aligned toasts.All six production-editable fields (name, email, position, notes, is_primary, phone) are editable again — a full Feature Parity Inventory is in the plan doc.
4. Person archive (new feature)
Lets users retire departed people so they drop out of everyday views, reversibly, without hard-deleting.
Person.is_activealready existed as a soft-delete flag but no HTTP path could reach it.The rule (
is_activeis the single source of truth — never computed from link counts):Backend —
remove_company_linkcascade; un-archive input_company_link; theis_active=Truegates relaxed on the person/link/contact-method lookups so archived people stay viewable and restorable; newPOST /api/people/{id}/archive/;include_archivedquery on the directory;is_activeexposed onPersonSummary.Frontend — "Show archived" filter + Archived badge in
/crm/people; Archived badge + "Archive person" button on PersonDetail; new e2epeople-archive.spec.ts(archive → show-archived → restore).For a single-company person, the job-settings Delete button now archives via the shared cascade; for a multi-company person it just unlinks. No change was needed there — it routes through the same service.
5. Guardrail: "No feature removal" (
CLAUDE.md)§3 happened because a rewrite dropped features and nobody checked. A memory-only note wouldn't fire — feature-drop happens exactly when nobody is thinking about the old thing. So this adds a repo-shared rule (loaded by every agent) requiring a Feature Parity Inventory before any component/page/model/endpoint rewrite: enumerate every capability the old version exposed, with a keep/drop/defer decision each; default keep, dropping needs sign-off. Plus
docs/plans/_template.mdmaking that inventory a required plan section.Worth a reviewer's attention
The archive cascade was got wrong once, on purpose-preserved history. The first attempt (in
913cd927) flippedis_activebut left every endpoint gating onis_active=True— archived people 404'd and their links couldn't be restored, breakingpeople.spec.ts. Reverted in00b2bce8, then redone properly acrosscff80804…e31b662f. The revert is kept rather than squashed because the reason matters.is_activewas a lying type.ModelSerializerinferredrequired=Falsefrom the model'sBooleanField(default=True), so the response schema marked it optional and the generated zod producedis_active: z.boolean().optional()— meaning!person.is_activewould badge every row as archived if the field were ever absent. Declared explicitly so the contract says always-present (4db39590, ADR 0028). These are response-only serializers, so it's inert on input.A final whole-branch review caught two Criticals every green test missed (
d4e4d780), because the unit layer mocks the very endpoint that broke:PersonContactMethodsViewwas stillis_active-gated. PersonDetail loads person + contact-methods + links in onePromise.all, so that 404 rejected the whole load → archived person rendered "Failed to load person" with no restore button — the same unreachable state the revert above existed to prevent.classify_phone_ownershipskipped archived people, turning the phone-conflict "Restore company link" recovery path into a dead end that rendered "It belongs to and cannot be assigned to a person." — a regression of pre-branch behaviour.Both contradicted an out-of-scope note in my own spec; the note was a mistake, not a tradeoff, and the spec was corrected (
9601515f).Verification
apps.company143/143 pass (new tests cover archive-on-last-link, un-archive-on-restore, archived-person detail/contact-methods reachability, archived phone-owner recovery, and theinclude_archivedfilter).scripts/check_mypy.shclean (pre-push gate passed).usePersonManagement, people directory and PersonDetail pass;npm run type-checkclean.XERO_READONLY=True:people.spec.ts › manages link lifecycleis the key regression check (it's what the bad cascade broke);people-archive.spec.tsis new. Also still pending from §1–2:edit-job-settings › change companyreturning 2xx and, past themaxFailures=1abort, reachingphone-call-job-link.Known follow-ups (non-blocking, deliberately deferred)
select_for_updateonPersoninremove_company_link/put_company_link— a real but narrow race (concurrent link writes on the same person) that self-heals on the next link write;archive_persondoes lock.loadPerson()on link actions reverts unsaved identity-form edits (pre-existing pattern, inherited).frontend/CLAUDE.mdrule 5 misdescribes the schema regen:npm run update-schemaonly prettifiesschema.yml+ regenerates the client; the real regen isscripts/regen_openapi_schema.sh(run by pre-commit).🤖 Generated with Claude Code
Summary by CodeRabbit