Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
74dff61
fix(jobs): stop masking job-create errors as timeouts
corrin Jul 15, 2026
eecbdb1
fix(jobs): stop sending display-only person_name in job delta
corrin Jul 16, 2026
c343e0b
fix(jobs): block duplicate submit after create succeeds but nav fails
corrin Jul 16, 2026
7ddb211
fix(jobs): handle resolved router.push navigation failures on create
corrin Jul 16, 2026
cf9553f
fix(jobs): drop redundant job-person fetch that 404s after company ch…
corrin Jul 16, 2026
3882ca5
fix(crm): stop stale people-list fetch clobbering a newer search
corrin Jul 16, 2026
1f0360c
refactor(jobs): immutable ref updates in settings-tab person hydration
corrin Jul 16, 2026
913cd92
fix(crm): restore person edit + delete/archive in the Select-Person m…
corrin Jul 16, 2026
00b2bce
fix(crm): don't archive person on last-link removal — it broke link r…
corrin Jul 16, 2026
79f758e
docs(crm): design spec for person archive (retire departed people)
corrin Jul 16, 2026
6aa9542
docs(crm): implementation plan for person archive
corrin Jul 16, 2026
cff8080
feat(crm): archive a person when their last company link is removed
corrin Jul 16, 2026
0234480
feat(crm): un-archive a person when a company link is added or restored
corrin Jul 16, 2026
9fd1d1e
feat(crm): let archived people be viewed and their links restored
corrin Jul 16, 2026
0c08883
feat(crm): add explicit archive-person endpoint
corrin Jul 16, 2026
e31b662
feat(crm): directory include_archived filter and is_active on summaries
corrin Jul 16, 2026
424ae3d
feat(crm): show-archived filter and Archived badge in people directory
corrin Jul 16, 2026
4db3959
fix(crm): make Person is_active a required field in the response cont…
corrin Jul 16, 2026
ccb2911
feat(crm): archived badge and Archive-person button on PersonDetail
corrin Jul 16, 2026
39f84a1
test(crm): e2e for archive → show-archived → restore
corrin Jul 16, 2026
d4e4d78
fix(crm): keep archived people reachable, restorable, and consistent
corrin Jul 16, 2026
9601515
docs(crm): correct person-archive spec on contact-methods and phone-o…
corrin Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ Examples:
- If asked whether a bug is fixed, verify the behavior that matters to the user, not only that a unit test passed.
- If asked to review a proposal, assess whether it solves the problem and what risks remain, not only whether the text is internally consistent.

## No feature removal

Replacing or rewriting any component, page, model, or endpoint requires a **Feature Parity Inventory** first: enumerate every capability the old version exposed (buttons, actions, fields, shortcuts, edge cases — sourced from its template/emits, its tests, E2E specs, ADRs) with a keep/drop/defer decision for each. Default is keep; dropping a feature needs explicit user sign-off. A silently dropped capability is a release-blocking regression. Plans for such work must carry the inventory as a section (see the template in `docs/plans/`).

## Tokens are precious

Every single line in CLAUDE.md will make agents worse at unrelated tasks. Every single word must have significant lasting benefit or it must not be added. Do not repeat yourself, do not add lines even if you screw up, if it is unlikely a similar screw up will happen again. Always give the most general fix, to increase the likelihood the guidence is future-directed.
Expand Down
8 changes: 7 additions & 1 deletion apps/company/person_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,18 @@ class PersonCompanySummarySerializer(serializers.Serializer[dict[str, str]]):


class PersonSummarySerializer(serializers.ModelSerializer[Person]):
# Declared explicitly so the response contract says always-present. The
# ModelSerializer default would infer required=False from the model default,
# which renders as optional in the schema even though every row carries it.
# These serializers are response-only; identity writes use
# PersonIdentityUpdateSerializer.
is_active = serializers.BooleanField()
primary_phone = serializers.SerializerMethodField()
companies = serializers.SerializerMethodField()

class Meta:
model = Person
fields = ["id", "name", "email", "primary_phone", "companies"]
fields = ["id", "name", "email", "is_active", "primary_phone", "companies"]

def get_primary_phone(self, person: Person) -> str:
if "primary_phone" in person.__dict__:
Expand Down
2 changes: 2 additions & 0 deletions apps/company/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
PhoneCompanyOwner,
PhoneOwnershipResult,
PhonePersonMatch,
archive_person,
classify_phone_ownership,
create_person_for_company,
put_company_link,
Expand Down Expand Up @@ -121,6 +122,7 @@
"PhonePersonMatch",
"RetainedDecision",
"apply_reviewed_duplicate_cleanup",
"archive_person",
"classify_phone_ownership",
"create_person_for_company",
"geocode_address",
Expand Down
51 changes: 36 additions & 15 deletions apps/company/services/person_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,21 +63,22 @@ def __init__(self, ownership: PhoneOwnershipResult) -> None:

