Skip to content

KAN-278: Complete first-class People management - #453

Merged
corrin merged 2 commits into
mainfrom
feat/KAN-278-remaining-work
Jul 14, 2026
Merged

KAN-278: Complete first-class People management#453
corrin merged 2 commits into
mainfrom
feat/KAN-278-remaining-work

Conversation

@corrin

@corrin corrin commented Jul 14, 2026

Copy link
Copy Markdown
Owner

📝 Description

Completes the remaining first-class People management work for KAN-278. DocketWorks now treats companies, people, employment links, and contact methods as distinct records throughout the API and UI, while preventing new duplicate identities at data entry.

🔗 Related Jira Work Item

Jira: KAN-278

🚀 Changes

  • Adds a searchable, paginated People directory and Person detail page.
  • Supports creating a Person with their required initial Company, editing identity/contact methods, and adding, removing, or restoring Company links.
  • Adds People management to Company Detail and migrates job, purchasing, CRM call, and settings callers off the old person-link API.
  • Allows different People at one Company to share an office number.
  • When a phone belongs to a Person at another Company, returns every matching Person and strongly offers linking the existing identity instead of creating a duplicate.
  • Prevents unlinking a Person when that would turn a legitimate shared-company phone into an unexplained cross-company conflict.
  • Removes the old person-link/contact endpoints, regenerates the typed OpenAPI client, and renames remaining CRM telemetry sources from client to company terminology.
  • Serializes primary-link selection and preserves database routing, rematches calls when a contact method stops being a phone, and clears stale duplicate-phone UI state.

✅ Checklist

Vue.js (Composition API)

  • Used Composition API and composables; no heavy template logic added
  • Shared People workflow logic remains in usePersonManagement
  • Communication uses props/emits or existing stores/composables
  • No ad-hoc reactive globals added
  • Routes remain lazy-loaded through file-based routing

Quality & Formatting

  • Prettier/pre-commit gates pass
  • ESLint passes with zero warnings
  • Props/emits retain typed contracts
  • New and updated unit/E2E tests included

Definition of Done

  • Browser JavaScript console checked in UAT
  • Django checks and focused server workflows pass locally
  • Build, type-check, schema, OpenAPI, and generated-route warnings reviewed
  • Frontend typing reviewed; generated API client is used throughout
  • Affected workflow rehearsed against a disposable real-production-data copy
  • People/Company workflow reviewed end-to-end in code and automated tests

Validation

  • Backend Company, CRM, telemetry-migration, job duplicate-phone, and terminology tests pass.
  • manage.py check passes and makemigrations --check --dry-run reports no changes.
  • Authoritative strict mypy gate passes with zero new errors; the historical baseline shrinks by four entries.
  • Push/CI gates pass: generated API/schema checks, typed routes, 326 frontend unit tests, ESLint, Vue type-check, workflow formatting, pre-commit, and the full backend suite.
  • New Playwright specs compile and are discoverable for People create/link management and cross-company duplicate-phone reuse.
  • CodeRabbit correctness findings are addressed with focused regression coverage.

Production-data rehearsal (no production mutation)

The exact deploy sequence was rehearsed on a separate disposable clone of the designated real-production-data hotfix database, with Xero tokens cleared and XERO_READONLY=True. The source copy and production were not modified; the rehearsal database was dropped afterward.

  • Baseline: 4,119 company rows, 1,087 legacy contacts, 4,713 legacy contact methods, 2,233 jobs.
  • Result: 3,892 canonical companies, 967 People total / 965 active, 970 active Company-Person links, and 4,445 contact methods.
  • Integrity: zero active People without a Company; five People with more than one active Company; zero missing Person references on jobs or calls; zero Xero tokens in the rehearsal copy.
  • Rolled-back real-data checks: same-company shared office phone allowed; cross-company duplicate phone returned all existing candidates and blocked duplicate creation atomically; unsafe unlink blocked; safe unlink allowed.
  • A second deploy was idempotent: relabelling reported already complete and Django had no migrations to apply.
  • Post-cleanup report: one explicitly retained Company candidate group, ten explicitly retained Person groups, and zero automatic merges remaining. User-approved and agent-retained decision ledgers remain separate.

UAT

