Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions api/controllers/common/human_input_v2_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ class ContactOption(ResponseModel):
name: str = Field(description="Display name shown in the contact picker.")
avatar_url: str | None = Field(default=None, description="Signed avatar URL if one is available.")
email: str | None = Field(default=None, description="Current deliverable Email, when available.")
im_providers: list[IMProvider] = Field(
description="Distinct providers of effective IM bindings in the current workspace; not a delivery health check."
)


class ExternalContactCreateResponse(ResponseModel):
Expand Down
8 changes: 6 additions & 2 deletions api/controllers/console/human_input_v2/contact.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,13 +356,15 @@ def _contact_payload(view: ContactWithIMBindings) -> dict[str, object]:
}


def _contact_option_payload(contact: Contact) -> dict[str, object]:
def _contact_option_payload(view: ContactWithIMBindings) -> dict[str, object]:
contact = view.contact
return {
"id": contact.id,
"type": contact.type,
"name": contact.name,
"avatar_url": _contact_avatar_url(contact) or None,
"email": contact.email,
"im_providers": list(dict.fromkeys(binding.provider for binding in view.im_bindings)),
}


Expand Down Expand Up @@ -469,7 +471,8 @@ class WorkspaceContactOptionsApi(Resource):
params=query_params_from_model(ContactOptionsQuery),
description=(
"List editor-safe Contact options for static recipient selection. "
"The projection omits IM bindings and management metadata; unavailable Contacts are omitted."
"The projection includes effective IM providers and omits binding details and management metadata; "
"unavailable Contacts are omitted."
),
)
@console_ns.response(200, "Success", console_ns.models[ListContactOptionsResponse.__name__])
Expand Down Expand Up @@ -920,6 +923,7 @@ class BatchGetContactOptionsAPI(Resource):
params=query_params_from_model(BatchGetContactOptionsQuery),
description=(
"Resolve Contact IDs persisted in workflow recipient configuration. "
"Include effective IM providers for each Contact. "
"Contacts unavailable in the current workspace are omitted."
),
)
Expand Down
5 changes: 3 additions & 2 deletions api/openapi/markdown/console-openapi.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions api/services/human_input_v2/contact_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,20 +106,21 @@ def list_contact_options(
page: int,
limit: int,
keyword: str,
) -> Page[Contact]:
return self._contacts.list_contact(
) -> Page[ContactWithIMBindings]:
contact_page = self._contacts.list_contact(
tenant_id,
page,
limit,
ContactQuery(keyword=keyword),
)
return Page(self._with_bindings(tenant_id, contact_page.items), contact_page.page, contact_page.limit)

def get_contact_options(
self,
tenant_id: TenantId,
contact_ids: Sequence[ContactId],
) -> Sequence[Contact]:
return self._contacts.get_contacts_by_ids(tenant_id, contact_ids)
) -> tuple[ContactWithIMBindings, ...]:
return self._with_bindings(tenant_id, self._contacts.get_contacts_by_ids(tenant_id, contact_ids))

