feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
import { makeObservable, observable } from "mobx";
import { computedFn } from "mobx-utils";
// plane imports
import type { IUserLite } from "@plane/types";
// plane web imports
import type { IProjectMemberStore } from "@/plane-web/store/member/project-member.store";
import { ProjectMemberStore } from "@/plane-web/store/member/project-member.store";
import type { RootStore } from "@/plane-web/store/root.store";
// local imports
import type { IWorkspaceMemberStore } from "./workspace/workspace-member.store";
import { WorkspaceMemberStore } from "./workspace/workspace-member.store";
export interface IMemberRootStore {
// observables
memberMap: Record<string, IUserLite>;
// computed actions
getMemberIds: () => string[];
getUserDetails: (userId: string) => IUserLite | undefined;
// sub-stores
workspace: IWorkspaceMemberStore;
project: IProjectMemberStore;
}
export class MemberRootStore implements IMemberRootStore {
// observables
memberMap: Record<string, IUserLite> = {};
// sub-stores
workspace: IWorkspaceMemberStore;
project: IProjectMemberStore;
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observables
memberMap: observable,
});
// sub-stores
this.workspace = new WorkspaceMemberStore(this, _rootStore);
this.project = new ProjectMemberStore(this, _rootStore);
}
/**
* @description get all member ids
*/
getMemberIds = computedFn(() => Object.keys(this.memberMap));
/**
* @description get user details from userId
* @param userId
*/
getUserDetails = computedFn((userId: string): IUserLite | undefined => this.memberMap?.[userId] ?? undefined);
}

View File