Deploy this branch to the normal hotfix/UAT environment restored from production, with XERO_READONLY=True on the backend and every worker/beat process. Verify /crm/people, Person contact methods and Company links, Company Detail People, the job Person selector, same-company shared office numbers, and cross-company existing-Person linking.

Then run the full Playwright suite in the foreground and allow global teardown to finish. It was not started locally because the configured backend/frontend/ngrok environment is offline and repository safety instructions explicitly prohibit an agent from starting those services.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@corrin, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 27 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4a428b0e-5ed8-406f-97ff-806e14514793

📥 Commits

Reviewing files that changed from the base of the PR and between 5bf012f and 0d6e150.

📒 Files selected for processing (8)
  • apps/company/models.py
  • apps/company/services/person_service.py
  • apps/company/tests/test_person_api.py
  • apps/company/views/person_views.py
  • frontend/src/components/PersonSelector.vue
  • frontend/src/composables/__tests__/usePersonManagement.test.ts
  • frontend/src/composables/usePersonManagement.ts
  • frontend/src/pages/crm/calls.vue
📝 Walkthrough

Walkthrough

This PR introduces a person directory with company relationships, contact-method management, phone ownership classification, new REST endpoints, frontend pages, conflict-handling workflows, generated API schemas, tests, and supporting telemetry and routing updates.

Changes

Person directory and ownership

