diff --git a/plane-src/apps/api/plane/app/views/workspace/base.py b/plane-src/apps/api/plane/app/views/workspace/base.py index d19e89f..d5cd33f 100644 --- a/plane-src/apps/api/plane/app/views/workspace/base.py +++ b/plane-src/apps/api/plane/app/views/workspace/base.py @@ -30,6 +30,7 @@ from plane.app.permissions import ( WorkSpaceBasePermission, WorkspaceEntityPermission, ) +from plane.app.realtime.nodedc_events import publish_nodedc_event_to_users_on_commit # Module imports from plane.app.serializers import WorkSpaceSerializer, WorkspaceThemeSerializer @@ -197,6 +198,14 @@ class WorkSpaceViewSet(BaseViewSet): def destroy(self, request, *args, **kwargs): # Get the workspace workspace = self.get_object() + workspace_member_ids = list( + WorkspaceMember.objects.filter( + workspace_id=workspace.id, + is_active=True, + member__is_bot=False, + deleted_at__isnull=True, + ).values_list("member_id", flat=True) + ) self.remove_last_workspace_ids_from_user_settings(workspace.id) track_event.delay( user_id=request.user.id, @@ -211,7 +220,16 @@ class WorkSpaceViewSet(BaseViewSet): "deleted_at": str(timezone.now().isoformat()), }, ) - return super().destroy(request, *args, **kwargs) + response = super().destroy(request, *args, **kwargs) + publish_nodedc_event_to_users_on_commit( + "workspace.deleted", + workspace_member_ids, + { + "workspace_id": str(workspace.id), + "workspace_slug": workspace.slug, + }, + ) + return response class UserWorkSpacesEndpoint(BaseAPIView): diff --git a/plane-src/apps/api/plane/authentication/views/nodedc_logout.py b/plane-src/apps/api/plane/authentication/views/nodedc_logout.py index 1117168..8fb9252 100644 --- a/plane-src/apps/api/plane/authentication/views/nodedc_logout.py +++ b/plane-src/apps/api/plane/authentication/views/nodedc_logout.py @@ -13,6 +13,8 @@ from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.csrf import csrf_exempt +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.utils.host import user_ip from plane.db.models import ExternalIdentityLink, IssueAssignee, ProjectMember, Session, User, WorkspaceMember @@ -240,10 +242,57 @@ def delete_queryset(queryset): def revoke_user_tasker_access(user): + workspace_memberships = list( + WorkspaceMember.objects.filter(member=user) + .select_related("workspace") + .only("id", "workspace_id", "workspace__id", "workspace__slug") + ) + project_memberships = list( + ProjectMember.objects.filter(member=user) + .select_related("workspace", "project") + .only("id", "workspace_id", "project_id", "workspace__id", "workspace__slug") + ) + workspace_by_id = {} + project_ids_by_workspace_id = {} + + for membership in workspace_memberships: + workspace_by_id[membership.workspace_id] = membership.workspace + project_ids_by_workspace_id.setdefault(membership.workspace_id, set()) + + for membership in project_memberships: + workspace_by_id[membership.workspace_id] = membership.workspace + project_ids_by_workspace_id.setdefault(membership.workspace_id, set()).add(membership.project_id) + publish_assignee_cleanup_issue_events_on_commit(project_id=membership.project_id, assignee_id=user.id) + deleted_issue_assignees = delete_queryset(IssueAssignee.objects.filter(assignee=user)) deleted_project_memberships = delete_queryset(ProjectMember.objects.filter(member=user)) deleted_workspace_memberships = delete_queryset(WorkspaceMember.objects.filter(member=user)) + for workspace_id, workspace in workspace_by_id.items(): + project_ids = [str(project_id) for project_id in project_ids_by_workspace_id.get(workspace_id, set())] + publish_nodedc_workspace_event_on_commit( + workspace, + "workspace_member.deleted", + payload={ + "member_id": str(user.id), + "project_ids": project_ids, + "source": "launcher", + }, + extra_user_ids=[user.id], + ) + + for membership in project_memberships: + publish_nodedc_workspace_event_on_commit( + membership.workspace, + "project_member.deleted", + payload={ + "project_id": str(membership.project_id), + "member_id": str(user.id), + "source": "launcher", + }, + extra_user_ids=[user.id], + ) + return { "workspaceMemberships": deleted_workspace_memberships, "projectMemberships": deleted_project_memberships, diff --git a/plane-src/apps/web/core/components/workspace-notifications/notifications-modal.tsx b/plane-src/apps/web/core/components/workspace-notifications/notifications-modal.tsx index 58eee5c..40548ab 100644 --- a/plane-src/apps/web/core/components/workspace-notifications/notifications-modal.tsx +++ b/plane-src/apps/web/core/components/workspace-notifications/notifications-modal.tsx @@ -10,8 +10,10 @@ import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { X } from "lucide-react"; // plane imports +import { ENotificationLoader, ENotificationQueryParamType } from "@plane/constants"; import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; // hooks +import { useWorkspaceNotifications } from "@/hooks/store/notifications"; import { useWorkspace } from "@/hooks/store/use-workspace"; // local imports import { NotificationsRoot } from "./root"; @@ -31,6 +33,7 @@ const getInitialOpenState = () => { export const WorkspaceNotificationsModal = observer(function WorkspaceNotificationsModal() { const [isOpen, setIsOpen] = useState(getInitialOpenState); const { currentWorkspace } = useWorkspace(); + const { getNotifications, setCurrentSelectedNotificationId } = useWorkspaceNotifications(); useEffect(() => { const syncFromLocation = () => { @@ -54,6 +57,17 @@ export const WorkspaceNotificationsModal = observer(function WorkspaceNotificati }; }, []); + useEffect(() => { + if (!isOpen || !currentWorkspace?.slug) return; + + setCurrentSelectedNotificationId(undefined); + void getNotifications( + currentWorkspace.slug, + ENotificationLoader.MUTATION_LOADER, + ENotificationQueryParamType.CURRENT + ); + }, [currentWorkspace?.slug, getNotifications, isOpen, setCurrentSelectedNotificationId]); + const handleClose = () => closeWorkspaceNotificationsModal(); return ( diff --git a/plane-src/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx b/plane-src/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx index 1d22935..57eac4f 100644 --- a/plane-src/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx +++ b/plane-src/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx @@ -44,10 +44,11 @@ type WorkspaceMenuStateSyncProps = { sidebarPanelButtonRef: RefObject; onSidebarDropdownToggle: (value: boolean) => void; onSidebarPanelPositionChange: (position: { left: number; top: number; width: number } | null) => void; + onOpen?: () => void; }; function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) { - const { open, variant, sidebarPanelButtonRef, onSidebarDropdownToggle, onSidebarPanelPositionChange } = props; + const { open, variant, sidebarPanelButtonRef, onOpen, onSidebarDropdownToggle, onSidebarPanelPositionChange } = props; const updateSidebarPanelMenuPosition = useCallback(() => { if ( @@ -72,6 +73,10 @@ function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) { onSidebarDropdownToggle(open); }, [onSidebarDropdownToggle, open]); + useEffect(() => { + if (open) onOpen?.(); + }, [onOpen, open]); + useLayoutEffect(() => { if (!open || !["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant)) { onSidebarPanelPositionChange(null); @@ -102,7 +107,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work const { signOut } = useUser(); const { updateUserProfile } = useUserProfile(); const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace(); - const { data: nodedcWorkspacePolicy } = useSWR(currentUser ? "NODEDC_WORKSPACE_POLICY" : null, () => + const { data: nodedcWorkspacePolicy, mutate: mutateNodeDCWorkspacePolicy } = useSWR(currentUser ? "NODEDC_WORKSPACE_POLICY" : null, () => workspaceService.getNodeDCWorkspacePolicy() ); // derived values @@ -118,6 +123,9 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work } | null>(null); const sidebarPanelButtonRef = useRef(null); + const handleWorkspaceMenuOpen = useCallback(() => { + void mutateNodeDCWorkspacePolicy(); + }, [mutateNodeDCWorkspacePolicy]); const handleWorkspaceNavigation = (workspace: IWorkspace) => updateUserProfile({ last_workspace_id: workspace?.id }); @@ -157,6 +165,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work variant={variant} sidebarPanelButtonRef={sidebarPanelButtonRef} onSidebarDropdownToggle={toggleAnySidebarDropdown} + onOpen={handleWorkspaceMenuOpen} onSidebarPanelPositionChange={setSidebarPanelMenuPosition} /> {variant === "sidebar" && ( diff --git a/plane-src/apps/web/core/hooks/use-nodedc-realtime-events.ts b/plane-src/apps/web/core/hooks/use-nodedc-realtime-events.ts index 130afd1..36b9d1a 100644 --- a/plane-src/apps/web/core/hooks/use-nodedc-realtime-events.ts +++ b/plane-src/apps/web/core/hooks/use-nodedc-realtime-events.ts @@ -99,6 +99,7 @@ export const useNodeDCRealtimeEvents = (enabled: boolean, currentUserId?: string const refreshWorkspaceScope = async (event: TNodeDCRealtimeEvent) => { const workspaceSlug = event.workspace_slug; if (!workspaceSlug) return; + const projectIds = [...new Set(event.project_ids?.filter(Boolean) ?? [])]; await Promise.allSettled([ workspaceRoot.fetchWorkspaces(), @@ -106,6 +107,9 @@ export const useNodeDCRealtimeEvents = (enabled: boolean, currentUserId?: string memberRoot.workspace.fetchWorkspaceMemberInvitations(workspaceSlug), projectStore.fetchProjects(workspaceSlug), userStore.permission.fetchUserProjectPermissions(workspaceSlug), + mutate("NODEDC_WORKSPACE_POLICY"), + mutate(`NODEDC_WORKSPACE_POLICY_${workspaceSlug}`), + ...projectIds.map((projectId) => memberRoot.project.fetchProjectMembers(workspaceSlug, projectId, true)), ]); if ( @@ -127,6 +131,8 @@ export const useNodeDCRealtimeEvents = (enabled: boolean, currentUserId?: string memberRoot.workspace.fetchWorkspaceMembers(workspaceSlug), projectStore.fetchProjects(workspaceSlug), userStore.permission.fetchUserProjectPermissions(workspaceSlug), + mutate("NODEDC_WORKSPACE_POLICY"), + mutate(`NODEDC_WORKSPACE_POLICY_${workspaceSlug}`), projectId ? memberRoot.project.fetchProjectMembers(workspaceSlug, projectId, true) : Promise.resolve(), ]); @@ -140,6 +146,21 @@ export const useNodeDCRealtimeEvents = (enabled: boolean, currentUserId?: string } }; + const refreshDeletedWorkspaceScope = async (event: TNodeDCRealtimeEvent) => { + const workspaceSlug = event.workspace_slug; + if (!workspaceSlug) return; + + await Promise.allSettled([ + workspaceRoot.fetchWorkspaces(), + mutate("NODEDC_WORKSPACE_POLICY"), + mutate(`NODEDC_WORKSPACE_POLICY_${workspaceSlug}`), + ]); + + if (isWorkspacePath(pathnameRef.current, workspaceSlug)) { + router.replace(workspaceRoot.getWorkspaceRedirectionUrl()); + } + }; + const refreshInviteScope = async (event: TNodeDCRealtimeEvent) => { await Promise.allSettled([ mutate("USER_WORKSPACE_INVITATIONS_NOTICE"), @@ -163,6 +184,11 @@ export const useNodeDCRealtimeEvents = (enabled: boolean, currentUserId?: string const handleEvent = async (event: TNodeDCRealtimeEvent) => { if (!rememberEvent(event.event_id)) return; + if (event.type === "workspace.deleted") { + await refreshDeletedWorkspaceScope(event); + return; + } + if (event.type?.startsWith("workspace_member.")) { await refreshWorkspaceScope(event); return;