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 8a53e02..6084fd5 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 @@ -46,6 +46,17 @@ const isProjectPath = (pathname: string | null, workspaceSlug: string, projectId pathname === `/${workspaceSlug}/projects/${projectId}` || !!pathname?.startsWith(`/${workspaceSlug}/projects/${projectId}/`); +const getProjectIdFromPath = (pathname: string | null, workspaceSlug: string) => { + if (!pathname) return undefined; + const match = pathname.match(new RegExp(`^/${workspaceSlug}/projects/([^/]+)`)); + return match?.[1]; +}; + +const getProjectWorkspaceId = (project?: { workspace?: string | { id?: string } | null }) => { + if (!project) return undefined; + return typeof project.workspace === "string" ? project.workspace : project.workspace?.id; +}; + const isCurrentUserAffected = (event: TNodeDCRealtimeEvent, currentUserId?: string) => { if (!currentUserId) return false; if (event.member_id === currentUserId) return true; @@ -100,24 +111,65 @@ export const useNodeDCRealtimeEvents = (enabled: boolean, currentUserId?: string const workspaceSlug = event.workspace_slug; if (!workspaceSlug) return; const projectIds = [...new Set(event.project_ids?.filter(Boolean) ?? [])]; + const affectsCurrentUser = isCurrentUserAffected(event, currentUserIdRef.current); + const workspaceWasRemovedForCurrentUser = event.type === "workspace_member.deleted" && affectsCurrentUser; + const workspaceWasUpdatedForCurrentUser = event.type === "workspace_member.updated" && affectsCurrentUser; + const shouldRefreshWorkspaceList = + workspaceWasRemovedForCurrentUser || (event.type === "workspace_member.created" && affectsCurrentUser); + const shouldRefreshCurrentUserPermissions = affectsCurrentUser && event.type !== "workspace_member.deleted"; + const shouldRefreshProjectsForCurrentUser = affectsCurrentUser && event.type !== "workspace_member.deleted"; + + if (workspaceWasUpdatedForCurrentUser) { + await Promise.allSettled([ + userStore.permission.fetchUserWorkspaceInfo(workspaceSlug, { silent: true }), + userStore.permission.fetchUserProjectPermissions(workspaceSlug), + projectStore.fetchProjects(workspaceSlug, { silent: true }), + memberRoot.workspace.fetchWorkspaceMembers(workspaceSlug), + memberRoot.workspace.fetchWorkspaceMemberInvitations(workspaceSlug), + mutate("NODEDC_WORKSPACE_POLICY"), + mutate(`NODEDC_WORKSPACE_POLICY_${workspaceSlug}`), + ...projectIds.map((projectId) => memberRoot.project.fetchProjectMembers(workspaceSlug, projectId, true)), + ]); + + const routeProjectId = getProjectIdFromPath(pathnameRef.current, workspaceSlug); + const workspaceId = workspaceRoot.getWorkspaceBySlug(workspaceSlug)?.id; + const routeProject = routeProjectId ? projectStore.projectMap?.[routeProjectId] : undefined; + const routeProjectBelongsToWorkspace = + !!routeProject && !!workspaceId && getProjectWorkspaceId(routeProject) === workspaceId; + + if ( + routeProjectId && + isProjectPath(pathnameRef.current, workspaceSlug, routeProjectId) && + !routeProjectBelongsToWorkspace + ) { + router.replace(`/${workspaceSlug}`); + } + return; + } await Promise.allSettled([ - workspaceRoot.fetchWorkspaces(), - userStore.permission.fetchUserWorkspaceInfo(workspaceSlug), + shouldRefreshWorkspaceList ? workspaceRoot.fetchWorkspaces() : Promise.resolve(), + shouldRefreshCurrentUserPermissions + ? userStore.permission.fetchUserWorkspaceInfo(workspaceSlug, { silent: true }) + : Promise.resolve(), memberRoot.workspace.fetchWorkspaceMembers(workspaceSlug), memberRoot.workspace.fetchWorkspaceMemberInvitations(workspaceSlug), - projectStore.fetchProjects(workspaceSlug), - userStore.permission.fetchUserProjectPermissions(workspaceSlug), - mutate("NODEDC_WORKSPACE_POLICY"), - mutate(`NODEDC_WORKSPACE_POLICY_${workspaceSlug}`), + shouldRefreshProjectsForCurrentUser + ? projectStore.fetchProjects(workspaceSlug, { silent: true }) + : Promise.resolve(), + shouldRefreshCurrentUserPermissions + ? userStore.permission.fetchUserProjectPermissions(workspaceSlug) + : Promise.resolve(), + shouldRefreshCurrentUserPermissions || workspaceWasRemovedForCurrentUser + ? mutate("NODEDC_WORKSPACE_POLICY") + : Promise.resolve(), + shouldRefreshCurrentUserPermissions || workspaceWasRemovedForCurrentUser + ? mutate(`NODEDC_WORKSPACE_POLICY_${workspaceSlug}`) + : Promise.resolve(), ...projectIds.map((projectId) => memberRoot.project.fetchProjectMembers(workspaceSlug, projectId, true)), ]); - if ( - event.type === "workspace_member.deleted" && - isCurrentUserAffected(event, currentUserIdRef.current) && - isWorkspacePath(pathnameRef.current, workspaceSlug) - ) { + if (workspaceWasRemovedForCurrentUser && isWorkspacePath(pathnameRef.current, workspaceSlug)) { router.replace(workspaceRoot.getWorkspaceRedirectionUrl()); } }; @@ -126,21 +178,34 @@ export const useNodeDCRealtimeEvents = (enabled: boolean, currentUserId?: string const workspaceSlug = event.workspace_slug; const projectId = event.project_id; if (!workspaceSlug) return; + const affectsCurrentUser = isCurrentUserAffected(event, currentUserIdRef.current); + const projectWasRemovedForCurrentUser = event.type === "project_member.deleted" && affectsCurrentUser; + const shouldRefreshCurrentProjectDetails = + !!projectId && affectsCurrentUser && event.type !== "project_member.deleted"; + + if (projectId && projectWasRemovedForCurrentUser) { + projectStore.removeProjectFromStore(workspaceSlug, projectId); + } await Promise.allSettled([ - workspaceRoot.fetchWorkspaces(), + workspaceRoot.fetchWorkspaces({ silent: true }), memberRoot.workspace.fetchWorkspaceMembers(workspaceSlug), - projectStore.fetchProjects(workspaceSlug), + shouldRefreshCurrentProjectDetails && projectId + ? projectStore.fetchProjectDetails(workspaceSlug, projectId) + : !projectId && affectsCurrentUser + ? projectStore.fetchProjects(workspaceSlug, { silent: true }) + : Promise.resolve(), userStore.permission.fetchUserProjectPermissions(workspaceSlug), mutate("NODEDC_WORKSPACE_POLICY"), mutate(`NODEDC_WORKSPACE_POLICY_${workspaceSlug}`), - projectId ? memberRoot.project.fetchProjectMembers(workspaceSlug, projectId, true) : Promise.resolve(), + projectId && !projectWasRemovedForCurrentUser + ? memberRoot.project.fetchProjectMembers(workspaceSlug, projectId, true) + : Promise.resolve(), ]); if ( projectId && - event.type === "project_member.deleted" && - isCurrentUserAffected(event, currentUserIdRef.current) && + projectWasRemovedForCurrentUser && isProjectPath(pathnameRef.current, workspaceSlug, projectId) ) { router.replace(`/${workspaceSlug}`); diff --git a/plane-src/apps/web/core/store/project/project.store.ts b/plane-src/apps/web/core/store/project/project.store.ts index c3a35f1..3e92fe6 100644 --- a/plane-src/apps/web/core/store/project/project.store.ts +++ b/plane-src/apps/web/core/store/project/project.store.ts @@ -19,6 +19,14 @@ import { ProjectService, ProjectStateService, ProjectArchiveService } from "@/se import type { CoreRootStore } from "../root.store"; type ProjectOverviewCollapsible = "links" | "attachments" | "milestones"; +type TFetchProjectsOptions = { + silent?: boolean; +}; + +const getProjectWorkspaceId = (project: TProject | TPartialProject | undefined) => { + if (!project) return undefined; + return typeof project.workspace === "string" ? project.workspace : project.workspace?.id; +}; export interface IProjectStore { // observables @@ -53,10 +61,11 @@ export interface IProjectStore { // helper actions processProjectAfterCreation: (workspaceSlug: string, data: TProject) => void; + removeProjectFromStore: (workspaceSlug: string, projectId: string) => void; // fetch actions fetchPartialProjects: (workspaceSlug: string) => Promise; - fetchProjects: (workspaceSlug: string) => Promise; + fetchProjects: (workspaceSlug: string, options?: TFetchProjectsOptions) => Promise; fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise; fetchProjectAnalyticsCount: ( workspaceSlug: string, @@ -117,6 +126,7 @@ export class ProjectStore implements IProjectStore { currentProjectNextSequenceId: computed, // helper actions processProjectAfterCreation: action, + removeProjectFromStore: action, // fetch actions fetchPartialProjects: action, fetchProjects: action, @@ -221,7 +231,10 @@ export class ProjectStore implements IProjectStore { */ get currentProjectDetails() { if (!this.rootStore.router.projectId) return; - return this.projectMap?.[this.rootStore.router.projectId]; + const project = this.projectMap?.[this.rootStore.router.projectId]; + const currentWorkspace = this.rootStore.workspaceRoot.currentWorkspace; + if (!project || !currentWorkspace) return project; + return getProjectWorkspaceId(project) === currentWorkspace.id ? project : undefined; } /** @@ -301,6 +314,21 @@ export class ProjectStore implements IProjectStore { }); }; + removeProjectFromStore = (workspaceSlug: string, projectId: string) => { + const workspaceId = this.rootStore.workspaceRoot.getWorkspaceBySlug(workspaceSlug)?.id; + const project = this.projectMap[projectId]; + const projectWorkspaceId = getProjectWorkspaceId(project); + + if (project && workspaceId && projectWorkspaceId !== workspaceId) return; + + runInAction(() => { + delete this.projectMap[projectId]; + if (this.rootStore.user.permission.workspaceProjectsPermissions?.[workspaceSlug]) { + delete this.rootStore.user.permission.workspaceProjectsPermissions[workspaceSlug][projectId]; + } + }); + }; + /** * get Workspace projects partial data using workspace slug * @param workspaceSlug @@ -332,12 +360,14 @@ export class ProjectStore implements IProjectStore { * @returns Promise * */ - fetchProjects = async (workspaceSlug: string) => { + fetchProjects = async (workspaceSlug: string, options: TFetchProjectsOptions = {}) => { try { - if (this.workspaceProjectIds && this.workspaceProjectIds.length > 0) { - this.loader = "mutation"; - } else { - this.loader = "init-loader"; + if (!options.silent) { + if (this.workspaceProjectIds && this.workspaceProjectIds.length > 0) { + this.loader = "mutation"; + } else { + this.loader = "init-loader"; + } } const projectsResponse = await this.projectService.getProjects(workspaceSlug); runInAction(() => { @@ -358,13 +388,13 @@ export class ProjectStore implements IProjectStore { projectsResponse.forEach((project) => { update(this.projectMap, [project.id], (p) => ({ ...p, ...project })); }); - this.loader = "loaded"; + if (!options.silent) this.loader = "loaded"; this.fetchStatus = "complete"; }); return projectsResponse; } catch (error) { console.log("Failed to fetch project from workspace store"); - this.loader = "loaded"; + if (!options.silent) this.loader = "loaded"; throw error; } }; diff --git a/plane-src/apps/web/core/store/user/base-permissions.store.ts b/plane-src/apps/web/core/store/user/base-permissions.store.ts index 8810ab2..ec633f5 100644 --- a/plane-src/apps/web/core/store/user/base-permissions.store.ts +++ b/plane-src/apps/web/core/store/user/base-permissions.store.ts @@ -27,6 +27,9 @@ import userService from "@/services/user.service"; const workspaceService = new WorkspaceService(); type ETempUserRole = TUserPermissions | EUserWorkspaceRoles | EUserProjectRoles; // TODO: Remove this once we have migrated user permissions to enums to plane constants package +type TFetchUserWorkspaceInfoOptions = { + silent?: boolean; +}; export interface IBaseUserPermissionStore { loader: boolean; @@ -51,7 +54,10 @@ export interface IBaseUserPermissionStore { onPermissionAllowed?: () => boolean ) => boolean; // actions - fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise; + fetchUserWorkspaceInfo: ( + workspaceSlug: string, + options?: TFetchUserWorkspaceInfoOptions + ) => Promise; leaveWorkspace: (workspaceSlug: string) => Promise; fetchUserProjectInfo: (workspaceSlug: string, projectId: string) => Promise; fetchUserProjectPermissions: (workspaceSlug: string) => Promise; @@ -234,20 +240,23 @@ export abstract class BaseUserPermissionStore implements IBaseUserPermissionStor * @param { string } workspaceSlug * @returns { Promise } */ - fetchUserWorkspaceInfo = async (workspaceSlug: string): Promise => { + fetchUserWorkspaceInfo = async ( + workspaceSlug: string, + options?: TFetchUserWorkspaceInfoOptions + ): Promise => { try { - this.loader = true; + if (!options?.silent) this.loader = true; const response = await workspaceService.workspaceMemberMe(workspaceSlug); if (response) { runInAction(() => { set(this.workspaceUserInfo, [workspaceSlug], response); - this.loader = false; + if (!options?.silent) this.loader = false; }); } return response; } catch (error) { console.error("Error fetching user workspace information", error); - this.loader = false; + if (!options?.silent) this.loader = false; throw error; } }; diff --git a/plane-src/apps/web/core/store/workspace/index.ts b/plane-src/apps/web/core/store/workspace/index.ts index 3c9d3db..293c706 100644 --- a/plane-src/apps/web/core/store/workspace/index.ts +++ b/plane-src/apps/web/core/store/workspace/index.ts @@ -26,6 +26,10 @@ import { HomeStore } from "./home"; import type { IWebhookStore } from "./webhook.store"; import { WebhookStore } from "./webhook.store"; +type TFetchWorkspacesOptions = { + silent?: boolean; +}; + export interface IWorkspaceRootStore { loader: boolean; // observables @@ -40,7 +44,7 @@ export interface IWorkspaceRootStore { getWorkspaceBySlug: (workspaceSlug: string) => IWorkspace | null; getWorkspaceById: (workspaceId: string) => IWorkspace | null; // fetch actions - fetchWorkspaces: () => Promise; + fetchWorkspaces: (options?: TFetchWorkspacesOptions) => Promise; // crud actions createWorkspace: (data: Partial) => Promise; updateWorkspace: (workspaceSlug: string, data: Partial) => Promise; @@ -183,8 +187,8 @@ export abstract class BaseWorkspaceRootStore implements IWorkspaceRootStore { /** * fetch user workspaces from API */ - fetchWorkspaces = async () => { - this.loader = true; + fetchWorkspaces = async (options: TFetchWorkspacesOptions = {}) => { + if (!options.silent) this.loader = true; try { const workspaceResponse = await this.workspaceService.userWorkspaces(); runInAction(() => { @@ -195,7 +199,7 @@ export abstract class BaseWorkspaceRootStore implements IWorkspaceRootStore { }); return workspaceResponse; } finally { - this.loader = false; + if (!options.silent) this.loader = false; } };