def create_external_contact(
self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,14 @@ def test_editing_roles_can_select_contacts(
options = client.get(f"{_BASE_URL}/contact-options")
assert options.status_code == 200
assert options.get_json()["data"] == [
{"id": contact_id, "type": "external", "name": "Reviewer", "email": "contact@example.com", "avatar_url": None}
{
"id": contact_id,
"type": "external",
"name": "Reviewer",
"email": "contact@example.com",
"avatar_url": None,
"im_providers": [],
}
]

resolved = client.get(f"{_BASE_URL}/contact-options/batch", query_string={"contact_ids": contact_id})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -766,20 +766,22 @@ def list_contact_options(
page: int,
limit: int,
keyword: str,
) -> Page[Contact]:
) -> Page[ContactWithIMBindings]:
assert tenant_id == TenantId("workspace-1")
assert (page, limit, keyword) == (1, 20, "")
return Page((self.view.contact,), page, limit)
return Page((self.view,), page, limit)

def get_contacts(self, tenant_id: TenantId, contact_ids: list[ContactId]) -> tuple[ContactWithIMBindings, ...]:
assert tenant_id == TenantId("workspace-1")
assert contact_ids == [self.view.contact.id]
return (self.view,)

def get_contact_options(self, tenant_id: TenantId, contact_ids: list[ContactId]) -> tuple[Contact, ...]:
def get_contact_options(
self, tenant_id: TenantId, contact_ids: list[ContactId]
) -> tuple[ContactWithIMBindings, ...]:
assert tenant_id == TenantId("workspace-1")
assert contact_ids == [self.view.contact.id]
return (self.view.contact,)
return (self.view,)


def test_contact_read_handlers_preserve_current_and_editor_safe_projections(
Expand Down Expand Up @@ -818,10 +820,37 @@ def test_contact_read_handlers_preserve_current_and_editor_safe_projections(
assert options["data"][0]["id"] == contact_id
assert batch["data"][0]["id"] == contact_id
assert batch_options["data"][0]["id"] == contact_id
assert options["data"][0]["im_providers"] == ["feishu"]
assert batch_options["data"] == options["data"]
assert "im_bindings" not in options["data"][0]
assert "email" not in batch["data"][0]


@pytest.mark.parametrize("provider", list(IMProvider))
def test_contact_options_return_distinct_effective_im_providers(
app: Flask, monkeypatch: pytest.MonkeyPatch, provider: IMProvider
) -> None:
service = _ContactService()
binding = replace(service.view.im_bindings[0], provider=provider)
service.view = replace(service.view, im_bindings=(binding, binding))
monkeypatch.setattr(_CONTROLLER_MODULE, "_contact_service", lambda _session, _tenant_id: service)

with app.test_request_context(method="GET"):
options = unwrap(WorkspaceContactOptionsApi.get)(WorkspaceContactOptionsApi(), object(), "workspace-1")
with app.test_request_context(method="GET", query_string=[("contact_ids", str(service.view.contact.id))]):
batch = unwrap(BatchGetContactOptionsAPI.get)(BatchGetContactOptionsAPI(), object(), "workspace-1")

assert options["data"] == batch["data"]
assert options["data"][0] == {
"id": str(service.view.contact.id),
"type": "workspace",
"name": "Reviewer",
"avatar_url": None,
"email": "reviewer@example.com",
"im_providers": [provider.value],
}


@pytest.mark.parametrize("scope", [IMBindingScope.WORKSPACE, None])
def test_contact_detail_returns_workspace_binding_or_empty_details(
app: Flask, monkeypatch: pytest.MonkeyPatch, scope: IMBindingScope | None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,10 @@ def test_tenant_scoped_core_service_needs_only_contact_and_binding_read_ports()
assert tuple(item.contact for item in contacts_with_bindings) == (contact,)
assert selected is not None
assert selected.contact == contact
assert tuple(options) == (contact,)
assert tuple(option.contact for option in options) == (contact,)
assert all(not option.im_bindings for option in options)
assert contact_port.tenant_calls == [_TENANT_ID, _TENANT_ID, _TENANT_ID]
assert binding_port.calls == [(_TENANT_ID, (contact.id,)), (_TENANT_ID, (contact.id,))]
assert binding_port.calls == [(_TENANT_ID, (contact.id,)), (_TENANT_ID, (contact.id,)), (_TENANT_ID, (contact.id,))]


def test_candidate_id_round_trips_unchanged_through_the_enterprise_service_port() -> None:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Contact option queries include the current effective IM bindings."""

from datetime import datetime
from unittest.mock import create_autospec

from core.human_input_v2.entities import IMBindingScope, IMProvider
from core.human_input_v2.shared import ContactId, IMBindingId, IMIdentityId, TenantId
from repositories.human_input_v2.contact import (
Contact,
ContactIMBindingRepository,
ContactRepository,
ContactType,
IMBinding,
Page,
)
from services.human_input_v2.contact_service import ContactManagementService


def test_contact_options_include_effective_bindings_for_listed_and_resolved_contacts() -> None:
tenant_id = TenantId("workspace-1")
contact = Contact(
id=ContactId("contact-1"),
type=ContactType.WORKSPACE,
name="Reviewer",
email="reviewer@example.com",
avatar_file_id=None,
created_at=datetime(2026, 11, 1),
)
binding = IMBinding(
id=IMBindingId("binding-1"),
scope=IMBindingScope.WORKSPACE,
contact_id=contact.id,
identity_id=IMIdentityId("identity-1"),
provider=IMProvider.FEISHU,
)
contacts = create_autospec(ContactRepository, instance=True)
contacts.list_contact.return_value = Page((contact,), page=2, limit=10)
contacts.get_contacts_by_ids.return_value = (contact,)
bindings = create_autospec(ContactIMBindingRepository, instance=True)
bindings.get_im_bindings.return_value = (binding,)
service = ContactManagementService(contacts, bindings, None)

page = service.list_contact_options(tenant_id, page=2, limit=10, keyword="reviewer")
resolved = service.get_contact_options(tenant_id, (contact.id,))

assert (page.page, page.limit) == (2, 10)
assert len(page.items) == 1
assert page.items[0].contact == contact
assert page.items[0].im_bindings == (binding,)
assert resolved == page.items

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,14 @@ vi.mock('@/service/console', async () => {

const firstPage: ListContactOptionsResponse = {
data: [
{ id: 'contact-1', name: 'First contact', email: null, avatar_url: null, type: 'platform' },
{
id: 'contact-1',
name: 'First contact',
email: null,
avatar_url: null,
type: 'platform',
im_providers: [],
},
],
page: 1,
limit: 20,
Expand All @@ -50,7 +57,13 @@ describe('Contact recipient API provider', () => {
api.search.mockResolvedValueOnce(firstPage).mockResolvedValueOnce({
...firstPage,
data: [
{ id: 'contact-21', name: 'Last contact', email: 'last@example.com', type: 'external' },
{
id: 'contact-21',
name: 'Last contact',
email: 'last@example.com',
type: 'external',
im_providers: [],
},
],
page: 2,
})
Expand Down
Loading