FIX - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: бесшовный realtime проектов Tasker

This commit is contained in:
DCCONSTRUCTIONS 2026-05-13 01:39:18 +03:00
parent 2717726440
commit af01a205f0
4 changed files with 142 additions and 34 deletions

View File

@ -46,6 +46,17 @@ const isProjectPath = (pathname: string | null, workspaceSlug: string, projectId
pathname === `/${workspaceSlug}/projects/${projectId}` || pathname === `/${workspaceSlug}/projects/${projectId}` ||
!!pathname?.startsWith(`/${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) => { const isCurrentUserAffected = (event: TNodeDCRealtimeEvent, currentUserId?: string) => {
if (!currentUserId) return false; if (!currentUserId) return false;
if (event.member_id === currentUserId) return true; if (event.member_id === currentUserId) return true;
@ -100,24 +111,65 @@ export const useNodeDCRealtimeEvents = (enabled: boolean, currentUserId?: string
const workspaceSlug = event.workspace_slug; const workspaceSlug = event.workspace_slug;
if (!workspaceSlug) return; if (!workspaceSlug) return;
const projectIds = [...new Set(event.project_ids?.filter(Boolean) ?? [])]; 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([ await Promise.allSettled([
workspaceRoot.fetchWorkspaces(), shouldRefreshWorkspaceList ? workspaceRoot.fetchWorkspaces() : Promise.resolve(),
userStore.permission.fetchUserWorkspaceInfo(workspaceSlug), shouldRefreshCurrentUserPermissions
? userStore.permission.fetchUserWorkspaceInfo(workspaceSlug, { silent: true })
: Promise.resolve(),
memberRoot.workspace.fetchWorkspaceMembers(workspaceSlug), memberRoot.workspace.fetchWorkspaceMembers(workspaceSlug),
memberRoot.workspace.fetchWorkspaceMemberInvitations(workspaceSlug), memberRoot.workspace.fetchWorkspaceMemberInvitations(workspaceSlug),
projectStore.fetchProjects(workspaceSlug), shouldRefreshProjectsForCurrentUser
userStore.permission.fetchUserProjectPermissions(workspaceSlug), ? projectStore.fetchProjects(workspaceSlug, { silent: true })
mutate("NODEDC_WORKSPACE_POLICY"), : Promise.resolve(),
mutate(`NODEDC_WORKSPACE_POLICY_${workspaceSlug}`), 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)), ...projectIds.map((projectId) => memberRoot.project.fetchProjectMembers(workspaceSlug, projectId, true)),
]); ]);
if ( if (workspaceWasRemovedForCurrentUser && isWorkspacePath(pathnameRef.current, workspaceSlug)) {
event.type === "workspace_member.deleted" &&
isCurrentUserAffected(event, currentUserIdRef.current) &&
isWorkspacePath(pathnameRef.current, workspaceSlug)
) {
router.replace(workspaceRoot.getWorkspaceRedirectionUrl()); router.replace(workspaceRoot.getWorkspaceRedirectionUrl());
} }
}; };
@ -126,21 +178,34 @@ export const useNodeDCRealtimeEvents = (enabled: boolean, currentUserId?: string
const workspaceSlug = event.workspace_slug; const workspaceSlug = event.workspace_slug;
const projectId = event.project_id; const projectId = event.project_id;
if (!workspaceSlug) return; 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([ await Promise.allSettled([
workspaceRoot.fetchWorkspaces(), workspaceRoot.fetchWorkspaces({ silent: true }),
memberRoot.workspace.fetchWorkspaceMembers(workspaceSlug), 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), userStore.permission.fetchUserProjectPermissions(workspaceSlug),
mutate("NODEDC_WORKSPACE_POLICY"), mutate("NODEDC_WORKSPACE_POLICY"),
mutate(`NODEDC_WORKSPACE_POLICY_${workspaceSlug}`), 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 ( if (
projectId && projectId &&
event.type === "project_member.deleted" && projectWasRemovedForCurrentUser &&
isCurrentUserAffected(event, currentUserIdRef.current) &&
isProjectPath(pathnameRef.current, workspaceSlug, projectId) isProjectPath(pathnameRef.current, workspaceSlug, projectId)
) { ) {
router.replace(`/${workspaceSlug}`); router.replace(`/${workspaceSlug}`);

View File

@ -19,6 +19,14 @@ import { ProjectService, ProjectStateService, ProjectArchiveService } from "@/se
import type { CoreRootStore } from "../root.store"; import type { CoreRootStore } from "../root.store";
type ProjectOverviewCollapsible = "links" | "attachments" | "milestones"; 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 { export interface IProjectStore {
// observables // observables
@ -53,10 +61,11 @@ export interface IProjectStore {
// helper actions // helper actions
processProjectAfterCreation: (workspaceSlug: string, data: TProject) => void; processProjectAfterCreation: (workspaceSlug: string, data: TProject) => void;
removeProjectFromStore: (workspaceSlug: string, projectId: string) => void;
// fetch actions // fetch actions
fetchPartialProjects: (workspaceSlug: string) => Promise<TPartialProject[]>; fetchPartialProjects: (workspaceSlug: string) => Promise<TPartialProject[]>;
fetchProjects: (workspaceSlug: string) => Promise<TProject[]>; fetchProjects: (workspaceSlug: string, options?: TFetchProjectsOptions) => Promise<TProject[]>;
fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise<TProject>; fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise<TProject>;
fetchProjectAnalyticsCount: ( fetchProjectAnalyticsCount: (
workspaceSlug: string, workspaceSlug: string,
@ -117,6 +126,7 @@ export class ProjectStore implements IProjectStore {
currentProjectNextSequenceId: computed, currentProjectNextSequenceId: computed,
// helper actions // helper actions
processProjectAfterCreation: action, processProjectAfterCreation: action,
removeProjectFromStore: action,
// fetch actions // fetch actions
fetchPartialProjects: action, fetchPartialProjects: action,
fetchProjects: action, fetchProjects: action,
@ -221,7 +231,10 @@ export class ProjectStore implements IProjectStore {
*/ */
get currentProjectDetails() { get currentProjectDetails() {
if (!this.rootStore.router.projectId) return; 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 * get Workspace projects partial data using workspace slug
* @param workspaceSlug * @param workspaceSlug
@ -332,12 +360,14 @@ export class ProjectStore implements IProjectStore {
* @returns Promise<TProject[]> * @returns Promise<TProject[]>
* *
*/ */
fetchProjects = async (workspaceSlug: string) => { fetchProjects = async (workspaceSlug: string, options: TFetchProjectsOptions = {}) => {
try { try {
if (this.workspaceProjectIds && this.workspaceProjectIds.length > 0) { if (!options.silent) {
this.loader = "mutation"; if (this.workspaceProjectIds && this.workspaceProjectIds.length > 0) {
} else { this.loader = "mutation";
this.loader = "init-loader"; } else {
this.loader = "init-loader";
}
} }
const projectsResponse = await this.projectService.getProjects(workspaceSlug); const projectsResponse = await this.projectService.getProjects(workspaceSlug);
runInAction(() => { runInAction(() => {
@ -358,13 +388,13 @@ export class ProjectStore implements IProjectStore {
projectsResponse.forEach((project) => { projectsResponse.forEach((project) => {
update(this.projectMap, [project.id], (p) => ({ ...p, ...project })); update(this.projectMap, [project.id], (p) => ({ ...p, ...project }));
}); });
this.loader = "loaded"; if (!options.silent) this.loader = "loaded";
this.fetchStatus = "complete"; this.fetchStatus = "complete";
}); });
return projectsResponse; return projectsResponse;
} catch (error) { } catch (error) {
console.log("Failed to fetch project from workspace store"); console.log("Failed to fetch project from workspace store");
this.loader = "loaded"; if (!options.silent) this.loader = "loaded";
throw error; throw error;
} }
}; };

View File

@ -27,6 +27,9 @@ import userService from "@/services/user.service";
const workspaceService = new WorkspaceService(); 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 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 { export interface IBaseUserPermissionStore {
loader: boolean; loader: boolean;
@ -51,7 +54,10 @@ export interface IBaseUserPermissionStore {
onPermissionAllowed?: () => boolean onPermissionAllowed?: () => boolean
) => boolean; ) => boolean;
// actions // actions
fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise<IWorkspaceMemberMe>; fetchUserWorkspaceInfo: (
workspaceSlug: string,
options?: TFetchUserWorkspaceInfoOptions
) => Promise<IWorkspaceMemberMe>;
leaveWorkspace: (workspaceSlug: string) => Promise<void>; leaveWorkspace: (workspaceSlug: string) => Promise<void>;
fetchUserProjectInfo: (workspaceSlug: string, projectId: string) => Promise<TProjectMembership>; fetchUserProjectInfo: (workspaceSlug: string, projectId: string) => Promise<TProjectMembership>;
fetchUserProjectPermissions: (workspaceSlug: string) => Promise<IUserProjectsRole>; fetchUserProjectPermissions: (workspaceSlug: string) => Promise<IUserProjectsRole>;
@ -234,20 +240,23 @@ export abstract class BaseUserPermissionStore implements IBaseUserPermissionStor
* @param { string } workspaceSlug * @param { string } workspaceSlug
* @returns { Promise<IWorkspaceMemberMe | undefined> } * @returns { Promise<IWorkspaceMemberMe | undefined> }
*/ */
fetchUserWorkspaceInfo = async (workspaceSlug: string): Promise<IWorkspaceMemberMe> => { fetchUserWorkspaceInfo = async (
workspaceSlug: string,
options?: TFetchUserWorkspaceInfoOptions
): Promise<IWorkspaceMemberMe> => {
try { try {
this.loader = true; if (!options?.silent) this.loader = true;
const response = await workspaceService.workspaceMemberMe(workspaceSlug); const response = await workspaceService.workspaceMemberMe(workspaceSlug);
if (response) { if (response) {
runInAction(() => { runInAction(() => {
set(this.workspaceUserInfo, [workspaceSlug], response); set(this.workspaceUserInfo, [workspaceSlug], response);
this.loader = false; if (!options?.silent) this.loader = false;
}); });
} }
return response; return response;
} catch (error) { } catch (error) {
console.error("Error fetching user workspace information", error); console.error("Error fetching user workspace information", error);
this.loader = false; if (!options?.silent) this.loader = false;
throw error; throw error;
} }
}; };

View File

@ -26,6 +26,10 @@ import { HomeStore } from "./home";
import type { IWebhookStore } from "./webhook.store"; import type { IWebhookStore } from "./webhook.store";
import { WebhookStore } from "./webhook.store"; import { WebhookStore } from "./webhook.store";
type TFetchWorkspacesOptions = {
silent?: boolean;
};
export interface IWorkspaceRootStore { export interface IWorkspaceRootStore {
loader: boolean; loader: boolean;
// observables // observables
@ -40,7 +44,7 @@ export interface IWorkspaceRootStore {
getWorkspaceBySlug: (workspaceSlug: string) => IWorkspace | null; getWorkspaceBySlug: (workspaceSlug: string) => IWorkspace | null;
getWorkspaceById: (workspaceId: string) => IWorkspace | null; getWorkspaceById: (workspaceId: string) => IWorkspace | null;
// fetch actions // fetch actions
fetchWorkspaces: () => Promise<IWorkspace[]>; fetchWorkspaces: (options?: TFetchWorkspacesOptions) => Promise<IWorkspace[]>;
// crud actions // crud actions
createWorkspace: (data: Partial<IWorkspace>) => Promise<IWorkspace>; createWorkspace: (data: Partial<IWorkspace>) => Promise<IWorkspace>;
updateWorkspace: (workspaceSlug: string, data: Partial<IWorkspace>) => Promise<IWorkspace>; updateWorkspace: (workspaceSlug: string, data: Partial<IWorkspace>) => Promise<IWorkspace>;
@ -183,8 +187,8 @@ export abstract class BaseWorkspaceRootStore implements IWorkspaceRootStore {
/** /**
* fetch user workspaces from API * fetch user workspaces from API
*/ */
fetchWorkspaces = async () => { fetchWorkspaces = async (options: TFetchWorkspacesOptions = {}) => {
this.loader = true; if (!options.silent) this.loader = true;
try { try {
const workspaceResponse = await this.workspaceService.userWorkspaces(); const workspaceResponse = await this.workspaceService.userWorkspaces();
runInAction(() => { runInAction(() => {
@ -195,7 +199,7 @@ export abstract class BaseWorkspaceRootStore implements IWorkspaceRootStore {
}); });
return workspaceResponse; return workspaceResponse;
} finally { } finally {
this.loader = false; if (!options.silent) this.loader = false;
} }
}; };