From a90882a360eb32efcd0415accd342f9a01bab738 Mon Sep 17 00:00:00 2001 From: Marcus Cheung Date: Tue, 19 May 2026 18:26:11 -0700 Subject: [PATCH] =?UTF-8?q?fix(api):=20mirror=20assignees=20=E2=86=92=20as?= =?UTF-8?q?signee=5Fids=20so=20Lark=20DM=20fires=20on=20personal-task=20cr?= =?UTF-8?q?eate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The actual Lark notification path is NOT model_activity / webhook fan-out (PR #12 was based on a wrong hypothesis). Lark DMs are dispatched inline from `issue_activity` itself: after IssueActivity rows are bulk_create'd, `dispatch_lark_for_activities` walks the rows and fires `notify_issue_assigned_task.delay` for any row with `field="assignees"` + `new_identifier=`. That assignee row is only created when `create_issue_activity` calls `track_assignees`, and the gate at issue_activities_task.py:581 is: if requested_data.get("assignee_ids") is not None: ... The plane/api IssueSerializer uses `assignees` as its write key (vs. the plane/app variant which uses `assignee_ids`). personal_task.py forwards the request body as-is, so the gate never fires for the system-token create path → no assignee IssueActivity row → no Lark DM, even though `LARK_NOTIFICATIONS_ENABLED=1` and the worker correctly received the issue_activity task (verified in prod logs). Fix: mirror `payload["assignees"]` to `payload["assignee_ids"]` in the dict passed to `issue_activity.delay`. The serializer.save() above still consumes `assignees` (the serializer key it knows about); only the activity tracker payload is augmented. Adds a contract test that asserts the requested_data sent to issue_activity includes `assignee_ids` matching the resolved owner — the precise contract the gate at line 581 needs. PATCH path is unaffected: `update_issue_activity` ATTRIBUTE_MAPPER already maps both `"assignees"` and `"assignee_ids"` to track_assignees. --- apps/api/plane/api/views/personal_task.py | 13 ++++++- .../tests/contract/api/test_personal_tasks.py | 35 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/api/plane/api/views/personal_task.py b/apps/api/plane/api/views/personal_task.py index a4a023ce719..34decae2bd2 100644 --- a/apps/api/plane/api/views/personal_task.py +++ b/apps/api/plane/api/views/personal_task.py @@ -190,9 +190,20 @@ def post(self, request, slug): serializer.save() issue = Issue.objects.get(pk=serializer.data["id"]) + # `create_issue_activity` (issue_activities_task.py) gates the + # assignee tracking branch on `requested_data["assignee_ids"]` + # being present, but the API IssueSerializer uses `assignees` as + # its write key. Without this alias the assignee IssueActivity + # row is never written, so `dispatch_lark_for_activities` has + # nothing to fan out and Lark DMs never fire for assignees on + # personal-task creation. + activity_payload = dict(payload) + if "assignees" in activity_payload and "assignee_ids" not in activity_payload: + activity_payload["assignee_ids"] = activity_payload["assignees"] + issue_activity.delay( type="issue.activity.created", - requested_data=json.dumps(payload, cls=DjangoJSONEncoder), + requested_data=json.dumps(activity_payload, cls=DjangoJSONEncoder), actor_id=str(request.user.id), issue_id=str(issue.id), project_id=str(project.id), diff --git a/apps/api/plane/tests/contract/api/test_personal_tasks.py b/apps/api/plane/tests/contract/api/test_personal_tasks.py index 39c05adc4fe..7617ac057a5 100644 --- a/apps/api/plane/tests/contract/api/test_personal_tasks.py +++ b/apps/api/plane/tests/contract/api/test_personal_tasks.py @@ -5,6 +5,7 @@ """Contract tests for the personal-tasks token endpoint (``/api/v1/workspaces/{slug}/personal-tasks/``).""" +import json import uuid import pytest @@ -397,6 +398,40 @@ def test_patch_triggers_model_activity_for_webhook_fanout( assert kwargs["current_instance"] is not None assert kwargs["slug"] == workspace_with_owner.slug + @pytest.mark.django_db + def test_post_requested_data_includes_assignee_ids_for_lark_gate( + self, system_api_client, workspace_with_owner, owner_user, mocker + ): + """The real Lark fan-out path: ``create_issue_activity`` + (issue_activities_task.py:581) only calls ``track_assignees`` + when ``requested_data.get('assignee_ids') is not None``. Our + API serializer uses the ``assignees`` key, so the issue_activity + payload MUST mirror it under ``assignee_ids`` or the assignee + IssueActivity row is never emitted and dispatch_lark_for_activities + sees nothing to DM. + """ + spy = mocker.patch("plane.api.views.personal_task.issue_activity.delay") + response = system_api_client.post( + _personal_tasks_url(workspace_with_owner.slug), + data={ + "owner": str(owner_user.id), + "name": "Notify me on Lark", + "external_source": "task-manager-v1", + "external_id": "lark-gate-1", + }, + format="json", + HTTP_HOST="task.example.com", + ) + assert response.status_code == status.HTTP_201_CREATED, response.content + assert spy.called, "issue_activity.delay was not invoked" + requested_data = json.loads(spy.call_args.kwargs["requested_data"]) + assert "assignee_ids" in requested_data, ( + "issue_activity.requested_data is missing 'assignee_ids' — " + "create_issue_activity will skip track_assignees and no Lark " + "DM will fire." + ) + assert requested_data["assignee_ids"] == [str(owner_user.id)] + @pytest.mark.django_db def test_idempotent_409_reuse_does_not_trigger_model_activity( self, system_api_client, workspace_with_owner, owner_user, mocker