import type { RouteLocationNormalized } from 'vue-router' import { SeatTypes, waitForever, type MaybeAsync, type MaybeNullOrUndefined, type Optional, type WorkspaceSeatType } from '@speckle/shared' import { useApolloClient, useMutation, useSubscription, useQuery } from '@vue/apollo-composable' import { graphql } from '~/lib/common/generated/gql' import type { OnWorkspaceUpdatedSubscription, UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragment, Workspace, WorkspaceCreateInput, WorkspaceInviteCreateInput, WorkspaceInvitedTeamArgs, WorkspaceInviteUseInput, WorkspaceRoleUpdateInput } from '~/lib/common/generated/gql/graphql' import { evictObjectFields, getCacheId, getFirstErrorMessage, getObjectReference, modifyObjectField, modifyObjectFields, ROOT_QUERY } from '~/lib/common/helpers/graphql' import { useNavigateToHome, workspaceRoute } from '~/lib/common/helpers/route' import { useMixpanel } from '~/lib/core/composables/mp' import { createWorkspaceMutation, inviteToWorkspaceMutation, processWorkspaceInviteMutation, setDefaultRegionMutation, workspaceUpdateRoleMutation, workspacesUpdateSeatTypeMutation } from '~/lib/workspaces/graphql/mutations' import { isFunction } from 'lodash-es' import type { GraphQLError, GraphQLFormattedError } from 'graphql' import { onWorkspaceUpdatedSubscription } from '~/lib/workspaces/graphql/subscriptions' import { useLock } from '~/lib/common/composables/singleton' import type { Get } from 'type-fest' import type { ApolloCache } from '@apollo/client/core' import { workspaceLastAdminCheckQuery } from '../graphql/queries' import { useNavigation } from '~/lib/navigation/composables/navigation' export const useInviteUserToWorkspace = () => { const { activeUser } = useActiveUser() const { triggerNotification } = useGlobalToast() const { mutate } = useMutation(inviteToWorkspaceMutation) const isWorkspacesEnabled = useIsWorkspacesEnabled() return async (args: { workspaceId: string inputs: WorkspaceInviteCreateInput[] hideNotifications?: boolean }) => { const { workspaceId, inputs, hideNotifications } = args const userId = activeUser.value?.id if (!userId) return if (!isWorkspacesEnabled.value) return const { data, errors } = (await mutate( { workspaceId, input: inputs }, { update: (cache, { data }) => { if (!data?.workspaceMutations.invites.batchCreate.id) return const invitedTeam = data.workspaceMutations.invites.batchCreate.invitedTeam if (!invitedTeam) return modifyObjectFields( cache, getCacheId('Workspace', workspaceId), (_fieldName, vars) => { if (vars.filter?.search?.length) return return invitedTeam.map((i) => getObjectReference('PendingWorkspaceCollaborator', i.id) ) }, { fieldNameWhitelist: ['invitedTeam'] } ) // Evict the cache for the invited team if the search filter is active evictObjectFields( cache, getCacheId('Workspace', workspaceId), (fieldName, vars) => { if (fieldName !== 'invitedTeam') return false return vars.filter?.search?.length !== 0 } ) } } ).catch(convertThrowIntoFetchResult)) || {} if (!data?.workspaceMutations.invites.batchCreate.id && !hideNotifications) { const err = getFirstErrorMessage(errors) triggerNotification({ type: ToastNotificationType.Danger, title: 'Invitation failed', description: err }) } else { if (!hideNotifications) { triggerNotification({ type: ToastNotificationType.Success, title: 'Invite successfully sent' }) } } return data?.workspaceMutations.invites.batchCreate } } export const useProcessWorkspaceInvite = () => { const { mutate } = useMutation(processWorkspaceInviteMutation) const { activeUser } = useActiveUser() const { triggerNotification } = useGlobalToast() const mp = useMixpanel() const isWorkspacesEnabled = useIsWorkspacesEnabled() return async ( params: { input: WorkspaceInviteUseInput workspaceId: string inviteId: string }, options?: Partial<{ /** * Do something once mutation has finished, before all cache updates */ callback: () => MaybeAsync preventErrorToasts?: | boolean | (( errors: GraphQLError[] | GraphQLFormattedError[], errMsg: string ) => boolean) }> ) => { if (!isWorkspacesEnabled.value) return const userId = activeUser.value?.id if (!userId) return const { input, workspaceId, inviteId } = params const { data, errors } = (await mutate( { input }, { update: async (cache, { data, errors }) => { if (errors?.length) return const accepted = data?.workspaceMutations.invites.use if (accepted) { // Evict Query.workspace modifyObjectField( cache, ROOT_QUERY, 'workspace', ({ variables, helpers: { evict } }) => { if (variables.id === workspaceId) return evict() } ) // Evict all User.workspaces modifyObjectField( cache, getCacheId('User', userId), 'workspaces', ({ helpers: { evict } }) => evict() ) } // Set Query.workspaceInvite(id) = null (no invite) modifyObjectField( cache, ROOT_QUERY, 'workspaceInvite', ({ value, variables, helpers: { readField } }) => { if (value) { const workspace = readField(value, 'workspace') if (workspace) { const inviteWorkspaceId = workspace.id if (inviteWorkspaceId === workspaceId) return null } } else { if (variables.workspaceId === workspaceId) return null } } ) // Evict invite itself cache.evict({ id: getCacheId('PendingWorkspaceCollaborator', inviteId) }) if (options?.callback) await options.callback() } } ).catch(convertThrowIntoFetchResult)) || {} if (data?.workspaceMutations.invites.use) { triggerNotification({ type: ToastNotificationType.Success, title: input.accept ? 'Workspace invite accepted' : 'Workspace invite dismissed' }) mp.track('Workspace Joined', { // eslint-disable-next-line camelcase workspace_id: workspaceId }) mp.track('Invite Action', { type: 'workspace invite', accepted: input.accept, // eslint-disable-next-line camelcase workspace_id: workspaceId }) } else { const err = getFirstErrorMessage(errors) const preventErrorToasts = isFunction(options?.preventErrorToasts) ? options?.preventErrorToasts(errors?.slice() || [], err) : options?.preventErrorToasts if (!preventErrorToasts) { const err = getFirstErrorMessage(errors) triggerNotification({ type: ToastNotificationType.Danger, title: 'Failed to process invite', description: err }) } } return !!data?.workspaceMutations.invites.use } } graphql(` fragment UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator { id token workspace { ...WorkspaceInviteCard_LimitedWorkspace } user { id } } `) export const useWorkspaceInviteManager = < Invite extends UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragment = UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragment >( params: { invite: Ref> }, options?: Partial<{ /** * Whether to prevent any reloads/redirects on successful processing of the invite */ preventRedirect: boolean route: RouteLocationNormalized preventErrorToasts: | boolean | ((errors: GraphQLError[] | GraphQLFormattedError[], errMsg: string) => boolean) }> ) => { const isWorkspacesEnabled = useIsWorkspacesEnabled() const { invite } = params const { preventRedirect, preventErrorToasts } = options || {} const useInvite = useProcessWorkspaceInvite() const route = options?.route || useRoute() const goHome = useNavigateToHome() const { activeUser } = useActiveUser() const { mutateActiveWorkspaceSlug } = useNavigation() const loading = ref(false) const token = computed( () => (route.query.token as Optional) || invite.value?.token ) const isCurrentUserTarget = computed( () => activeUser.value && invite.value?.user && activeUser.value.id === invite.value.user.id ) const targetUser = computed((): Invite['user'] => invite.value?.user) const needsToAddNewEmail = computed( () => !isCurrentUserTarget.value && !targetUser.value ) const canAddNewEmail = computed(() => needsToAddNewEmail.value && token.value) const processInvite = async ( accept: boolean, options?: Partial<{ /** * If invite is attached to an unregistered email, the invite can only be used if this is set to true. * Upon accepting such an invite, the unregistered email will be added to the user's account as well. */ addNewEmail: boolean }> ) => { const { addNewEmail } = options || {} if (!isWorkspacesEnabled.value) return false if (!token.value || !invite.value) return false const workspaceId = invite.value.workspace.id const workspaceSlug = invite.value.workspace.slug const shouldAddNewEmail = canAddNewEmail.value && addNewEmail loading.value = true const success = await useInvite( { workspaceId, input: { accept, token: token.value, ...(shouldAddNewEmail ? { addNewEmail: shouldAddNewEmail } : {}) }, inviteId: invite.value.id }, { callback: async () => { if (!preventRedirect) { // Redirect if (accept) { if (workspaceSlug) { navigateTo(workspaceRoute(workspaceSlug)) mutateActiveWorkspaceSlug(workspaceSlug) } else { window.location.reload() } await waitForever() // to prevent UI changes while reload is happening } else { await goHome() } } }, preventErrorToasts } ) loading.value = false return !!success } return { loading: computed(() => loading.value), token, isCurrentUserTarget, targetUser, accept: (options?: Parameters[1]) => processInvite(true, options), decline: (options?: Parameters[1]) => processInvite(false, options) } } export function useCreateWorkspace() { const apollo = useApolloClient().client const { triggerNotification } = useGlobalToast() const { activeUser } = useActiveUser() const router = useRouter() const { mutateActiveWorkspaceSlug } = useNavigation() return async ( input: WorkspaceCreateInput, options?: Partial<{ /** * Determines whether to navigate to the new workspace upon creation. * Defaults to false. */ navigateOnSuccess: boolean hideNotifications: boolean }> ) => { const userId = activeUser.value?.id if (!userId) return const res = await apollo .mutate({ mutation: createWorkspaceMutation, variables: { input }, update: (cache, { data }) => { const workspaceId = data?.workspaceMutations.create.id if (!workspaceId) return // Navigation to workspace is gonna fetch everything needed for the page, so we only // really need to update workspace fields used in sidebar & settings: User.workspaces modifyObjectField( cache, getCacheId('User', userId), 'workspaces', ({ helpers: { createUpdatedValue, ref } }) => { return createUpdatedValue(({ update }) => { update('totalCount', (totalCount) => totalCount + 1) update('items', (items) => [...items, ref('Workspace', workspaceId)]) }) }, { autoEvictFiltered: true } ) } }) .catch(convertThrowIntoFetchResult) if (res.data?.workspaceMutations.create.id) { if (!options?.hideNotifications) { triggerNotification({ type: ToastNotificationType.Success, title: 'Workspace successfully created' }) } if (options?.navigateOnSuccess === true) { router.push(workspaceRoute(res.data?.workspaceMutations.create.slug)) mutateActiveWorkspaceSlug(res.data?.workspaceMutations.create.slug) } } else { const err = getFirstErrorMessage(res.errors) triggerNotification({ type: ToastNotificationType.Danger, title: 'Workspace creation failed', description: err }) } return res } } export const useWorkspaceUpdateRole = () => { const { mutate } = useMutation(workspaceUpdateRoleMutation) const { triggerNotification } = useGlobalToast() const mixpanel = useMixpanel() return async (input: WorkspaceRoleUpdateInput) => { const result = await mutate( { input }, { update: (cache) => { if (!input.role) { cache.evict({ id: getCacheId('WorkspaceCollaborator', input.userId) }) modifyObjectField( cache, getCacheId('Workspace', input.workspaceId), 'team', ({ helpers: { createUpdatedValue } }) => { return createUpdatedValue(({ update }) => { update('totalCount', (totalCount) => totalCount - 1) }) }, { autoEvictFiltered: true } ) } modifyObjectField( cache, getCacheId('Workspace', input.workspaceId), 'teamByRole', ({ helpers: { evict } }) => { return evict() } ) modifyObjectField( cache, getCacheId('WorkspaceCollaborator', input.userId), 'seatType', () => SeatTypes.Editor ) if (input.role) { modifyObjectField( cache, getCacheId('WorkspaceCollaborator', input.userId), 'role', () => input.role! ) } } } ).catch(convertThrowIntoFetchResult) if (result?.data) { triggerNotification({ type: ToastNotificationType.Success, title: input.role ? 'User role updated' : 'User removed', description: input.role ? 'The user role has been updated' : 'The user has been removed from the workspace' }) if (input.role) { mixpanel.track('Workspace User Role Updated', { newRole: input.role, // eslint-disable-next-line camelcase workspace_id: input.workspaceId }) } else { mixpanel.track('Workspace User Removed', { // eslint-disable-next-line camelcase workspace_id: input.workspaceId }) } } else { const errorMessage = getFirstErrorMessage(result?.errors) triggerNotification({ type: ToastNotificationType.Danger, title: input.role ? 'Failed to update role' : 'Failed to remove user', description: errorMessage }) } } } export const useWorkspaceUpdateSeatType = () => { const { mutate } = useMutation(workspacesUpdateSeatTypeMutation) const { triggerNotification } = useGlobalToast() const mixpanel = useMixpanel() return async ( input: { userId: string workspaceId: string seatType: WorkspaceSeatType }, options?: { hideNotifications: boolean } ) => { const { hideNotifications } = options ?? {} const result = await mutate( { input }, { update: (cache) => { // Update the team member's seat type in the cache modifyObjectField( cache, getCacheId('WorkspaceCollaborator', input.userId), 'seatType', () => input.seatType ) } } ).catch(convertThrowIntoFetchResult) if (result?.data) { if (!hideNotifications) { triggerNotification({ type: ToastNotificationType.Success, title: 'Seat updated', description: `The user's seat has been updated to ${input.seatType}` }) } mixpanel.track('Workspace User Seat Type Updated', { newSeatType: input.seatType, // eslint-disable-next-line camelcase workspace_id: input.workspaceId }) } else { const errorMessage = getFirstErrorMessage(result?.errors) triggerNotification({ type: ToastNotificationType.Danger, title: 'Failed to update seat type', description: errorMessage }) } } } export const copyWorkspaceLink = async (slug: string) => { const { copy } = useClipboard() const url = new URL(workspaceRoute(slug), window.location.toString()).toString() await copy(url, { successMessage: 'Copied workspace link to clipboard' }) } export const useSetDefaultWorkspaceRegion = () => { const { mutate } = useMutation(setDefaultRegionMutation) const { triggerNotification } = useGlobalToast() return async (params: { workspaceId: string; regionKey: string }) => { const { workspaceId, regionKey } = params const res = await mutate({ workspaceId, regionKey }).catch( convertThrowIntoFetchResult ) if (res?.data?.workspaceMutations.setDefaultRegion) { triggerNotification({ type: ToastNotificationType.Success, title: 'Default region set successfully' }) } else { const err = getFirstErrorMessage(res?.errors) triggerNotification({ type: ToastNotificationType.Danger, title: 'Failed to set default region', description: err }) } return res?.data?.workspaceMutations.setDefaultRegion } } export const useOnWorkspaceUpdated = (params: { workspaceSlug: Ref /** * Optionally do extra work on each message, besides the main cache update */ handler?: ( data: NonNullable>, cache: ApolloCache ) => void }) => { const { workspaceSlug, handler } = params const apollo = useApolloClient().client const { hasLock } = useLock( computed(() => `useOnWorkspaceUpdated-${unref(workspaceSlug.value)}`) ) const enabled = computed(() => !!(hasLock.value || handler)) const { onResult } = useSubscription( onWorkspaceUpdatedSubscription, () => ({ workspaceSlug: params.workspaceSlug.value }), () => ({ enabled: enabled.value, errorPolicy: 'all' }) ) // Main, locked cache update onResult((result) => { if (!result.data?.workspaceUpdated || !hasLock.value) return }) // Optional handler if (handler) { onResult((result) => { if (!result.data?.workspaceUpdated) return handler(result.data.workspaceUpdated, apollo.cache) }) } } export const useWorkspaceLastAdminCheck = (params: { workspaceSlug: Ref> }) => { const { workspaceSlug } = params const { result } = useQuery( workspaceLastAdminCheckQuery, () => ({ slug: workspaceSlug.value || '' }), () => ({ enabled: !!workspaceSlug.value }) ) const isLastAdmin = computed( () => result.value?.workspaceBySlug?.teamByRole?.admins?.totalCount === 1 ) return { isLastAdmin } }