@@ -0,0 +1,410 @@
import { uniq, unset, set, update, sortBy } from "lodash-es";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// plane imports
import { EUserPermissions } from "@plane/constants";
import type { EUserProjectRoles, IProjectBulkAddFormData, IUserLite, TProjectMembership } from "@plane/types";
// plane web imports
import type { RootStore } from "@/plane-web/store/root.store";
// services
import { ProjectMemberService } from "@/services/project";
// store
import type { IProjectStore } from "@/store/project/project.store";
import type { IRouterStore } from "@/store/router.store";
import type { IUserStore } from "@/store/user";
// local imports
import type { IMemberRootStore } from "../index";
import { sortProjectMembers } from "../utils";
import type { IProjectMemberFiltersStore } from "./project-member-filters.store";
import { ProjectMemberFiltersStore } from "./project-member-filters.store";
export interface IProjectMemberDetails extends Omit<TProjectMembership, "member"> {
member: IUserLite;
}
export interface IBaseProjectMemberStore {
// observables
projectMemberFetchStatusMap: {
[projectId: string]: boolean;
};
projectMemberMap: {
[projectId: string]: Record<string, TProjectMembership>;
};
// filters store
filters: IProjectMemberFiltersStore;
// computed
projectMemberIds: string[] | null;
// computed actions
getProjectMemberFetchStatus: (projectId: string) => boolean;
getProjectMemberDetails: (userId: string, projectId: string) => IProjectMemberDetails | null;
getProjectMemberIds: (projectId: string, includeGuestUsers: boolean) => string[] | null;
getFilteredProjectMemberDetails: (userId: string, projectId: string) => IProjectMemberDetails | null;
// fetch actions
fetchProjectMembers: (
workspaceSlug: string,
projectId: string,
clearExistingMembers?: boolean
) => Promise<TProjectMembership[]>;
// bulk operation actions
bulkAddMembersToProject: (
workspaceSlug: string,
projectId: string,
data: IProjectBulkAddFormData
) => Promise<TProjectMembership[]>;
// crud actions
updateMemberRole: (
workspaceSlug: string,
projectId: string,
userId: string,
role: EUserProjectRoles
) => Promise<TProjectMembership>;
removeMemberFromProject: (workspaceSlug: string, projectId: string, userId: string) => Promise<void>;
}
export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore {
// observables
projectMemberFetchStatusMap: {
[projectId: string]: boolean;
} = {};
projectMemberMap: {
[projectId: string]: Record<string, TProjectMembership>;
} = {};
// filters store
filters: IProjectMemberFiltersStore;
// stores
routerStore: IRouterStore;
userStore: IUserStore;
memberRoot: IMemberRootStore;
projectRoot: IProjectStore;
rootStore: RootStore;
// services
projectMemberService;
constructor(_memberRoot: IMemberRootStore, _rootStore: RootStore) {
makeObservable(this, {
// observables
projectMemberMap: observable,
// computed
projectMemberIds: computed,
// actions
fetchProjectMembers: action,
bulkAddMembersToProject: action,
updateMemberRole: action,
removeMemberFromProject: action,
});
// root store
this.rootStore = _rootStore;
this.routerStore = _rootStore.router;
this.userStore = _rootStore.user;
this.memberRoot = _memberRoot;
this.projectRoot = _rootStore.projectRoot.project;
this.filters = new ProjectMemberFiltersStore();
// services
this.projectMemberService = new ProjectMemberService();
}
/**
* @description get the list of all the user ids of all the members of the current project
* Returns filtered and sorted member IDs based on current filters
*/
get projectMemberIds() {
const projectId = this.routerStore.projectId;
if (!projectId) return null;
const members = Object.values(this.projectMemberMap?.[projectId] ?? {});
if (members.length === 0) return null;
// Access the filters directly to ensure MobX tracking
const currentFilters = this.filters.filtersMap[projectId];
// Apply filters and sorting directly here to ensure MobX tracking
const sortedMembers = sortProjectMembers(
members,
this.memberRoot?.memberMap || {},
(member) => member.member,
currentFilters
);
return sortedMembers.map((member) => member.member);
}
/**
* @description get the fetch status of a project member
* @param projectId
*/
getProjectMemberFetchStatus = computedFn((projectId: string) => this.projectMemberFetchStatusMap?.[projectId]);
/**
* @description get the project memberships
* @param projectId
*/
protected getProjectMemberships = computedFn((projectId: string) =>
Object.values(this.projectMemberMap?.[projectId] ?? {})
);
/**
* @description get the project membership by user id
* @param userId
* @param projectId
*/
protected getProjectMembershipByUserId = computedFn(
(userId: string, projectId: string) => this.projectMemberMap?.[projectId]?.[userId]
);
/**
* @description get the role from the project membership
* @param userId
* @param projectId
*/
protected getRoleFromProjectMembership = computedFn(
(userId: string, projectId: string): EUserProjectRoles | undefined => {
const projectMembership = this.getProjectMembershipByUserId(userId, projectId);
if (!projectMembership) return undefined;
const projectMembershipRole = projectMembership.original_role ?? projectMembership.role;
return projectMembershipRole ? (projectMembershipRole as EUserProjectRoles) : undefined;
}
);
/**
* @description Returns the project membership role for a user
* @description This method is specifically used when adding new members to a project. For existing members,
* the role is fetched directly from the backend during member listing.
* @param { string } userId - The ID of the user
* @param { string } projectId - The ID of the project
* @returns { EUserProjectRoles | undefined } The user's role in the project, or undefined if not found
*/
abstract getUserProjectRole: (userId: string, projectId: string) => EUserProjectRoles | undefined;
/**
* @description get the details of a project member
* @param userId
* @param projectId
*/
getProjectMemberDetails = computedFn((userId: string, projectId: string) => {
const projectMember = this.getProjectMembershipByUserId(userId, projectId);
const userDetails = this.memberRoot?.memberMap?.[projectMember?.member];
if (!projectMember || !userDetails) return null;
const memberDetails: IProjectMemberDetails = {
id: projectMember.id,
role: projectMember.role,
original_role: projectMember.original_role,
member: {
...userDetails,
joining_date: projectMember.created_at ?? undefined,
},
created_at: projectMember.created_at,
};
return memberDetails;
});
/**
* @description get the list of all the user ids of all the members of a project using projectId
* @param projectId
*/
getProjectMemberIds = computedFn((projectId: string, includeGuestUsers: boolean): string[] | null => {
if (!this.projectMemberMap?.[projectId]) return null;
let members = this.getProjectMemberships(projectId);
if (includeGuestUsers === false) {
members = members.filter((m) => m.role !== EUserPermissions.GUEST);
}
members = sortBy(members, [
(m) => m.member !== this.userStore.data?.id,
(m) => this.memberRoot?.memberMap?.[m.member]?.display_name?.toLowerCase(),
]);
const memberIds = members.map((m) => m.member);
return memberIds;
});
/**
* @description get the filtered project member details for a specific user
* @param userId
* @param projectId
*/
getFilteredProjectMemberDetails = computedFn((userId: string, projectId: string) => {
const projectMember = this.getProjectMembershipByUserId(userId, projectId);
const userDetails = this.memberRoot?.memberMap?.[projectMember?.member];
if (!projectMember || !userDetails) return null;
// Check if this member passes the current filters
const allMembers = this.getProjectMemberships(projectId);
const filteredMemberIds = this.filters.getFilteredMemberIds(
allMembers,
this.memberRoot?.memberMap || {},
(member) => member.member,
projectId
);
// Return null if this user doesn't pass the filters
if (!filteredMemberIds.includes(userId)) return null;
const memberDetails: IProjectMemberDetails = {
id: projectMember.id,
role: projectMember.role,
original_role: projectMember.original_role,
member: {
...userDetails,
joining_date: projectMember.created_at ?? undefined,
},
created_at: projectMember.created_at,
};
return memberDetails;
});
/**
* @description fetch the list of all the members of a project
* @param workspaceSlug
* @param projectId
*/
fetchProjectMembers = async (workspaceSlug: string, projectId: string, clearExistingMembers: boolean = false) =>
await this.projectMemberService.fetchProjectMembers(workspaceSlug, projectId).then((response) => {
runInAction(() => {
if (clearExistingMembers) {
unset(this.projectMemberMap, [projectId]);
}
response.forEach((member) => {
set(this.projectMemberMap, [projectId, member.member], member);
});
set(this.projectMemberFetchStatusMap, [projectId], true);
});
return response;
});
/**
* @description bulk add members to a project
* @param workspaceSlug
* @param projectId
* @param data
* @returns Promise<TProjectMembership[]>
*/
bulkAddMembersToProject = async (workspaceSlug: string, projectId: string, data: IProjectBulkAddFormData) =>
await this.projectMemberService.bulkAddMembersToProject(workspaceSlug, projectId, data).then((response) => {
runInAction(() => {
response.forEach((member) => {
set(this.projectMemberMap, [projectId, member.member], {
...member,
role: this.getUserProjectRole(member.member, projectId) ?? member.role,
original_role: member.role,
});
});
});
update(this.projectRoot.projectMap, [projectId, "members"], (memberIds) =>
uniq([...memberIds, ...data.members.map((m) => m.member_id)])
);
this.projectRoot.projectMap[projectId].members = this.projectRoot.projectMap?.[projectId]?.members?.concat(
data.members.map((m) => m.member_id)
);
return response;
});
/**
* @description update the role of a member in a project
* @param projectId
* @param userId
* @param role
*/
abstract getProjectMemberRoleForUpdate: (
projectId: string,
userId: string,
role: EUserProjectRoles
) => EUserProjectRoles;
/**
* @description update the role of a member in a project
* @param workspaceSlug
* @param projectId
* @param userId
* @param data
*/
updateMemberRole = async (workspaceSlug: string, projectId: string, userId: string, role: EUserProjectRoles) => {
const memberDetails = this.getProjectMemberDetails(userId, projectId);
if (!memberDetails || !memberDetails?.id) throw new Error("Member not found");
// original data to revert back in case of error
const isCurrentUser = this.rootStore.user.data?.id === userId;
const membershipBeforeUpdate = { ...this.getProjectMembershipByUserId(userId, projectId) };
const permissionBeforeUpdate = isCurrentUser
? this.rootStore.user.permission.getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId)
: undefined;
const updatedProjectRole = this.getProjectMemberRoleForUpdate(projectId, userId, role);
try {
runInAction(() => {
set(this.projectMemberMap, [projectId, userId, "original_role"], role);
set(this.projectMemberMap, [projectId, userId, "role"], updatedProjectRole);
if (isCurrentUser) {
set(
this.rootStore.user.permission.workspaceProjectsPermissions,
[workspaceSlug, projectId],
updatedProjectRole
);
}
set(this.rootStore.user.permission.projectUserInfo, [workspaceSlug, projectId, "role"], updatedProjectRole);
});
const response = await this.projectMemberService.updateProjectMember(
workspaceSlug,
projectId,
memberDetails?.id,
{
role,
}
);
return response;
} catch (error) {
// revert back to original members in case of error
runInAction(() => {
set(this.projectMemberMap, [projectId, userId, "original_role"], membershipBeforeUpdate?.original_role);
set(this.projectMemberMap, [projectId, userId, "role"], membershipBeforeUpdate?.role);
if (isCurrentUser) {
set(
this.rootStore.user.permission.workspaceProjectsPermissions,
[workspaceSlug, projectId],
membershipBeforeUpdate?.original_role
);
set(
this.rootStore.user.permission.projectUserInfo,
[workspaceSlug, projectId, "role"],
permissionBeforeUpdate
);
}
});
throw error;
}
};
/**
* @description Handles the removal of a member from a project
* @param projectId - The ID of the project to remove the member from
* @param userId - The ID of the user to remove from the project
*/
protected handleMemberRemoval = (projectId: string, userId: string) => {
unset(this.projectMemberMap, [projectId, userId]);
set(
this.projectRoot.projectMap,
[projectId, "members"],
this.projectRoot.projectMap?.[projectId]?.members?.filter((memberId) => memberId !== userId)
);
};
/**
* @description Processes the removal of a member from a project
* This abstract method handles the cleanup of member data from the project member map
* @param projectId - The ID of the project to remove the member from
* @param userId - The ID of the user to remove from the project
*/
abstract processMemberRemoval: (projectId: string, userId: string) => void;
/**
* @description remove a member from a project
* @param workspaceSlug
* @param projectId
* @param userId
*/
removeMemberFromProject = async (workspaceSlug: string, projectId: string, userId: string) => {
const memberDetails = this.getProjectMemberDetails(userId, projectId);
if (!memberDetails || !memberDetails?.id) throw new Error("Member not found");
await this.projectMemberService.deleteProjectMember(workspaceSlug, projectId, memberDetails?.id).then(() => {
runInAction(() => {
this.processMemberRemoval(projectId, userId);
});
});
};
}

