diff --git a/src/libs/Fullstory/utils.ts b/src/libs/Fullstory/utils.ts index aa8c3581876e..1ffe106b7268 100644 --- a/src/libs/Fullstory/utils.ts +++ b/src/libs/Fullstory/utils.ts @@ -1,6 +1,5 @@ import {filterObject} from '@libs/ObjectUtils'; -import {getActivePolicies, isControlPolicy} from '@libs/PolicyUtils'; -import getHighestPolicyRole from '@libs/PolicyUtils/getHighestPolicyRole'; +import {getActivePolicies, getHighestPolicyRole, isControlPolicy} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; diff --git a/src/libs/PolicyUtils/approvals.ts b/src/libs/PolicyUtils/approvals.ts index 28fcccc0cb17..12564d99188d 100644 --- a/src/libs/PolicyUtils/approvals.ts +++ b/src/libs/PolicyUtils/approvals.ts @@ -22,6 +22,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import {isPolicyMember} from './members'; import {isPolicyApprover} from './permissions'; import {isGroupPolicy} from './policyType'; import {getTagApproverRule} from './rules'; @@ -90,6 +91,14 @@ function getDefaultApprover(policy: OnyxEntry): string { return policy?.approver || policy?.owner || ''; } +/** + * Whether an approver email points at someone who is no longer on the workspace. The owner always counts as a member, + * and HR advanced (manager) mode is excluded, because its final approver doesn't have to be a workspace member. + */ +function isNonMemberApprover(policy: OnyxEntry, approverEmail: string | undefined): boolean { + return !!approverEmail && !isPolicyMember(policy, approverEmail) && !getHRAdvancedModeFinalApprover(policy); +} + /** * Whether the policy has at least one custom approval workflow. A workflow is considered custom when either: * - the default workflow was modified by changing its first approver or adding an "Approves to" user, or @@ -441,6 +450,7 @@ export { isSubmitterAndApprover, getApprovalWorkflow, getDefaultApprover, + isNonMemberApprover, hasCustomApprovalWorkflow, getRuleApprovers, evaluateApprovalWorkflowRule, diff --git a/src/libs/PolicyUtils/domains.ts b/src/libs/PolicyUtils/domains.ts new file mode 100644 index 000000000000..961627780537 --- /dev/null +++ b/src/libs/PolicyUtils/domains.ts @@ -0,0 +1,113 @@ +/** + * Workspace domain names, admin email domains, and the workspace a card or card feed belongs to. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import {isPublicDomain} from '@libs/ValidationUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Card, Policy} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +import {Str} from 'expensify-common'; + +import {isExpensifyTeam} from './members'; + +function getDomainNameForPolicy(policyID?: string): string { + if (!policyID) { + return ''; + } + + return `${CONST.EXPENSIFY_POLICY_DOMAIN}${policyID.toLowerCase()}${CONST.EXPENSIFY_POLICY_DOMAIN_EXTENSION}`; +} + +function getAdminsPrivateEmailDomains(policy?: Policy) { + if (!policy) { + return []; + } + + const adminDomains = Object.entries(policy.employeeList ?? {}).reduce((domains, [email, employee]) => { + if (employee.role !== CONST.POLICY.ROLE.ADMIN) { + return domains; + } + domains.push(Str.extractEmailDomain(email).toLowerCase()); + return domains; + }, [] as string[]); + + const ownerDomains = policy.owner ? [Str.extractEmailDomain(policy.owner).toLowerCase()] : []; + + const privateDomains = [...new Set(adminDomains.concat(ownerDomains))].filter((domain) => !isPublicDomain(domain)); + + // If the policy is not owned by Expensify there is no point in showing the domain for provisioning. + if (!isExpensifyTeam(policy.owner)) { + return privateDomains.filter((domain) => domain !== CONST.EXPENSIFY_PARTNER_NAME && domain !== CONST.EMAIL.GUIDES_DOMAIN); + } + + return privateDomains; +} + +/** + * Determines the most frequent domain from the `acceptedDomains` list + * that appears in the email addresses of policy members. + * + * @param acceptedDomains - List of domains to consider. + * @param policy - The policy object. + */ +function getMostFrequentEmailDomain(acceptedDomains: string[], policy?: Policy) { + if (!policy) { + return undefined; + } + const domainOccurrences = {} as Record; + for (const memberDomain of Object.keys(policy.employeeList ?? {}) + .concat(policy.owner) + .map((email) => Str.extractEmailDomain(email).toLowerCase())) { + if (!acceptedDomains.includes(memberDomain)) { + continue; + } + domainOccurrences[memberDomain] = (domainOccurrences[memberDomain] || 0) + 1; + } + let mostFrequent = {domain: '', count: 0}; + for (const [domain, count] of Object.entries(domainOccurrences)) { + if (count <= mostFrequent.count) { + continue; + } + mostFrequent = {domain, count}; + } + if (mostFrequent.count === 0) { + return undefined; + } + return mostFrequent.domain; +} + +const getPolicyIDFromDomainName = (domainName: string): string | undefined => domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1]?.toUpperCase(); + +/** + * Returns the workspace an assigned card belongs to. + * + * A card names its workspace with `fundID`, which is the policy's `policyAccountID`. That holds whatever the feed's + * domain looks like, so a card on a company's own domain resolves the same workspace as one on a workspace feed's + * `expensify-policy.exfy` domain. The domain is read only as a fallback, for a card that arrives without a `fundID`. + */ +const getPolicyForAssignedCard = (card: OnyxEntry>, policies: OnyxCollection): OnyxEntry => { + const workspaceAccountID = Number(card?.fundID); + const policyForFund = workspaceAccountID ? Object.values(policies ?? {}).find((policy) => policy?.policyAccountID === workspaceAccountID) : undefined; + if (policyForFund) { + return policyForFund; + } + + const policyID = card?.domainName ? getPolicyIDFromDomainName(card.domainName) : undefined; + return policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] : undefined; +}; + +const getDescriptionForPolicyDomainCard = (domainName: string, policies: OnyxCollection): string => { + // A domain name containing a policyID indicates that this is a workspace feed + const policyID = getPolicyIDFromDomainName(domainName); + if (policyID) { + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + return policy?.name ?? domainName; + } + return domainName; +}; + +export {getDomainNameForPolicy, getAdminsPrivateEmailDomains, getMostFrequentEmailDomain, getPolicyIDFromDomainName, getPolicyForAssignedCard, getDescriptionForPolicyDomainCard}; diff --git a/src/libs/PolicyUtils/getHighestPolicyRole.ts b/src/libs/PolicyUtils/getHighestPolicyRole.ts deleted file mode 100644 index 7b1dd306f738..000000000000 --- a/src/libs/PolicyUtils/getHighestPolicyRole.ts +++ /dev/null @@ -1,34 +0,0 @@ -import CONST from '@src/CONST'; -import type {OnyxInputOrEntry, Policy} from '@src/types/onyx'; - -import type {TupleToUnion} from 'type-fest'; - -/** - * Workspace roles from highest to lowest privilege, matching the role hierarchy in Auth. - */ -const POLICY_ROLE_RANKING = [ - CONST.POLICY.ROLE.ADMIN, - CONST.POLICY.ROLE.PAYMENTS_ADMIN, - CONST.POLICY.ROLE.CARD_ADMIN, - CONST.POLICY.ROLE.PEOPLE_ADMIN, - CONST.POLICY.ROLE.AUDITOR, - CONST.POLICY.ROLE.EDITOR, - CONST.POLICY.ROLE.USER, - CONST.POLICY.ROLE.GUEST, -] as const; - -/** - * Returns the highest-privilege role held across the given policies. - * Defaults to user when no ranked role is found, so a member on any workspace is not reported as a guest. - */ -function getHighestPolicyRole(policyList: Array>>): TupleToUnion { - const roles = new Set(policyList.map((policy) => policy?.role)); - for (const rankedRole of POLICY_ROLE_RANKING) { - if (roles.has(rankedRole)) { - return rankedRole; - } - } - return CONST.POLICY.ROLE.USER; -} - -export default getHighestPolicyRole; diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index 1baa2a4f60a4..b8561de29653 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -1,573 +1,4 @@ -import type {PersonalDetailsByLogin} from '@components/PersonalDetailsByLoginProvider'; - -import {getBankAccountFromID} from '@libs/actions/BankAccounts'; -import {getHRAdvancedModeFinalApprover} from '@libs/merge/HRUtils'; -import {getIsOffline} from '@libs/NetworkState'; -import {generateAccountID} from '@libs/UserUtils'; -import {isPublicDomain} from '@libs/ValidationUtils'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Card, PersonalDetailsList, Policy, PolicyEmployeeList, TravelSettings} from '@src/types/onyx'; -import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; -import type {WorkspaceTravelSettings} from '@src/types/onyx/TravelSettings'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; - -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; - -import {Str} from 'expensify-common'; - -import {canMemberWrite} from './permissions'; -import {getActivePolicies, shouldShowPolicy} from './policy'; -import {isPaidGroupPolicy} from './policyType'; - -type MemberEmailsToAccountIDs = Record; - -type TravelStep = ValueOf; - -/** Return the emails that can receive a shared bank account from the current user. */ -function getEligibleBankAccountShareRecipientEmails(policies: OnyxCollection | null, currentUserLogin: string | undefined, bankAccountID: string | undefined): string[] { - if (!currentUserLogin) { - return []; - } - - const currentBankAccount = getBankAccountFromID(Number(bankAccountID)); - const isOffline = getIsOffline(); - const activePolicies = getActivePolicies(policies, currentUserLogin).filter( - (policy) => shouldShowPolicy(policy, isOffline, currentUserLogin) && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS), - ); - const recipientEmails = new Set(); - const shareesSet = new Set(currentBankAccount?.accountData?.sharees ?? []); - - for (const policy of activePolicies) { - for (const [email, employee] of Object.entries(policy.employeeList ?? {})) { - if ( - employee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || - !employee.role || - !canMemberWrite(policy, email, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS) || - email === currentUserLogin || - recipientEmails.has(email) || - shareesSet.has(email) || - (isExpensifyTeam(email) && shouldFilterExpensifyTeam(policy.owner, currentUserLogin)) - ) { - continue; - } - - recipientEmails.add(email); - } - } - - return Array.from(recipientEmails); -} - -/** Return whether the current user has someone they can share a bank account with. */ -function hasEligibleBankAccountShareRecipient(policies: OnyxCollection | null, currentUserLogin: string | undefined, bankAccountID: string | undefined): boolean { - return getEligibleBankAccountShareRecipientEmails(policies, currentUserLogin, bankAccountID).length > 0; -} - -/** - * Checks if a specific user is a member of the policy. - * Note that employeeList can be blank when the policy is not the user's default/active policy - */ -function isPolicyMember(policy: OnyxEntry, userLogin: string | undefined): boolean { - return !!policy && !!userLogin && (!!policy.employeeList?.[userLogin] || policy.owner === userLogin); -} - -function isMemberInHomeAndOfficeWorkspace(policy: OnyxEntry, memberLogin: string): boolean { - return !!memberLogin && policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && !!policy.employeeList?.[memberLogin]; -} - -function isPolicyMemberWithoutPendingDelete(currentUserLogin: string | undefined, policy: OnyxEntry): boolean { - if (!currentUserLogin || !policy?.id) { - return false; - } - - const policyEmployee = policy?.employeeList?.[currentUserLogin]; - return !!policyEmployee && policyEmployee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; -} - -/** Check if the passed employee holds an active Expensify Card on the policy, as reported by the backend in the policy's employeeList */ -function hasActiveExpensifyCard(policy: OnyxEntry, employeeLogin: string) { - const primaryLogin = policy?.primaryLoginsInvited?.[employeeLogin]; - return !!policy?.employeeList?.[employeeLogin]?.hasActiveExpensifyCard || (!!primaryLogin && !!policy?.employeeList?.[primaryLogin]?.hasActiveExpensifyCard); -} - -function isExpensifyTeam(email: string | undefined): boolean { - const emailDomain = Str.extractEmailDomain(email ?? ''); - return emailDomain === CONST.EXPENSIFY_PARTNER_NAME || emailDomain === CONST.EMAIL.GUIDES_DOMAIN; -} - -/** Whether Expensify team members should be hidden from the given policy/user combination. */ -function shouldFilterExpensifyTeam(policyOwner: string | undefined, currentUserLogin: string | undefined): boolean { - return !!policyOwner && !!currentUserLogin && !isExpensifyTeam(policyOwner) && !isExpensifyTeam(currentUserLogin); -} - -/** - * Creates a selector for useOnyx that computes the filtered member count. - * Returns a primitive number to prevent unnecessary re-renders when unrelated personal details change. - */ -function createFilteredMemberCountSelector( - employeeList: PolicyEmployeeList | undefined, - policyOwner: string | undefined, - currentUserLogin: string | undefined, - personalDetailsByLogins: PersonalDetailsByLogin, -) { - return (personalDetails: PersonalDetailsList | undefined): number => { - const shouldFilter = shouldFilterExpensifyTeam(policyOwner, currentUserLogin); - const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(employeeList, personalDetailsByLogins, false, false); - - return Object.keys(policyMemberEmailsToAccountIDs).reduce((count, email) => { - const accountID = policyMemberEmailsToAccountIDs[email]; - const details = personalDetails?.[accountID]; - - if (shouldFilter && isExpensifyTeam(details?.login ?? details?.displayName)) { - return count; - } - - return count + 1; - }, 0); - }; -} - -/** - * Create an object mapping member emails to their accountIDs. Filter for members without errors if includeMemberWithErrors is false, and get the login email from the personalDetail object using the accountID. - * - * If includeMemberWithErrors is false, We only return members without errors. Otherwise, the members with errors would immediately be removed before the user has a chance to read the error. - */ -function getMemberAccountIDsForWorkspace( - employeeList: PolicyEmployeeList | undefined, - personalDetailsByLogins: PersonalDetailsByLogin, - includeMemberWithErrors = false, - includeMemberWithPendingDelete = true, -): MemberEmailsToAccountIDs { - const members = employeeList ?? {}; - const memberEmailsToAccountIDs: MemberEmailsToAccountIDs = {}; - for (const email of Object.keys(members)) { - if (!includeMemberWithErrors) { - const member = members?.[email]; - if (Object.keys(member?.errors ?? {})?.length > 0) { - continue; - } - } - if (!includeMemberWithPendingDelete) { - const member = members?.[email]; - if (member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - continue; - } - } - const personalDetail = personalDetailsByLogins?.[email]; - if (!personalDetail?.login) { - continue; - } - memberEmailsToAccountIDs[email] = Number(personalDetail.accountID); - } - return memberEmailsToAccountIDs; -} - -/** - * Resolves the accountID for a submit-to recipient chosen in the submit-to popover. - * Uses personal details first, then the workspace employee list (same source as ReportSubmitToContent). - * When the member is in employeeList but not yet in personal details, returns a stable optimistic accountID. - */ -function getAccountIDForSubmitManagerEmail( - managerEmail: string | undefined, - employeeList: PolicyEmployeeList | undefined, - personalDetailsByLogins: PersonalDetailsByLogin, -): number | undefined { - const trimmed = managerEmail?.trim(); - if (!trimmed) { - return undefined; - } - - const fromPersonalDetails = personalDetailsByLogins[trimmed]?.accountID; - if (fromPersonalDetails !== undefined) { - return fromPersonalDetails; - } - - if (!employeeList) { - return undefined; - } - - const normalizedEmail = trimmed.toLowerCase(); - const memberAccountIDs = getMemberAccountIDsForWorkspace(employeeList, personalDetailsByLogins, true, false); - - for (const [email, accountID] of Object.entries(memberAccountIDs)) { - if (email.toLowerCase() === normalizedEmail) { - return accountID; - } - } - - for (const [listKey, employee] of Object.entries(employeeList)) { - const employeeEmail = (employee.email ?? listKey).trim(); - const listKeyNormalized = listKey.trim().toLowerCase(); - const employeeEmailNormalized = employeeEmail.toLowerCase(); - - if (employeeEmailNormalized !== normalizedEmail && listKeyNormalized !== normalizedEmail) { - continue; - } - - const accountIDFromMap = memberAccountIDs[employeeEmail] ?? memberAccountIDs[listKey]; - - if (accountIDFromMap !== undefined) { - return accountIDFromMap; - } - - return generateAccountID(trimmed); - } - - return undefined; -} - -/** - * Get login list that we should not show in the workspace invite options - */ -function getIneligibleInvitees(employeeList?: PolicyEmployeeList): string[] { - const policyEmployeeList = employeeList ?? {}; - const memberEmailsToExclude: string[] = [...CONST.EXPENSIFY_EMAILS]; - for (const email of Object.keys(policyEmployeeList)) { - const policyEmployee = policyEmployeeList?.[email]; - // Policy members that are pending delete or have errors are not valid and we should show them in the invite options (don't exclude them). - if (policyEmployee?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policyEmployee?.errors ?? {}).length > 0) { - continue; - } - if (!email) { - continue; - } - memberEmailsToExclude.push(email); - } - - return memberEmailsToExclude; -} - -/** - * Get excluded users as a Record for use in search selector - */ -function getExcludedUsers(employeeList?: PolicyEmployeeList): Record { - return Object.fromEntries(getIneligibleInvitees(employeeList).map((login) => [login, true])); -} - -/** - * Get Guide and Account Manager information including their emails/logins and exclusion record. - * Used for filtering Guide/AM from contact lists while allowing manual entry. - * - * @param policy - The policy to get the assigned guide from - * @param accountManagerAccountID - The account manager's account ID from the account object - * @param personalDetails - Personal details collection to look up account manager login - * @returns Object containing extracted emails/logins and exclusions record - */ -function getGuideAndAccountManagerInfo( - policy: OnyxEntry, - accountManagerAccountID: number | undefined, - personalDetails: OnyxEntry, -): { - assignedGuideEmail: string | undefined; - accountManagerLogin: string | undefined; - exclusions: Record; -} { - const assignedGuideEmail = policy?.assignedGuide?.email?.toLowerCase(); - const accountManagerLogin = accountManagerAccountID ? personalDetails?.[accountManagerAccountID]?.login?.toLowerCase() : undefined; - - const exclusions: Record = {}; - if (assignedGuideEmail) { - exclusions[assignedGuideEmail] = true; - } - if (accountManagerLogin) { - exclusions[accountManagerLogin] = true; - } - - return { - assignedGuideEmail, - accountManagerLogin, - exclusions, - }; -} - -/** - * Build a soft-exclusion map of Expensify-team logins (current and former AMs/Guides) so search - * filters don't surface internal Expensify staff to customers. Mirrors the visibility rule used - * by WorkspaceMembersPage: skip the filter when the current user is themselves Expensify staff, - * and preserve Expensify-team members of any Expensify-owned policy the current user belongs to. - */ -function getExpensifyTeamExclusions(personalDetails: OnyxEntry, policies: OnyxCollection, currentUserLogin: string | undefined): Record { - if (!currentUserLogin || isExpensifyTeam(currentUserLogin)) { - return {}; - } - - const lowerCurrentUserLogin = currentUserLogin.toLowerCase(); - const allowedExpensifyTeamLogins = new Set(); - - for (const policy of Object.values(policies ?? {})) { - if (!policy?.owner || !policy.employeeList || !isExpensifyTeam(policy.owner)) { - continue; - } - const currentUserIsInPolicy = Object.keys(policy.employeeList).some((email) => email.toLowerCase() === lowerCurrentUserLogin); - if (!currentUserIsInPolicy) { - continue; - } - for (const email of Object.keys(policy.employeeList)) { - const lowerEmail = email.toLowerCase(); - if (isExpensifyTeam(lowerEmail)) { - allowedExpensifyTeamLogins.add(lowerEmail); - } - } - } - - const exclusion: Record = {}; - for (const details of Object.values(personalDetails ?? {})) { - const login = details?.login?.toLowerCase(); - if (!login || !isExpensifyTeam(login)) { - continue; - } - if (allowedExpensifyTeamLogins.has(login)) { - continue; - } - exclusion[login] = true; - } - return exclusion; -} - -/** - * Get soft exclusions (Guide/Account Manager) that should be hidden from auto-suggestions - * but can still be manually entered by the user. - * - * @param policy - The policy to get the assigned guide from - * @param accountManagerAccountID - The account manager's account ID from the account object - * @param personalDetails - Personal details collection to look up account manager login - * @returns Record mapping lowercase emails to true for Guide and Account Manager - */ -function getSoftExclusionsForGuideAndAccountManager( - policy: OnyxEntry, - accountManagerAccountID: number | undefined, - personalDetails: OnyxEntry, -): Record { - return getGuideAndAccountManagerInfo(policy, accountManagerAccountID, personalDetails).exclusions; -} - -/** - * Filter out Guide and Account Manager from a list of items. - * Used for filtering local data (e.g., policy.employeeList) that isn't filtered at the data layer. - * - * @param items - Array of items to filter (must have login or alternateText properties) - * @param assignedGuideEmail - The assigned guide's email (should be lowercase) - * @param accountManagerLogin - The account manager's login (should be lowercase) - * @returns Filtered array with Guide and Account Manager removed - */ -function filterGuideAndAccountManager( - items: T[], - assignedGuideEmail: string | undefined, - accountManagerLogin: string | undefined, -): T[] { - return items.filter((item) => { - const itemLogin = item.login?.toLowerCase(); - const itemAltText = item.alternateText?.toLowerCase(); - - if (assignedGuideEmail && (itemLogin === assignedGuideEmail || itemAltText === assignedGuideEmail)) { - return false; - } - - if (accountManagerLogin && (itemLogin === accountManagerLogin || itemAltText === accountManagerLogin)) { - return false; - } - - return true; - }); -} - -/** - * Whether an approver email points at someone who is no longer on the workspace. The owner always counts as a member, - * and HR advanced (manager) mode is excluded, because its final approver doesn't have to be a workspace member. - */ -function isNonMemberApprover(policy: OnyxEntry, approverEmail: string | undefined): boolean { - return !!approverEmail && !isPolicyMember(policy, approverEmail) && !getHRAdvancedModeFinalApprover(policy); -} - -function getAdminEmployees(policy: OnyxEntry): PolicyEmployee[] { - if (!policy?.employeeList) { - return []; - } - return Object.keys(policy.employeeList) - .map((email) => ({...policy.employeeList?.[email], email})) - .filter((employee) => employee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && employee.role === CONST.POLICY.ROLE.ADMIN); -} - -/** - * Check if the policy member is deleted from the workspace - */ -function isDeletedPolicyEmployee(policyEmployee: PolicyEmployee, isOffline: boolean) { - return !isOffline && policyEmployee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isEmptyObject(policyEmployee.errors); -} - -function getDomainNameForPolicy(policyID?: string): string { - if (!policyID) { - return ''; - } - - return `${CONST.EXPENSIFY_POLICY_DOMAIN}${policyID.toLowerCase()}${CONST.EXPENSIFY_POLICY_DOMAIN_EXTENSION}`; -} - -function getAdminsPrivateEmailDomains(policy?: Policy) { - if (!policy) { - return []; - } - - const adminDomains = Object.entries(policy.employeeList ?? {}).reduce((domains, [email, employee]) => { - if (employee.role !== CONST.POLICY.ROLE.ADMIN) { - return domains; - } - domains.push(Str.extractEmailDomain(email).toLowerCase()); - return domains; - }, [] as string[]); - - const ownerDomains = policy.owner ? [Str.extractEmailDomain(policy.owner).toLowerCase()] : []; - - const privateDomains = [...new Set(adminDomains.concat(ownerDomains))].filter((domain) => !isPublicDomain(domain)); - - // If the policy is not owned by Expensify there is no point in showing the domain for provisioning. - if (!isExpensifyTeam(policy.owner)) { - return privateDomains.filter((domain) => domain !== CONST.EXPENSIFY_PARTNER_NAME && domain !== CONST.EMAIL.GUIDES_DOMAIN); - } - - return privateDomains; -} - -/** - * Determines the most frequent domain from the `acceptedDomains` list - * that appears in the email addresses of policy members. - * - * @param acceptedDomains - List of domains to consider. - * @param policy - The policy object. - */ -function getMostFrequentEmailDomain(acceptedDomains: string[], policy?: Policy) { - if (!policy) { - return undefined; - } - const domainOccurrences = {} as Record; - for (const memberDomain of Object.keys(policy.employeeList ?? {}) - .concat(policy.owner) - .map((email) => Str.extractEmailDomain(email).toLowerCase())) { - if (!acceptedDomains.includes(memberDomain)) { - continue; - } - domainOccurrences[memberDomain] = (domainOccurrences[memberDomain] || 0) + 1; - } - let mostFrequent = {domain: '', count: 0}; - for (const [domain, count] of Object.entries(domainOccurrences)) { - if (count <= mostFrequent.count) { - continue; - } - mostFrequent = {domain, count}; - } - if (mostFrequent.count === 0) { - return undefined; - } - return mostFrequent.domain; -} - -const getPolicyIDFromDomainName = (domainName: string): string | undefined => domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1]?.toUpperCase(); - -/** - * Returns the workspace an assigned card belongs to. - * - * A card names its workspace with `fundID`, which is the policy's `policyAccountID`. That holds whatever the feed's - * domain looks like, so a card on a company's own domain resolves the same workspace as one on a workspace feed's - * `expensify-policy.exfy` domain. The domain is read only as a fallback, for a card that arrives without a `fundID`. - */ -const getPolicyForAssignedCard = (card: OnyxEntry>, policies: OnyxCollection): OnyxEntry => { - const workspaceAccountID = Number(card?.fundID); - const policyForFund = workspaceAccountID ? Object.values(policies ?? {}).find((policy) => policy?.policyAccountID === workspaceAccountID) : undefined; - if (policyForFund) { - return policyForFund; - } - - const policyID = card?.domainName ? getPolicyIDFromDomainName(card.domainName) : undefined; - return policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] : undefined; -}; - -const getDescriptionForPolicyDomainCard = (domainName: string, policies: OnyxCollection): string => { - // A domain name containing a policyID indicates that this is a workspace feed - const policyID = getPolicyIDFromDomainName(domainName); - if (policyID) { - const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - return policy?.name ?? domainName; - } - return domainName; -}; - -/** Whether a workspace has been provisioned with a Spotnana entity. */ -function isWorkspaceProvisionedForTravel(travelSettings?: WorkspaceTravelSettings): boolean { - return !!(travelSettings?.spotnanaCompanyID ?? travelSettings?.associatedTravelDomainAccountID); -} - -/** Whether travel is enabled on a workspace, meaning it can be used to book a trip. */ -function hasAcceptedTravelTerms(policy: OnyxEntry, accountTravelSettings: TravelSettings | undefined): boolean { - return !!(policy?.travelSettings?.hasAcceptedTerms ?? (accountTravelSettings?.hasAcceptedTerms && isWorkspaceProvisionedForTravel(policy?.travelSettings))); -} - -function isNonUSDPolicy(policy: OnyxEntry): boolean { - return !!policy?.outputCurrency && policy.outputCurrency !== CONST.CURRENCY.USD; -} - -/** - * Determines which travel step should be shown based on policy state - */ -function getTravelStep( - policy: OnyxEntry, - travelSettings: TravelSettings | undefined, - isTravelVerifiedBetaEnabled: boolean, - policies: OnyxCollection, - currentUserLogin: string | undefined, -): TravelStep { - const adminDomains = getAdminsPrivateEmailDomains(policy); - const activePolicies = getActivePolicies(policies, currentUserLogin); - const groupPaidPolicies = activePolicies.filter(isPaidGroupPolicy); - - if (adminDomains.length === 0 || groupPaidPolicies.length < 1 || !isPaidGroupPolicy(policy)) { - return CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL; - } - if (policy?.travelSettings?.hasAcceptedTerms) { - return CONST.TRAVEL.STEPS.BOOK_OR_MANAGE_YOUR_TRIP; - } - if (!isTravelVerifiedBetaEnabled && travelSettings?.lastTravelSignupRequestTime) { - return CONST.TRAVEL.STEPS.REVIEWING_REQUEST; - } - return CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL; -} - -export { - getAdminEmployees, - getIneligibleInvitees, - getExcludedUsers, - getMemberAccountIDsForWorkspace, - getAccountIDForSubmitManagerEmail, - getGuideAndAccountManagerInfo, - getSoftExclusionsForGuideAndAccountManager, - getExpensifyTeamExclusions, - filterGuideAndAccountManager, - isExpensifyTeam, - shouldFilterExpensifyTeam, - createFilteredMemberCountSelector, - isDeletedPolicyEmployee, - hasEligibleBankAccountShareRecipient, - isPolicyMember, - isMemberInHomeAndOfficeWorkspace, - getEligibleBankAccountShareRecipientEmails, - isNonMemberApprover, - getDomainNameForPolicy, - getAdminsPrivateEmailDomains, - getMostFrequentEmailDomain, - getPolicyIDFromDomainName, - getPolicyForAssignedCard, - getDescriptionForPolicyDomainCard, - isPolicyMemberWithoutPendingDelete, - getTravelStep, - isWorkspaceProvisionedForTravel, - hasAcceptedTravelTerms, - isNonUSDPolicy, - hasActiveExpensifyCard, -}; - -// Re-exported with `export *` rather than through the block above: a named re-export becomes a getter that throws +// Re-exported with `export *` rather than named re-exports: a named re-export becomes a getter that throws // while this module is still loading, which breaks tests that spread `jest.requireActual('@libs/PolicyUtils')` // from inside an import cycle. export * from './tag'; @@ -587,5 +18,6 @@ export * from './approvals'; export * from './payments'; export * from './policy'; export * from './navigation'; - -export type {MemberEmailsToAccountIDs}; +export * from './members'; +export * from './domains'; +export * from './travel'; diff --git a/src/libs/PolicyUtils/members.ts b/src/libs/PolicyUtils/members.ts new file mode 100644 index 000000000000..9735c5395cba --- /dev/null +++ b/src/libs/PolicyUtils/members.ts @@ -0,0 +1,411 @@ +/** + * Workspace membership, Expensify-team filtering, invite exclusions, and member account IDs. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import type {PersonalDetailsByLogin} from '@components/PersonalDetailsByLoginProvider'; + +import {getBankAccountFromID} from '@libs/actions/BankAccounts'; +import {getIsOffline} from '@libs/NetworkState'; +import {generateAccountID} from '@libs/UserUtils'; + +import CONST from '@src/CONST'; +import type {PersonalDetailsList, Policy, PolicyEmployeeList} from '@src/types/onyx'; +import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +import {Str} from 'expensify-common'; + +import {canMemberWrite} from './permissions'; +import {getActivePolicies, shouldShowPolicy} from './policy'; + +type MemberEmailsToAccountIDs = Record; + +/** Return the emails that can receive a shared bank account from the current user. */ +function getEligibleBankAccountShareRecipientEmails(policies: OnyxCollection | null, currentUserLogin: string | undefined, bankAccountID: string | undefined): string[] { + if (!currentUserLogin) { + return []; + } + + const currentBankAccount = getBankAccountFromID(Number(bankAccountID)); + const isOffline = getIsOffline(); + const activePolicies = getActivePolicies(policies, currentUserLogin).filter( + (policy) => shouldShowPolicy(policy, isOffline, currentUserLogin) && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS), + ); + const recipientEmails = new Set(); + const shareesSet = new Set(currentBankAccount?.accountData?.sharees ?? []); + + for (const policy of activePolicies) { + for (const [email, employee] of Object.entries(policy.employeeList ?? {})) { + if ( + employee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || + !employee.role || + !canMemberWrite(policy, email, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS) || + email === currentUserLogin || + recipientEmails.has(email) || + shareesSet.has(email) || + (isExpensifyTeam(email) && shouldFilterExpensifyTeam(policy.owner, currentUserLogin)) + ) { + continue; + } + + recipientEmails.add(email); + } + } + + return Array.from(recipientEmails); +} + +/** Return whether the current user has someone they can share a bank account with. */ +function hasEligibleBankAccountShareRecipient(policies: OnyxCollection | null, currentUserLogin: string | undefined, bankAccountID: string | undefined): boolean { + return getEligibleBankAccountShareRecipientEmails(policies, currentUserLogin, bankAccountID).length > 0; +} + +/** + * Checks if a specific user is a member of the policy. + * Note that employeeList can be blank when the policy is not the user's default/active policy + */ +function isPolicyMember(policy: OnyxEntry, userLogin: string | undefined): boolean { + return !!policy && !!userLogin && (!!policy.employeeList?.[userLogin] || policy.owner === userLogin); +} + +function isMemberInHomeAndOfficeWorkspace(policy: OnyxEntry, memberLogin: string): boolean { + return !!memberLogin && policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && !!policy.employeeList?.[memberLogin]; +} + +function isPolicyMemberWithoutPendingDelete(currentUserLogin: string | undefined, policy: OnyxEntry): boolean { + if (!currentUserLogin || !policy?.id) { + return false; + } + + const policyEmployee = policy?.employeeList?.[currentUserLogin]; + return !!policyEmployee && policyEmployee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; +} + +/** Check if the passed employee holds an active Expensify Card on the policy, as reported by the backend in the policy's employeeList */ +function hasActiveExpensifyCard(policy: OnyxEntry, employeeLogin: string) { + const primaryLogin = policy?.primaryLoginsInvited?.[employeeLogin]; + return !!policy?.employeeList?.[employeeLogin]?.hasActiveExpensifyCard || (!!primaryLogin && !!policy?.employeeList?.[primaryLogin]?.hasActiveExpensifyCard); +} + +function isExpensifyTeam(email: string | undefined): boolean { + const emailDomain = Str.extractEmailDomain(email ?? ''); + return emailDomain === CONST.EXPENSIFY_PARTNER_NAME || emailDomain === CONST.EMAIL.GUIDES_DOMAIN; +} + +/** Whether Expensify team members should be hidden from the given policy/user combination. */ +function shouldFilterExpensifyTeam(policyOwner: string | undefined, currentUserLogin: string | undefined): boolean { + return !!policyOwner && !!currentUserLogin && !isExpensifyTeam(policyOwner) && !isExpensifyTeam(currentUserLogin); +} + +/** + * Creates a selector for useOnyx that computes the filtered member count. + * Returns a primitive number to prevent unnecessary re-renders when unrelated personal details change. + */ +function createFilteredMemberCountSelector( + employeeList: PolicyEmployeeList | undefined, + policyOwner: string | undefined, + currentUserLogin: string | undefined, + personalDetailsByLogins: PersonalDetailsByLogin, +) { + return (personalDetails: PersonalDetailsList | undefined): number => { + const shouldFilter = shouldFilterExpensifyTeam(policyOwner, currentUserLogin); + const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(employeeList, personalDetailsByLogins, false, false); + + return Object.keys(policyMemberEmailsToAccountIDs).reduce((count, email) => { + const accountID = policyMemberEmailsToAccountIDs[email]; + const details = personalDetails?.[accountID]; + + if (shouldFilter && isExpensifyTeam(details?.login ?? details?.displayName)) { + return count; + } + + return count + 1; + }, 0); + }; +} + +/** + * Create an object mapping member emails to their accountIDs. Filter for members without errors if includeMemberWithErrors is false, and get the login email from the personalDetail object using the accountID. + * + * If includeMemberWithErrors is false, We only return members without errors. Otherwise, the members with errors would immediately be removed before the user has a chance to read the error. + */ +function getMemberAccountIDsForWorkspace( + employeeList: PolicyEmployeeList | undefined, + personalDetailsByLogins: PersonalDetailsByLogin, + includeMemberWithErrors = false, + includeMemberWithPendingDelete = true, +): MemberEmailsToAccountIDs { + const members = employeeList ?? {}; + const memberEmailsToAccountIDs: MemberEmailsToAccountIDs = {}; + for (const email of Object.keys(members)) { + if (!includeMemberWithErrors) { + const member = members?.[email]; + if (Object.keys(member?.errors ?? {})?.length > 0) { + continue; + } + } + if (!includeMemberWithPendingDelete) { + const member = members?.[email]; + if (member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + continue; + } + } + const personalDetail = personalDetailsByLogins?.[email]; + if (!personalDetail?.login) { + continue; + } + memberEmailsToAccountIDs[email] = Number(personalDetail.accountID); + } + return memberEmailsToAccountIDs; +} + +/** + * Resolves the accountID for a submit-to recipient chosen in the submit-to popover. + * Uses personal details first, then the workspace employee list (same source as ReportSubmitToContent). + * When the member is in employeeList but not yet in personal details, returns a stable optimistic accountID. + */ +function getAccountIDForSubmitManagerEmail( + managerEmail: string | undefined, + employeeList: PolicyEmployeeList | undefined, + personalDetailsByLogins: PersonalDetailsByLogin, +): number | undefined { + const trimmed = managerEmail?.trim(); + if (!trimmed) { + return undefined; + } + + const fromPersonalDetails = personalDetailsByLogins[trimmed]?.accountID; + if (fromPersonalDetails !== undefined) { + return fromPersonalDetails; + } + + if (!employeeList) { + return undefined; + } + + const normalizedEmail = trimmed.toLowerCase(); + const memberAccountIDs = getMemberAccountIDsForWorkspace(employeeList, personalDetailsByLogins, true, false); + + for (const [email, accountID] of Object.entries(memberAccountIDs)) { + if (email.toLowerCase() === normalizedEmail) { + return accountID; + } + } + + for (const [listKey, employee] of Object.entries(employeeList)) { + const employeeEmail = (employee.email ?? listKey).trim(); + const listKeyNormalized = listKey.trim().toLowerCase(); + const employeeEmailNormalized = employeeEmail.toLowerCase(); + + if (employeeEmailNormalized !== normalizedEmail && listKeyNormalized !== normalizedEmail) { + continue; + } + + const accountIDFromMap = memberAccountIDs[employeeEmail] ?? memberAccountIDs[listKey]; + + if (accountIDFromMap !== undefined) { + return accountIDFromMap; + } + + return generateAccountID(trimmed); + } + + return undefined; +} + +/** + * Get login list that we should not show in the workspace invite options + */ +function getIneligibleInvitees(employeeList?: PolicyEmployeeList): string[] { + const policyEmployeeList = employeeList ?? {}; + const memberEmailsToExclude: string[] = [...CONST.EXPENSIFY_EMAILS]; + for (const email of Object.keys(policyEmployeeList)) { + const policyEmployee = policyEmployeeList?.[email]; + // Policy members that are pending delete or have errors are not valid and we should show them in the invite options (don't exclude them). + if (policyEmployee?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policyEmployee?.errors ?? {}).length > 0) { + continue; + } + if (!email) { + continue; + } + memberEmailsToExclude.push(email); + } + + return memberEmailsToExclude; +} + +/** + * Get excluded users as a Record for use in search selector + */ +function getExcludedUsers(employeeList?: PolicyEmployeeList): Record { + return Object.fromEntries(getIneligibleInvitees(employeeList).map((login) => [login, true])); +} + +/** + * Get Guide and Account Manager information including their emails/logins and exclusion record. + * Used for filtering Guide/AM from contact lists while allowing manual entry. + * + * @param policy - The policy to get the assigned guide from + * @param accountManagerAccountID - The account manager's account ID from the account object + * @param personalDetails - Personal details collection to look up account manager login + * @returns Object containing extracted emails/logins and exclusions record + */ +function getGuideAndAccountManagerInfo( + policy: OnyxEntry, + accountManagerAccountID: number | undefined, + personalDetails: OnyxEntry, +): { + assignedGuideEmail: string | undefined; + accountManagerLogin: string | undefined; + exclusions: Record; +} { + const assignedGuideEmail = policy?.assignedGuide?.email?.toLowerCase(); + const accountManagerLogin = accountManagerAccountID ? personalDetails?.[accountManagerAccountID]?.login?.toLowerCase() : undefined; + + const exclusions: Record = {}; + if (assignedGuideEmail) { + exclusions[assignedGuideEmail] = true; + } + if (accountManagerLogin) { + exclusions[accountManagerLogin] = true; + } + + return { + assignedGuideEmail, + accountManagerLogin, + exclusions, + }; +} + +/** + * Build a soft-exclusion map of Expensify-team logins (current and former AMs/Guides) so search + * filters don't surface internal Expensify staff to customers. Mirrors the visibility rule used + * by WorkspaceMembersPage: skip the filter when the current user is themselves Expensify staff, + * and preserve Expensify-team members of any Expensify-owned policy the current user belongs to. + */ +function getExpensifyTeamExclusions(personalDetails: OnyxEntry, policies: OnyxCollection, currentUserLogin: string | undefined): Record { + if (!currentUserLogin || isExpensifyTeam(currentUserLogin)) { + return {}; + } + + const lowerCurrentUserLogin = currentUserLogin.toLowerCase(); + const allowedExpensifyTeamLogins = new Set(); + + for (const policy of Object.values(policies ?? {})) { + if (!policy?.owner || !policy.employeeList || !isExpensifyTeam(policy.owner)) { + continue; + } + const currentUserIsInPolicy = Object.keys(policy.employeeList).some((email) => email.toLowerCase() === lowerCurrentUserLogin); + if (!currentUserIsInPolicy) { + continue; + } + for (const email of Object.keys(policy.employeeList)) { + const lowerEmail = email.toLowerCase(); + if (isExpensifyTeam(lowerEmail)) { + allowedExpensifyTeamLogins.add(lowerEmail); + } + } + } + + const exclusion: Record = {}; + for (const details of Object.values(personalDetails ?? {})) { + const login = details?.login?.toLowerCase(); + if (!login || !isExpensifyTeam(login)) { + continue; + } + if (allowedExpensifyTeamLogins.has(login)) { + continue; + } + exclusion[login] = true; + } + return exclusion; +} + +/** + * Get soft exclusions (Guide/Account Manager) that should be hidden from auto-suggestions + * but can still be manually entered by the user. + * + * @param policy - The policy to get the assigned guide from + * @param accountManagerAccountID - The account manager's account ID from the account object + * @param personalDetails - Personal details collection to look up account manager login + * @returns Record mapping lowercase emails to true for Guide and Account Manager + */ +function getSoftExclusionsForGuideAndAccountManager( + policy: OnyxEntry, + accountManagerAccountID: number | undefined, + personalDetails: OnyxEntry, +): Record { + return getGuideAndAccountManagerInfo(policy, accountManagerAccountID, personalDetails).exclusions; +} + +/** + * Filter out Guide and Account Manager from a list of items. + * Used for filtering local data (e.g., policy.employeeList) that isn't filtered at the data layer. + * + * @param items - Array of items to filter (must have login or alternateText properties) + * @param assignedGuideEmail - The assigned guide's email (should be lowercase) + * @param accountManagerLogin - The account manager's login (should be lowercase) + * @returns Filtered array with Guide and Account Manager removed + */ +function filterGuideAndAccountManager( + items: T[], + assignedGuideEmail: string | undefined, + accountManagerLogin: string | undefined, +): T[] { + return items.filter((item) => { + const itemLogin = item.login?.toLowerCase(); + const itemAltText = item.alternateText?.toLowerCase(); + + if (assignedGuideEmail && (itemLogin === assignedGuideEmail || itemAltText === assignedGuideEmail)) { + return false; + } + + if (accountManagerLogin && (itemLogin === accountManagerLogin || itemAltText === accountManagerLogin)) { + return false; + } + + return true; + }); +} + +function getAdminEmployees(policy: OnyxEntry): PolicyEmployee[] { + if (!policy?.employeeList) { + return []; + } + return Object.keys(policy.employeeList) + .map((email) => ({...policy.employeeList?.[email], email})) + .filter((employee) => employee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && employee.role === CONST.POLICY.ROLE.ADMIN); +} + +/** + * Check if the policy member is deleted from the workspace + */ +function isDeletedPolicyEmployee(policyEmployee: PolicyEmployee, isOffline: boolean) { + return !isOffline && policyEmployee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isEmptyObject(policyEmployee.errors); +} + +export { + getAdminEmployees, + getIneligibleInvitees, + getExcludedUsers, + getMemberAccountIDsForWorkspace, + getAccountIDForSubmitManagerEmail, + getGuideAndAccountManagerInfo, + getSoftExclusionsForGuideAndAccountManager, + getExpensifyTeamExclusions, + filterGuideAndAccountManager, + isExpensifyTeam, + shouldFilterExpensifyTeam, + createFilteredMemberCountSelector, + isDeletedPolicyEmployee, + hasEligibleBankAccountShareRecipient, + isPolicyMember, + isMemberInHomeAndOfficeWorkspace, + getEligibleBankAccountShareRecipientEmails, + isPolicyMemberWithoutPendingDelete, + hasActiveExpensifyCard, +}; + +export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/PolicyUtils/permissions.ts b/src/libs/PolicyUtils/permissions.ts index 9ccb9afb85a5..9c8b213fbf75 100644 --- a/src/libs/PolicyUtils/permissions.ts +++ b/src/libs/PolicyUtils/permissions.ts @@ -6,7 +6,7 @@ import CONST from '@src/CONST'; import type {OnyxInputOrEntry, Policy} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; +import type {TupleToUnion, ValueOf} from 'type-fest'; import {isArchivedPolicy, isControlPolicy, isSubmitPolicy} from './policyType'; @@ -234,6 +234,34 @@ function getPolicyRole(policy: OnyxInputOrEntry, currentUserLogin?: stri return (employeeList?.[currentUserLogin] ?? employeeList?.[currentUserLogin.toLowerCase()])?.role; } +/** + * Workspace roles from highest to lowest privilege, matching the role hierarchy in Auth. + */ +const POLICY_ROLE_RANKING = [ + CONST.POLICY.ROLE.ADMIN, + CONST.POLICY.ROLE.PAYMENTS_ADMIN, + CONST.POLICY.ROLE.CARD_ADMIN, + CONST.POLICY.ROLE.PEOPLE_ADMIN, + CONST.POLICY.ROLE.AUDITOR, + CONST.POLICY.ROLE.EDITOR, + CONST.POLICY.ROLE.USER, + CONST.POLICY.ROLE.GUEST, +] as const; + +/** + * Returns the highest-privilege role held across the given policies. + * Defaults to user when no ranked role is found, so a member on any workspace is not reported as a guest. + */ +function getHighestPolicyRole(policyList: Array>>): TupleToUnion { + const roles = new Set(policyList.map((policy) => policy?.role)); + for (const rankedRole of POLICY_ROLE_RANKING) { + if (roles.has(rankedRole)) { + return rankedRole; + } + } + return CONST.POLICY.ROLE.USER; +} + /** * Whether the given role is allowed to pay (reimburse) on a workspace. */ @@ -327,6 +355,7 @@ export { canMemberAssignElevatedRole, canMemberManageMemberWithRole, getPolicyRole, + getHighestPolicyRole, canRolePay, PAYER_ROLES, isPolicyApprover, diff --git a/src/libs/PolicyUtils/travel.ts b/src/libs/PolicyUtils/travel.ts new file mode 100644 index 000000000000..e8d805aa69e5 --- /dev/null +++ b/src/libs/PolicyUtils/travel.ts @@ -0,0 +1,58 @@ +/** + * Travel provisioning, accepted terms, and which travel step to show. + * Extracted from PolicyUtils/index.ts to keep that file smaller. + */ +import CONST from '@src/CONST'; +import type {Policy, TravelSettings} from '@src/types/onyx'; +import type {WorkspaceTravelSettings} from '@src/types/onyx/TravelSettings'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import {getAdminsPrivateEmailDomains} from './domains'; +import {getActivePolicies} from './policy'; +import {isPaidGroupPolicy} from './policyType'; + +type TravelStep = ValueOf; + +/** Whether a workspace has been provisioned with a Spotnana entity. */ +function isWorkspaceProvisionedForTravel(travelSettings?: WorkspaceTravelSettings): boolean { + return !!(travelSettings?.spotnanaCompanyID ?? travelSettings?.associatedTravelDomainAccountID); +} + +/** Whether travel is enabled on a workspace, meaning it can be used to book a trip. */ +function hasAcceptedTravelTerms(policy: OnyxEntry, accountTravelSettings: TravelSettings | undefined): boolean { + return !!(policy?.travelSettings?.hasAcceptedTerms ?? (accountTravelSettings?.hasAcceptedTerms && isWorkspaceProvisionedForTravel(policy?.travelSettings))); +} + +function isNonUSDPolicy(policy: OnyxEntry): boolean { + return !!policy?.outputCurrency && policy.outputCurrency !== CONST.CURRENCY.USD; +} + +/** + * Determines which travel step should be shown based on policy state + */ +function getTravelStep( + policy: OnyxEntry, + travelSettings: TravelSettings | undefined, + isTravelVerifiedBetaEnabled: boolean, + policies: OnyxCollection, + currentUserLogin: string | undefined, +): TravelStep { + const adminDomains = getAdminsPrivateEmailDomains(policy); + const activePolicies = getActivePolicies(policies, currentUserLogin); + const groupPaidPolicies = activePolicies.filter(isPaidGroupPolicy); + + if (adminDomains.length === 0 || groupPaidPolicies.length < 1 || !isPaidGroupPolicy(policy)) { + return CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL; + } + if (policy?.travelSettings?.hasAcceptedTerms) { + return CONST.TRAVEL.STEPS.BOOK_OR_MANAGE_YOUR_TRIP; + } + if (!isTravelVerifiedBetaEnabled && travelSettings?.lastTravelSignupRequestTime) { + return CONST.TRAVEL.STEPS.REVIEWING_REQUEST; + } + return CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL; +} + +export {isWorkspaceProvisionedForTravel, hasAcceptedTravelTerms, isNonUSDPolicy, getTravelStep}; diff --git a/src/libs/telemetry/TelemetrySynchronizer.ts b/src/libs/telemetry/TelemetrySynchronizer.ts index 9738f0781d7b..f4278f4ab14f 100644 --- a/src/libs/telemetry/TelemetrySynchronizer.ts +++ b/src/libs/telemetry/TelemetrySynchronizer.ts @@ -1,5 +1,4 @@ -import {getActivePolicies} from '@libs/PolicyUtils'; -import getHighestPolicyRole from '@libs/PolicyUtils/getHighestPolicyRole'; +import {getActivePolicies, getHighestPolicyRole} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/tests/actions/IOU/CreateDraftTransactionTest.ts b/tests/actions/IOU/CreateDraftTransactionTest.ts index 4459c006b259..f09653934179 100644 --- a/tests/actions/IOU/CreateDraftTransactionTest.ts +++ b/tests/actions/IOU/CreateDraftTransactionTest.ts @@ -87,6 +87,9 @@ jest.mock('@src/libs/SearchQueryUtils', () => { jest.mock('@libs/PolicyUtils', () => ({ ...jest.requireActual('@libs/PolicyUtils'), + // This factory runs while PolicyUtils is still loading inside an import cycle, so the barrel's `export *` helpers + // aren't on it yet. Spread them from their own modules, which load fully. + ...jest.requireActual>('@libs/PolicyUtils/members'), isPaidGroupPolicy: jest.fn().mockReturnValue(true), isGroupPolicy: jest.fn().mockReturnValue(true), isPolicyOwner: jest.fn().mockImplementation((policy?: OnyxEntry, currentUserAccountID?: number) => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID), diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index a47b25e292d6..685d9349b501 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -52,6 +52,7 @@ import { getGroupPoliciesWhereReportCanBeCreated, getIntegrationLastSuccessfulDate, getManagerAccountID, + getMemberAccountIDsForWorkspace, getActiveVendorMatchingIntegration, getMatchingVendorByID, getMatchingVendors, @@ -89,6 +90,7 @@ import { getGLCodeFromPolicyTag, getTagList, getTagListByOrderWeight, + getTravelStep, getUberConnectionErrorDirectlyFromPolicy, getUnitRateValue, getXeroExpenseAccounts, @@ -109,6 +111,7 @@ import { isArchivedPolicy, isDualEntryVendorMatchingActive, isCertiniaVendorMatchingActive, + isExpensifyTeam, isInvoiceFieldsEnabled, isMatchingVendorListLoaded, isMaxExpenseAmountSet, @@ -141,7 +144,7 @@ import {getApprovalChain, isWorkspaceEligibleForReportChange} from '@libs/Report import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetailsList, Policy, PolicyEmployeeList, PolicyTags, PolicyTagLists, Report, Transaction} from '@src/types/onyx'; +import type {PersonalDetailsList, Policy, PolicyEmployeeList, PolicyTags, PolicyTagLists, Report, Transaction, TravelSettings} from '@src/types/onyx'; import type {ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; import type { Connections, @@ -3070,6 +3073,32 @@ describe('PolicyUtils', () => { expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail, undefined, personalDetailsByLogins)).toBe(adminAccountID); }); }); + + describe('isNonMemberApprover', () => { + const ownerEmail = 'owner@example.com'; + const policy = createMock({ + id: '1', + owner: ownerEmail, + employeeList: { + [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER}, + }, + }); + + it('flags an approver who is no longer on the workspace, but not a member', () => { + // Given a workspace with one member + // When checking a removed approver and that member + // Then only the removed approver is flagged + expect(isNonMemberApprover(policy, 'removed@example.com')).toBe(true); + expect(isNonMemberApprover(policy, employeeEmail)).toBe(false); + }); + + it('does not flag the owner when their employee entry is missing', () => { + // Given a workspace whose employeeList has no entry for the owner + // When checking the owner as an approver + // Then they still count as a member, since the owner always belongs to the workspace + expect(isNonMemberApprover(policy, ownerEmail)).toBe(false); + }); + }); }); describe('isWorkspaceEligibleForReportChange', () => { @@ -3856,67 +3885,6 @@ describe('PolicyUtils', () => { }); }); - describe('isNonMemberApprover', () => { - const ownerEmail = 'owner@example.com'; - const policy = createMock({ - id: '1', - owner: ownerEmail, - employeeList: { - [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER}, - }, - }); - - it('flags an approver who is no longer on the workspace, but not a member', () => { - // Given a workspace with one member - // When checking a removed approver and that member - // Then only the removed approver is flagged - expect(isNonMemberApprover(policy, 'removed@example.com')).toBe(true); - expect(isNonMemberApprover(policy, employeeEmail)).toBe(false); - }); - - it('does not flag the owner when their employee entry is missing', () => { - // Given a workspace whose employeeList has no entry for the owner - // When checking the owner as an approver - // Then they still count as a member, since the owner always belongs to the workspace - expect(isNonMemberApprover(policy, ownerEmail)).toBe(false); - }); - }); - - describe('isPolicyMemberWithoutPendingDelete', () => { - it('should return true if the policy member is not pending delete', () => { - const policy = createMock({ - id: '1', - employeeList: { - [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER}, - }, - }); - const result = isPolicyMemberWithoutPendingDelete(employeeEmail, policy); - expect(result).toBe(true); - }); - - it('should return false if the policy member is pending delete', () => { - const policy = createMock({ - id: '1', - employeeList: { - [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, - }, - }); - const result = isPolicyMemberWithoutPendingDelete(employeeEmail, policy); - expect(result).toBe(false); - }); - - it('should return false if the policy member is not found', () => { - const policy = createMock({ - id: '1', - employeeList: { - [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER}, - }, - }); - const result = isPolicyMemberWithoutPendingDelete('fakeEmail', policy); - expect(result).toBe(false); - }); - }); - describe('payments', () => { describe('getReimburserEmail', () => { it('should return undefined when reimbursement is disabled', () => { @@ -4741,353 +4709,676 @@ describe('PolicyUtils', () => { }); }); - describe('getEligibleBankAccountShareRecipientEmails', () => { - beforeEach(() => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + describe('members', () => { + describe('isPolicyMemberWithoutPendingDelete', () => { + it('should return true if the policy member is not pending delete', () => { + const policy = createMock({ + id: '1', + employeeList: { + [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER}, + }, + }); + const result = isPolicyMemberWithoutPendingDelete(employeeEmail, policy); + expect(result).toBe(true); + }); + + it('should return false if the policy member is pending delete', () => { + const policy = createMock({ + id: '1', + employeeList: { + [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + }, + }); + const result = isPolicyMemberWithoutPendingDelete(employeeEmail, policy); + expect(result).toBe(false); + }); + + it('should return false if the policy member is not found', () => { + const policy = createMock({ + id: '1', + employeeList: { + [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER}, + }, + }); + const result = isPolicyMemberWithoutPendingDelete('fakeEmail', policy); + expect(result).toBe(false); + }); }); - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); + + describe('getEligibleBankAccountShareRecipientEmails', () => { + beforeEach(() => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + }); + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + it('should return empty array if no admins in policies', async () => { + const bankAccountID = '1'; + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { + 1: { + methodID: 12345, + accountData: { + additionalData: {policyID: '1'}, + }, + }, + }); + const policies = { + '1': {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, + '2': {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, + }; + const result = getEligibleBankAccountShareRecipientEmails(policies, approverEmail, bankAccountID); + expect(result).toHaveLength(0); + }); + it('should return array with admins from the bank account workspace', async () => { + const bankAccountID = '1'; + const currentUserLogin = adminEmail; + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { + 1: { + methodID: 12345, + accountData: { + additionalData: {policyID: '1'}, + }, + }, + }); + + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + employeeList: { + [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.ADMIN}, + }, + }, + '2': {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, + }; + const result = getEligibleBankAccountShareRecipientEmails(policies, approverEmail, bankAccountID); + expect(result).toHaveLength(1); + }); + it('should not return user with already shared bank account', async () => { + const bankAccountID = '1'; + const currentUserLogin = adminEmail; + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { + 1: { + methodID: 12345, + accountData: { + additionalData: {policyID: '1'}, + sharees: [adminEmail], + }, + }, + }); + + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + employeeList: { + [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.ADMIN}, + }, + }, + '2': {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, + }; + const result = getEligibleBankAccountShareRecipientEmails(policies, approverEmail, bankAccountID); + expect(result).toHaveLength(0); + }); + it('should not return current user for sharing account', async () => { + const bankAccountID = '1'; + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { + 1: { + methodID: 12345, + accountData: { + additionalData: {policyID: '1'}, + }, + }, + }); + + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + employeeList: { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.ADMIN}, + [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, + }, + }; + const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, bankAccountID); + expect(result).toHaveLength(1); + }); + it('should allow Payments Admins to share with and receive from members who can manage payments', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + pendingAction: undefined, + role: CONST.POLICY.ROLE.PAYMENTS_ADMIN, + employeeList: { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, + [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, + }, + }, + }; + + const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); + + expect(result).toHaveLength(1); + expect(result.at(0)).toBe(approverEmail); + }); + it('should not return members who cannot manage payments', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + pendingAction: undefined, + role: CONST.POLICY.ROLE.PAYMENTS_ADMIN, + employeeList: { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, + [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.PEOPLE_ADMIN}, + }, + }, + }; + + const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); + + expect(result).toHaveLength(0); + }); + it('should not fall back to the current user role when a recipient role is missing', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + pendingAction: undefined, + role: CONST.POLICY.ROLE.PAYMENTS_ADMIN, + employeeList: { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, + [approverEmail]: {email: approverEmail}, + }, + }, + }; + + const recipients = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); + const hasEligibleRecipient = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); + + expect(recipients).toHaveLength(0); + expect(hasEligibleRecipient).toBe(false); + }); + it('should not return Expensify guide when policy owner is not Expensify team', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'normalowner@test.com', + employeeList: { + [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, + }, + }; + const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); + expect(result).toHaveLength(0); + }); + it('should return Expensify guide when policy owner is Expensify team', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'owner@expensify.com', + employeeList: { + [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, + }, + }; + const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); + expect(result).toHaveLength(1); + }); + it('should return Expensify guide when current user is Expensify team', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'normalowner@test.com', + employeeList: { + [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, + }, + }; + const result = getEligibleBankAccountShareRecipientEmails(policies, 'someone@expensify.com', '1'); + expect(result).toHaveLength(1); + }); }); - it('should return empty array if no admins in policies', async () => { - const bankAccountID = '1'; - await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { - 1: { - methodID: 12345, - accountData: { - additionalData: {policyID: '1'}, + + describe('hasEligibleBankAccountShareRecipient', () => { + beforeEach(() => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + }); + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + it('should return false when the only admin is an Expensify guide on a non-Expensify policy', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'normalowner@test.com', + employeeList: { + [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, }, - }, + }; + const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); + expect(result).toBe(false); + }); + it('should return true when there is a non-guide admin', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'normalowner@test.com', + employeeList: { + [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, + [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, + }, + }; + const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); + expect(result).toBe(true); + }); + it('should return true when the guide is on an Expensify-owned policy', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'owner@expensify.com', + employeeList: { + [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, + }, + }; + const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); + expect(result).toBe(true); + }); + it('should return true when another admin is available in the bank account workspace', async () => { + const bankAccountID = '1'; + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { + 1: { + methodID: 12345, + accountData: { + additionalData: {policyID: '1'}, + }, + }, + }); + + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + employeeList: { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.ADMIN}, + [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, + }, + }; + const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, bankAccountID); + expect(result).toBe(true); + }); + it('should return true when a Payments Admin can share with another payment-capable member', () => { + const policies = { + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + pendingAction: undefined, + role: CONST.POLICY.ROLE.PAYMENTS_ADMIN, + employeeList: { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, + [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, + }, + }; + + const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); + + expect(result).toBe(true); + }); + it('should return false when the user only joined another workspace as a member', async () => { + const bankAccountID = '1'; + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { + 1: { + methodID: 12345, + accountData: { + additionalData: {policyID: '1'}, + }, + }, + }); + + const policies = { + // The bank account's own workspace - current user is the only admin, no one to share with + '1': { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.ADMIN, + employeeList: { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, + }, + // Another user's workspace the current user only joined as a member - has its own admin + '2': { + ...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), + pendingAction: undefined, + role: CONST.POLICY.ROLE.USER, + employeeList: { + [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.USER}, + }, + }, + }; + const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, bankAccountID); + expect(result).toBe(false); }); - const policies = { - '1': {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, - '2': {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, + }); + + describe('getExpensifyTeamExclusions', () => { + const buildPersonalDetails = (logins: string[]): PersonalDetailsList => { + const result: PersonalDetailsList = {}; + for (const [index, login] of logins.entries()) { + const accountID = index + 1; + result[accountID] = {accountID, login}; + } + return result; }; - const result = getEligibleBankAccountShareRecipientEmails(policies, approverEmail, bankAccountID); - expect(result).toHaveLength(0); - }); - it('should return array with admins from the bank account workspace', async () => { - const bankAccountID = '1'; - const currentUserLogin = adminEmail; - await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { - 1: { - methodID: 12345, - accountData: { - additionalData: {policyID: '1'}, + const buildPolicies = ( + specs: Array<{ + owner: string; + employeeLogins: string[]; + }>, + ): OnyxCollection => { + const result: OnyxCollection = {}; + for (const [index, {owner, employeeLogins}] of specs.entries()) { + const memberList: PolicyEmployeeList = {}; + for (const login of employeeLogins) { + memberList[login] = {email: login}; + } + result[`policy_${index + 1}`] = createMock({id: `${index + 1}`, owner, employeeList: memberList}); + } + return result; + }; + + it('returns empty when currentUserLogin is undefined', () => { + const details = buildPersonalDetails(['am@expensify.com', 'bob@acme.com']); + const policies = buildPolicies([{owner: 'customer@acme.com', employeeLogins: ['bob@acme.com']}]); + expect(getExpensifyTeamExclusions(details, policies, undefined)).toEqual({}); + }); + + it('returns empty when currentUserLogin is on the Expensify team (exception)', () => { + const details = buildPersonalDetails(['am@expensify.com', 'bob@acme.com']); + const policies = buildPolicies([{owner: 'customer@acme.com', employeeLogins: ['am@expensify.com', 'bob@acme.com']}]); + expect(getExpensifyTeamExclusions(details, policies, 'staff@expensify.com')).toEqual({}); + }); + + it('excludes all Expensify-team logins from personalDetails when current user is non-Expensify and has no Expensify-owned policies', () => { + const details = buildPersonalDetails(['am@expensify.com', 'guide@team.expensify.com', 'bob@acme.com']); + const policies = buildPolicies([{owner: 'customer@acme.com', employeeLogins: ['bob@acme.com']}]); + const result = getExpensifyTeamExclusions(details, policies, 'customer@acme.com'); + expect(result['am@expensify.com']).toBe(true); + expect(result['guide@team.expensify.com']).toBe(true); + expect(result['bob@acme.com']).toBeUndefined(); + }); + + it('preserves Expensify-team members of an Expensify-owned policy the current user belongs to', () => { + const details = buildPersonalDetails(['lead@expensify.com', 'unrelated_am@expensify.com', 'contractor@acme.com']); + const policies = buildPolicies([{owner: 'lead@expensify.com', employeeLogins: ['contractor@acme.com', 'lead@expensify.com']}]); + const result = getExpensifyTeamExclusions(details, policies, 'contractor@acme.com'); + expect(result['lead@expensify.com']).toBeUndefined(); + expect(result['unrelated_am@expensify.com']).toBe(true); + expect(result['contractor@acme.com']).toBeUndefined(); + }); + + it('does not preserve Expensify-team members of policies the current user is not in', () => { + const details = buildPersonalDetails(['lead@expensify.com', 'bob@acme.com']); + const policies = buildPolicies([{owner: 'lead@expensify.com', employeeLogins: ['lead@expensify.com', 'someone_else@acme.com']}]); + const result = getExpensifyTeamExclusions(details, policies, 'bob@acme.com'); + expect(result['lead@expensify.com']).toBe(true); + }); + + it('does not preserve when policy owner is non-Expensify', () => { + const details = buildPersonalDetails(['am@expensify.com', 'bob@acme.com']); + const policies = buildPolicies([{owner: 'bob@acme.com', employeeLogins: ['am@expensify.com', 'bob@acme.com']}]); + const result = getExpensifyTeamExclusions(details, policies, 'bob@acme.com'); + expect(result['am@expensify.com']).toBe(true); + }); + + it('handles undefined and empty inputs gracefully', () => { + expect(getExpensifyTeamExclusions(undefined, undefined, 'customer@acme.com')).toEqual({}); + // The null values deliberately exercise the runtime guard for malformed Onyx data. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Null is the malformed-input scenario under test. + const nullPersonalDetails = null as unknown as Parameters[0]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Null is the malformed-input scenario under test. + const nullPolicies = null as unknown as Parameters[1]; + expect(getExpensifyTeamExclusions(nullPersonalDetails, nullPolicies, 'customer@acme.com')).toEqual({}); + expect(getExpensifyTeamExclusions({}, {}, 'customer@acme.com')).toEqual({}); + }); + + it('lowercases comparison keys for current user and employeeList matching', () => { + const details = buildPersonalDetails(['AM@expensify.com', 'Bob@Acme.com']); + const policies = buildPolicies([{owner: 'lead@expensify.com', employeeLogins: ['AM@expensify.com', 'Contractor@Acme.com']}]); + const result = getExpensifyTeamExclusions(details, policies, 'Contractor@Acme.com'); + expect(result['am@expensify.com']).toBeUndefined(); + expect(result['bob@acme.com']).toBeUndefined(); + }); + }); + + describe('getExcludedUsers', () => { + it('marks every active policy member as excluded', () => { + const result = getExcludedUsers(employeeList); + expect(result['owner@test.com']).toBe(true); + expect(result['admin@test.com']).toBe(true); + expect(result['employee@test.com']).toBe(true); + }); + + it('always excludes Expensify emails', () => { + const result = getExcludedUsers(employeeList); + for (const email of CONST.EXPENSIFY_EMAILS) { + expect(result[email]).toBe(true); + } + }); + + it('does not exclude a member that is pending delete', () => { + const list: PolicyEmployeeList = { + 'employee@test.com': {email: 'employee@test.com', role: 'user', submitsTo: '', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + }; + const result = getExcludedUsers(list); + expect(result['employee@test.com']).toBeUndefined(); + }); + + it('does not exclude a member that has errors', () => { + const list: PolicyEmployeeList = { + 'employee@test.com': {email: 'employee@test.com', role: 'user', submitsTo: '', errors: {error1: 'Something went wrong'}}, + }; + const result = getExcludedUsers(list); + expect(result['employee@test.com']).toBeUndefined(); + }); + + it('returns only Expensify emails when the employee list is undefined', () => { + const result = getExcludedUsers(undefined); + expect(Object.keys(result)).toEqual([...CONST.EXPENSIFY_EMAILS]); + }); + }); + + describe('hasActiveExpensifyCard', () => { + it('returns false when policy is undefined', () => { + expect(hasActiveExpensifyCard(undefined, 'cardholder@test.com')).toBe(false); + }); + + it('returns true when the backend flags the member as holding an active Expensify Card', () => { + const policy: Policy = { + ...createRandomPolicy(0), + employeeList: { + 'cardholder@test.com': {email: 'cardholder@test.com', hasActiveExpensifyCard: true}, }, - }, + }; + expect(hasActiveExpensifyCard(policy, 'cardholder@test.com')).toBe(true); }); - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, + it('returns false when the flag is false or missing', () => { + const policy: Policy = { + ...createRandomPolicy(0), employeeList: { - [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.ADMIN}, + 'former-cardholder@test.com': {email: 'former-cardholder@test.com', hasActiveExpensifyCard: false}, + 'employee@test.com': {email: 'employee@test.com'}, }, - }, - '2': {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, - }; - const result = getEligibleBankAccountShareRecipientEmails(policies, approverEmail, bankAccountID); - expect(result).toHaveLength(1); - }); - it('should not return user with already shared bank account', async () => { - const bankAccountID = '1'; - const currentUserLogin = adminEmail; - await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { - 1: { - methodID: 12345, - accountData: { - additionalData: {policyID: '1'}, - sharees: [adminEmail], - }, - }, + }; + expect(hasActiveExpensifyCard(policy, 'former-cardholder@test.com')).toBe(false); + expect(hasActiveExpensifyCard(policy, 'employee@test.com')).toBe(false); }); - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, + it('returns false when the member is not in the employeeList', () => { + const policy: Policy = { + ...createRandomPolicy(0), employeeList: { - [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.ADMIN}, - }, - }, - '2': {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), pendingAction: undefined}, - }; - const result = getEligibleBankAccountShareRecipientEmails(policies, approverEmail, bankAccountID); - expect(result).toHaveLength(0); - }); - it('should not return current user for sharing account', async () => { - const bankAccountID = '1'; - await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { - 1: { - methodID: 12345, - accountData: { - additionalData: {policyID: '1'}, + 'cardholder@test.com': {email: 'cardholder@test.com', hasActiveExpensifyCard: true}, }, - }, + }; + expect(hasActiveExpensifyCard(policy, 'someone-else@test.com')).toBe(false); }); - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, + it('returns true when the secondary login is checked and the backend flags its paired primary login', () => { + const policy: Policy = { + ...createRandomPolicy(0), + primaryLoginsInvited: {'secondary@test.com': 'primary@test.com'}, employeeList: { - [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.ADMIN}, - [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, + 'secondary@test.com': {email: 'secondary@test.com'}, + 'primary@test.com': {email: 'primary@test.com', hasActiveExpensifyCard: true}, }, - }, - }; - const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, bankAccountID); - expect(result).toHaveLength(1); + }; + expect(hasActiveExpensifyCard(policy, 'secondary@test.com')).toBe(true); + }); }); - it('should allow Payments Admins to share with and receive from members who can manage payments', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - pendingAction: undefined, - role: CONST.POLICY.ROLE.PAYMENTS_ADMIN, - employeeList: { - [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, - [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, - }, - }, - }; - const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); + describe('isMemberInHomeAndOfficeWorkspace', () => { + it('only allows members of home and office workspaces', () => { + // Given a workspace whose employee list contains one member + const memberLogin = 'member@example.com'; + const policy = createMock({ + commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE}, + employeeList: {[memberLogin]: {email: memberLogin}}, + }); + const otherWorkspace = {...policy, commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE}}; - expect(result).toHaveLength(1); - expect(result.at(0)).toBe(approverEmail); + // When workspace mode and membership are checked + const isEligibleMember = isMemberInHomeAndOfficeWorkspace(policy, memberLogin); + const isEligibleForOtherWorkspaceMode = isMemberInHomeAndOfficeWorkspace(otherWorkspace, memberLogin); + const isEligibleForUnknownMember = isMemberInHomeAndOfficeWorkspace(policy, 'unknown@example.com'); + + // Then only the known member in a home and office workspace is eligible + expect(isEligibleMember).toBe(true); + expect(isEligibleForOtherWorkspaceMode).toBe(false); + expect(isEligibleForUnknownMember).toBe(false); + }); }); - it('should not return members who cannot manage payments', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - pendingAction: undefined, - role: CONST.POLICY.ROLE.PAYMENTS_ADMIN, - employeeList: { - [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, - [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.PEOPLE_ADMIN}, - }, - }, - }; - const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); + describe('isExpensifyTeam', () => { + it('returns true for emails on the Expensify and guides domains', () => { + // Given emails on the Expensify partner domain and the guides domain + // When checking whether they belong to the Expensify team + // Then both count as Expensify team members + expect(isExpensifyTeam(guideEmail)).toBe(true); + expect(isExpensifyTeam('guide@team.expensify.com')).toBe(true); + }); + + it('returns false for emails on other domains', () => { + // Given an email on a customer domain and one on another subdomain of expensify.com + // When checking whether they belong to the Expensify team + // Then neither counts, because only an exact domain match is treated as Expensify + expect(isExpensifyTeam(employeeEmail)).toBe(false); + expect(isExpensifyTeam('someone@sub.expensify.com')).toBe(false); + }); - expect(result).toHaveLength(0); + it('returns false for a missing or empty email', () => { + // Given no email at all + // When checking whether it belongs to the Expensify team + // Then it does not, so callers can pass optional logins safely + expect(isExpensifyTeam(undefined)).toBe(false); + expect(isExpensifyTeam('')).toBe(false); + }); }); - it('should not fall back to the current user role when a recipient role is missing', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - pendingAction: undefined, - role: CONST.POLICY.ROLE.PAYMENTS_ADMIN, - employeeList: { - [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, - [approverEmail]: {email: approverEmail}, - }, - }, - }; - const recipients = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); - const hasEligibleRecipient = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); + describe('getMemberAccountIDsForWorkspace', () => { + it('maps every member email to its accountID from personal details', () => { + // Given a workspace whose members all have personal details + // When building the email-to-accountID map + const result = getMemberAccountIDsForWorkspace(employeeList, personalDetailsByLogins); - expect(recipients).toHaveLength(0); - expect(hasEligibleRecipient).toBe(false); - }); - it('should not return Expensify guide when policy owner is not Expensify team', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, - owner: 'normalowner@test.com', - employeeList: { - [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, - }, - }, - }; - const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); - expect(result).toHaveLength(0); - }); - it('should return Expensify guide when policy owner is Expensify team', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, - owner: 'owner@expensify.com', - employeeList: { - [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, - }, - }, - }; - const result = getEligibleBankAccountShareRecipientEmails(policies, adminEmail, '1'); - expect(result).toHaveLength(1); - }); - it('should return Expensify guide when current user is Expensify team', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, - owner: 'normalowner@test.com', - employeeList: { - [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, - }, - }, - }; - const result = getEligibleBankAccountShareRecipientEmails(policies, 'someone@expensify.com', '1'); - expect(result).toHaveLength(1); - }); - }); + // Then every member resolves to its accountID + expect(result).toEqual({ + 'owner@test.com': ownerAccountID, + [adminEmail]: adminAccountID, + [employeeEmail]: employeeAccountID, + [categoryApprover1Email]: categoryApprover1AccountID, + 'categoryapprover2@test.com': categoryApprover2AccountID, + 'tagapprover1@test.com': tagApprover1AccountID, + 'tagapprover2@test.com': tagApprover2AccountID, + }); + }); - describe('hasEligibleBankAccountShareRecipient', () => { - beforeEach(() => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - }); - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); - }); - it('should return false when the only admin is an Expensify guide on a non-Expensify policy', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, - owner: 'normalowner@test.com', - employeeList: { - [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, - }, - }, - }; - const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); - expect(result).toBe(false); - }); - it('should return true when there is a non-guide admin', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, - owner: 'normalowner@test.com', - employeeList: { - [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, - [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, - }, - }, - }; - const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); - expect(result).toBe(true); - }); - it('should return true when the guide is on an Expensify-owned policy', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, - owner: 'owner@expensify.com', - employeeList: { - [guideEmail]: {email: guideEmail, role: CONST.POLICY.ROLE.ADMIN}, - }, - }, - }; - const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); - expect(result).toBe(true); - }); - }); + it('skips members without personal details', () => { + // Given a member whose login is not in personal details yet + const list: PolicyEmployeeList = { + [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER}, + 'unknown@test.com': {email: 'unknown@test.com', role: CONST.POLICY.ROLE.USER}, + }; - describe('hasEligibleBankAccountShareRecipient', () => { - beforeEach(() => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - }); - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdatesWithAct(); - }); - it('should return true when another admin is available in the bank account workspace', async () => { - const bankAccountID = '1'; - await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { - 1: { - methodID: 12345, - accountData: { - additionalData: {policyID: '1'}, - }, - }, + // When building the email-to-accountID map + const result = getMemberAccountIDsForWorkspace(list, personalDetailsByLogins); + + // Then that member is left out, since there is no accountID to map to + expect(result).toEqual({[employeeEmail]: employeeAccountID}); }); - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, - employeeList: { - [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.ADMIN}, - [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, - }, - }, - }; - const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, bankAccountID); - expect(result).toBe(true); - }); - it('should return true when a Payments Admin can share with another payment-capable member', () => { - const policies = { - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - pendingAction: undefined, - role: CONST.POLICY.ROLE.PAYMENTS_ADMIN, - employeeList: { - [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN}, - [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, - }, - }, - }; + it('excludes members with errors unless includeMemberWithErrors is true', () => { + // Given a member with an error, such as a failed invite + const list: PolicyEmployeeList = { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.ADMIN}, + [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER, errors: {error1: 'Something went wrong'}}, + }; - const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, '1'); + // When building the map with and without includeMemberWithErrors + const withoutErrors = getMemberAccountIDsForWorkspace(list, personalDetailsByLogins); + const withErrors = getMemberAccountIDsForWorkspace(list, personalDetailsByLogins, true); - expect(result).toBe(true); - }); - it('should return false when the user only joined another workspace as a member', async () => { - const bankAccountID = '1'; - await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { - 1: { - methodID: 12345, - accountData: { - additionalData: {policyID: '1'}, - }, - }, + // Then the member is only kept when asked for, so the error stays visible until the user reads it + expect(withoutErrors).toEqual({[adminEmail]: adminAccountID}); + expect(withErrors).toEqual({[adminEmail]: adminAccountID, [employeeEmail]: employeeAccountID}); }); - const policies = { - // The bank account's own workspace - current user is the only admin, no one to share with - '1': { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.ADMIN, - employeeList: { - [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.ADMIN}, - }, - }, - // Another user's workspace the current user only joined as a member - has its own admin - '2': { - ...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), - pendingAction: undefined, - role: CONST.POLICY.ROLE.USER, - employeeList: { - [approverEmail]: {email: approverEmail, role: CONST.POLICY.ROLE.ADMIN}, - [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.USER}, - }, - }, - }; - const result = hasEligibleBankAccountShareRecipient(policies, adminEmail, bankAccountID); - expect(result).toBe(false); + it('excludes members pending delete only when includeMemberWithPendingDelete is false', () => { + // Given a member who is pending removal + const list: PolicyEmployeeList = { + [adminEmail]: {email: adminEmail, role: CONST.POLICY.ROLE.ADMIN}, + [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + }; + + // When building the map with the default and with includeMemberWithPendingDelete set to false + const withPendingDelete = getMemberAccountIDsForWorkspace(list, personalDetailsByLogins); + const withoutPendingDelete = getMemberAccountIDsForWorkspace(list, personalDetailsByLogins, false, false); + + // Then the member is kept by default and dropped when pending deletes are excluded + expect(withPendingDelete).toEqual({[adminEmail]: adminAccountID, [employeeEmail]: employeeAccountID}); + expect(withoutPendingDelete).toEqual({[adminEmail]: adminAccountID}); + }); + + it('returns an empty object when the employee list is undefined', () => { + // Given a workspace with no employee list loaded + // When building the email-to-accountID map + // Then there are no members to map + expect(getMemberAccountIDsForWorkspace(undefined, personalDetailsByLogins)).toEqual({}); + }); }); }); @@ -5801,96 +6092,6 @@ describe('PolicyUtils', () => { }); }); - describe('getExpensifyTeamExclusions', () => { - const buildPersonalDetails = (logins: string[]): PersonalDetailsList => { - const result: PersonalDetailsList = {}; - for (const [index, login] of logins.entries()) { - const accountID = index + 1; - result[accountID] = {accountID, login}; - } - return result; - }; - const buildPolicies = ( - specs: Array<{ - owner: string; - employeeLogins: string[]; - }>, - ): OnyxCollection => { - const result: OnyxCollection = {}; - for (const [index, {owner, employeeLogins}] of specs.entries()) { - const memberList: PolicyEmployeeList = {}; - for (const login of employeeLogins) { - memberList[login] = {email: login}; - } - result[`policy_${index + 1}`] = createMock({id: `${index + 1}`, owner, employeeList: memberList}); - } - return result; - }; - - it('returns empty when currentUserLogin is undefined', () => { - const details = buildPersonalDetails(['am@expensify.com', 'bob@acme.com']); - const policies = buildPolicies([{owner: 'customer@acme.com', employeeLogins: ['bob@acme.com']}]); - expect(getExpensifyTeamExclusions(details, policies, undefined)).toEqual({}); - }); - - it('returns empty when currentUserLogin is on the Expensify team (exception)', () => { - const details = buildPersonalDetails(['am@expensify.com', 'bob@acme.com']); - const policies = buildPolicies([{owner: 'customer@acme.com', employeeLogins: ['am@expensify.com', 'bob@acme.com']}]); - expect(getExpensifyTeamExclusions(details, policies, 'staff@expensify.com')).toEqual({}); - }); - - it('excludes all Expensify-team logins from personalDetails when current user is non-Expensify and has no Expensify-owned policies', () => { - const details = buildPersonalDetails(['am@expensify.com', 'guide@team.expensify.com', 'bob@acme.com']); - const policies = buildPolicies([{owner: 'customer@acme.com', employeeLogins: ['bob@acme.com']}]); - const result = getExpensifyTeamExclusions(details, policies, 'customer@acme.com'); - expect(result['am@expensify.com']).toBe(true); - expect(result['guide@team.expensify.com']).toBe(true); - expect(result['bob@acme.com']).toBeUndefined(); - }); - - it('preserves Expensify-team members of an Expensify-owned policy the current user belongs to', () => { - const details = buildPersonalDetails(['lead@expensify.com', 'unrelated_am@expensify.com', 'contractor@acme.com']); - const policies = buildPolicies([{owner: 'lead@expensify.com', employeeLogins: ['contractor@acme.com', 'lead@expensify.com']}]); - const result = getExpensifyTeamExclusions(details, policies, 'contractor@acme.com'); - expect(result['lead@expensify.com']).toBeUndefined(); - expect(result['unrelated_am@expensify.com']).toBe(true); - expect(result['contractor@acme.com']).toBeUndefined(); - }); - - it('does not preserve Expensify-team members of policies the current user is not in', () => { - const details = buildPersonalDetails(['lead@expensify.com', 'bob@acme.com']); - const policies = buildPolicies([{owner: 'lead@expensify.com', employeeLogins: ['lead@expensify.com', 'someone_else@acme.com']}]); - const result = getExpensifyTeamExclusions(details, policies, 'bob@acme.com'); - expect(result['lead@expensify.com']).toBe(true); - }); - - it('does not preserve when policy owner is non-Expensify', () => { - const details = buildPersonalDetails(['am@expensify.com', 'bob@acme.com']); - const policies = buildPolicies([{owner: 'bob@acme.com', employeeLogins: ['am@expensify.com', 'bob@acme.com']}]); - const result = getExpensifyTeamExclusions(details, policies, 'bob@acme.com'); - expect(result['am@expensify.com']).toBe(true); - }); - - it('handles undefined and empty inputs gracefully', () => { - expect(getExpensifyTeamExclusions(undefined, undefined, 'customer@acme.com')).toEqual({}); - // The null values deliberately exercise the runtime guard for malformed Onyx data. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Null is the malformed-input scenario under test. - const nullPersonalDetails = null as unknown as Parameters[0]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Null is the malformed-input scenario under test. - const nullPolicies = null as unknown as Parameters[1]; - expect(getExpensifyTeamExclusions(nullPersonalDetails, nullPolicies, 'customer@acme.com')).toEqual({}); - expect(getExpensifyTeamExclusions({}, {}, 'customer@acme.com')).toEqual({}); - }); - - it('lowercases comparison keys for current user and employeeList matching', () => { - const details = buildPersonalDetails(['AM@expensify.com', 'Bob@Acme.com']); - const policies = buildPolicies([{owner: 'lead@expensify.com', employeeLogins: ['AM@expensify.com', 'Contractor@Acme.com']}]); - const result = getExpensifyTeamExclusions(details, policies, 'Contractor@Acme.com'); - expect(result['am@expensify.com']).toBeUndefined(); - expect(result['bob@acme.com']).toBeUndefined(); - }); - }); - describe('navigation', () => { describe('tryNavigateToSubmitWorkspaceUpgrade', () => { const submitPolicyForNavTest: Policy = {...createRandomPolicy(99003, CONST.POLICY.TYPE.SUBMIT), id: 'policy-submit-nav-test'}; @@ -7199,43 +7400,6 @@ describe('PolicyUtils', () => { }); }); - describe('getExcludedUsers', () => { - it('marks every active policy member as excluded', () => { - const result = getExcludedUsers(employeeList); - expect(result['owner@test.com']).toBe(true); - expect(result['admin@test.com']).toBe(true); - expect(result['employee@test.com']).toBe(true); - }); - - it('always excludes Expensify emails', () => { - const result = getExcludedUsers(employeeList); - for (const email of CONST.EXPENSIFY_EMAILS) { - expect(result[email]).toBe(true); - } - }); - - it('does not exclude a member that is pending delete', () => { - const list: PolicyEmployeeList = { - 'employee@test.com': {email: 'employee@test.com', role: 'user', submitsTo: '', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, - }; - const result = getExcludedUsers(list); - expect(result['employee@test.com']).toBeUndefined(); - }); - - it('does not exclude a member that has errors', () => { - const list: PolicyEmployeeList = { - 'employee@test.com': {email: 'employee@test.com', role: 'user', submitsTo: '', errors: {error1: 'Something went wrong'}}, - }; - const result = getExcludedUsers(list); - expect(result['employee@test.com']).toBeUndefined(); - }); - - it('returns only Expensify emails when the employee list is undefined', () => { - const result = getExcludedUsers(undefined); - expect(Object.keys(result)).toEqual([...CONST.EXPENSIFY_EMAILS]); - }); - }); - describe('connections', () => { describe('getUberConnectionErrorDirectlyFromPolicy', () => { it('should return true if Uber connection is enabled and has an error', () => { @@ -7615,135 +7779,142 @@ describe('PolicyUtils', () => { }); }); }); -}); -describe('getPolicyIDFromDomainName', () => { - it('extracts the policy ID from an Expensify workspace-feed domain name', () => { - expect(getPolicyIDFromDomainName('expensify-policyA1B2C3.exfy')).toBe('A1B2C3'); - }); + describe('domains', () => { + describe('getPolicyIDFromDomainName', () => { + it('extracts the policy ID from an Expensify workspace-feed domain name', () => { + expect(getPolicyIDFromDomainName('expensify-policyA1B2C3.exfy')).toBe('A1B2C3'); + }); - it('converts the extracted policy ID to uppercase', () => { - expect(getPolicyIDFromDomainName(`expensify-policy${'a1b2c3'}.exfy`)).toBe('A1B2C3'); - }); + it('converts the extracted policy ID to uppercase', () => { + expect(getPolicyIDFromDomainName(`expensify-policy${'a1b2c3'}.exfy`)).toBe('A1B2C3'); + }); - it('returns undefined for a domain name that is not a workspace feed', () => { - expect(getPolicyIDFromDomainName('example.com')).toBeUndefined(); - }); + it('returns undefined for a domain name that is not a workspace feed', () => { + expect(getPolicyIDFromDomainName('example.com')).toBeUndefined(); + }); - it('returns undefined for an empty string', () => { - expect(getPolicyIDFromDomainName('')).toBeUndefined(); - }); + it('returns undefined for an empty string', () => { + expect(getPolicyIDFromDomainName('')).toBeUndefined(); + }); - it('returns undefined when the extension is missing', () => { - expect(getPolicyIDFromDomainName('expensify-policyA1B2C3')).toBeUndefined(); - }); -}); + it('returns undefined when the extension is missing', () => { + expect(getPolicyIDFromDomainName('expensify-policyA1B2C3')).toBeUndefined(); + }); + }); -describe('getPolicyForAssignedCard', () => { - const policy: Policy = {...createRandomPolicy(0), id: 'A1B2C3', policyAccountID: 88801}; - const policies = {[`${ONYXKEYS.COLLECTION.POLICY}A1B2C3`]: policy}; + describe('getPolicyForAssignedCard', () => { + const policy: Policy = {...createRandomPolicy(0), id: 'A1B2C3', policyAccountID: 88801}; + const policies = {[`${ONYXKEYS.COLLECTION.POLICY}A1B2C3`]: policy}; - // The workspace is what decides whether the cardholder is shown the fix link or told to ask an admin, so a card - // has to find it whatever the company named their domain. - it('finds the workspace for a card on a domain that is not a workspace feed', () => { - const card = {domainName: 'acme-corp.com', fundID: '88801'}; + // The workspace is what decides whether the cardholder is shown the fix link or told to ask an admin, so a card + // has to find it whatever the company named their domain. + it('finds the workspace for a card on a domain that is not a workspace feed', () => { + const card = {domainName: 'acme-corp.com', fundID: '88801'}; - expect(getPolicyForAssignedCard(card, policies)).toEqual(policy); - }); + expect(getPolicyForAssignedCard(card, policies)).toEqual(policy); + }); - it('finds the workspace for a card on a workspace-feed domain', () => { - const card = {domainName: 'expensify-policyA1B2C3.exfy', fundID: '88801'}; + it('finds the workspace for a card on a workspace-feed domain', () => { + const card = {domainName: 'expensify-policyA1B2C3.exfy', fundID: '88801'}; - expect(getPolicyForAssignedCard(card, policies)).toEqual(policy); - }); + expect(getPolicyForAssignedCard(card, policies)).toEqual(policy); + }); - it('falls back to the domain name for a card that has no fundID', () => { - const card = {domainName: 'expensify-policyA1B2C3.exfy'}; + it('falls back to the domain name for a card that has no fundID', () => { + const card = {domainName: 'expensify-policyA1B2C3.exfy'}; - expect(getPolicyForAssignedCard(card, policies)).toEqual(policy); - }); + expect(getPolicyForAssignedCard(card, policies)).toEqual(policy); + }); - it('returns undefined when no workspace matches the card', () => { - const card = {domainName: 'acme-corp.com', fundID: '99999'}; + it('returns undefined when no workspace matches the card', () => { + const card = {domainName: 'acme-corp.com', fundID: '99999'}; - expect(getPolicyForAssignedCard(card, policies)).toBeUndefined(); - }); + expect(getPolicyForAssignedCard(card, policies)).toBeUndefined(); + }); - it('returns undefined when there are no policies', () => { - const card = {domainName: 'acme-corp.com', fundID: '88801'}; + it('returns undefined when there are no policies', () => { + const card = {domainName: 'acme-corp.com', fundID: '88801'}; - expect(getPolicyForAssignedCard(card, {})).toBeUndefined(); + expect(getPolicyForAssignedCard(card, {})).toBeUndefined(); + }); + }); }); -}); -describe('hasActiveExpensifyCard', () => { - it('returns false when policy is undefined', () => { - expect(hasActiveExpensifyCard(undefined, 'cardholder@test.com')).toBe(false); - }); + describe('travel', () => { + describe('getTravelStep', () => { + const currentUserLogin = 'admin@acme.com'; + const travelPolicy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'Travel workspace'), + owner: 'owner@acme.com', + role: CONST.POLICY.ROLE.ADMIN, + pendingAction: undefined, + employeeList: { + [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.ADMIN}, + }, + }; + const travelPolicies: OnyxCollection = {[`${ONYXKEYS.COLLECTION.POLICY}${travelPolicy.id}`]: travelPolicy}; + const travelSettings: TravelSettings = {accountIDs: {}, hasAcceptedTerms: false}; + const pendingTravelSettings: TravelSettings = {...travelSettings, lastTravelSignupRequestTime: testDate}; - it('returns true when the backend flags the member as holding an active Expensify Card', () => { - const policy: Policy = { - ...createRandomPolicy(0), - employeeList: { - 'cardholder@test.com': {email: 'cardholder@test.com', hasActiveExpensifyCard: true}, - }, - }; - expect(hasActiveExpensifyCard(policy, 'cardholder@test.com')).toBe(true); - }); + it('returns the get started step when the workspace admins only use public email domains', () => { + // Given a paid workspace whose owner and admins are on a public email domain, and a pending signup request + const policy: Policy = { + ...travelPolicy, + owner: 'owner@gmail.com', + employeeList: {'admin@gmail.com': {email: 'admin@gmail.com', role: CONST.POLICY.ROLE.ADMIN}}, + }; - it('returns false when the flag is false or missing', () => { - const policy: Policy = { - ...createRandomPolicy(0), - employeeList: { - 'former-cardholder@test.com': {email: 'former-cardholder@test.com', hasActiveExpensifyCard: false}, - 'employee@test.com': {email: 'employee@test.com'}, - }, - }; - expect(hasActiveExpensifyCard(policy, 'former-cardholder@test.com')).toBe(false); - expect(hasActiveExpensifyCard(policy, 'employee@test.com')).toBe(false); - }); + // When getting the travel step + // Then the user starts from the beginning, since there is no private domain to provision travel for + expect(getTravelStep(policy, pendingTravelSettings, false, travelPolicies, currentUserLogin)).toBe(CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL); + }); - it('returns false when the member is not in the employeeList', () => { - const policy: Policy = { - ...createRandomPolicy(0), - employeeList: { - 'cardholder@test.com': {email: 'cardholder@test.com', hasActiveExpensifyCard: true}, - }, - }; - expect(hasActiveExpensifyCard(policy, 'someone-else@test.com')).toBe(false); - }); + it('returns the get started step when the user has no active paid workspaces', () => { + // Given a paid workspace with a private admin domain and a pending signup request, but no active workspaces for the user + // When getting the travel step + // Then the user starts from the beginning, since travel needs an active paid workspace + expect(getTravelStep(travelPolicy, pendingTravelSettings, false, {}, currentUserLogin)).toBe(CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL); + }); - it('returns true when the secondary login is checked and the backend flags its paired primary login', () => { - const policy: Policy = { - ...createRandomPolicy(0), - primaryLoginsInvited: {'secondary@test.com': 'primary@test.com'}, - employeeList: { - 'secondary@test.com': {email: 'secondary@test.com'}, - 'primary@test.com': {email: 'primary@test.com', hasActiveExpensifyCard: true}, - }, - }; - expect(hasActiveExpensifyCard(policy, 'secondary@test.com')).toBe(true); - }); -}); + it('returns the get started step when the workspace is not a paid group workspace', () => { + // Given a personal workspace and a pending signup request, even though the user has another active paid workspace + const policy: Policy = {...travelPolicy, type: CONST.POLICY.TYPE.PERSONAL}; + + // When getting the travel step + // Then the user starts from the beginning, since travel needs a paid group workspace + expect(getTravelStep(policy, pendingTravelSettings, false, travelPolicies, currentUserLogin)).toBe(CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL); + }); + + it('returns the book or manage step when the workspace has accepted the travel terms', () => { + // Given a paid workspace whose admin accepted the travel terms, and a pending signup request + const policy: Policy = {...travelPolicy, travelSettings: {hasAcceptedTerms: true}}; -describe('isMemberInHomeAndOfficeWorkspace', () => { - it('only allows members of home and office workspaces', () => { - // Given a workspace whose employee list contains one member - const memberLogin = 'member@example.com'; - const policy = createMock({ - commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE}, - employeeList: {[memberLogin]: {email: memberLogin}}, - }); - const otherWorkspace = {...policy, commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE}}; - - // When workspace mode and membership are checked - const isEligibleMember = isMemberInHomeAndOfficeWorkspace(policy, memberLogin); - const isEligibleForOtherWorkspaceMode = isMemberInHomeAndOfficeWorkspace(otherWorkspace, memberLogin); - const isEligibleForUnknownMember = isMemberInHomeAndOfficeWorkspace(policy, 'unknown@example.com'); - - // Then only the known member in a home and office workspace is eligible - expect(isEligibleMember).toBe(true); - expect(isEligibleForOtherWorkspaceMode).toBe(false); - expect(isEligibleForUnknownMember).toBe(false); + // When getting the travel step + // Then the user can book or manage trips right away, since accepted terms take priority over the request + expect(getTravelStep(policy, pendingTravelSettings, false, travelPolicies, currentUserLogin)).toBe(CONST.TRAVEL.STEPS.BOOK_OR_MANAGE_YOUR_TRIP); + }); + + it('returns the reviewing request step when a signup request is pending and the verified beta is off', () => { + // Given a paid workspace without accepted terms and a pending travel signup request + // When getting the travel step with the travel verified beta disabled + // Then the user sees that their request is being reviewed + expect(getTravelStep(travelPolicy, pendingTravelSettings, false, travelPolicies, currentUserLogin)).toBe(CONST.TRAVEL.STEPS.REVIEWING_REQUEST); + }); + + it('returns the get started step for a pending signup request when the verified beta is on', () => { + // Given a paid workspace without accepted terms and a pending travel signup request + // When getting the travel step with the travel verified beta enabled + // Then the user starts from the beginning, since the beta skips the manual review step + expect(getTravelStep(travelPolicy, pendingTravelSettings, true, travelPolicies, currentUserLogin)).toBe(CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL); + }); + + it('returns the get started step when there is no pending signup request', () => { + // Given a paid workspace without accepted terms and no signup request + // When getting the travel step + // Then the user starts from the beginning + expect(getTravelStep(travelPolicy, travelSettings, false, travelPolicies, currentUserLogin)).toBe(CONST.TRAVEL.STEPS.GET_STARTED_TRAVEL); + }); + }); }); }); diff --git a/tests/unit/ReportPrimaryActionUtilsTest.ts b/tests/unit/ReportPrimaryActionUtilsTest.ts index d3c8521620f2..a89ae7a54021 100644 --- a/tests/unit/ReportPrimaryActionUtilsTest.ts +++ b/tests/unit/ReportPrimaryActionUtilsTest.ts @@ -51,6 +51,9 @@ jest.mock('@components/ConfirmedRoute.tsx'); jest.mock('@libs/PolicyUtils', () => ({ ...jest.requireActual('@libs/PolicyUtils'), + // This factory runs while PolicyUtils is still loading inside an import cycle, so the barrel's `export *` helpers + // aren't on it yet. Spread them from their own modules, which load fully. + ...jest.requireActual>('@libs/PolicyUtils/members'), isPolicyAdmin: jest.fn().mockImplementation((policy?: Policy) => policy?.role === 'admin'), getValidConnectedIntegration: jest.fn(), isPaidGroupPolicy: jest.fn().mockReturnValue(true), diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index dc7f6aedf7dc..8d92e4bdc532 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -117,6 +117,7 @@ jest.mock('@libs/PolicyUtils', () => ({ ...jest.requireActual>('@libs/PolicyUtils/workflows'), ...jest.requireActual>('@libs/PolicyUtils/approvals'), ...jest.requireActual>('@libs/PolicyUtils/payments'), + ...jest.requireActual>('@libs/PolicyUtils/members'), isPreferredExporter: jest.fn().mockReturnValue(true), hasAccountingConnections: jest.fn().mockReturnValue(true), isPolicyAdmin: jest.fn().mockReturnValue(true), diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index b63f182c2e0a..5a5b2495915a 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -375,6 +375,7 @@ jest.mock('@libs/PolicyUtils', () => { // aren't on it yet. Spread them from their own modules, which load fully. ...jest.requireActual>('@libs/PolicyUtils/customUnits'), ...jest.requireActual>('@libs/PolicyUtils/tax'), + ...jest.requireActual>('@libs/PolicyUtils/members'), isPolicyAdmin: jest.fn().mockImplementation((policy?: Policy) => policy?.role === 'admin'), isPaidGroupPolicy, // isGroupPolicy delegates to the isPaidGroupPolicy mock so tests that tweak isPaidGroupPolicy still drive group-policy checks diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts index 3668acfa8479..e5ba9afd16e7 100644 --- a/tests/unit/SidebarUtilsTest.ts +++ b/tests/unit/SidebarUtilsTest.ts @@ -51,6 +51,9 @@ import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct' // Mock PolicyUtils jest.mock('@libs/PolicyUtils', () => ({ ...jest.requireActual('@libs/PolicyUtils'), + // This factory runs while PolicyUtils is still loading inside an import cycle, so the barrel's `export *` helpers + // aren't on it yet. Spread them from their own modules, which load fully. + ...jest.requireActual>('@libs/PolicyUtils/members'), getConnectedIntegration: jest.fn(() => true), isPolicyAdmin: jest.fn(() => true), })); diff --git a/tests/unit/TelemetrySynchronizerTest.ts b/tests/unit/TelemetrySynchronizerTest.ts index 725e0683323a..4454e796ef65 100644 --- a/tests/unit/TelemetrySynchronizerTest.ts +++ b/tests/unit/TelemetrySynchronizerTest.ts @@ -23,6 +23,7 @@ jest.mock('@sentry/react-native', () => ({ jest.mock('@libs/PolicyUtils', () => ({ getActivePolicies: jest.fn(), + getHighestPolicyRole: jest.requireActual>('@libs/PolicyUtils/permissions').getHighestPolicyRole, })); jest.mock('@libs/telemetry/sendMemoryContext', () => ({