class PersonDirectoryService:
@staticmethod
def search(query: str) -> QuerySet[Person]:
people = (
Person.objects.filter(is_active=True)
.annotate(
primary_phone=ContactMethod.primary_phone_annotation(
owner="person", outer_ref="pk"
)
def search(query: str, *, include_archived: bool = False) -> QuerySet[Person]:
base = (
Person.objects.all()
if include_archived
else Person.objects.filter(is_active=True)
)
people = base.annotate(
primary_phone=ContactMethod.primary_phone_annotation(
owner="person", outer_ref="pk"
)
.prefetch_related(
Prefetch(
"company_links",
queryset=CompanyPersonLink.objects.filter(
is_active=True
).select_related("company"),
)
).prefetch_related(
Prefetch(
"company_links",
queryset=CompanyPersonLink.objects.filter(
is_active=True
).select_related("company"),
)
)
search = query.strip()
Expand Down Expand Up @@ -174,7 +175,7 @@ def classify_phone_ownership(
for method in methods:
if method.person_id is not None:
person = method.person
if person is None or not person.is_active:
if person is None:
continue
people_by_id.setdefault(
person.id,
Expand Down Expand Up @@ -318,10 +319,26 @@ def put_company_link(
]
)
link = existing
if not person.is_active:
person.is_active = True
person.save(update_fields=["is_active", "updated_at"])
_schedule_person_phone_rematch(person)
return link


def archive_person(*, person: Person) -> None:
"""Retire a person everywhere: deactivate all active links, then archive."""
with transaction.atomic():
locked = Person.objects.select_for_update().get(pk=person.pk)
CompanyPersonLink.objects.filter(person=locked, is_active=True).update(
is_active=False, is_primary=False
)
if locked.is_active:
locked.is_active = False
locked.save(update_fields=["is_active", "updated_at"])
_schedule_person_phone_rematch(locked)


def remove_company_link(*, person: Person, company: Company) -> None:
with transaction.atomic():
link = (
Expand Down Expand Up @@ -353,4 +370,8 @@ def remove_company_link(*, person: Person, company: Company) -> None:
link.is_active = False
link.is_primary = False
link.save(update_fields=["is_active", "is_primary", "updated_at"])
if not projected_company_ids and person.is_active:
# Removing the person's last active company link retires them.
person.is_active = False
person.save(update_fields=["is_active", "updated_at"])
_schedule_person_phone_rematch(person)
159 changes: 159 additions & 0 deletions apps/company/tests/test_person_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,48 @@ def test_removing_link_preserves_person_and_other_company(self) -> None:

self.assertEqual(response.status_code, 204)
self.assertTrue(Person.objects.filter(id=person.id).exists())
person.refresh_from_db()
self.assertTrue(person.is_active)
other.refresh_from_db()
self.assertTrue(other.is_active)

def test_removing_last_link_archives_person(self) -> None:
"""Removing a person's only active company link retires (archives) them."""
person = self._person(company=self.company_a)

with patch("apps.crm.tasks.rematch_phone_calls_task.delay"):
response = self.client.delete(
f"/api/people/{person.id}/company-links/{self.company_a.id}/"
)

self.assertEqual(response.status_code, 204)
person.refresh_from_db()
self.assertFalse(person.is_active)
link = CompanyPersonLink.objects.get(person=person, company=self.company_a)
self.assertFalse(link.is_active)

def test_restoring_a_link_unarchives_the_person(self) -> None:
"""Adding/reactivating any company link brings an archived person back."""
from apps.company.services.person_service import (
put_company_link,
remove_company_link,
)

person = self._person(company=self.company_a)
with patch("apps.crm.tasks.rematch_phone_calls_task.delay"):
remove_company_link(person=person, company=self.company_a)
person.refresh_from_db()
self.assertFalse(person.is_active)

with patch("apps.crm.tasks.rematch_phone_calls_task.delay"):
put_company_link(
person=person,
company=self.company_a,
data={"position": None, "notes": None, "is_primary": False},
)
person.refresh_from_db()
self.assertTrue(person.is_active)

def test_removing_link_is_blocked_when_phone_would_cross_companies(self) -> None:
"""Relationship edits must not create the duplicate-phone problem they manage."""
person = self._person(company=self.company_a)
Expand Down Expand Up @@ -248,3 +287,123 @@ def test_old_person_links_collection_is_removed(self) -> None:
response = self.client.get("/api/companies/person-links/")

self.assertEqual(response.status_code, 404)

def test_detail_returns_an_archived_person(self) -> None:
person = self._person(company=self.company_a)
with patch("apps.crm.tasks.rematch_phone_calls_task.delay"):
self.client.delete(
f"/api/people/{person.id}/company-links/{self.company_a.id}/"
)

response = self.client.get(f"/api/people/{person.id}/")
self.assertEqual(response.status_code, 200)
self.assertFalse(response.json()["is_active"])

def test_restore_link_over_http_unarchives_archived_person(self) -> None:
person = self._person(company=self.company_a)
with patch("apps.crm.tasks.rematch_phone_calls_task.delay"):
self.client.delete(
f"/api/people/{person.id}/company-links/{self.company_a.id}/"
)
person.refresh_from_db()
self.assertFalse(person.is_active)

with patch("apps.crm.tasks.rematch_phone_calls_task.delay"):
response = self.client.put(
f"/api/people/{person.id}/company-links/{self.company_a.id}/",
data={"position": "", "notes": "", "is_primary": False},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["is_active"])
person.refresh_from_db()
self.assertTrue(person.is_active)

def test_archive_person_endpoint_deactivates_links_and_archives(self) -> None:
person = self._person(company=self.company_a)
CompanyPersonLink.objects.create(company=self.company_b, person=person)

with patch("apps.crm.tasks.rematch_phone_calls_task.delay"):
response = self.client.post(f"/api/people/{person.id}/archive/")

self.assertEqual(response.status_code, 200)
self.assertFalse(response.json()["is_active"])
person.refresh_from_db()
self.assertFalse(person.is_active)
self.assertFalse(
CompanyPersonLink.objects.filter(person=person, is_active=True).exists()
)

def test_directory_excludes_archived_by_default(self) -> None:
person = self._person(company=self.company_a)
with patch("apps.crm.tasks.rematch_phone_calls_task.delay"):
self.client.delete(
f"/api/people/{person.id}/company-links/{self.company_a.id}/"
)

response = self.client.get("/api/people/")
ids = [row["id"] for row in response.json()["results"]]
self.assertNotIn(str(person.id), ids)

def test_directory_includes_archived_when_requested(self) -> None:
person = self._person(company=self.company_a)
with patch("apps.crm.tasks.rematch_phone_calls_task.delay"):
self.client.delete(
f"/api/people/{person.id}/company-links/{self.company_a.id}/"
)

response = self.client.get("/api/people/", {"include_archived": "true"})
rows = {row["id"]: row for row in response.json()["results"]}
self.assertIn(str(person.id), rows)
self.assertFalse(rows[str(person.id)]["is_active"])

def test_contact_methods_are_reachable_for_an_archived_person(self) -> None:
"""The PersonDetail page loads contact-methods alongside the person; a 404
here fails the whole Promise.all and hides the restore-link button."""
person = self._person(company=self.company_a)
with patch("apps.crm.tasks.rematch_phone_calls_task.delay"):
self.client.delete(
f"/api/people/{person.id}/company-links/{self.company_a.id}/"
)
person.refresh_from_db()
self.assertFalse(person.is_active)

response = self.client.get(f"/api/people/{person.id}/contact-methods/")

self.assertEqual(response.status_code, 200)

def test_phone_ownership_offers_restore_for_an_archived_person(self) -> None:
"""An archived person who still owns a phone must come back as status
'people' (not 'company') so the modal can offer to restore the link."""
person = self._person(company=self.company_a)
ContactMethod.objects.create(
person=person,
method_type=ContactMethod.MethodType.PHONE,
value="021 222 2222",
is_primary=True,
)
with patch("apps.crm.tasks.rematch_phone_calls_task.delay"):
self.client.delete(
f"/api/people/{person.id}/company-links/{self.company_a.id}/"
)
person.refresh_from_db()
self.assertFalse(person.is_active)

response = self.client.post(
f"/api/companies/{self.company_a.id}/people/phone-ownership/",
{"phone": "0212222222"},
format="json",
)

self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["status"], "people")
self.assertEqual(body["people"][0]["person_id"], str(person.id))
company_links = body["people"][0]["company_links"]
self.assertFalse(
next(
link
for link in company_links
if link["company_id"] == str(self.company_a.id)
)["is_active"]
)
6 changes: 6 additions & 0 deletions apps/company/urls_people_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.urls import path

from apps.company.views.person_views import (
PersonArchiveView,
PersonCompanyLinkDetailView,
PersonCompanyLinksView,
PersonContactMethodDetailView,
Expand Down Expand Up @@ -36,4 +37,9 @@
PersonContactMethodDetailView.as_view(),
name="person_contact_method_detail",
),
path(
"<uuid:person_id>/archive/",
PersonArchiveView.as_view(),
name="person_archive",
),
]
2 changes: 2 additions & 0 deletions apps/company/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .person_views import (
CompanyPeopleView,
CompanyPersonPhoneOwnershipView,
PersonArchiveView,
PersonCompanyLinkDetailView,
PersonCompanyLinksView,
PersonContactMethodDetailView,
Expand Down Expand Up @@ -49,6 +50,7 @@
"CompanyUpdateRestView",
"ContactMethodViewSet",
"JobPersonRestView",
"PersonArchiveView",
"PersonCompanyLinkDetailView",
"PersonCompanyLinksView",
"PersonContactMethodDetailView",
Expand Down
Loading
Loading