View File

@@ -0,0 +1,71 @@
import { action, makeObservable, observable } from "mobx";
import { computedFn } from "mobx-utils";
// types
import type { IUserLite, TProjectMembership } from "@plane/types";
// local imports
import type { IMemberFilters } from "../utils";
import { sortProjectMembers } from "../utils";
export interface IProjectMemberFiltersStore {
// observables
filtersMap: Record<string, IMemberFilters>;
// computed actions
getFilteredMemberIds: (
members: TProjectMembership[],
memberDetailsMap: Record<string, IUserLite>,
getMemberKey: (member: TProjectMembership) => string,
projectId: string
) => string[];
// actions
updateFilters: (projectId: string, filters: Partial<IMemberFilters>) => void;
getFilters: (projectId: string) => IMemberFilters | undefined;
}
export class ProjectMemberFiltersStore implements IProjectMemberFiltersStore {
// observables
filtersMap: Record<string, IMemberFilters> = {};
constructor() {
makeObservable(this, {
// observables
filtersMap: observable,
// actions
updateFilters: action,
});
}
/**
* @description get filtered and sorted member ids
* @param members - array of project membership objects
* @param memberDetailsMap - map of member details by user id
* @param getMemberKey - function to get member key from membership object
* @param projectId - project id to get filters for
*/
getFilteredMemberIds = computedFn(
(
members: TProjectMembership[],
memberDetailsMap: Record<string, IUserLite>,
getMemberKey: (member: TProjectMembership) => string,
projectId: string
): string[] => {
if (!members || members.length === 0) return [];
// Apply filters and sorting
const sortedMembers = sortProjectMembers(members, memberDetailsMap, getMemberKey, this.filtersMap[projectId]);
return sortedMembers.map(getMemberKey);
}
);
getFilters = (projectId: string) => this.filtersMap[projectId];
/**
* @description update filters
* @param projectId - project id
* @param filters - partial filters to update
*/
updateFilters = (projectId: string, filters: Partial<IMemberFilters>) => {
const current = this.filtersMap[projectId] ?? {};
this.filtersMap[projectId] = { ...current, ...filters };
};
}