Layer / File(s) Summary
Person contracts and domain services
apps/company/person_serializers.py, apps/company/services/person_service.py, apps/company/models.py
Adds serializers, directory search, phone ownership classification, person creation, company-link upsert/removal, and active-link ownership enforcement.
Backend person API and routing
apps/company/views/person_views.py, apps/company/urls_people_rest.py, apps/company/urls_rest.py, apps/company/tests/*
Adds person, company-link, contact-method, and phone-ownership endpoints while removing the legacy person-links route.
Frontend API contracts and state wiring
frontend/schema.yml, frontend/src/stores/companyStore.ts, frontend/src/composables/*
Replaces company person-link models with CompanyPerson and adds phone conflict, creation, linking, and caching flows.
Frontend person management UI
frontend/src/components/*, frontend/src/pages/crm/people/*, frontend/src/pages/crm/companies/*
Adds People directory/detail screens and updates selectors, company pages, navigation, and related integrations.
Frontend validation and generated support
frontend/tests/crm/people.spec.ts, frontend/src/pages/crm/people/__tests__/*, frontend/src/typed-router.d.ts, docs/*
Adds unit and end-to-end coverage, route typings, screenshot targets, and updated endpoint documentation.

CRM telemetry and filtering maintenance

Layer / File(s) Summary
CRM filtering and active-link checks
apps/crm/services/phone_call_service.py, apps/crm/views/phone_call_views.py, apps/crm/tests/test_phone_call_service.py
Requires active person-company links for phone assignment and removes legacy client_match rejection.
Telemetry source migration
apps/workflow/migrations/*, apps/workflow/tests/*
Adds reversible CRM telemetry source renames and verifies forward and reverse migrations.
Schema and type-checking support
docketworks/settings.py, docketworks/urls.py, mypy-baseline.txt
Adds enum schema overrides, mounts the people API, and updates the type-checking baseline.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • corrin/docketworks#440: Introduced the person, company-link, and contact-method ownership model extended by this PR.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title is concise, specific, and clearly matches the main People management changes.
Description check ✅ Passed The description follows the template with description, Jira item, changes, checklist, validation, and UAT notes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/KAN-278-remaining-work

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
corrin marked this pull request as ready for review July 14, 2026 02:35
@corrin

corrin commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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: 5

Caution

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

⚠️ Outside diff range comments (1)
frontend/src/pages/crm/calls.vue (1)

351-356: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add an early return to prevent API calls with an empty company ID.

When the selected company is cleared, companyId becomes an empty string. The watcher currently proceeds to call loadCompanyPeople(companyId), which may trigger an API error downstream since the backend expects a valid UUID.

🐛 Proposed fix
 watch(selectedCompanyId, (companyId) => {
   if (!companyId) {
     selectedPersonId.value = ''
+    return
   }
   void loadCompanyPeople(companyId)
 })
🤖 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/pages/crm/calls.vue` around lines 351 - 356, Update the
selectedCompanyId watcher to return immediately after clearing selectedPersonId
when companyId is empty, and only call loadCompanyPeople for a valid company ID.
🧹 Nitpick comments (9)
docketworks/settings.py (1)

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

Use title case for enum labels.

The labels for PhoneOwnershipStatusEnum are lowercase, whereas other enum labels in this file (e.g., ContactMethodTypeEnum, JobStatusEnum) use title case. Consider capitalizing them for consistency in the generated schema documentation.

🎨 Proposed fix
         "PhoneOwnershipStatusEnum": (
-            ("available", "available"),
-            ("people", "people"),
-            ("company", "company"),
-            ("internal", "internal"),
+            ("available", "Available"),
+            ("people", "People"),
+            ("company", "Company"),
+            ("internal", "Internal"),
         ),
🤖 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 `@docketworks/settings.py` around lines 365 - 370, Update the labels in
PhoneOwnershipStatusEnum to title case while preserving their lowercase enum
values: use “Available,” “People,” “Company,” and “Internal” as the display
labels for schema documentation consistency.
frontend/src/pages/crm/people/__tests__/people-directory.test.ts (1)

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

Rename test files to match the component naming convention.

As per coding guidelines, test specs should have filenames matching ComponentName.test.ts. Please consider renaming these test files to use PascalCase.

  • frontend/src/pages/crm/people/__tests__/people-directory.test.ts#L1-L2: Rename this file to PeopleDirectory.test.ts.
  • frontend/src/pages/crm/people/__tests__/person-detail.test.ts#L1-L2: Rename this file to PersonDetailPage.test.ts.
🤖 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/pages/crm/people/__tests__/people-directory.test.ts` around
lines 1 - 2, Rename
frontend/src/pages/crm/people/__tests__/people-directory.test.ts to
PeopleDirectory.test.ts and
frontend/src/pages/crm/people/__tests__/person-detail.test.ts to
PersonDetailPage.test.ts, preserving their test contents and matching the
PascalCase component naming convention.

Source: Coding guidelines

frontend/scripts/capture-screenshots.ts (1)

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

Add waitFor: 'main' to ensure the page has fully loaded.

Consider adding waitFor: 'main' (or an equivalent selector) for the people-list screenshot to prevent capturing a loading state before the data is fetched, ensuring consistency with the companies-list configuration.

🛠️ Proposed fix
   {
     id: 'people-list',
     description: 'People directory with company relationships',
     route: '/crm/people',
+    waitFor: 'main',
   },
🤖 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/scripts/capture-screenshots.ts` around lines 210 - 213, Update the
people-list screenshot configuration to include waitFor: 'main', matching the
companies-list configuration, so capture waits for the main page content before
taking the screenshot.
frontend/src/components/__tests__/PersonSelectionModal.phoneConflict.test.ts (1)

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

Filename deviates from the ComponentName.test.ts convention.

As per coding guidelines, unit test specs should be named ComponentName.test.ts; this file adds a .phoneConflict scope suffix. Splitting a large suite by scenario is reasonable, but consider consolidating into PersonSelectionModal.test.ts (or a describe block within it) if there's no other file already covering this component.

🤖 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/__tests__/PersonSelectionModal.phoneConflict.test.ts`
around lines 1 - 125, Rename the test file to follow the ComponentName.test.ts
convention by using PersonSelectionModal.test.ts, and preserve the existing
phone ownership scenarios under an appropriate describe block. Before renaming,
consolidate with any existing PersonSelectionModal test suite if one already
covers this component, avoiding duplicate test files.

Source: Coding guidelines

apps/company/services/person_service.py (1)

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

Implicit active/inactive scope depends on incidental prefetch state.

company_links() returns active-only links when the caller's Prefetch filtered on is_active=True, and all links otherwise. The two current callers happen to want different scopes, but nothing enforces that pairing explicitly — it's coupled to how each caller built their Prefetch. A future prefetch change on either side would silently change this method's output shape with no visible signal.

♻️ Make the scope explicit
 class PersonDirectoryService:
     `@staticmethod`
-    def company_links(person: Person) -> list[PersonCompanyLinkData]:
-        prefetched = getattr(person, "_prefetched_objects_cache", {}).get(
-            "company_links"
-        )
-        if prefetched is None:
-            links = list(
-                CompanyPersonLink.objects.filter(person=person).select_related(
-                    "company"
-                )
-            )
-        else:
-            links = list(person.company_links.all())
+    def company_links(
+        person: Person, *, include_inactive: bool = True
+    ) -> list[PersonCompanyLinkData]:
+        links = list(
+            CompanyPersonLink.objects.filter(person=person).select_related("company")
+        )
+        if not include_inactive:
+            links = [link for link in links if link.is_active]
🤖 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/company/services/person_service.py` around lines 104 - 134, Make the
active/inactive link scope explicit in the company_links method instead of
deriving it from the person’s prefetched company_links queryset. Add an explicit
scope parameter with a clear default or required value, apply the corresponding
is_active filter when querying links, and update each caller to pass its
intended scope while preserving the existing ordering and response mapping.
apps/company/tests/test_person_api.py (1)

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

Duplicated auth/office-staff setup boilerplate.

Same setUp pattern duplicated in apps/company/tests/test_contact_methods.py.

🤖 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/company/tests/test_person_api.py` around lines 10 - 14, Consolidate the
duplicated authentication and office-staff setup from test_person_api.py and
test_contact_methods.py into a shared test setup helper or base class. Update
both test suites to reuse that shared setup while preserving the existing
super().setUp(), staff update, and force_authenticate behavior.
apps/company/tests/test_contact_methods.py (1)

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

Duplicated auth/office-staff setup boilerplate.

This setUp (toggle is_office_staff, save, force-authenticate) is duplicated verbatim in apps/company/tests/test_person_api.py. Candidate for a shared helper/mixin in BaseAPITestCase.

🤖 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/company/tests/test_contact_methods.py` around lines 418 - 423,
Consolidate the duplicated office-staff authentication setup from this test
class and apps/company/tests/test_person_api.py into a shared helper or mixin on
BaseAPITestCase. Update each affected setUp to reuse that helper while
preserving the existing is_office_staff update and authentication behavior.
frontend/src/pages/crm/people/[id].vue (2)

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

Some interactive buttons lack data-automation-id, unlike sibling save actions in this same file.

The identity/method/link "save" buttons all have stable data-automation-ids, but the contact-method Edit/Remove/Cancel buttons and the company-link Edit/"Company" buttons don't. Adding them keeps this page consistent for future E2E test authors targeting stable selectors instead of DOM position.

Also applies to: 159-176, 201-203, 219-225

🤖 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/pages/crm/people/`[id].vue around lines 112 - 121, Add stable
data-automation-id attributes to the contact-method Edit, Remove, and Cancel
buttons and the company-link Edit and Company buttons in the relevant template
sections, matching the naming convention used by the existing
identity/method/link save buttons. Keep each identifier unique and tied to its
action.

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

Inconsistent error surfacing across handlers in this file.

saveLink/removeLink/restoreLink/removeMethod all use extractErrorMessage(err), but saveMethod falls back to err instanceof Error ? err.message : '...' and saveIdentity shows a fixed string with no error detail at all. Standardizing on extractErrorMessage would give users consistent, more informative error messages (e.g. surfaced validation detail) for every mutation on this page.

♻️ Proposed fix
   } catch {
-    toast.error('Failed to update identity')
+    toast.error(`Identity not updated: ${extractErrorMessage(undefined)}`)
   } finally {
   } catch (err) {
-    toast.error(err instanceof Error ? err.message : 'Failed to save contact method')
+    toast.error(`Contact method not saved: ${extractErrorMessage(err)}`)
   } finally {
🤖 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/pages/crm/people/`[id].vue around lines 304 - 371, Standardize
mutation error handling in saveIdentity and saveMethod by using
extractErrorMessage(err) in their catch blocks, matching saveLink, removeLink,
restoreLink, and removeMethod. Preserve each handler’s existing contextual toast
message while replacing the fixed or Error-only fallback with the extracted
error detail.
🤖 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/company/models.py`:
- Around line 390-393: Update the CompanyPersonLink query in the save flow to
use the save operation’s database alias via using(db), and filter by
company_id=self.company_id instead of dereferencing self.company. Keep the
existing primary, active, and self-exclusion conditions unchanged.

In `@apps/company/services/person_service.py`:
- Around line 259-268: Update the DjangoValidationError handler in the person
service flow to call persist_app_error(exc) before translating the exception.
Re-raise the resulting PersonPhoneConflictError using the project’s
AlreadyLoggedException two-arm dedup pattern, ensuring the caught exception is
persisted exactly once while preserving phone ownership classification.
- Around line 272-312: Update put_company_link and create_person_for_company to
lock the company-wide active CompanyPersonLink rows with select_for_update
before evaluating whether another active link exists and deriving is_primary.
Ensure the lock and primary decision occur inside the existing
transaction.atomic scope, preserving the current behavior for explicit primary
requests and first active links.

In `@apps/company/views/person_views.py`:
- Around line 259-272: Update the patch method’s rematch condition to schedule
phone-call rematching when either the original method type or
updated.method_type is PHONE. Preserve the existing old and new normalized
values in the rematch set, while ensuring non-phone-to-non-phone changes do not
schedule the task.

In `@frontend/src/components/PersonSelector.vue`:
- Around line 184-205: Reset phoneOwnership at the start of the fresh
person-save flow before invoking createNewPerson, including the path that
handles a newly available number. Ensure non-409 create failures reach the
generic error toast and do not retain the stale conflict banner, while
preserving the existing conflict handling behavior.

---

Outside diff comments:
In `@frontend/src/pages/crm/calls.vue`:
- Around line 351-356: Update the selectedCompanyId watcher to return
immediately after clearing selectedPersonId when companyId is empty, and only
call loadCompanyPeople for a valid company ID.

---

Nitpick comments:
In `@apps/company/services/person_service.py`:
- Around line 104-134: Make the active/inactive link scope explicit in the
company_links method instead of deriving it from the person’s prefetched
company_links queryset. Add an explicit scope parameter with a clear default or
required value, apply the corresponding is_active filter when querying links,
and update each caller to pass its intended scope while preserving the existing
ordering and response mapping.

In `@apps/company/tests/test_contact_methods.py`:
- Around line 418-423: Consolidate the duplicated office-staff authentication
setup from this test class and apps/company/tests/test_person_api.py into a
shared helper or mixin on BaseAPITestCase. Update each affected setUp to reuse
that helper while preserving the existing is_office_staff update and
authentication behavior.

In `@apps/company/tests/test_person_api.py`:
- Around line 10-14: Consolidate the duplicated authentication and office-staff
setup from test_person_api.py and test_contact_methods.py into a shared test
setup helper or base class. Update both test suites to reuse that shared setup
while preserving the existing super().setUp(), staff update, and
force_authenticate behavior.

In `@docketworks/settings.py`:
- Around line 365-370: Update the labels in PhoneOwnershipStatusEnum to title
case while preserving their lowercase enum values: use “Available,” “People,”
“Company,” and “Internal” as the display labels for schema documentation
consistency.

In `@frontend/scripts/capture-screenshots.ts`:
- Around line 210-213: Update the people-list screenshot configuration to
include waitFor: 'main', matching the companies-list configuration, so capture
waits for the main page content before taking the screenshot.

In
`@frontend/src/components/__tests__/PersonSelectionModal.phoneConflict.test.ts`:
- Around line 1-125: Rename the test file to follow the ComponentName.test.ts
convention by using PersonSelectionModal.test.ts, and preserve the existing
phone ownership scenarios under an appropriate describe block. Before renaming,
consolidate with any existing PersonSelectionModal test suite if one already
covers this component, avoiding duplicate test files.

In `@frontend/src/pages/crm/people/__tests__/people-directory.test.ts`:
- Around line 1-2: Rename
frontend/src/pages/crm/people/__tests__/people-directory.test.ts to
PeopleDirectory.test.ts and
frontend/src/pages/crm/people/__tests__/person-detail.test.ts to
PersonDetailPage.test.ts, preserving their test contents and matching the
PascalCase component naming convention.

In `@frontend/src/pages/crm/people/`[id].vue:
- Around line 112-121: Add stable data-automation-id attributes to the
contact-method Edit, Remove, and Cancel buttons and the company-link Edit and
Company buttons in the relevant template sections, matching the naming
convention used by the existing identity/method/link save buttons. Keep each
identifier unique and tied to its action.
- Around line 304-371: Standardize mutation error handling in saveIdentity and
saveMethod by using extractErrorMessage(err) in their catch blocks, matching
saveLink, removeLink, restoreLink, and removeMethod. Preserve each handler’s
existing contextual toast message while replacing the fixed or Error-only
fallback with the extracted error detail.
🪄 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: e20ac448-b0fa-4432-a82c-d12eb88ac1d9

📥 Commits

Reviewing files that changed from the base of the PR and between c314822 and 5bf012f.

⛔ Files ignored due to path filters (1)
  • frontend/src/api/generated/api.ts is excluded by !**/generated/**
📒 Files selected for processing (53)
  • apps/company/__init__.py
  • apps/company/models.py
  • apps/company/person_serializers.py
  • apps/company/services/__init__.py
  • apps/company/services/person_service.py
  • apps/company/tests/test_contact_methods.py
  • apps/company/tests/test_person_api.py
  • apps/company/urls_people_rest.py
  • apps/company/urls_rest.py
  • apps/company/views/__init__.py
  • apps/company/views/contact_method_viewset.py
  • apps/company/views/contact_viewset.py
  • apps/company/views/person_views.py
  • apps/crm/services/phone_call_service.py
  • apps/crm/tests/test_phone_call_service.py
  • apps/crm/views/phone_call_views.py
  • apps/workflow/migrations/0009_rename_remaining_crm_telemetry_sources.py
  • apps/workflow/tests/test_search_telemetry_migration.py
  • docketworks/settings.py
  • docketworks/urls.py
  • docs/test_plans/client_contact_management_test_plan.md
  • docs/test_plans/company_people_management_test_plan.md
  • docs/urls/client.md
  • docs/urls/company.md
  • frontend/docs/jobview-etag-guide.md
  • frontend/router-auto-options.ts
  • frontend/schema.yml
  • frontend/scripts/capture-screenshots.ts
  • frontend/src/assets/main.css
  • frontend/src/components/AppNavbar.vue
  • frontend/src/components/PersonSelectionModal.vue
  • frontend/src/components/PersonSelector.vue
  • frontend/src/components/__tests__/CompanyLookup.test.ts
  • frontend/src/components/__tests__/PersonSelectionModal.phoneConflict.test.ts
  • frontend/src/components/crm/PhoneNumberManager.vue
  • frontend/src/components/job/JobSettingsTab.vue
  • frontend/src/composables/__tests__/usePersonManagement.test.ts
  • frontend/src/composables/useCompanyLookup.ts
  • frontend/src/composables/usePersonManagement.ts
  • frontend/src/pages/crm/calls.vue
  • frontend/src/pages/crm/companies/(index).vue
  • frontend/src/pages/crm/companies/[id].vue
  • frontend/src/pages/crm/people/(index).vue
  • frontend/src/pages/crm/people/[id].vue
  • frontend/src/pages/crm/people/__tests__/people-directory.test.ts
  • frontend/src/pages/crm/people/__tests__/person-detail.test.ts
  • frontend/src/pages/jobs/create.vue
  • frontend/src/pages/purchasing/po/[id].vue
  • frontend/src/stores/__tests__/companyStore.test.ts
  • frontend/src/stores/companyStore.ts
  • frontend/src/typed-router.d.ts
  • frontend/tests/crm/people.spec.ts
  • mypy-baseline.txt
💤 Files with no reviewable changes (7)
  • docs/test_plans/client_contact_management_test_plan.md
  • docs/urls/client.md
  • apps/company/views/contact_viewset.py
  • frontend/src/assets/main.css
  • apps/crm/views/phone_call_views.py
  • apps/crm/tests/test_phone_call_service.py
  • mypy-baseline.txt

Comment thread apps/company/models.py
Comment thread apps/company/services/person_service.py Outdated
Comment on lines +259 to +268
if raw_phone:
from apps.company.serializers import set_primary_phone

try:
set_primary_phone(person, raw_phone)
except DjangoValidationError as exc:
ownership = classify_phone_ownership(
company=company, raw_phone=raw_phone
)
raise PersonPhoneConflictError(ownership) from exc

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

Persist the exception before re-raising, per project convention.

DjangoValidationError is caught and translated to PersonPhoneConflictError without going through persist_app_error first. As per coding guidelines: "For Python exception handling, persist every exception once with persist_app_error(exc) and re-raise through the AlreadyLoggedException two-arm dedup pattern."

🛠️ Proposed fix
             try:
                 set_primary_phone(person, raw_phone)
             except DjangoValidationError as exc:
                 ownership = classify_phone_ownership(
                     company=company, raw_phone=raw_phone
                 )
-                raise PersonPhoneConflictError(ownership) from exc
+                conflict_error = PersonPhoneConflictError(ownership)
+                persist_app_error(conflict_error)
+                raise conflict_error from exc
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if raw_phone:
from apps.company.serializers import set_primary_phone
try:
set_primary_phone(person, raw_phone)
except DjangoValidationError as exc:
ownership = classify_phone_ownership(
company=company, raw_phone=raw_phone
)
raise PersonPhoneConflictError(ownership) from exc
if raw_phone:
from apps.company.serializers import set_primary_phone
try:
set_primary_phone(person, raw_phone)
except DjangoValidationError as exc:
ownership = classify_phone_ownership(
company=company, raw_phone=raw_phone
)
conflict_error = PersonPhoneConflictError(ownership)
persist_app_error(conflict_error)
raise conflict_error from exc
🤖 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/company/services/person_service.py` around lines 259 - 268, Update the
DjangoValidationError handler in the person service flow to call
persist_app_error(exc) before translating the exception. Re-raise the resulting
PersonPhoneConflictError using the project’s AlreadyLoggedException two-arm
dedup pattern, ensuring the caught exception is persisted exactly once while
preserving phone ownership classification.

Source: Coding guidelines

Comment on lines +272 to +312
def put_company_link(
*, person: Person, company: Company, data: CompanyLinkData
) -> CompanyPersonLink:
with transaction.atomic():
existing = (
CompanyPersonLink.objects.select_for_update()
.filter(person=person, company=company)
.first()
)
other_active_exists = (
CompanyPersonLink.objects.filter(company=company, is_active=True)
.exclude(person=person)
.exists()
)
is_primary = data["is_primary"] or not other_active_exists
if existing is None:
link = CompanyPersonLink.objects.create(
person=person,
company=company,
position=data["position"],
notes=data["notes"],
is_primary=is_primary,
is_active=True,
)
else:
existing.position = data["position"]
existing.notes = data["notes"]
existing.is_primary = is_primary
existing.is_active = True
existing.save(
update_fields=[
"position",
"notes",
"is_primary",
"is_active",
"updated_at",
]
)
link = existing
_schedule_person_phone_rematch(person)
return link

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== apps/company/models.py: relevant definitions ==\n'
ast-grep outline apps/company/models.py --view expanded || true

printf '\n== search for is_primary / uniqueness constraints ==\n'
rg -n "is_primary|UniqueConstraint|unique_together|Constraint|company.*primary|primary.*company" apps/company/models.py apps/company/services/person_service.py apps/company -g '!**/__pycache__/**' || true

printf '\n== file sizes ==\n'
wc -l apps/company/models.py apps/company/services/person_service.py

printf '\n== relevant slices ==\n'
sed -n '1,260p' apps/company/models.py
printf '\n--- person_service slice ---\n'
sed -n '220,340p' apps/company/services/person_service.py

Repository: corrin/docketworks

Length of output: 46864


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

models = Path("apps/company/models.py").read_text()
service = Path("apps/company/services/person_service.py").read_text()

# Quick heuristic extraction of constraints mentioning CompanyPersonLink
for label, text in [("models", models), ("service", service)]:
    print(f"\n== {label} matches ==")
    for m in re.finditer(r".{0,80}(is_primary|UniqueConstraint|unique_together|Constraint|company).{0,120}", text):
        line = text.count("\n", 0, m.start()) + 1
        print(f"{line}: {m.group(0).replace(chr(10), ' ')}")
PY

Repository: corrin/docketworks

Length of output: 11493


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "Inspect model class around CompanyPersonLink"
rg -n -A80 -B20 "class CompanyPersonLink|class Company" apps/company/models.py

Repository: corrin/docketworks

Length of output: 8722


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

text = Path("apps/company/models.py").read_text()
patterns = [
    r"UniqueConstraint\([^\)]*is_primary",
    r"unique_together",
    r"company.*is_primary",
    r"is_primary.*company",
]
for pat in patterns:
    print(f"PATTERN {pat}")
    for m in re.finditer(pat, text, re.S):
        line = text.count("\n", 0, m.start()) + 1
        print(" line", line, ":", m.group(0)[:300].replace("\n", " "))
PY

Repository: corrin/docketworks

Length of output: 1202


Serialize primary-link assignment. There isn’t a DB-level uniqueness constraint on (company, is_primary) here; the invariant is enforced in CompanyPersonLink.save(). The remaining gap is that create_person_for_company() and put_company_link() decide is_primary before locking the company-wide active link set, so two concurrent requests can both persist is_primary=True and leave duplicate primaries. Lock the company’s active links before deriving is_primary.

🤖 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/company/services/person_service.py` around lines 272 - 312, Update
put_company_link and create_person_for_company to lock the company-wide active
CompanyPersonLink rows with select_for_update before evaluating whether another
active link exists and deriving is_primary. Ensure the lock and primary decision
occur inside the existing transaction.atomic scope, preserving the current
behavior for explicit primary requests and first active links.

Comment thread apps/company/views/person_views.py
Comment on lines +184 to +205
toast.success('Person created successfully!', {
dismissible: true,
position: 'top-left',
})
} else {
toast.error(
`Failed to ${isEditing.value ? 'update' : 'create'} person. Please check the form and try again.`,
)
} else if (!phoneOwnership.value) {
toast.error('Failed to create person. Please check the form and try again.')
}
}

const handleEditPerson = (person: CompanyPersonLink) => {
debugLog('PersonSelector - handleEditPerson:', person)
startEditPerson(person)
const handleLinkPerson = async (person: PhonePersonMatch) => {
toast.info('Linking existing person...', { id: 'save-person' })
const success = await linkExistingPerson(person)
toast.dismiss('save-person')
if (success) toast.success('Existing person linked successfully')
}

const handleDeletePerson = async (personLinkId: string) => {
debugLog('PersonSelector - handleDeletePerson:', personLinkId)
toast.info('Deleting person...', { id: 'delete-person' })

const success = await deletePerson(personLinkId)

toast.dismiss('delete-person')

if (success) {
toast.success('Person removed successfully')
} else {
toast.error('Failed to remove person. Please try again.')
}
const handleCreateSeparatePerson = async () => {
toast.info('Creating separate person...', { id: 'save-person' })
const success = await createNewPerson(true)
toast.dismiss('save-person')
if (success) toast.success('Person created successfully')
else toast.error('Failed to create separate person')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'phoneOwnership' frontend/src/composables/usePersonManagement.ts

Repository: corrin/docketworks

Length of output: 486


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '45,135p' frontend/src/composables/usePersonManagement.ts
printf '\n----\n'
sed -n '200,285p' frontend/src/composables/usePersonManagement.ts
printf '\n----\n'
sed -n '175,215p' frontend/src/components/PersonSelector.vue

Repository: corrin/docketworks

Length of output: 6709


🏁 Script executed:

...

Repository: corrin/docketworks

Length of output: 197


🏁 Script executed:

#!/bin/bash
set -euo pipefail

nl -ba frontend/src/composables/usePersonManagement.ts | sed -n '50,130p'
printf '\n----\n'
nl -ba frontend/src/composables/usePersonManagement.ts | sed -n '205,280p'
printf '\n----\n'
nl -ba frontend/src/components/PersonSelector.vue | sed -n '175,215p'

Repository: corrin/docketworks

Length of output: 196


Clear phoneOwnership before proceeding with a fresh save. phoneOwnership only resets on phone edits or modal close, so if a prior conflict is still in state when the number later becomes available, a subsequent non-409 create failure will skip the generic error toast and leave the stale conflict banner visible.

🤖 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/PersonSelector.vue` around lines 184 - 205, Reset
phoneOwnership at the start of the fresh person-save flow before invoking
createNewPerson, including the path that handles a newly available number.
Ensure non-409 create failures reach the generic error toast and do not retain
the stale conflict banner, while preserving the existing conflict handling
behavior.

@corrin
corrin merged commit 25a42ba into main Jul 14, 2026
9 checks passed
@corrin
corrin deleted the feat/KAN-278-remaining-work branch July 14, 2026 03:16
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