ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: единый realtime слой Tasker

Добавлен NODE.DC realtime event stream для workspace/project/member/invite/profile событий. Обновлены frontend stores и live controller. Доработаны settings modal, member dropdown и confirm remove под NODE.DC UX.
This commit is contained in:
DCCONSTRUCTIONS 2026-05-12 17:28:40 +03:00
parent fc59481703
commit 480f85cce8
23 changed files with 993 additions and 72 deletions

View File

@ -135,3 +135,34 @@ def publish_issue_event_on_commit(event_type, issue, actor_id=None, changed_fiel
transaction.on_commit(_publish) transaction.on_commit(_publish)
if publish_external_bridge: if publish_external_bridge:
publish_external_contour_issue_event_on_commit(event_type, issue, actor_id=actor_id, changed_fields=changed_fields) publish_external_contour_issue_event_on_commit(event_type, issue, actor_id=actor_id, changed_fields=changed_fields)
def publish_assignee_cleanup_issue_events_on_commit(project_id=None, workspace_id=None, assignee_id=None, actor_id=None):
if not assignee_id:
return
from plane.db.models import Issue
issues = (
Issue.objects.filter(
deleted_at__isnull=True,
issue_assignee__assignee_id=assignee_id,
issue_assignee__deleted_at__isnull=True,
)
.select_related("workspace")
.distinct()
)
if project_id:
issues = issues.filter(project_id=project_id)
if workspace_id:
issues = issues.filter(workspace_id=workspace_id)
for issue in issues:
publish_issue_event_on_commit(
"issue.updated",
issue,
actor_id=actor_id,
changed_fields=["assignees"],
publish_external_bridge=True,
)

View File

@ -0,0 +1,161 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import json
import logging
from uuid import uuid4
from django.core.serializers.json import DjangoJSONEncoder
from django.db import transaction
from django.utils import timezone
from plane.settings.redis import redis_instance
logger = logging.getLogger(__name__)
NODEDC_EVENT_CHANNEL_PREFIX = "plane:nodedc-events:user"
def nodedc_user_event_channel(user_id):
return f"{NODEDC_EVENT_CHANNEL_PREFIX}:{user_id}"
def _normalize_user_ids(user_ids):
normalized_user_ids = []
seen_user_ids = set()
for user_id in user_ids or []:
if not user_id:
continue
normalized_user_id = str(user_id)
if normalized_user_id in seen_user_ids:
continue
seen_user_ids.add(normalized_user_id)
normalized_user_ids.append(normalized_user_id)
return normalized_user_ids
def _publish_payload_to_users(user_ids, payload):
client = redis_instance()
for user_id in _normalize_user_ids(user_ids):
client.publish(
nodedc_user_event_channel(user_id),
json.dumps({**payload, "target_user_id": user_id}, cls=DjangoJSONEncoder),
)
def publish_nodedc_event_to_users_on_commit(event_type, user_ids, payload=None):
event_payload = {
"event_id": str(uuid4()),
"type": event_type,
"emitted_at": timezone.now(),
**(payload or {}),
}
def _publish():
try:
_publish_payload_to_users(user_ids, event_payload)
except Exception:
logger.exception("Failed to publish NODE.DC realtime event")
transaction.on_commit(_publish)
def publish_nodedc_workspace_event_on_commit(workspace, event_type, payload=None, extra_user_ids=None):
workspace_payload = {
"workspace_id": str(workspace.id),
"workspace_slug": workspace.slug,
**(payload or {}),
}
extra_user_ids = _normalize_user_ids(extra_user_ids or [])
def _publish():
try:
from plane.db.models import WorkspaceMember
workspace_user_ids = WorkspaceMember.objects.filter(
workspace_id=workspace.id,
is_active=True,
member__is_bot=False,
deleted_at__isnull=True,
).values_list("member_id", flat=True)
_publish_payload_to_users(
[*workspace_user_ids, *extra_user_ids],
{
"event_id": str(uuid4()),
"type": event_type,
"emitted_at": timezone.now(),
**workspace_payload,
},
)
except Exception:
logger.exception("Failed to publish NODE.DC workspace realtime event")
transaction.on_commit(_publish)
def publish_nodedc_user_profile_event_on_commit(user, changed_fields=None):
changed_fields = sorted(set(changed_fields or []))
payload = {
"member_id": str(user.id),
"email": user.email,
"display_name": user.display_name,
"avatar": user.avatar or None,
"changed_fields": changed_fields,
}
def _publish():
try:
from plane.db.models import WorkspaceMember
memberships = (
WorkspaceMember.objects.filter(
member_id=user.id,
is_active=True,
deleted_at__isnull=True,
)
.select_related("workspace")
.only("workspace_id", "workspace__slug")
)
workspace_ids = []
client = redis_instance()
for membership in memberships:
workspace_ids.append(str(membership.workspace_id))
workspace_user_ids = WorkspaceMember.objects.filter(
workspace_id=membership.workspace_id,
is_active=True,
member__is_bot=False,
deleted_at__isnull=True,
).values_list("member_id", flat=True)
event_payload = {
"event_id": str(uuid4()),
"type": "user.profile.updated",
"emitted_at": timezone.now(),
"workspace_id": str(membership.workspace_id),
"workspace_slug": membership.workspace.slug,
**payload,
}
for target_user_id in _normalize_user_ids([*workspace_user_ids, user.id]):
client.publish(
nodedc_user_event_channel(target_user_id),
json.dumps({**event_payload, "target_user_id": target_user_id}, cls=DjangoJSONEncoder),
)
if not workspace_ids:
_publish_payload_to_users(
[user.id],
{
"event_id": str(uuid4()),
"type": "user.profile.updated",
"emitted_at": timezone.now(),
**payload,
},
)
except Exception:
logger.exception("Failed to publish NODE.DC profile realtime event")
transaction.on_commit(_publish)

View File

@ -9,6 +9,8 @@ from django.db.models import Min
# Module imports # Module imports
from .base import BaseViewSet, BaseAPIView from .base import BaseViewSet, BaseAPIView
from plane.app.realtime.issue_events import publish_assignee_cleanup_issue_events_on_commit
from plane.app.realtime.nodedc_events import publish_nodedc_workspace_event_on_commit
from plane.authentication.nodedc_workspace_policy import ( from plane.authentication.nodedc_workspace_policy import (
is_nodedc_launcher_managed_workspace, is_nodedc_launcher_managed_workspace,
nodedc_launcher_managed_workspace_response, nodedc_launcher_managed_workspace_response,
@ -176,6 +178,18 @@ class ProjectMemberViewSet(BaseViewSet):
], ],
batch_size=10, batch_size=10,
) )
for project_member in project_members:
publish_nodedc_workspace_event_on_commit(
project_member.workspace,
"project_member.created",
payload={
"project_id": str(project_member.project_id),
"member_id": str(project_member.member_id),
"role": project_member.role,
"source": "tasker",
},
extra_user_ids=[project_member.member_id],
)
# Send emails to notify the users # Send emails to notify the users
[ [
project_add_user_email.delay( project_add_user_email.delay(
@ -301,6 +315,17 @@ class ProjectMemberViewSet(BaseViewSet):
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
publish_nodedc_workspace_event_on_commit(
project_member.workspace,
"project_member.updated",
payload={
"project_id": str(project_member.project_id),
"member_id": str(project_member.member_id),
"role": project_member.role,
"source": "tasker",
},
extra_user_ids=[project_member.member_id],
)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -336,9 +361,24 @@ class ProjectMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
publish_assignee_cleanup_issue_events_on_commit(
project_id=project_id,
assignee_id=project_member.member_id,
actor_id=request.user.id,
)
project_member.is_active = False project_member.is_active = False
project_member.save() project_member.save()
IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete() IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete()
publish_nodedc_workspace_event_on_commit(
project_member.workspace,
"project_member.deleted",
payload={
"project_id": str(project_member.project_id),
"member_id": str(project_member.member_id),
"source": "tasker",
},
extra_user_ids=[project_member.member_id],
)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@ -367,10 +407,25 @@ class ProjectMemberViewSet(BaseViewSet):
}, },
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
publish_assignee_cleanup_issue_events_on_commit(
project_id=project_id,
assignee_id=project_member.member_id,
actor_id=request.user.id,
)
# Deactivate the user # Deactivate the user
project_member.is_active = False project_member.is_active = False
project_member.save() project_member.save()
IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete() IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete()
publish_nodedc_workspace_event_on_commit(
project_member.workspace,
"project_member.deleted",
payload={
"project_id": str(project_member.project_id),
"member_id": str(project_member.member_id),
"source": "tasker",
},
extra_user_ids=[project_member.member_id],
)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -20,6 +20,7 @@ from rest_framework.response import Response
# Module imports # Module imports
from plane.app.permissions import WorkSpaceAdminPermission from plane.app.permissions import WorkSpaceAdminPermission
from plane.app.realtime.nodedc_events import publish_nodedc_workspace_event_on_commit
from plane.authentication.nodedc_workspace_policy import ( from plane.authentication.nodedc_workspace_policy import (
get_nodedc_workspace_creation_policy, get_nodedc_workspace_creation_policy,
is_nodedc_launcher_managed_workspace, is_nodedc_launcher_managed_workspace,
@ -159,6 +160,18 @@ class WorkspaceInvitationsViewset(BaseViewSet):
invitation.nodedc_approval_request_id = approval_request_id invitation.nodedc_approval_request_id = approval_request_id
invitation.save(update_fields=["nodedc_approval_request_id", "updated_at"]) invitation.save(update_fields=["nodedc_approval_request_id", "updated_at"])
approved_requests.append(approval_request_id) approved_requests.append(approval_request_id)
invited_user = User.objects.filter(email__iexact=invitation.email, is_bot=False).first()
publish_nodedc_workspace_event_on_commit(
workspace,
"workspace_invite.created",
payload={
"invite_id": str(invitation.id),
"email": invitation.email,
"status": invitation.nodedc_approval_status,
"source": "tasker",
},
extra_user_ids=[request.user.id, getattr(invited_user, "id", None)],
)
except Exception: except Exception:
WorkspaceMemberInvite.objects.filter(id__in=[invitation.id for invitation in workspace_invitations]).delete() WorkspaceMemberInvite.objects.filter(id__in=[invitation.id for invitation in workspace_invitations]).delete()
return Response( return Response(
@ -197,6 +210,18 @@ class WorkspaceInvitationsViewset(BaseViewSet):
"invitee_email": invitation.email, "invitee_email": invitation.email,
}, },
) )
invited_user = User.objects.filter(email__iexact=invitation.email, is_bot=False).first()
publish_nodedc_workspace_event_on_commit(
workspace,
"workspace_invite.created",
payload={
"invite_id": str(invitation.id),
"email": invitation.email,
"status": invitation.nodedc_approval_status,
"source": "tasker",
},
extra_user_ids=[request.user.id, getattr(invited_user, "id", None)],
)
return Response({"message": "Emails sent successfully"}, status=status.HTTP_200_OK) return Response({"message": "Emails sent successfully"}, status=status.HTTP_200_OK)
@ -214,7 +239,23 @@ class WorkspaceInvitationsViewset(BaseViewSet):
status=status.HTTP_502_BAD_GATEWAY, status=status.HTTP_502_BAD_GATEWAY,
) )
invited_user = User.objects.filter(email__iexact=workspace_member_invite.email, is_bot=False).first()
workspace = workspace_member_invite.workspace
invite_payload = {
"invite_id": str(workspace_member_invite.id),
"email": workspace_member_invite.email,
"status": workspace_member_invite.nodedc_approval_status,
"source": "tasker",
}
extra_user_ids = [workspace_member_invite.created_by_id, getattr(invited_user, "id", None)]
workspace_member_invite.delete() workspace_member_invite.delete()
publish_nodedc_workspace_event_on_commit(
workspace,
"workspace_invite.deleted",
payload=invite_payload,
extra_user_ids=extra_user_ids,
)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@ -340,6 +381,17 @@ class WorkspaceJoinEndpoint(BaseAPIView):
}, },
) )
publish_nodedc_workspace_event_on_commit(
workspace_invite.workspace,
"workspace_member.created",
payload={
"member_id": str(user.id),
"role": workspace_invite.role,
"source": "tasker",
},
extra_user_ids=[user.id, workspace_invite.created_by_id],
)
# Delete the invitation # Delete the invitation
workspace_invite.delete() workspace_invite.delete()
@ -447,6 +499,16 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
}, },
) )
) )
publish_nodedc_workspace_event_on_commit(
invitation.workspace,
"workspace_member.created",
payload={
"member_id": str(request.user.id),
"role": invitation.role,
"source": "tasker",
},
extra_user_ids=[request.user.id, invitation.created_by_id],
)
# Bulk create the user for all the workspaces # Bulk create the user for all the workspaces
WorkspaceMember.objects.bulk_create( WorkspaceMember.objects.bulk_create(

View File

@ -12,6 +12,8 @@ from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE
from plane.app.realtime.issue_events import publish_assignee_cleanup_issue_events_on_commit
from plane.app.realtime.nodedc_events import publish_nodedc_workspace_event_on_commit
from plane.authentication.nodedc_workspace_policy import ( from plane.authentication.nodedc_workspace_policy import (
get_nodedc_workspace_creation_policy, get_nodedc_workspace_creation_policy,
is_nodedc_launcher_managed_workspace, is_nodedc_launcher_managed_workspace,
@ -54,7 +56,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
workspace_member = WorkspaceMember.objects.get(member=request.user, workspace__slug=slug, is_active=True) workspace_member = WorkspaceMember.objects.get(member=request.user, workspace__slug=slug, is_active=True)
# Get all active workspace members # Get all active workspace members
workspace_members = self.get_queryset() workspace_members = self.get_queryset().filter(is_active=True)
if workspace_member.role > 5: if workspace_member.role > 5:
serializer = WorkspaceMemberAdminSerializer(workspace_members, fields=("id", "member", "role"), many=True) serializer = WorkspaceMemberAdminSerializer(workspace_members, fields=("id", "member", "role"), many=True)
else: else:
@ -102,6 +104,16 @@ class WorkSpaceMemberViewSet(BaseViewSet):
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
publish_nodedc_workspace_event_on_commit(
workspace_member.workspace,
"workspace_member.updated",
payload={
"member_id": str(workspace_member.member_id),
"role": workspace_member.role,
"source": "tasker",
},
extra_user_ids=[workspace_member.member_id],
)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -166,6 +178,20 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_502_BAD_GATEWAY, status=status.HTTP_502_BAD_GATEWAY,
) )
project_ids = list(
ProjectMember.objects.filter(
workspace__slug=slug,
member_id=workspace_member.member_id,
is_active=True,
).values_list("project_id", flat=True)
)
publish_assignee_cleanup_issue_events_on_commit(
workspace_id=workspace_member.workspace_id,
assignee_id=workspace_member.member_id,
actor_id=request.user.id,
)
# Deactivate the users from the projects where the user is part of # Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter( _ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
@ -174,6 +200,16 @@ class WorkSpaceMemberViewSet(BaseViewSet):
workspace_member.is_active = False workspace_member.is_active = False
workspace_member.save() workspace_member.save()
publish_nodedc_workspace_event_on_commit(
workspace_member.workspace,
"workspace_member.deleted",
payload={
"member_id": str(workspace_member.member_id),
"project_ids": [str(project_id) for project_id in project_ids],
"source": "tasker",
},
extra_user_ids=[workspace_member.member_id],
)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@invalidate_cache( @invalidate_cache(
@ -224,6 +260,20 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
project_ids = list(
ProjectMember.objects.filter(
workspace__slug=slug,
member_id=workspace_member.member_id,
is_active=True,
).values_list("project_id", flat=True)
)
publish_assignee_cleanup_issue_events_on_commit(
workspace_id=workspace_member.workspace_id,
assignee_id=workspace_member.member_id,
actor_id=request.user.id,
)
# # Deactivate the users from the projects where the user is part of # # Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter( _ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
@ -233,6 +283,16 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# # Deactivate the user # # Deactivate the user
workspace_member.is_active = False workspace_member.is_active = False
workspace_member.save() workspace_member.save()
publish_nodedc_workspace_event_on_commit(
workspace_member.workspace,
"workspace_member.deleted",
payload={
"member_id": str(workspace_member.member_id),
"project_ids": [str(project_id) for project_id in project_ids],
"source": "tasker",
},
extra_user_ids=[workspace_member.member_id],
)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -10,6 +10,11 @@ from django.views import View
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized
from plane.app.realtime.issue_events import publish_assignee_cleanup_issue_events_on_commit
from plane.app.realtime.nodedc_events import (
publish_nodedc_user_profile_event_on_commit,
publish_nodedc_workspace_event_on_commit,
)
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.db.models import ( from plane.db.models import (
ExternalIdentityLink, ExternalIdentityLink,
@ -361,6 +366,9 @@ class NodeDCInternalUserProfileSyncEndpoint(View):
with transaction.atomic(): with transaction.atomic():
updated_fields = sync_user_profile_from_payload(user, payload) updated_fields = sync_user_profile_from_payload(user, payload)
if updated_fields:
publish_nodedc_user_profile_event_on_commit(user, changed_fields=updated_fields)
return JsonResponse( return JsonResponse(
{ {
"ok": True, "ok": True,
@ -434,6 +442,17 @@ class NodeDCInternalWorkspaceMembershipEnsureEndpoint(View):
profile.last_workspace_id = workspace.id profile.last_workspace_id = workspace.id
profile.save(update_fields=["last_workspace_id", "updated_at"]) profile.save(update_fields=["last_workspace_id", "updated_at"])
publish_nodedc_workspace_event_on_commit(
workspace,
"workspace_member.created" if created else "workspace_member.updated",
payload={
"member_id": str(user.id),
"role": membership.role,
"source": "launcher",
},
extra_user_ids=[user.id],
)
return JsonResponse({"ok": True, "membership": serialize_membership(membership, created)}) return JsonResponse({"ok": True, "membership": serialize_membership(membership, created)})
@ -475,7 +494,16 @@ class NodeDCInternalWorkspaceMembershipRemoveEndpoint(View):
} }
) )
project_ids = list(
ProjectMember.objects.filter(
project__workspace=workspace,
member=user,
is_active=True,
).values_list("project_id", flat=True)
)
with transaction.atomic(): with transaction.atomic():
publish_assignee_cleanup_issue_events_on_commit(workspace_id=workspace.id, assignee_id=user.id)
ProjectMember.objects.filter( ProjectMember.objects.filter(
project__workspace=workspace, project__workspace=workspace,
member=user, member=user,
@ -488,6 +516,17 @@ class NodeDCInternalWorkspaceMembershipRemoveEndpoint(View):
membership.is_active = False membership.is_active = False
membership.save(update_fields=["is_active", "updated_at"]) membership.save(update_fields=["is_active", "updated_at"])
publish_nodedc_workspace_event_on_commit(
workspace,
"workspace_member.deleted",
payload={
"member_id": str(user.id),
"project_ids": [str(project_id) for project_id in project_ids],
"source": "launcher",
},
extra_user_ids=[user.id],
)
return JsonResponse( return JsonResponse(
{ {
"ok": True, "ok": True,
@ -530,6 +569,19 @@ class NodeDCInternalWorkspaceInviteApproveEndpoint(View):
] ]
) )
invited_user = User.objects.filter(email__iexact=invitation.email, is_bot=False).first()
publish_nodedc_workspace_event_on_commit(
invitation.workspace,
"workspace_invite.approved",
payload={
"invite_id": str(invitation.id),
"email": invitation.email,
"status": invitation.nodedc_approval_status,
"source": "launcher",
},
extra_user_ids=[invitation.created_by_id, getattr(invited_user, "id", None)],
)
return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)}) return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)})
@ -561,6 +613,19 @@ class NodeDCInternalWorkspaceInviteRejectEndpoint(View):
] ]
) )
invited_user = User.objects.filter(email__iexact=invitation.email, is_bot=False).first()
publish_nodedc_workspace_event_on_commit(
invitation.workspace,
"workspace_invite.rejected",
payload={
"invite_id": str(invitation.id),
"email": invitation.email,
"status": invitation.nodedc_approval_status,
"source": "launcher",
},
extra_user_ids=[invitation.created_by_id, getattr(invited_user, "id", None)],
)
return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)}) return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)})
@ -648,6 +713,28 @@ class NodeDCInternalProjectMembershipEnsureEndpoint(View):
profile.last_workspace_id = workspace.id profile.last_workspace_id = workspace.id
profile.save(update_fields=["last_workspace_id", "updated_at"]) profile.save(update_fields=["last_workspace_id", "updated_at"])
publish_nodedc_workspace_event_on_commit(
workspace,
"workspace_member.updated",
payload={
"member_id": str(user.id),
"role": workspace_membership.role,
"source": "launcher",
},
extra_user_ids=[user.id],
)
publish_nodedc_workspace_event_on_commit(
workspace,
"project_member.created" if created else "project_member.updated",
payload={
"project_id": str(project.id),
"member_id": str(user.id),
"role": project_member.role,
"source": "launcher",
},
extra_user_ids=[user.id],
)
return JsonResponse({"ok": True, "membership": serialize_project_membership(project_member, created)}) return JsonResponse({"ok": True, "membership": serialize_project_membership(project_member, created)})
@ -695,10 +782,22 @@ class NodeDCInternalProjectMembershipRemoveEndpoint(View):
} }
) )
publish_assignee_cleanup_issue_events_on_commit(project_id=project.id, assignee_id=user.id)
project_member.is_active = False project_member.is_active = False
project_member.save(update_fields=["is_active", "updated_at"]) project_member.save(update_fields=["is_active", "updated_at"])
IssueAssignee.objects.filter(project=project, assignee=user).delete() IssueAssignee.objects.filter(project=project, assignee=user).delete()
publish_nodedc_workspace_event_on_commit(
workspace,
"project_member.deleted",
payload={
"project_id": str(project.id),
"member_id": str(user.id),
"source": "launcher",
},
extra_user_ids=[user.id],
)
return JsonResponse( return JsonResponse(
{ {
"ok": True, "ok": True,

View File

@ -3,7 +3,7 @@ FROM node:22-alpine AS base
# Setup pnpm package manager with corepack and configure global bin directory for caching # Setup pnpm package manager with corepack and configure global bin directory for caching
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
RUN corepack enable RUN corepack enable
# ***************************************************************************** # *****************************************************************************

View File

@ -8,6 +8,7 @@ import { CollaborationController } from "./collaboration.controller";
import { DocumentController } from "./document.controller"; import { DocumentController } from "./document.controller";
import { HealthController } from "./health.controller"; import { HealthController } from "./health.controller";
import { IssueStreamController } from "./issue-stream.controller"; import { IssueStreamController } from "./issue-stream.controller";
import { NodeDCStreamController } from "./nodedc-stream.controller";
import { PdfExportController } from "./pdf-export.controller"; import { PdfExportController } from "./pdf-export.controller";
export const CONTROLLERS = [ export const CONTROLLERS = [
@ -15,5 +16,6 @@ export const CONTROLLERS = [
DocumentController, DocumentController,
HealthController, HealthController,
IssueStreamController, IssueStreamController,
NodeDCStreamController,
PdfExportController, PdfExportController,
]; ];

View File

@ -0,0 +1,126 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Request } from "express";
import type Redis from "ioredis";
import type { WebSocket as WSSocket } from "ws";
// plane imports
import { Controller, WebSocket as WSDecorator } from "@plane/decorators";
import { logger } from "@plane/logger";
// redis
import { redisManager } from "@/redis";
// services
import { UserService } from "@/services/user.service";
const NODEDC_EVENT_CHANNEL_PREFIX = "plane:nodedc-events:user";
const HEARTBEAT_INTERVAL_MS = 25_000;
const sendJson = (ws: WSSocket, payload: Record<string, unknown>) => {
if (ws.readyState !== 1) return;
ws.send(JSON.stringify(payload));
};
@Controller("/nodedc")
export class NodeDCStreamController {
[key: string]: unknown;
@WSDecorator("/stream")
handleConnection(ws: WSSocket, req: Request) {
void this.handleNodeDCStream(ws, req);
}
private async handleNodeDCStream(ws: WSSocket, req: Request) {
const cookie = req.headers.cookie?.toString();
if (!cookie) {
ws.close(1008, "Missing NODE.DC stream credentials");
return;
}
let subscriber: Redis | undefined;
let heartbeat: NodeJS.Timeout | undefined;
let isCleanedUp = false;
const cleanup = async () => {
if (isCleanedUp) return;
isCleanedUp = true;
if (heartbeat) clearInterval(heartbeat);
if (!subscriber) return;
try {
await subscriber.unsubscribe();
subscriber.disconnect();
} catch (error) {
logger.error("NODEDC_STREAM_CONTROLLER: Redis cleanup failed:", error);
}
};
try {
const userService = new UserService();
const user = await userService.currentUser(cookie);
const redisClient = redisManager.getClient();
if (!redisClient) {
ws.close(1011, "NODE.DC stream unavailable");
return;
}
const channel = `${NODEDC_EVENT_CHANNEL_PREFIX}:${user.id}`;
subscriber = redisClient.duplicate({ lazyConnect: true });
await subscriber.connect();
await subscriber.subscribe(channel);
subscriber.on("message", (_channel, message) => {
try {
const event = JSON.parse(message) as Record<string, unknown>;
sendJson(ws, event);
} catch (error) {
logger.error("NODEDC_STREAM_CONTROLLER: Failed to forward event:", error);
}
});
subscriber.on("error", (error) => {
logger.error("NODEDC_STREAM_CONTROLLER: Redis subscriber error:", error);
ws.close(1011, "NODE.DC stream subscriber failed");
});
heartbeat = setInterval(() => {
sendJson(ws, { type: "nodedc.stream.ping", server_ts: new Date().toISOString() });
}, HEARTBEAT_INTERVAL_MS);
sendJson(ws, {
type: "nodedc.stream.ready",
user_id: user.id,
server_ts: new Date().toISOString(),
});
} catch (error) {
logger.error("NODEDC_STREAM_CONTROLLER: WebSocket authentication failed:", error);
ws.close(1008, "NODE.DC stream authentication failed");
await cleanup();
return;
}
ws.on("message", (message) => {
try {
const payload = JSON.parse(message.toString()) as { type?: string };
if (payload.type === "nodedc.stream.pong") return;
} catch {
// Client messages are optional for this stream.
}
});
ws.on("close", () => {
void cleanup();
});
ws.on("error", (error: Error) => {
logger.error("NODEDC_STREAM_CONTROLLER: WebSocket connection error:", error);
ws.close(1011, "NODE.DC stream connection failed");
void cleanup();
});
}
}

View File

@ -6,7 +6,6 @@
import { useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
// types // types
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
@ -22,12 +21,11 @@ type Props = {
onSubmit: () => Promise<void>; onSubmit: () => Promise<void>;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
projectId: string;
}; };
export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMemberRemove(props: Props) { export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMemberRemove(props: Props) {
const { data, onSubmit, isOpen, onClose } = props; const { data, onSubmit, isOpen, onClose, projectId } = props;
// router
const { projectId } = useParams();
// states // states
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// store hooks // store hooks
@ -50,44 +48,62 @@ export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMember
if (!projectId) return <></>; if (!projectId) return <></>;
const isCurrentUser = currentUser?.id === data?.id; const isCurrentUser = currentUser?.id === data?.id;
const currentProjectDetails = getProjectById(projectId.toString()); const currentProjectDetails = getProjectById(projectId);
const memberName = data?.display_name || "участника";
return ( return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}> <ModalCore
<div className="bg-surface-1 px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> isOpen={isOpen}
<div className="sm:flex sm:items-start"> handleClose={handleClose}
<div className="mx-auto flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-danger-subtle sm:mx-0 sm:h-10 sm:w-10"> position={EModalPosition.CENTER}
<AlertTriangle className="h-6 w-6 text-danger-primary" aria-hidden="true" /> width={EModalWidth.XXL}
className="overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] p-0 shadow-[0_28px_80px_rgba(0,0,0,0.42)]"
>
<div className="p-6 sm:p-7">
<div className="flex items-start gap-4">
<div className="grid size-12 shrink-0 place-items-center rounded-[1.2rem] bg-[rgb(var(--nodedc-accent-rgb))]/18 text-[rgb(var(--nodedc-accent-rgb))]">
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <div className="min-w-0">
<h3 className="text-16 leading-6 font-medium text-primary"> <p className="text-12 font-semibold tracking-[0.24em] text-tertiary uppercase">NODE.DC Tasker</p>
{isCurrentUser ? "Leave project?" : `Remove ${data?.display_name}?`} <h3 className="mt-2 text-18 leading-6 font-semibold text-primary">
{isCurrentUser ? "Покинуть проект?" : `Удалить ${memberName} из проекта?`}
</h3> </h3>
<div className="mt-2"> <p className="mt-3 text-13 leading-6 text-secondary">
<p className="text-13 text-secondary"> {isCurrentUser ? (
{isCurrentUser ? ( <>
<> Вы потеряете доступ к проекту{" "}
Are you sure you want to leave the <span className="font-bold">{currentProjectDetails?.name}</span>{" "} <span className="font-semibold text-primary">{currentProjectDetails?.name}</span>. Вернуться можно
project? You will be able to join the project if invited again or if it{"'"}s public. будет только после нового приглашения или если проект публичный.
</> </>
) : ( ) : (
<> <>
Are you sure you want to remove member- <span className="font-bold">{data?.display_name}</span>? Пользователь <span className="font-semibold text-primary">{memberName}</span> потеряет доступ к
They will no longer have access to this project. This action cannot be undone. проекту. Действие нельзя отменить автоматически.
</> </>
)} )}
</p> </p>
</div>
</div> </div>
</div> </div>
</div> <div className="mt-7 flex justify-end gap-3">
<div className="flex justify-end gap-2 p-4 sm:px-6"> <Button
<Button variant="secondary" size="lg" onClick={handleClose}> variant="secondary"
Cancel size="lg"
</Button> onClick={handleClose}
<Button variant="error-fill" size="lg" tabIndex={1} onClick={handleDeletion} loading={isDeleteLoading}> className="nodedc-modal-secondary-button min-w-[11.5rem] px-8"
{isCurrentUser ? (isDeleteLoading ? "Leaving..." : "Leave") : isDeleteLoading ? "Removing..." : "Remove"} >
</Button> Отмена
</Button>
<Button
variant="primary"
size="lg"
onClick={handleDeletion}
loading={isDeleteLoading}
className="nodedc-modal-primary-button min-w-[12.5rem] px-8"
>
{isCurrentUser ? (isDeleteLoading ? "Выходим..." : "Покинуть") : isDeleteLoading ? "Удаляем..." : "Удалить"}
</Button>
</div>
</div> </div>
</ModalCore> </ModalCore>
); );

View File

@ -48,6 +48,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
await leaveProject(workspaceSlug.toString(), projectId.toString()) await leaveProject(workspaceSlug.toString(), projectId.toString())
.then(async () => { .then(async () => {
router.push(`/${workspaceSlug}/projects`); router.push(`/${workspaceSlug}/projects`);
return undefined;
}) })
.catch((err) => { .catch((err) => {
setToast({ setToast({
@ -55,6 +56,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
title: "You cant leave this project yet.", title: "You cant leave this project yet.",
message: err?.error || "Something went wrong. Please try again.", message: err?.error || "Something went wrong. Please try again.",
}); });
return undefined;
}); });
} else } else
await removeMemberFromProject(workspaceSlug.toString(), projectId.toString(), memberId).catch((err) => await removeMemberFromProject(workspaceSlug.toString(), projectId.toString(), memberId).catch((err) =>
@ -73,6 +75,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
<ConfirmProjectMemberRemove <ConfirmProjectMemberRemove
isOpen={removeMemberModal !== null} isOpen={removeMemberModal !== null}
onClose={() => setRemoveMemberModal(null)} onClose={() => setRemoveMemberModal(null)}
projectId={projectId}
data={{ id: removeMemberModal.member.id, display_name: removeMemberModal.member.display_name || "" }} data={{ id: removeMemberModal.member.id, display_name: removeMemberModal.member.display_name || "" }}
onSubmit={() => handleRemove(removeMemberModal.member.id)} onSubmit={() => handleRemove(removeMemberModal.member.id)}
/> />
@ -82,7 +85,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
columns={columns} columns={columns}
data={(memberDetails?.filter((member): member is IProjectMemberDetails => member !== null) ?? []) as any} data={(memberDetails?.filter((member): member is IProjectMemberDetails => member !== null) ?? []) as any}
keyExtractor={(rowData) => rowData?.member.id ?? ""} keyExtractor={(rowData) => rowData?.member.id ?? ""}
tableClassName="nodedc-settings-table-surface w-max table-auto border-separate border-spacing-0 overflow-visible" tableClassName="nodedc-settings-table-surface min-w-full table-auto border-separate border-spacing-0 overflow-visible"
tHeadClassName="border-b border-white/6" tHeadClassName="border-b border-white/6"
thClassName="text-left font-medium divide-x-0 text-placeholder" thClassName="text-left font-medium divide-x-0 text-placeholder"
tBodyClassName="divide-y-0" tBodyClassName="divide-y-0"

View File

@ -69,12 +69,12 @@ export const MemberSelect = observer(function MemberSelect(props: Props) {
<SearchSelectionDropdown <SearchSelectionDropdown
value={value} value={value}
label={ label={
<div className="flex h-3.5 items-center gap-2"> <div className="flex min-h-5 min-w-0 items-center gap-2">
{selectedOption && ( {selectedOption && (
<Avatar name={selectedOption.member?.display_name} src={getFileURL(selectedOption.member?.avatar_url)} /> <Avatar name={selectedOption.member?.display_name} src={getFileURL(selectedOption.member?.avatar_url)} />
)} )}
{selectedOption ? ( {selectedOption ? (
selectedOption.member?.display_name <span className="truncate">{selectedOption.member?.display_name}</span>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Ban className="h-3.5 w-3.5 rotate-90 text-placeholder" /> <Ban className="h-3.5 w-3.5 rotate-90 text-placeholder" />

View File

@ -19,6 +19,7 @@ import { Loader, ToggleSwitch } from "@plane/ui";
import { PROJECT_DETAILS } from "@/constants/fetch-keys"; import { PROJECT_DETAILS } from "@/constants/fetch-keys";
// hooks // hooks
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
import { useMember } from "@/hooks/store/use-member";
import { useUserPermissions } from "@/hooks/store/user"; import { useUserPermissions } from "@/hooks/store/user";
// local imports // local imports
import { MemberSelect } from "./member-select"; import { MemberSelect } from "./member-select";
@ -60,6 +61,9 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
// store hooks // store hooks
const { allowPermissions } = useUserPermissions(); const { allowPermissions } = useUserPermissions();
const {
project: { fetchProjectMembers, getProjectMemberFetchStatus },
} = useMember();
const { currentProjectDetails, fetchProjectDetails, updateProject } = useProject(); const { currentProjectDetails, fetchProjectDetails, updateProject } = useProject();
// derived values // derived values
const isAdmin = allowPermissions( const isAdmin = allowPermissions(
@ -76,6 +80,14 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug, projectId) : null workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug, projectId) : null
); );
const hasFetchedProjectMembers = getProjectMemberFetchStatus(projectId);
useEffect(() => {
if (!workspaceSlug || !projectId || hasFetchedProjectMembers) return;
void fetchProjectMembers(workspaceSlug, projectId, true).catch(console.error);
}, [fetchProjectMembers, hasFetchedProjectMembers, projectId, workspaceSlug]);
useEffect(() => { useEffect(() => {
if (!currentProjectDetails) return; if (!currentProjectDetails) return;

View File

@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
@ -194,7 +193,7 @@ export const ProjectSettingsModal = observer(function ProjectSettingsModal() {
handleClose={handleClose} handleClose={handleClose}
position={EModalPosition.CENTER} position={EModalPosition.CENTER}
width={EModalWidth.VIIXL} width={EModalWidth.VIIXL}
className="h-[88vh] max-h-[920px] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)]" className="h-[88vh] max-h-[920px] !max-w-[calc(100vw-1.5rem)] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)] sm:!max-w-[calc(100vw-2rem)] xl:!max-w-[88rem]"
> >
{workspaceSlug && activeProjectId ? ( {workspaceSlug && activeProjectId ? (
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={activeProjectId}> <ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={activeProjectId}>
@ -233,7 +232,7 @@ export const ProjectSettingsModal = observer(function ProjectSettingsModal() {
size="sm" size="sm"
className="min-h-0 flex-1 overflow-y-auto" className="min-h-0 flex-1 overflow-y-auto"
> >
<div className="mx-auto w-full max-w-[74rem] px-5 pb-7 lg:px-8"> <div className="mx-auto w-full max-w-[82rem] px-5 pb-7 lg:px-6">
<ProjectSettingsModalContent <ProjectSettingsModalContent
activeTab={activeTab} activeTab={activeTab}
projectId={activeProjectId} projectId={activeProjectId}

View File

@ -71,11 +71,22 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-end gap-2 p-4 sm:px-6"> <div className="flex justify-end gap-3 p-4 sm:px-6">
<Button variant="secondary" size="lg" onClick={handleClose}> <Button
variant="secondary"
size="lg"
onClick={handleClose}
className="nodedc-modal-secondary-button min-w-[11.5rem] px-8"
>
Отменить Отменить
</Button> </Button>
<Button variant="primary" size="lg" tabIndex={1} onClick={handleDeletion} loading={isRemoving}> <Button
variant="primary"
size="lg"
onClick={handleDeletion}
loading={isRemoving}
className="nodedc-modal-primary-button min-w-[12.5rem] px-8"
>
{currentUser?.id === userDetails.id {currentUser?.id === userDetails.id
? isRemoving ? isRemoving
? "Выход..." ? "Выход..."

View File

@ -91,7 +91,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
if (isEmpty(columns)) return <MembersLayoutLoader />; if (isEmpty(columns)) return <MembersLayoutLoader />;
return ( return (
<div className="horizontal-scrollbar scrollbar-sm w-full overflow-x-auto overflow-y-hidden rounded-[1.2rem]"> <div className="w-full overflow-visible rounded-[1.2rem]">
{removeMemberModal && ( {removeMemberModal && (
<ConfirmWorkspaceMemberRemove <ConfirmWorkspaceMemberRemove
isOpen={removeMemberModal.member.id.length > 0} isOpen={removeMemberModal.member.id.length > 0}
@ -109,7 +109,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
(memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as unknown as RowData[] (memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as unknown as RowData[]
} }
keyExtractor={(rowData) => rowData?.member.id ?? ""} keyExtractor={(rowData) => rowData?.member.id ?? ""}
tableClassName="nodedc-settings-table-surface w-max table-auto border-separate border-spacing-0 overflow-visible" tableClassName="nodedc-settings-table-surface min-w-full table-auto border-separate border-spacing-0 overflow-visible"
tHeadClassName="border-b border-white/6" tHeadClassName="border-b border-white/6"
thClassName="text-left font-medium divide-x-0 text-placeholder" thClassName="text-left font-medium divide-x-0 text-placeholder"
tBodyClassName="divide-y-0" tBodyClassName="divide-y-0"

View File

@ -49,7 +49,14 @@ import {
const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["billing-and-plans"]); const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["billing-and-plans"]);
const LAUNCHER_MANAGED_HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["members"]); const LAUNCHER_MANAGED_HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["members"]);
const WORKSPACE_FEATURE_GATED_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["ai-voice-tasker"]); const WORKSPACE_FEATURE_GATED_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["ai-voice-tasker"]);
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>(["general", "members", "export", "storage", "webhooks", "ai-voice-tasker"]); const MODAL_TABS = new Set<TWorkspaceSettingsTabs>([
"general",
"members",
"export",
"storage",
"webhooks",
"ai-voice-tasker",
]);
const workspaceAIService = new WorkspaceAIService(); const workspaceAIService = new WorkspaceAIService();
const workspaceService = new WorkspaceService(); const workspaceService = new WorkspaceService();
@ -77,7 +84,11 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
const { t } = useTranslation(); const { t } = useTranslation();
const canLoadVoiceTaskerEntitlement = const canLoadVoiceTaskerEntitlement =
!!currentWorkspace?.slug && !!currentWorkspace?.slug &&
allowPermissions(WORKSPACE_SETTINGS["ai-voice-tasker"].access, EUserPermissionsLevel.WORKSPACE, currentWorkspace.slug); allowPermissions(
WORKSPACE_SETTINGS["ai-voice-tasker"].access,
EUserPermissionsLevel.WORKSPACE,
currentWorkspace.slug
);
const { data: aiSettings, isLoading: isVoiceTaskerEntitlementLoading } = useSWR( const { data: aiSettings, isLoading: isVoiceTaskerEntitlementLoading } = useSWR(
canLoadVoiceTaskerEntitlement ? `WORKSPACE_AI_SETTINGS_${currentWorkspace.slug}` : null, canLoadVoiceTaskerEntitlement ? `WORKSPACE_AI_SETTINGS_${currentWorkspace.slug}` : null,
() => workspaceAIService.retrieveSettings(currentWorkspace?.slug as string) () => workspaceAIService.retrieveSettings(currentWorkspace?.slug as string)
@ -94,7 +105,9 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
const tab = getWorkspaceSettingsModalTabFromSearch(window.location.search); const tab = getWorkspaceSettingsModalTabFromSearch(window.location.search);
setIsOpen(Boolean(tab)); setIsOpen(Boolean(tab));
if (tab) setActiveTab(tab); if (tab) setActiveTab(tab);
setActiveWebhookId(tab === "webhooks" ? getWorkspaceSettingsWebhookIdFromSearch(window.location.search) : undefined); setActiveWebhookId(
tab === "webhooks" ? getWorkspaceSettingsWebhookIdFromSearch(window.location.search) : undefined
);
}; };
const handleModalEvent = (event: Event) => { const handleModalEvent = (event: Event) => {
@ -162,7 +175,9 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
} }
if (activeTab === "webhooks" && currentWorkspace?.slug) { if (activeTab === "webhooks" && currentWorkspace?.slug) {
return <WorkspaceWebhooksSettingsContent selectedWebhookId={activeWebhookId} workspaceSlug={currentWorkspace.slug} />; return (
<WorkspaceWebhooksSettingsContent selectedWebhookId={activeWebhookId} workspaceSlug={currentWorkspace.slug} />
);
} }
return <WorkspaceDetails />; return <WorkspaceDetails />;
@ -178,7 +193,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
handleClose={handleClose} handleClose={handleClose}
position={EModalPosition.CENTER} position={EModalPosition.CENTER}
width={EModalWidth.VIIXL} width={EModalWidth.VIIXL}
className="h-[88vh] max-h-[920px] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)]" className="h-[88vh] max-h-[920px] !max-w-[calc(100vw-1.5rem)] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)] sm:!max-w-[calc(100vw-2rem)] xl:!max-w-[88rem]"
> >
<div className="flex h-full min-h-0"> <div className="flex h-full min-h-0">
<div className="hidden h-full w-[296px] shrink-0 md:block"> <div className="hidden h-full w-[296px] shrink-0 md:block">
@ -212,7 +227,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
</div> </div>
<ScrollArea scrollType="hover" orientation="vertical" size="sm" className="min-h-0 flex-1 overflow-y-auto"> <ScrollArea scrollType="hover" orientation="vertical" size="sm" className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-[74rem] px-5 pb-7 lg:px-8">{renderContent()}</div> <div className="mx-auto w-full max-w-[78rem] px-5 pb-7 lg:px-8">{renderContent()}</div>
</ScrollArea> </ScrollArea>
</div> </div>
</div> </div>
@ -260,7 +275,7 @@ function WorkspaceModalSidebar({
return ( return (
<div key={category} className="shrink-0 py-3.5 first:pt-0 last:pb-0"> <div key={category} className="shrink-0 py-3.5 first:pt-0 last:pb-0">
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary"> <div className="px-3 py-1.5 text-[11px] font-semibold tracking-[0.18em] text-tertiary uppercase">
{t(category)} {t(category)}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">

View File

@ -0,0 +1,238 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { useSWRConfig } from "swr";
// plane imports
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
type TNodeDCRealtimeEvent = {
event_id?: string;
type?: string;
workspace_slug?: string;
project_id?: string;
member_id?: string;
affected_user_ids?: string[];
project_ids?: string[];
};
const MAX_PROCESSED_EVENTS = 250;
const buildNodeDCStreamUrl = () => {
const liveBaseUrl = LIVE_BASE_URL?.trim() || window.location.origin;
const liveBasePath = LIVE_BASE_PATH?.trim() || "/live";
const url = new URL(liveBaseUrl);
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
url.pathname = `${liveBasePath.replace(/\/$/, "")}/nodedc/stream`;
return url.toString();
};
const isWorkspacePath = (pathname: string | null, workspaceSlug: string) =>
pathname === `/${workspaceSlug}` || !!pathname?.startsWith(`/${workspaceSlug}/`);
const isProjectPath = (pathname: string | null, workspaceSlug: string, projectId: string) =>
pathname === `/${workspaceSlug}/projects/${projectId}` ||
!!pathname?.startsWith(`/${workspaceSlug}/projects/${projectId}/`);
const isCurrentUserAffected = (event: TNodeDCRealtimeEvent, currentUserId?: string) => {
if (!currentUserId) return false;
if (event.member_id === currentUserId) return true;
return event.affected_user_ids?.includes(currentUserId) ?? false;
};
export const useNodeDCRealtimeEvents = (enabled: boolean, currentUserId?: string) => {
const pathname = usePathname();
const router = useAppRouter();
const { mutate } = useSWRConfig();
const workspaceRoot = useWorkspace();
const projectStore = useProject();
const memberRoot = useMember();
const userStore = useUser();
const pathnameRef = useRef(pathname);
const currentUserIdRef = useRef(currentUserId);
const processedEventIdsRef = useRef<string[]>([]);
const processedEventSetRef = useRef(new Set<string>());
useEffect(() => {
pathnameRef.current = pathname;
}, [pathname]);
useEffect(() => {
currentUserIdRef.current = currentUserId;
}, [currentUserId]);
useEffect(() => {
if (!enabled || typeof window === "undefined") return;
let socket: WebSocket | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let cancelled = false;
let reconnectAttempt = 0;
const rememberEvent = (eventId?: string) => {
if (!eventId) return true;
if (processedEventSetRef.current.has(eventId)) return false;
processedEventIdsRef.current.push(eventId);
processedEventSetRef.current.add(eventId);
if (processedEventIdsRef.current.length > MAX_PROCESSED_EVENTS) {
const removedEventId = processedEventIdsRef.current.shift();
if (removedEventId) processedEventSetRef.current.delete(removedEventId);
}
return true;
};
const refreshWorkspaceScope = async (event: TNodeDCRealtimeEvent) => {
const workspaceSlug = event.workspace_slug;
if (!workspaceSlug) return;
await Promise.allSettled([
workspaceRoot.fetchWorkspaces(),
memberRoot.workspace.fetchWorkspaceMembers(workspaceSlug),
memberRoot.workspace.fetchWorkspaceMemberInvitations(workspaceSlug),
projectStore.fetchProjects(workspaceSlug),
userStore.permission.fetchUserProjectPermissions(workspaceSlug),
]);
if (
event.type === "workspace_member.deleted" &&
isCurrentUserAffected(event, currentUserIdRef.current) &&
isWorkspacePath(pathnameRef.current, workspaceSlug)
) {
router.replace(workspaceRoot.getWorkspaceRedirectionUrl());
}
};
const refreshProjectScope = async (event: TNodeDCRealtimeEvent) => {
const workspaceSlug = event.workspace_slug;
const projectId = event.project_id;
if (!workspaceSlug) return;
await Promise.allSettled([
workspaceRoot.fetchWorkspaces(),
memberRoot.workspace.fetchWorkspaceMembers(workspaceSlug),
projectStore.fetchProjects(workspaceSlug),
userStore.permission.fetchUserProjectPermissions(workspaceSlug),
projectId ? memberRoot.project.fetchProjectMembers(workspaceSlug, projectId, true) : Promise.resolve(),
]);
if (
projectId &&
event.type === "project_member.deleted" &&
isCurrentUserAffected(event, currentUserIdRef.current) &&
isProjectPath(pathnameRef.current, workspaceSlug, projectId)
) {
router.replace(`/${workspaceSlug}`);
}
};
const refreshInviteScope = async (event: TNodeDCRealtimeEvent) => {
await Promise.allSettled([
mutate("USER_WORKSPACE_INVITATIONS_NOTICE"),
event.workspace_slug
? memberRoot.workspace.fetchWorkspaceMemberInvitations(event.workspace_slug)
: Promise.resolve(),
]);
};
const refreshProfileScope = async (event: TNodeDCRealtimeEvent) => {
await Promise.allSettled([
isCurrentUserAffected(event, currentUserIdRef.current) ? userStore.fetchCurrentUser() : Promise.resolve(),
event.workspace_slug ? memberRoot.workspace.fetchWorkspaceMembers(event.workspace_slug) : Promise.resolve(),
event.workspace_slug && event.project_id
? memberRoot.project.fetchProjectMembers(event.workspace_slug, event.project_id, true)
: Promise.resolve(),
mutate("USER_INFORMATION"),
]);
};
const handleEvent = async (event: TNodeDCRealtimeEvent) => {
if (!rememberEvent(event.event_id)) return;
if (event.type?.startsWith("workspace_member.")) {
await refreshWorkspaceScope(event);
return;
}
if (event.type?.startsWith("project_member.")) {
await refreshProjectScope(event);
return;
}
if (event.type?.startsWith("workspace_invite.")) {
await refreshInviteScope(event);
return;
}
if (event.type === "user.profile.updated") {
await refreshProfileScope(event);
}
};
const scheduleReconnect = () => {
if (cancelled) return;
const delay = Math.min(1000 * 2 ** reconnectAttempt, 15000);
reconnectAttempt += 1;
reconnectTimer = setTimeout(connect, delay);
};
const connect = () => {
try {
socket = new WebSocket(buildNodeDCStreamUrl());
socket.addEventListener("open", () => {
reconnectAttempt = 0;
});
socket.addEventListener("message", (message) => {
try {
const event = JSON.parse(message.data) as TNodeDCRealtimeEvent;
if (event.type === "nodedc.stream.ping") {
socket?.send(JSON.stringify({ type: "nodedc.stream.pong" }));
return;
}
if (event.type === "nodedc.stream.ready") return;
void handleEvent(event);
} catch (error) {
console.error("Failed to process NODE.DC realtime event", error);
}
});
socket.addEventListener("close", () => {
socket = undefined;
scheduleReconnect();
});
socket.addEventListener("error", () => {
socket?.close();
});
} catch (error) {
console.error("Failed to connect NODE.DC realtime stream", error);
scheduleReconnect();
}
};
connect();
return () => {
cancelled = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
socket?.close();
};
}, [enabled, memberRoot, mutate, projectStore, router, userStore, workspaceRoot]);
};

View File

@ -18,6 +18,7 @@ import { buildNodeDCOIDCLoginUrl, getCurrentRelativePath, shouldUseNodeDCOIDC }
import { useWorkspace } from "@/hooks/store/use-workspace"; import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUser, useUserProfile, useUserSettings } from "@/hooks/store/user"; import { useUser, useUserProfile, useUserSettings } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
import { useNodeDCRealtimeEvents } from "@/hooks/use-nodedc-realtime-events";
// services // services
import { WorkspaceService } from "@/services/workspace.service"; import { WorkspaceService } from "@/services/workspace.service";
@ -64,6 +65,8 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro
const shouldWatchPendingInvites = const shouldWatchPendingInvites =
pageType === EPageTypes.AUTHENTICATED && !!currentUser?.id && isUserOnboard && pathname !== "/invitations"; pageType === EPageTypes.AUTHENTICATED && !!currentUser?.id && isUserOnboard && pathname !== "/invitations";
useNodeDCRealtimeEvents(pageType === EPageTypes.AUTHENTICATED && !!currentUser?.id && isUserOnboard, currentUser?.id);
const { data: pendingWorkspaceInvitations } = useSWR( const { data: pendingWorkspaceInvitations } = useSWR(
shouldWatchPendingInvites ? "USER_WORKSPACE_INVITATIONS_NOTICE" : null, shouldWatchPendingInvites ? "USER_WORKSPACE_INVITATIONS_NOTICE" : null,
() => workspaceService.userWorkspaceInvitations(), () => workspaceService.userWorkspaceInvitations(),
@ -79,7 +82,12 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro
const inviteKey = pendingWorkspaceInvitations const inviteKey = pendingWorkspaceInvitations
.map((invitation) => invitation.id) .map((invitation) => invitation.id)
.toSorted() .reduce<string[]>((sortedInvitationIds, invitationId) => {
const insertAt = sortedInvitationIds.findIndex((sortedInvitationId) => sortedInvitationId > invitationId);
if (insertAt === -1) sortedInvitationIds.push(invitationId);
else sortedInvitationIds.splice(insertAt, 0, invitationId);
return sortedInvitationIds;
}, [])
.join(":"); .join(":");
if (pendingInviteToastKey.current === inviteKey) return; if (pendingInviteToastKey.current === inviteKey) return;
pendingInviteToastKey.current = inviteKey; pendingInviteToastKey.current = inviteKey;

View File

@ -236,15 +236,17 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
fetchWorkspaceMembers = async (workspaceSlug: string) => fetchWorkspaceMembers = async (workspaceSlug: string) =>
await this.workspaceService.fetchWorkspaceMembers(workspaceSlug).then((response) => { await this.workspaceService.fetchWorkspaceMembers(workspaceSlug).then((response) => {
runInAction(() => { runInAction(() => {
const nextWorkspaceMemberMap: Record<string, IWorkspaceMembership> = {};
response.forEach((member) => { response.forEach((member) => {
set(this.memberRoot?.memberMap, member.member.id, { ...member.member, joining_date: member.created_at }); set(this.memberRoot?.memberMap, member.member.id, { ...member.member, joining_date: member.created_at });
set(this.workspaceMemberMap, [workspaceSlug, member.member.id], { nextWorkspaceMemberMap[member.member.id] = {
id: member.id, id: member.id,
member: member.member.id, member: member.member.id,
role: member.role, role: member.role as EUserPermissions,
is_active: member.is_active, is_active: member.is_active,
}); };
}); });
set(this.workspaceMemberMap, workspaceSlug, nextWorkspaceMemberMap);
}); });
return response; return response;
}); });

View File

@ -341,6 +341,20 @@ export class ProjectStore implements IProjectStore {
} }
const projectsResponse = await this.projectService.getProjects(workspaceSlug); const projectsResponse = await this.projectService.getProjects(workspaceSlug);
runInAction(() => { runInAction(() => {
const workspaceId = this.rootStore.workspaceRoot.getWorkspaceBySlug(workspaceSlug)?.id;
const projectIds = new Set(projectsResponse.map((project) => project.id));
if (workspaceId) {
Object.values(this.projectMap)
.filter((project) => project.workspace === workspaceId && !projectIds.has(project.id))
.forEach((project) => {
delete this.projectMap[project.id];
if (this.rootStore.user.permission.workspaceProjectsPermissions?.[workspaceSlug]) {
delete this.rootStore.user.permission.workspaceProjectsPermissions[workspaceSlug][project.id];
}
});
}
projectsResponse.forEach((project) => { projectsResponse.forEach((project) => {
update(this.projectMap, [project.id], (p) => ({ ...p, ...project })); update(this.projectMap, [project.id], (p) => ({ ...p, ...project }));
}); });

View File

@ -188,9 +188,10 @@ export abstract class BaseWorkspaceRootStore implements IWorkspaceRootStore {
try { try {
const workspaceResponse = await this.workspaceService.userWorkspaces(); const workspaceResponse = await this.workspaceService.userWorkspaces();
runInAction(() => { runInAction(() => {
workspaceResponse.forEach((workspace) => { this.workspaces = workspaceResponse.reduce<Record<string, IWorkspace>>((workspaceMap, workspace) => {
set(this.workspaces, [workspace.id], workspace); workspaceMap[workspace.id] = workspace;
}); return workspaceMap;
}, {});
}); });
return workspaceResponse; return workspaceResponse;
} finally { } finally {

View File

@ -58,14 +58,6 @@ export function CustomSearchSelect(props: ICustomSearchSelectProps) {
const filteredOptions = const filteredOptions =
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())); query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
const comboboxProps: any = {
value,
onChange,
disabled,
};
if (multiple) comboboxProps.multiple = true;
const openDropdown = () => { const openDropdown = () => {
setIsOpen(true); setIsOpen(true);
if (referenceElement) referenceElement.focus(); if (referenceElement) referenceElement.focus();
@ -74,7 +66,7 @@ export function CustomSearchSelect(props: ICustomSearchSelectProps) {
const closeDropdown = () => { const closeDropdown = () => {
setIsOpen(false); setIsOpen(false);
onClose && onClose(); onClose?.();
}; };
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
@ -85,10 +77,24 @@ export function CustomSearchSelect(props: ICustomSearchSelectProps) {
else openDropdown(); else openDropdown();
}; };
const handleChange = (nextValue: any) => {
onChange(nextValue);
if (!multiple) closeDropdown();
};
const comboboxProps: any = {
value,
onChange: handleChange,
disabled,
};
if (multiple) comboboxProps.multiple = true;
return ( return (
<Combobox <Combobox
as="div" as="div"
ref={dropdownRef} ref={dropdownRef}
role="presentation"
tabIndex={tabIndex} tabIndex={tabIndex}
className={cn("relative flex-shrink-0 text-left", className)} className={cn("relative flex-shrink-0 text-left", className)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}