View File

@@ -0,0 +1,187 @@
// Types and utilities for member filtering
import type { EUserPermissions, TMemberOrderByOptions } from "@plane/constants";
import type { IUserLite, TProjectMembership } from "@plane/types";
export interface IMemberFilters {
order_by?: TMemberOrderByOptions;
roles?: string[];
}
// Helper function to parse order key and direction
export const parseOrderKey = (orderKey?: TMemberOrderByOptions): { field: string; direction: "asc" | "desc" } => {
// Default to sorting by display_name in ascending order when no order key is provided
if (!orderKey) {
return {
field: "display_name",
direction: "asc",
};
}
const isDescending = orderKey.startsWith("-");
const field = isDescending ? orderKey.slice(1) : orderKey;
return {
field,
direction: isDescending ? "desc" : "asc",
};
};
// Unified function to get sort key for any member type
export const getMemberSortKey = (memberDetails: IUserLite, field: string, memberRole?: string): string | Date => {
switch (field) {
case "display_name":
return memberDetails.display_name?.toLowerCase() || "";
case "full_name": {
const firstName = memberDetails.first_name || "";
const lastName = memberDetails.last_name || "";
return `${firstName} ${lastName}`.toLowerCase().trim();
}
case "email":
return memberDetails.email?.toLowerCase() || "";
case "joining_date": {
if (!memberDetails.joining_date) {
// Return a very old date for missing dates to sort them last
return new Date(0);
}
const date = new Date(memberDetails.joining_date);
// Return a very old date for invalid dates to sort them last
return isNaN(date.getTime()) ? new Date(0) : date;
}
case "role":
return (memberRole ?? "").toString().toLowerCase();
default:
return "";
}
};
// Filter functions
export const filterProjectMembersByRole = (
members: TProjectMembership[],
roleFilters: string[]
): TProjectMembership[] => {
if (roleFilters.length === 0) return members;
return members.filter((member) => {
const memberRole = String(member.role ?? member.original_role ?? "");
return roleFilters.includes(memberRole);
});
};
export const filterWorkspaceMembersByRole = <T extends { role: string | EUserPermissions; is_active?: boolean }>(
members: T[],
roleFilters: string[]
): T[] => {
if (roleFilters.length === 0) return members;
return members.filter((member) => {
const memberRole = String(member.role ?? "");
const isSuspended = member.is_active === false;
// Check if suspended is in the role filters
const hasSuspendedFilter = roleFilters.includes("suspended");
// Get non-suspended role filters
const activeRoleFilters = roleFilters.filter((role) => role !== "suspended");
// For suspended users, include them only if suspended filter is selected
if (isSuspended) {
return hasSuspendedFilter;
}
// For active users, include them only if their role matches any active role filter
return activeRoleFilters.includes(memberRole);
});
};
// Unified sorting function
export const sortMembers = <T>(
members: T[],
memberDetailsMap: Record<string, IUserLite>,
getMemberKey: (member: T) => string,
getMemberRole: (member: T) => string,
orderBy?: TMemberOrderByOptions
): T[] => {
if (!orderBy) return members;
const { field, direction } = parseOrderKey(orderBy);
return [...members].sort((a, b) => {
const aKey = getMemberKey(a);
const bKey = getMemberKey(b);
const aMemberDetails = memberDetailsMap[aKey];
const bMemberDetails = memberDetailsMap[bKey];
if (!aMemberDetails || !bMemberDetails) return 0;
const aRole = getMemberRole(a);
const bRole = getMemberRole(b);
const aValue = getMemberSortKey(aMemberDetails, field, aRole);
const bValue = getMemberSortKey(bMemberDetails, field, bRole);
let comparison = 0;
if (field === "joining_date") {
// For dates, we need to handle Date objects and ensure they're valid
const aDate = aValue instanceof Date ? aValue : new Date(aValue);
const bDate = bValue instanceof Date ? bValue : new Date(bValue);
// Handle invalid dates by treating them as very old dates
const aTime = isNaN(aDate.getTime()) ? 0 : aDate.getTime();
const bTime = isNaN(bDate.getTime()) ? 0 : bDate.getTime();
comparison = aTime - bTime;
} else {
// For strings, use localeCompare for proper alphabetical sorting
const aStr = String(aValue);
const bStr = String(bValue);
comparison = aStr.localeCompare(bStr);
}
return direction === "desc" ? -comparison : comparison;
});
};
// Specific implementations using the unified functions
export const sortProjectMembers = (
members: TProjectMembership[],
memberDetailsMap: Record<string, IUserLite>,
getMemberKey: (member: TProjectMembership) => string,
filters?: IMemberFilters
): TProjectMembership[] => {
// Apply role filtering first
const filteredMembers =
filters?.roles && filters.roles.length > 0 ? filterProjectMembersByRole(members, filters.roles) : members;
// If no order_by filter, return filtered members
if (!filters?.order_by) return filteredMembers;
// Apply sorting
return sortMembers(
filteredMembers,
memberDetailsMap,
getMemberKey,
(member) => String(member.role ?? member.original_role ?? ""),
filters.order_by
);
};
export const sortWorkspaceMembers = <T extends { role: string | EUserPermissions; is_active?: boolean }>(
members: T[],
memberDetailsMap: Record<string, IUserLite>,
getMemberKey: (member: T) => string,
filters?: IMemberFilters
): T[] => {
const filteredMembers =
filters?.roles && filters.roles.length > 0 ? filterWorkspaceMembersByRole(members, filters.roles) : members;
// If no order_by filter, return filtered members
if (!filters?.order_by) return filteredMembers;
// Apply sorting
return sortMembers(
filteredMembers,
memberDetailsMap,
getMemberKey,
(member) => String(member.role ?? ""),
filters.order_by
);
};

