diff --git a/api/controllers/common/human_input_v2_contracts.py b/api/controllers/common/human_input_v2_contracts.py index b098adfb66d574..b3cbf70037a9d2 100644 --- a/api/controllers/common/human_input_v2_contracts.py +++ b/api/controllers/common/human_input_v2_contracts.py @@ -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): diff --git a/api/controllers/console/human_input_v2/contact.py b/api/controllers/console/human_input_v2/contact.py index 9f06e9aa3f9450..bcf96ac1b67555 100644 --- a/api/controllers/console/human_input_v2/contact.py +++ b/api/controllers/console/human_input_v2/contact.py @@ -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)), } @@ -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__]) @@ -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." ), ) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index f27dcd00daf26c..51fbf07472a6e2 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -14928,7 +14928,7 @@ Update a plugin endpoint | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [GET] /workspaces/current/human-input/v2/contact-options -List editor-safe Contact options for static recipient selection. The projection omits IM bindings and management metadata; unavailable Contacts are omitted. +List editor-safe Contact options for static recipient selection. The projection includes effective IM providers and omits binding details and management metadata; unavailable Contacts are omitted. #### Parameters @@ -14946,7 +14946,7 @@ List editor-safe Contact options for static recipient selection. The projection | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [GET] /workspaces/current/human-input/v2/contact-options/batch -Resolve Contact IDs persisted in workflow recipient configuration. Contacts unavailable in the current workspace are omitted. +Resolve Contact IDs persisted in workflow recipient configuration. Include effective IM providers for each Contact. Contacts unavailable in the current workspace are omitted. #### Parameters @@ -23641,6 +23641,7 @@ Least-privilege contact projection returned to workflow editors. | avatar_url | string | Signed avatar URL if one is available. | No | | email | string | Current deliverable Email, when available. | No | | id | string | Unique contact identifier persisted in workflow recipient configuration. | Yes | +| im_providers | [ [IMProvider](#improvider) ] | Distinct providers of effective IM bindings in the current workspace; not a delivery health check. | Yes | | name | string | Display name shown in the contact picker. | Yes | | type | [HumanInputContactType](#humaninputcontacttype) | Resolved contact type in the current workspace scope. | Yes | diff --git a/api/services/human_input_v2/contact_service.py b/api/services/human_input_v2/contact_service.py index 5a2f84b81bee2d..3d92ae8407dd89 100644 --- a/api/services/human_input_v2/contact_service.py +++ b/api/services/human_input_v2/contact_service.py @@ -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, diff --git a/api/tests/unit_tests/controllers/console/human_input_v2/test_contact.py b/api/tests/unit_tests/controllers/console/human_input_v2/test_contact.py index 604ad9bfbc2436..4be06966e5037c 100644 --- a/api/tests/unit_tests/controllers/console/human_input_v2/test_contact.py +++ b/api/tests/unit_tests/controllers/console/human_input_v2/test_contact.py @@ -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}) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_im_contact_sync_api.py b/api/tests/unit_tests/controllers/console/workspace/test_im_contact_sync_api.py index 65d4e38d4052fc..3d60c4c6c796ac 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_im_contact_sync_api.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_im_contact_sync_api.py @@ -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( @@ -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 diff --git a/api/tests/unit_tests/services/human_input_v2/test_contact_capability_boundaries.py b/api/tests/unit_tests/services/human_input_v2/test_contact_capability_boundaries.py index 778d6b30e64af6..538b256bb2708e 100644 --- a/api/tests/unit_tests/services/human_input_v2/test_contact_capability_boundaries.py +++ b/api/tests/unit_tests/services/human_input_v2/test_contact_capability_boundaries.py @@ -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: diff --git a/api/tests/unit_tests/services/human_input_v2/test_contact_service.py b/api/tests/unit_tests/services/human_input_v2/test_contact_service.py new file mode 100644 index 00000000000000..9606e32b2feb30 --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_contact_service.py @@ -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 diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index 7b02b27bf37a24..af91c5ea9faee4 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -1474,12 +1474,12 @@ export const channels = { } /** - * Resolve Contact IDs persisted in workflow recipient configuration. Contacts unavailable in the current workspace are omitted. + * Resolve Contact IDs persisted in workflow recipient configuration. Include effective IM providers for each Contact. Contacts unavailable in the current workspace are omitted. */ export const get16 = oc .route({ description: - 'Resolve Contact IDs persisted in workflow recipient configuration. Contacts unavailable in the current workspace are omitted.', + 'Resolve Contact IDs persisted in workflow recipient configuration. Include effective IM providers for each Contact. Contacts unavailable in the current workspace are omitted.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentHumanInputV2ContactOptionsBatch', @@ -1494,12 +1494,12 @@ export const batch = { } /** - * List editor-safe Contact options for static recipient selection. The projection omits IM bindings and management metadata; unavailable Contacts are omitted. + * List editor-safe Contact options for static recipient selection. The projection includes effective IM providers and omits binding details and management metadata; unavailable Contacts are omitted. */ export const get17 = oc .route({ description: - 'List editor-safe Contact options for static recipient selection. The projection omits IM bindings and management metadata; unavailable Contacts are omitted.', + 'List editor-safe Contact options for static recipient selection. The projection includes effective IM providers and omits binding details and management metadata; unavailable Contacts are omitted.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentHumanInputV2ContactOptions', diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 792a2f9d2a3d98..338045b3a9fe78 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -1916,6 +1916,7 @@ export type ContactOption = { avatar_url?: string | null email?: string | null id: string + im_providers: Array name: string type: HumanInputContactType } diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 627fb4f003491b..651986b0612bf9 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -2946,6 +2946,7 @@ export const zContactOption = z.object({ avatar_url: z.string().nullish(), email: z.string().nullish(), id: z.string(), + im_providers: z.array(zImProvider), name: z.string(), type: zHumanInputContactType, }) diff --git a/web/app/components/workflow/nodes/human-input-v2/__tests__/contact-provider.spec.ts b/web/app/components/workflow/nodes/human-input-v2/__tests__/contact-provider.spec.ts index 7f6819e9042312..a1232f2f4be13f 100644 --- a/web/app/components/workflow/nodes/human-input-v2/__tests__/contact-provider.spec.ts +++ b/web/app/components/workflow/nodes/human-input-v2/__tests__/contact-provider.spec.ts @@ -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, @@ -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, })