View File

@@ -0,0 +1,72 @@
import { action, makeObservable, observable } from "mobx";
import { computedFn } from "mobx-utils";
// types
import type { EUserPermissions } from "@plane/constants";
import type { IUserLite } from "@plane/types";
// local imports
import type { IMemberFilters } from "../utils";
import { sortWorkspaceMembers } from "../utils";
// Workspace membership interface matching the store structure
interface IWorkspaceMembership {
id: string;
member: string;
role: EUserPermissions;
is_active?: boolean;
}
export interface IWorkspaceMemberFiltersStore {
// observables
filters: IMemberFilters;
// computed actions
getFilteredMemberIds: (
members: IWorkspaceMembership[],
memberDetailsMap: Record<string, IUserLite>,
getMemberKey: (member: IWorkspaceMembership) => string
) => string[];
// actions
updateFilters: (filters: Partial<IMemberFilters>) => void;
}
export class WorkspaceMemberFiltersStore implements IWorkspaceMemberFiltersStore {
// observables
filters: IMemberFilters = {};
constructor() {
makeObservable(this, {
// observables
filters: observable,
// actions
updateFilters: action,
});
}
/**
* @description get filtered and sorted member ids
* @param members - array of workspace membership objects
* @param memberDetailsMap - map of member details by user id
* @param getMemberKey - function to get member key from membership object
*/
getFilteredMemberIds = computedFn(
(
members: IWorkspaceMembership[],
memberDetailsMap: Record<string, IUserLite>,
getMemberKey: (member: IWorkspaceMembership) => string
): string[] => {
if (!members || members.length === 0) return [];
// Apply filters and sorting
const sortedMembers = sortWorkspaceMembers(members, memberDetailsMap, getMemberKey, this.filters);
return sortedMembers.map(getMemberKey);
}
);
/**
* @description update filters
* @param filters - partial filters to update
*/
updateFilters = (filters: Partial<IMemberFilters>) => {
this.filters = { ...this.filters, ...filters };
};
}

View File

@@ -0,0 +1,359 @@
import { set, sortBy } from "lodash-es";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// types
import type { EUserPermissions } from "@plane/constants";
import type { IWorkspaceBulkInviteFormData, IWorkspaceMember, IWorkspaceMemberInvitation } from "@plane/types";
// plane-web constants
// services
import { WorkspaceService } from "@/plane-web/services";
// types
import type { IRouterStore } from "@/store/router.store";
import type { IUserStore } from "@/store/user";
// store
import type { CoreRootStore } from "../../root.store";
import type { IMemberRootStore } from "../index.ts";
import type { IWorkspaceMemberFiltersStore } from "./workspace-member-filters.store";
import { WorkspaceMemberFiltersStore } from "./workspace-member-filters.store";
export interface IWorkspaceMembership {
id: string;
member: string;
role: EUserPermissions;
is_active?: boolean;
}
export interface IWorkspaceMemberStore {
// observables
workspaceMemberMap: Record<string, Record<string, IWorkspaceMembership>>;
workspaceMemberInvitations: Record<string, IWorkspaceMemberInvitation[]>;
// filters store
filtersStore: IWorkspaceMemberFiltersStore;
// computed
workspaceMemberIds: string[] | null;
workspaceMemberInvitationIds: string[] | null;
memberMap: Record<string, IWorkspaceMembership> | null;
// computed actions
getWorkspaceMemberIds: (workspaceSlug: string) => string[];
getFilteredWorkspaceMemberIds: (workspaceSlug: string) => string[];
getSearchedWorkspaceMemberIds: (searchQuery: string) => string[] | null;
getSearchedWorkspaceInvitationIds: (searchQuery: string) => string[] | null;
getWorkspaceMemberDetails: (workspaceMemberId: string) => IWorkspaceMember | null;
getWorkspaceInvitationDetails: (invitationId: string) => IWorkspaceMemberInvitation | null;
// fetch actions
fetchWorkspaceMembers: (workspaceSlug: string) => Promise<IWorkspaceMember[]>;
fetchWorkspaceMemberInvitations: (workspaceSlug: string) => Promise<IWorkspaceMemberInvitation[]>;
// crud actions
updateMember: (workspaceSlug: string, userId: string, data: { role: EUserPermissions }) => Promise<void>;
removeMemberFromWorkspace: (workspaceSlug: string, userId: string) => Promise<void>;
// invite actions
inviteMembersToWorkspace: (workspaceSlug: string, data: IWorkspaceBulkInviteFormData) => Promise<void>;
updateMemberInvitation: (
workspaceSlug: string,
invitationId: string,
data: Partial<IWorkspaceMemberInvitation>
) => Promise<void>;
deleteMemberInvitation: (workspaceSlug: string, invitationId: string) => Promise<void>;
isUserSuspended: (userId: string, workspaceSlug: string) => boolean;
}
export class WorkspaceMemberStore implements IWorkspaceMemberStore {
// observables
workspaceMemberMap: {
[workspaceSlug: string]: Record<string, IWorkspaceMembership>;
} = {}; // { workspaceSlug: { userId: userDetails } }
workspaceMemberInvitations: Record<string, IWorkspaceMemberInvitation[]> = {}; // { workspaceSlug: [invitations] }
// filters store
filtersStore: IWorkspaceMemberFiltersStore;
// stores
routerStore: IRouterStore;
userStore: IUserStore;
memberRoot: IMemberRootStore;
// services
workspaceService;
constructor(_memberRoot: IMemberRootStore, _rootStore: CoreRootStore) {
makeObservable(this, {
// observables
workspaceMemberMap: observable,
workspaceMemberInvitations: observable,
// computed
workspaceMemberIds: computed,
workspaceMemberInvitationIds: computed,
memberMap: computed,
// actions
fetchWorkspaceMembers: action,
updateMember: action,
removeMemberFromWorkspace: action,
fetchWorkspaceMemberInvitations: action,
updateMemberInvitation: action,
deleteMemberInvitation: action,
});
// initialize filters store
this.filtersStore = new WorkspaceMemberFiltersStore();
// root store
this.routerStore = _rootStore.router;
this.userStore = _rootStore.user;
this.memberRoot = _memberRoot;
// services
this.workspaceService = new WorkspaceService();
}
/**
* @description get the list of all the user ids of all the members of the current workspace
*/
get workspaceMemberIds() {
const workspaceSlug = this.routerStore.workspaceSlug;
if (!workspaceSlug) return null;
return this.getWorkspaceMemberIds(workspaceSlug);
}
get memberMap() {
const workspaceSlug = this.routerStore.workspaceSlug;
if (!workspaceSlug) return null;
return this.workspaceMemberMap?.[workspaceSlug] ?? {};
}
get workspaceMemberInvitationIds() {
const workspaceSlug = this.routerStore.workspaceSlug;
if (!workspaceSlug) return null;
return this.workspaceMemberInvitations?.[workspaceSlug]?.map((inv) => inv.id);
}
getWorkspaceMemberIds = computedFn((workspaceSlug: string) => {
let members = Object.values(this.workspaceMemberMap?.[workspaceSlug] ?? {});
members = sortBy(members, [
(m) => m.member !== this.userStore?.data?.id,
(m) => this.memberRoot?.memberMap?.[m.member]?.display_name?.toLowerCase(),
]);
//filter out bots
const memberIds = members.filter((m) => !this.memberRoot?.memberMap?.[m.member]?.is_bot).map((m) => m.member);
return memberIds;
});
/**
* @description get the filtered and sorted list of all the user ids of all the members of the workspace
* @param workspaceSlug
*/
getFilteredWorkspaceMemberIds = computedFn((workspaceSlug: string) => {
let members = Object.values(this.workspaceMemberMap?.[workspaceSlug] ?? {});
//filter out bots and inactive members
members = members.filter((m) => !this.memberRoot?.memberMap?.[m.member]?.is_bot);
// Use filters store to get filtered member ids
const memberIds = this.filtersStore.getFilteredMemberIds(
members,
this.memberRoot?.memberMap || {},
(member) => member.member
);
return memberIds;
});
/**
* @description get the list of all the user ids that match the search query of all the members of the current workspace
* @param searchQuery
*/
getSearchedWorkspaceMemberIds = computedFn((searchQuery: string) => {
const workspaceSlug = this.routerStore.workspaceSlug;
if (!workspaceSlug) return null;
const filteredMemberIds = this.getFilteredWorkspaceMemberIds(workspaceSlug);
if (!filteredMemberIds) return null;
const searchedWorkspaceMemberIds = filteredMemberIds.filter((userId) => {
const memberDetails = this.getWorkspaceMemberDetails(userId);
if (!memberDetails) return false;
const memberSearchQuery = `${memberDetails.member.first_name} ${memberDetails.member.last_name} ${
memberDetails.member?.display_name
} ${memberDetails.member.email ?? ""}`;
return memberSearchQuery.toLowerCase()?.includes(searchQuery.toLowerCase());
});
return searchedWorkspaceMemberIds;
});
/**
* @description get the list of all the invitation ids that match the search query of all the member invitations of the current workspace
* @param searchQuery
*/
getSearchedWorkspaceInvitationIds = computedFn((searchQuery: string) => {
const workspaceSlug = this.routerStore.workspaceSlug;
if (!workspaceSlug) return null;
const workspaceMemberInvitationIds = this.workspaceMemberInvitationIds;
if (!workspaceMemberInvitationIds) return null;
const searchedWorkspaceMemberInvitationIds = workspaceMemberInvitationIds.filter((invitationId) => {
const invitationDetails = this.getWorkspaceInvitationDetails(invitationId);
if (!invitationDetails) return false;
const invitationSearchQuery = `${invitationDetails.email}`;
return invitationSearchQuery.toLowerCase()?.includes(searchQuery.toLowerCase());
});
return searchedWorkspaceMemberInvitationIds;
});
/**
* @description get the details of a workspace member
* @param userId
*/
getWorkspaceMemberDetails = computedFn((userId: string) => {
const workspaceSlug = this.routerStore.workspaceSlug;
if (!workspaceSlug) return null;
const workspaceMember = this.workspaceMemberMap?.[workspaceSlug]?.[userId];
if (!workspaceMember) return null;
const memberDetails: IWorkspaceMember = {
id: workspaceMember.id,
role: workspaceMember.role,
member: this.memberRoot?.memberMap?.[workspaceMember.member],
is_active: workspaceMember.is_active,
};
return memberDetails;
});
/**
* @description get the details of a workspace member invitation
* @param workspaceSlug
* @param memberId
*/
getWorkspaceInvitationDetails = computedFn((invitationId: string) => {
const workspaceSlug = this.routerStore.workspaceSlug;
if (!workspaceSlug) return null;
const invitationsList = this.workspaceMemberInvitations?.[workspaceSlug];
if (!invitationsList) return null;
const invitation = invitationsList.find((inv) => inv.id === invitationId);
return invitation ?? null;
});
/**
* @description fetch all the members of a workspace
* @param workspaceSlug
*/
fetchWorkspaceMembers = async (workspaceSlug: string) =>
await this.workspaceService.fetchWorkspaceMembers(workspaceSlug).then((response) => {
runInAction(() => {
response.forEach((member) => {
set(this.memberRoot?.memberMap, member.member.id, { ...member.member, joining_date: member.created_at });
set(this.workspaceMemberMap, [workspaceSlug, member.member.id], {
id: member.id,
member: member.member.id,
role: member.role,
is_active: member.is_active,
});
});
});
return response;
});
/**
* @description update the role of a workspace member
* @param workspaceSlug
* @param userId
* @param data
*/
updateMember = async (workspaceSlug: string, userId: string, data: { role: EUserPermissions }) => {
const memberDetails = this.getWorkspaceMemberDetails(userId);
if (!memberDetails) throw new Error("Member not found");
// original data to revert back in case of error
const originalProjectMemberData = { ...this.workspaceMemberMap?.[workspaceSlug]?.[userId] };
try {
runInAction(() => {
set(this.workspaceMemberMap, [workspaceSlug, userId, "role"], data.role);
});
await this.workspaceService.updateWorkspaceMember(workspaceSlug, memberDetails.id, data);
} catch (error) {
// revert back to original members in case of error
runInAction(() => {
set(this.workspaceMemberMap, [workspaceSlug, userId], originalProjectMemberData);
});
throw error;
}
};
/**
* @description remove a member from workspace
* @param workspaceSlug
* @param userId
*/
removeMemberFromWorkspace = async (workspaceSlug: string, userId: string) => {
const memberDetails = this.getWorkspaceMemberDetails(userId);
if (!memberDetails) throw new Error("Member not found");
await this.workspaceService.deleteWorkspaceMember(workspaceSlug, memberDetails?.id).then(() => {
runInAction(() => {
set(this.workspaceMemberMap, [workspaceSlug, userId, "is_active"], false);
});
});
};
/**
* @description fetch all the member invitations of a workspace
* @param workspaceSlug
*/
fetchWorkspaceMemberInvitations = async (workspaceSlug: string) =>
await this.workspaceService.workspaceInvitations(workspaceSlug).then((response) => {
runInAction(() => {
set(this.workspaceMemberInvitations, workspaceSlug, response);
});
return response;
});
/**
* @description bulk invite members to a workspace
* @param workspaceSlug
* @param data
*/
inviteMembersToWorkspace = async (workspaceSlug: string, data: IWorkspaceBulkInviteFormData) => {
const response = await this.workspaceService.inviteWorkspace(workspaceSlug, data);
await this.fetchWorkspaceMemberInvitations(workspaceSlug);
return response;
};
/**
* @description update the role of a member invitation
* @param workspaceSlug
* @param invitationId
* @param data
*/
updateMemberInvitation = async (
workspaceSlug: string,
invitationId: string,
data: Partial<IWorkspaceMemberInvitation>
) => {
const originalMemberInvitations = [...this.workspaceMemberInvitations?.[workspaceSlug]]; // in case of error, we will revert back to original members
try {
const memberInvitations = originalMemberInvitations?.map((invitation) => ({
...invitation,
...(invitation.id === invitationId && data),
}));
// optimistic update
runInAction(() => {
set(this.workspaceMemberInvitations, workspaceSlug, memberInvitations);
});
await this.workspaceService.updateWorkspaceInvitation(workspaceSlug, invitationId, data);
} catch (error) {
// revert back to original members in case of error
runInAction(() => {
set(this.workspaceMemberInvitations, workspaceSlug, originalMemberInvitations);
});
throw error;
}
};
/**
* @description delete a member invitation
* @param workspaceSlug
* @param memberId
*/
deleteMemberInvitation = async (workspaceSlug: string, invitationId: string) =>
await this.workspaceService.deleteWorkspaceInvitations(workspaceSlug.toString(), invitationId).then(() => {
runInAction(() => {
this.workspaceMemberInvitations[workspaceSlug] = this.workspaceMemberInvitations[workspaceSlug].filter(
(inv) => inv.id !== invitationId
);
});
});
isUserSuspended = computedFn((userId: string, workspaceSlug: string) => {
if (!workspaceSlug) return false;
const workspaceMember = this.workspaceMemberMap?.[workspaceSlug]?.[userId];
return workspaceMember?.is_active === false;
});
}