Merge branch 'main' into iain/ratelimiter-should-respect-configuration
This commit is contained in:
@@ -31,7 +31,7 @@
|
||||
],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "yarn && yarn build:public && cp -n /workspaces/${localWorkspaceFolderBasename}/packages/server/.env-example /workspaces/${localWorkspaceFolderBasename}/packages/server/.env && cp -n /workspaces/${localWorkspaceFolderBasename}/packages/frontend-2/.env.example /workspaces/${localWorkspaceFolderBasename}/packages/frontend-2/.env",
|
||||
"postCreateCommand": "/workspaces/${localWorkspaceFolderBasename}/.devcontainer/postCreateCommand.sh",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eox pipefail
|
||||
|
||||
echo "Running postCreateCommand.sh"
|
||||
|
||||
# determine where the script is located, navigate into that directory, then find the root of the git repo in which it is located
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
cd "${SCRIPT_DIR}"
|
||||
GIT_ROOT="$(git rev-parse --show-toplevel)"
|
||||
|
||||
echo "Setting up environment variables by copying .env files"
|
||||
cp -n "${GIT_ROOT}/packages/server/.env-example" "${GIT_ROOT}/packages/server/.env" || true
|
||||
cp -n "${GIT_ROOT}/packages/frontend-2/.env.example" "${GIT_ROOT}/packages/frontend-2/.env" || true
|
||||
|
||||
echo "Installing nodejs dependencies and building shared packages"
|
||||
yarn
|
||||
yarn build:public
|
||||
@@ -1,33 +0,0 @@
|
||||
using Speckle.Sdk.Transports;
|
||||
|
||||
namespace Speckle.Converter;
|
||||
|
||||
public class ConsoleProgress : IProgress<ProgressArgs>
|
||||
{
|
||||
private readonly TimeSpan DEBOUNCE = TimeSpan.FromSeconds(1);
|
||||
private DateTime _lastTime = DateTime.UtcNow;
|
||||
|
||||
private long _totalBytes;
|
||||
|
||||
public void Report(ProgressArgs value)
|
||||
{
|
||||
if (value.ProgressEvent == ProgressEvent.DownloadBytes)
|
||||
{
|
||||
Interlocked.Add(ref _totalBytes, value.Count);
|
||||
}
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastTime >= DEBOUNCE)
|
||||
{
|
||||
if (value.ProgressEvent == ProgressEvent.DownloadBytes)
|
||||
{
|
||||
Console.WriteLine(value.ProgressEvent + " t " + _totalBytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(value.ProgressEvent + " c " + value.Count + " t " + value.Total);
|
||||
}
|
||||
|
||||
_lastTime = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Speckle.Importers.Ifc;
|
||||
using Speckle.Sdk.Common;
|
||||
using Speckle.WebIfc.Importer;
|
||||
|
||||
var filePathArgument = new Argument<string>(name: "filePath");
|
||||
var outputPathArgument = new Argument<string>("outputPath");
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
<PackageReference Include="Speckle.WebIfc.Importer" Version="0.0.7" />
|
||||
<PackageReference Include="Speckle.Importers.Ifc" Version="3.0.0-beta.220" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-2 w-full">
|
||||
<CommonCard
|
||||
v-for="workspace in workspaces"
|
||||
:key="workspace.id"
|
||||
class="w-full bg-foundation"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<WorkspaceAvatar :name="workspace.name" :logo="workspace.logo" size="xl" />
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-between flex-1">
|
||||
<div class="flex flex-col flex-1">
|
||||
<h6 class="text-heading-sm">{{ workspace.name }}</h6>
|
||||
<p class="text-body-2xs text-foreground-2">{{ workspace.description }}</p>
|
||||
</div>
|
||||
<FormButton
|
||||
color="outline"
|
||||
size="sm"
|
||||
:loading="loadingStates[workspace.id]"
|
||||
:disabled="requestedWorkspaces.includes(workspace.id)"
|
||||
@click="() => processRequest(true, workspace.id)"
|
||||
>
|
||||
{{
|
||||
requestedWorkspaces.includes(workspace.id)
|
||||
? 'Requested'
|
||||
: 'Request to join'
|
||||
}}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</CommonCard>
|
||||
<div class="mt-2 w-full">
|
||||
<FormButton size="lg" full-width @click="$emit('next')">Continue</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LimitedWorkspace } from '~/lib/common/generated/gql/graphql'
|
||||
import { dashboardRequestToJoinWorkspaceMutation } from '~~/lib/dashboard/graphql/mutations'
|
||||
import {
|
||||
convertThrowIntoFetchResult,
|
||||
getFirstErrorMessage
|
||||
} from '~~/lib/common/helpers/graphql'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
|
||||
defineProps<{
|
||||
workspaces: LimitedWorkspace[]
|
||||
}>()
|
||||
|
||||
defineEmits(['next'])
|
||||
|
||||
const mixpanel = useMixpanel()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
|
||||
const loadingStates = ref<Record<string, boolean>>({})
|
||||
|
||||
const { mutate: requestToJoin } = useMutation(dashboardRequestToJoinWorkspaceMutation)
|
||||
|
||||
const requestedWorkspaces = ref<string[]>([])
|
||||
|
||||
const processRequest = async (accept: boolean, workspaceId: string) => {
|
||||
if (accept) {
|
||||
loadingStates.value[workspaceId] = true
|
||||
|
||||
try {
|
||||
const result = await requestToJoin({
|
||||
input: { workspaceId }
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (result?.data) {
|
||||
requestedWorkspaces.value.push(workspaceId)
|
||||
mixpanel.track('Workspace Join Request Sent', {
|
||||
workspaceId,
|
||||
location: 'onboarding',
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: workspaceId
|
||||
})
|
||||
|
||||
triggerNotification({
|
||||
title: 'Request sent',
|
||||
description: 'Your request to join the workspace has been sent.',
|
||||
type: ToastNotificationType.Success
|
||||
})
|
||||
} else {
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
title: 'Failed to send request',
|
||||
description: errorMessage,
|
||||
type: ToastNotificationType.Danger
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
loadingStates.value[workspaceId] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -28,9 +28,11 @@
|
||||
import { useForm } from 'vee-validate'
|
||||
import type { OnboardingRole, OnboardingPlan, OnboardingSource } from '@speckle/shared'
|
||||
import { useProcessOnboarding } from '~~/lib/auth/composables/onboarding'
|
||||
import { homeRoute } from '~/lib/common/helpers/route'
|
||||
import { homeRoute, workspaceJoinRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
const isOnboardingForced = useIsOnboardingForced()
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
|
||||
|
||||
const { setUserOnboardingComplete, setMixpanelSegments } = useProcessOnboarding()
|
||||
|
||||
@@ -51,6 +53,10 @@ const onSubmit = handleSubmit(async () => {
|
||||
plans: values.plan,
|
||||
source: values.source
|
||||
})
|
||||
navigateTo(homeRoute)
|
||||
if (!isWorkspaceNewPlansEnabled.value && isWorkspacesEnabled.value) {
|
||||
navigateTo(workspaceJoinRoute)
|
||||
} else {
|
||||
navigateTo(homeRoute)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -13,14 +13,16 @@
|
||||
:invite="invite"
|
||||
/>
|
||||
<WorkspaceInviteDiscoverableWorkspaceBanner
|
||||
v-for="workspace in discoverableWorkspaces"
|
||||
v-for="workspace in filteredDiscoverableWorkspaces"
|
||||
:key="workspace.id"
|
||||
:workspace="workspace"
|
||||
@dismiss="handleDismiss"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
import { useSynchronizedCookie } from '~/lib/common/composables/reactiveCookie'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type {
|
||||
@@ -28,7 +30,7 @@ import type {
|
||||
ProjectsDashboardHeaderWorkspaces_UserFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { CookieKeys } from '~/lib/common/helpers/constants'
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
import { useDiscoverableWorkspaces } from '~/lib/workspaces/composables/discoverableWorkspaces'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectsDashboardHeaderProjects_User on User {
|
||||
@@ -40,9 +42,6 @@ graphql(`
|
||||
|
||||
graphql(`
|
||||
fragment ProjectsDashboardHeaderWorkspaces_User on User {
|
||||
discoverableWorkspaces {
|
||||
...WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace
|
||||
}
|
||||
workspaceInvites {
|
||||
...WorkspaceInviteBanner_PendingWorkspaceCollaborator
|
||||
}
|
||||
@@ -61,10 +60,12 @@ const dismissedDiscoverableWorkspaces = useSynchronizedCookie<string[]>(
|
||||
}
|
||||
)
|
||||
|
||||
const { discoverableWorkspaces } = useDiscoverableWorkspaces()
|
||||
|
||||
const workspaceInvites = computed(() => props.workspacesInvites?.workspaceInvites || [])
|
||||
const discoverableWorkspaces = computed(
|
||||
const filteredDiscoverableWorkspaces = computed(
|
||||
() =>
|
||||
props.workspacesInvites?.discoverableWorkspaces?.filter(
|
||||
discoverableWorkspaces.value?.filter(
|
||||
(workspace) => !dismissedDiscoverableWorkspaces.value.includes(workspace.id)
|
||||
) || []
|
||||
)
|
||||
@@ -73,7 +74,14 @@ const hasBanners = computed(() => {
|
||||
return (
|
||||
props.projectsInvites?.projectInvites?.length ||
|
||||
workspaceInvites.value.length ||
|
||||
discoverableWorkspaces.value.length
|
||||
filteredDiscoverableWorkspaces.value.length
|
||||
)
|
||||
})
|
||||
|
||||
const handleDismiss = (workspaceId: string) => {
|
||||
dismissedDiscoverableWorkspaces.value = [
|
||||
...dismissedDiscoverableWorkspaces.value,
|
||||
workspaceId
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</template>
|
||||
<template #nothing-selected>
|
||||
<span class="min-w-64 block">
|
||||
{{ multiple ? 'Select regions' : 'Select a region' }}
|
||||
{{ multiple ? 'Select default data regions' : 'Select default data region' }}
|
||||
</span>
|
||||
</template>
|
||||
<template #something-selected="{ value }">
|
||||
|
||||
@@ -63,17 +63,21 @@
|
||||
<div
|
||||
v-for="(filter, index) in relevantFiltersLimited"
|
||||
:key="index"
|
||||
v-tippy="filter.key"
|
||||
class="text-xs"
|
||||
>
|
||||
<button
|
||||
class="block w-full text-left hover:bg-primary-muted truncate rounded-md py-1 px-2 mx-2"
|
||||
class="flex w-full text-left hover:bg-primary-muted truncate rounded-md py-1 px-2 mx-2 text-[10px] text-foreground-3 gap-1 items-center"
|
||||
@click="
|
||||
;(showAllFilters = false),
|
||||
setPropertyFilter(filter),
|
||||
refreshColorsIfSetOrActiveFilterIsNumeric()
|
||||
"
|
||||
>
|
||||
{{ getPropertyName(filter.key) }}
|
||||
<span class="text-foreground text-body-2xs">
|
||||
{{ getPropertyName(filter.key) }}
|
||||
</span>
|
||||
<span class="truncate">{{ filter.key }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="itemCount < relevantFiltersSearched.length" class="mb-2">
|
||||
@@ -265,11 +269,11 @@ const getPropertyName = (key: string): string => {
|
||||
(f) => f.key === key.replace('.value', '.name')
|
||||
)
|
||||
if (correspondingProperty && isStringPropertyInfo(correspondingProperty)) {
|
||||
return correspondingProperty.valueGroups[0]?.value || key
|
||||
return correspondingProperty.valueGroups[0]?.value || key.split('.').pop() || key
|
||||
}
|
||||
}
|
||||
|
||||
// Return the key as is for non-Revit properties
|
||||
return key
|
||||
// For all other properties, just return the last part of the path
|
||||
return key.split('.').pop() || key
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,10 +9,34 @@
|
||||
/>
|
||||
</template>
|
||||
<template #header-right>
|
||||
<FormButton size="sm" color="outline" @click="onCancelClick">Cancel</FormButton>
|
||||
<FormButton
|
||||
v-if="requiresWorkspaceCreation"
|
||||
size="sm"
|
||||
color="outline"
|
||||
@click="logout()"
|
||||
>
|
||||
Sign out
|
||||
</FormButton>
|
||||
<FormButton v-else size="sm" color="outline" @click="onCancelClick">
|
||||
Cancel
|
||||
</FormButton>
|
||||
</template>
|
||||
|
||||
<WorkspaceWizard :workspace-id="workspaceId" />
|
||||
|
||||
<div
|
||||
v-if="requiresWorkspaceCreation && isFirstStep"
|
||||
class="w-full max-w-sm mx-auto mt-4"
|
||||
>
|
||||
<CommonAlert color="neutral" size="xs" hide-icon>
|
||||
<template #title>Why am I seeing this?</template>
|
||||
<template #description>
|
||||
This server now requires you to be a member of a workspace. Please create a
|
||||
new workspace to continue.
|
||||
</template>
|
||||
</CommonAlert>
|
||||
</div>
|
||||
|
||||
<WorkspaceWizardCancelDialog
|
||||
v-model:open="isCancelDialogOpen"
|
||||
:workspace-id="workspaceId"
|
||||
@@ -25,6 +49,9 @@ import { workspacesRoute } from '~~/lib/common/helpers/route'
|
||||
import { WizardSteps } from '~/lib/workspaces/helpers/types'
|
||||
import { useWorkspacesWizard } from '~/lib/workspaces/composables/wizard'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import { useAuthManager } from '~/lib/auth/composables/auth'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { activeUserWorkspaceExistenceCheckQuery } from '~/lib/auth/graphql/queries'
|
||||
|
||||
defineProps<{
|
||||
workspaceId?: string
|
||||
@@ -32,11 +59,26 @@ defineProps<{
|
||||
|
||||
const { currentStep, resetWizardState } = useWorkspacesWizard()
|
||||
const mixpanel = useMixpanel()
|
||||
const { logout } = useAuthManager()
|
||||
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
|
||||
const { result } = useQuery(activeUserWorkspaceExistenceCheckQuery)
|
||||
|
||||
const isCancelDialogOpen = ref(false)
|
||||
|
||||
const isFirstStep = computed(() => currentStep.value === WizardSteps.Details)
|
||||
|
||||
const requiresWorkspaceCreation = computed(() => {
|
||||
return (
|
||||
isWorkspacesEnabled.value &&
|
||||
isWorkspaceNewPlansEnabled.value &&
|
||||
(result.value?.activeUser?.workspaces?.totalCount || 0) === 0 &&
|
||||
// Legacy projects
|
||||
(result.value?.activeUser?.versions.totalCount || 0) === 0
|
||||
)
|
||||
})
|
||||
|
||||
const onCancelClick = () => {
|
||||
if (isFirstStep.value) {
|
||||
navigateTo(workspacesRoute)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<HeaderWithEmptyPage empty-header>
|
||||
<template #header-left>
|
||||
<HeaderLogoBlock :active="false" class="min-w-40 cursor-pointer" no-link />
|
||||
</template>
|
||||
<template #header-right>
|
||||
<FormButton
|
||||
v-if="isWorkspaceNewPlansEnabled"
|
||||
size="sm"
|
||||
color="outline"
|
||||
@click="() => logout({ skipRedirect: false })"
|
||||
>
|
||||
Sign out
|
||||
</FormButton>
|
||||
<FormButton v-else size="sm" color="outline" @click="() => navigateTo(homeRoute)">
|
||||
Skip
|
||||
</FormButton>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col items-center gap-2 w-full max-w-lg mx-auto">
|
||||
<h1 class="text-heading-xl text-foreground mb-2 font-normal mt-4">
|
||||
Join teammates
|
||||
</h1>
|
||||
<p class="text-center text-body-sm text-foreground-2 mb-8">
|
||||
{{ description }}
|
||||
</p>
|
||||
<WorkspaceDiscoverableWorkspacesCard
|
||||
v-for="workspace in discoverableWorkspacesAndJoinRequests"
|
||||
:key="`discoverable-${workspace.id}`"
|
||||
:workspace="workspace"
|
||||
/>
|
||||
<div class="mt-2 w-full flex flex-col gap-2">
|
||||
<FormButton
|
||||
v-if="hasDiscoverableJoinRequests && !isWorkspaceNewPlansEnabled"
|
||||
size="lg"
|
||||
full-width
|
||||
color="primary"
|
||||
@click="navigateTo(homeRoute)"
|
||||
>
|
||||
Continue
|
||||
</FormButton>
|
||||
<FormButton
|
||||
size="lg"
|
||||
full-width
|
||||
color="outline"
|
||||
@click="navigateTo(workspaceCreateRoute())"
|
||||
>
|
||||
Create a new workspace
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-if="!hasDiscoverableJoinRequests && !isWorkspaceNewPlansEnabled"
|
||||
size="lg"
|
||||
full-width
|
||||
color="subtle"
|
||||
@click="navigateTo(homeRoute)"
|
||||
>
|
||||
Skip for now
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</HeaderWithEmptyPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthManager } from '~/lib/auth/composables/auth'
|
||||
import { workspaceCreateRoute, homeRoute } from '~~/lib/common/helpers/route'
|
||||
import { useDiscoverableWorkspaces } from '~/lib/workspaces/composables/discoverableWorkspaces'
|
||||
|
||||
const { logout } = useAuthManager()
|
||||
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
|
||||
|
||||
const {
|
||||
discoverableWorkspacesAndJoinRequestsCount,
|
||||
discoverableWorkspacesAndJoinRequests,
|
||||
hasDiscoverableJoinRequests
|
||||
} = useDiscoverableWorkspaces()
|
||||
|
||||
const description = computed(() => {
|
||||
if (discoverableWorkspacesAndJoinRequestsCount.value === 1) {
|
||||
return 'We found a workspace that matches your email domain'
|
||||
}
|
||||
return 'We found workspaces that match your email domain'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<CommonCard class="w-full bg-foundation">
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<WorkspaceAvatar :name="workspace.name" :logo="workspace.logo" size="xl" />
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-between flex-1">
|
||||
<div class="flex flex-col flex-1">
|
||||
<h6 class="text-heading-sm">{{ workspace.name }}</h6>
|
||||
<p class="text-body-2xs text-foreground-2">
|
||||
{{ workspace.team?.totalCount }}
|
||||
{{ workspace.team?.totalCount === 1 ? 'member' : 'members' }}
|
||||
</p>
|
||||
</div>
|
||||
<FormButton
|
||||
v-if="workspace.requestStatus"
|
||||
color="outline"
|
||||
size="sm"
|
||||
disabled
|
||||
class="capitalize"
|
||||
>
|
||||
{{ workspace.requestStatus }}
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-else
|
||||
color="outline"
|
||||
size="sm"
|
||||
@click="() => onRequest(workspace.id)"
|
||||
>
|
||||
Request to join
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</CommonCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LimitedWorkspace } from '~/lib/common/generated/gql/graphql'
|
||||
import { useDiscoverableWorkspaces } from '~/lib/workspaces/composables/discoverableWorkspaces'
|
||||
|
||||
type WorkspaceWithStatus = LimitedWorkspace & {
|
||||
requestStatus: string | null
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
workspace: WorkspaceWithStatus
|
||||
}>()
|
||||
|
||||
const { processRequest } = useDiscoverableWorkspaces()
|
||||
|
||||
const onRequest = (workspaceId: string) => {
|
||||
processRequest(true, workspaceId)
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<InviteBanner :invite="invite" @processed="processRequest">
|
||||
<InviteBanner :invite="invite" @processed="handleRequest">
|
||||
<template #message>
|
||||
Your team is already using Workspaces, request to join the
|
||||
<span class="font-medium">{{ workspace.name }}</span>
|
||||
@@ -9,41 +9,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { useSynchronizedCookie } from '~/lib/common/composables/reactiveCookie'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspaceFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { CookieKeys } from '~/lib/common/helpers/constants'
|
||||
import { useDiscoverableWorkspaces } from '~/lib/workspaces/composables/discoverableWorkspaces'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { dashboardRequestToJoinWorkspaceMutation } from '~~/lib/dashboard/graphql/mutations'
|
||||
import {
|
||||
convertThrowIntoFetchResult,
|
||||
getFirstErrorMessage
|
||||
} from '~~/lib/common/helpers/graphql'
|
||||
|
||||
graphql(`
|
||||
fragment WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace on LimitedWorkspace {
|
||||
id
|
||||
name
|
||||
slug
|
||||
description
|
||||
logo
|
||||
}
|
||||
`)
|
||||
import type { LimitedWorkspace } from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
const props = defineProps<{
|
||||
workspace: WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspaceFragment
|
||||
workspace: LimitedWorkspace
|
||||
}>()
|
||||
|
||||
const { mutate: requestToJoin } = useMutation(dashboardRequestToJoinWorkspaceMutation)
|
||||
const { processRequest } = useDiscoverableWorkspaces()
|
||||
const mixpanel = useMixpanel()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const dismissedDiscoverableWorkspaces = useSynchronizedCookie<string[]>(
|
||||
CookieKeys.DismissedDiscoverableWorkspaces,
|
||||
{
|
||||
default: () => []
|
||||
}
|
||||
)
|
||||
|
||||
const invite = computed(() => ({
|
||||
workspace: {
|
||||
@@ -53,52 +29,22 @@ const invite = computed(() => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const processRequest = async (accept: boolean) => {
|
||||
const emit = defineEmits<{
|
||||
(e: 'dismiss', workspaceId: string): void
|
||||
}>()
|
||||
|
||||
const handleRequest = async (accept: boolean) => {
|
||||
if (accept) {
|
||||
const result = await requestToJoin({
|
||||
input: { workspaceId: props.workspace.id }
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (result?.data) {
|
||||
// Dismiss it show it doesnt show again
|
||||
dismissedDiscoverableWorkspaces.value = [
|
||||
...dismissedDiscoverableWorkspaces.value,
|
||||
props.workspace.id
|
||||
]
|
||||
|
||||
mixpanel.track('Workspace Join Request Sent', {
|
||||
workspaceId: props.workspace.id,
|
||||
location: 'discovery banner',
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: props.workspace.id
|
||||
})
|
||||
|
||||
triggerNotification({
|
||||
title: 'Request sent',
|
||||
description: 'Your request to join the workspace has been sent.',
|
||||
type: ToastNotificationType.Success
|
||||
})
|
||||
} else {
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
title: 'Failed to send request',
|
||||
description: errorMessage,
|
||||
type: ToastNotificationType.Danger
|
||||
})
|
||||
}
|
||||
await processRequest(true, props.workspace.id)
|
||||
emit('dismiss', props.workspace.id)
|
||||
} else {
|
||||
dismissedDiscoverableWorkspaces.value = [
|
||||
...dismissedDiscoverableWorkspaces.value,
|
||||
props.workspace.id
|
||||
]
|
||||
|
||||
emit('dismiss', props.workspace.id)
|
||||
mixpanel.track('Workspace Discovery Banner Dismissed', {
|
||||
workspaceId: props.workspace.id,
|
||||
location: 'discovery banner',
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: props.workspace.id
|
||||
})
|
||||
|
||||
triggerNotification({
|
||||
title: 'Discoverable workspace dismissed',
|
||||
type: ToastNotificationType.Info
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<WorkspaceWizardStep title="Create a workspace" description="Start with a good name">
|
||||
<WorkspaceWizardStep
|
||||
title="Create a workspace"
|
||||
description="Workspaces are environments where you can safely collaborate with your team and manage guests."
|
||||
>
|
||||
<form class="flex flex-col gap-4 w-full md:w-96" @submit="onSubmit">
|
||||
<FormTextInput
|
||||
id="workspace-name"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col items-center gap-y-3">
|
||||
<h1 class="text-heading-xl text-center">{{ title }}</h1>
|
||||
<p v-if="description" class="text-body text-foreground-2">
|
||||
<div class="flex flex-col items-center gap-y-3 max-w-md mx-auto text-center">
|
||||
<h1 class="text-heading-xl">{{ title }}</h1>
|
||||
<p v-if="description" class="text-body-sm text-foreground-2">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -59,3 +59,25 @@ export const authorizableAppMetadataQuery = graphql(`
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const activeUserWorkspaceExistenceCheckQuery = graphql(`
|
||||
query ActiveUserWorkspaceExistenceCheck {
|
||||
activeUser {
|
||||
id
|
||||
verified
|
||||
isOnboardingFinished
|
||||
versions(limit: 0) {
|
||||
totalCount
|
||||
}
|
||||
workspaces(limit: 0) {
|
||||
totalCount
|
||||
}
|
||||
discoverableWorkspaces {
|
||||
id
|
||||
}
|
||||
workspaceJoinRequests(limit: 0) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -96,7 +96,7 @@ type Documents = {
|
||||
"\n fragment ProjectsDashboardFilledProject on ProjectCollection {\n items {\n ...ProjectDashboardItem\n }\n }\n": typeof types.ProjectsDashboardFilledProjectFragmentDoc,
|
||||
"\n fragment ProjectsDashboardFilledUser on UserProjectCollection {\n items {\n ...ProjectDashboardItem\n }\n }\n": typeof types.ProjectsDashboardFilledUserFragmentDoc,
|
||||
"\n fragment ProjectsDashboardHeaderProjects_User on User {\n projectInvites {\n ...ProjectsInviteBanner\n }\n }\n": typeof types.ProjectsDashboardHeaderProjects_UserFragmentDoc,
|
||||
"\n fragment ProjectsDashboardHeaderWorkspaces_User on User {\n discoverableWorkspaces {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace\n }\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n": typeof types.ProjectsDashboardHeaderWorkspaces_UserFragmentDoc,
|
||||
"\n fragment ProjectsDashboardHeaderWorkspaces_User on User {\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n": typeof types.ProjectsDashboardHeaderWorkspaces_UserFragmentDoc,
|
||||
"\n fragment ProjectsDeleteDialog_Project on Project {\n id\n name\n role\n models(limit: 0) {\n totalCount\n }\n workspace {\n slug\n id\n }\n versions(limit: 0) {\n totalCount\n }\n }\n": typeof types.ProjectsDeleteDialog_ProjectFragmentDoc,
|
||||
"\n fragment ProjectsHiddenProjectWarning_User on User {\n id\n expiredSsoSessions {\n id\n slug\n name\n logo\n }\n }\n": typeof types.ProjectsHiddenProjectWarning_UserFragmentDoc,
|
||||
"\n fragment ProjectsMoveToWorkspaceDialog_Workspace on Workspace {\n id\n role\n name\n logo\n ...WorkspaceHasCustomDataResidency_Workspace\n ...ProjectsWorkspaceSelect_Workspace\n }\n": typeof types.ProjectsMoveToWorkspaceDialog_WorkspaceFragmentDoc,
|
||||
@@ -142,7 +142,6 @@ type Documents = {
|
||||
"\n fragment WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...BillingAlert_Workspace\n slug\n readOnly\n }\n": typeof types.WorkspaceHeader_WorkspaceFragmentDoc,
|
||||
"\n fragment WorkspaceInviteBanner_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": typeof types.WorkspaceInviteBanner_PendingWorkspaceCollaboratorFragmentDoc,
|
||||
"\n fragment WorkspaceInviteBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceId\n workspaceName\n token\n user {\n id\n name\n ...LimitedUserAvatar\n }\n title\n email\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": typeof types.WorkspaceInviteBlock_PendingWorkspaceCollaboratorFragmentDoc,
|
||||
"\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace on LimitedWorkspace {\n id\n name\n slug\n description\n logo\n }\n": typeof types.WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspaceFragmentDoc,
|
||||
"\n fragment WorkspaceJoinRequestApproveDialog_WorkspaceJoinRequest on WorkspaceJoinRequest {\n id\n user {\n id\n name\n }\n workspace {\n id\n }\n }\n": typeof types.WorkspaceJoinRequestApproveDialog_WorkspaceJoinRequestFragmentDoc,
|
||||
"\n fragment WorkspaceSidebarAbout_Workspace on Workspace {\n ...WorkspaceDashboardAbout_Workspace\n }\n": typeof types.WorkspaceSidebarAbout_WorkspaceFragmentDoc,
|
||||
"\n fragment WorkspaceSidebarSecurity_Workspace on Workspace {\n ...WorkspaceSecurity_Workspace\n }\n": typeof types.WorkspaceSidebarSecurity_WorkspaceFragmentDoc,
|
||||
@@ -157,6 +156,7 @@ type Documents = {
|
||||
"\n query AuthRegisterPanel($token: String) {\n serverInfo {\n inviteOnly\n authStrategies {\n id\n }\n ...AuthStategiesServerInfoFragment\n ...ServerTermsOfServicePrivacyPolicyFragment\n }\n serverInviteByToken(token: $token) {\n id\n email\n }\n }\n": typeof types.AuthRegisterPanelDocument,
|
||||
"\n query AuthLoginPanelWorkspaceInvite($token: String) {\n workspaceInvite(token: $token) {\n id\n email\n ...AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator\n ...AuthLoginWithEmailBlock_PendingWorkspaceCollaborator\n }\n }\n": typeof types.AuthLoginPanelWorkspaceInviteDocument,
|
||||
"\n query AuthorizableAppMetadata($id: String!) {\n app(id: $id) {\n id\n name\n description\n trustByDefault\n redirectUrl\n scopes {\n name\n description\n }\n author {\n name\n id\n avatar\n }\n }\n }\n": typeof types.AuthorizableAppMetadataDocument,
|
||||
"\n query ActiveUserWorkspaceExistenceCheck {\n activeUser {\n id\n verified\n isOnboardingFinished\n versions(limit: 0) {\n totalCount\n }\n workspaces(limit: 0) {\n totalCount\n }\n discoverableWorkspaces {\n id\n }\n workspaceJoinRequests(limit: 0) {\n totalCount\n }\n }\n }\n": typeof types.ActiveUserWorkspaceExistenceCheckDocument,
|
||||
"\n fragment FunctionRunStatusForSummary on AutomateFunctionRun {\n id\n status\n }\n": typeof types.FunctionRunStatusForSummaryFragmentDoc,
|
||||
"\n fragment TriggeredAutomationsStatusSummary on TriggeredAutomationsStatus {\n id\n automationRuns {\n id\n functionRuns {\n id\n ...FunctionRunStatusForSummary\n }\n }\n }\n": typeof types.TriggeredAutomationsStatusSummaryFragmentDoc,
|
||||
"\n fragment AutomationRunDetails on AutomateRun {\n id\n status\n functionRuns {\n ...FunctionRunStatusForSummary\n statusMessage\n }\n trigger {\n ... on VersionCreatedTrigger {\n version {\n id\n }\n model {\n id\n }\n }\n }\n createdAt\n updatedAt\n }\n": typeof types.AutomationRunDetailsFragmentDoc,
|
||||
@@ -178,7 +178,7 @@ type Documents = {
|
||||
"\n query ServerInfoBlobSizeLimit {\n serverInfo {\n configuration {\n blobSizeLimitBytes\n }\n }\n }\n": typeof types.ServerInfoBlobSizeLimitDocument,
|
||||
"\n query ServerInfoAllScopes {\n serverInfo {\n scopes {\n name\n description\n }\n }\n }\n": typeof types.ServerInfoAllScopesDocument,
|
||||
"\n query ProjectModelsSelectorValues($projectId: String!, $cursor: String) {\n project(id: $projectId) {\n id\n models(limit: 100, cursor: $cursor) {\n cursor\n totalCount\n items {\n ...CommonModelSelectorModel\n }\n }\n }\n }\n": typeof types.ProjectModelsSelectorValuesDocument,
|
||||
"\n query MainServerInfoData {\n serverInfo {\n adminContact\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n }\n }\n": typeof types.MainServerInfoDataDocument,
|
||||
"\n query MainServerInfoData {\n serverInfo {\n adminContact\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n configuration {\n isEmailEnabled\n }\n }\n }\n": typeof types.MainServerInfoDataDocument,
|
||||
"\n mutation DashboardRequestToJoinWorkspace($input: WorkspaceRequestToJoinInput!) {\n workspaceMutations {\n requestToJoin(input: $input)\n }\n }\n": typeof types.DashboardRequestToJoinWorkspaceDocument,
|
||||
"\n query DashboardProjectsPageQuery {\n activeUser {\n id\n projects(limit: 3) {\n items {\n ...DashboardProjectCard_Project\n }\n }\n ...ProjectsDashboardHeaderProjects_User\n }\n }\n": typeof types.DashboardProjectsPageQueryDocument,
|
||||
"\n query DashboardProjectsPageWorkspaceQuery {\n activeUser {\n id\n ...ProjectsDashboardHeaderWorkspaces_User\n }\n }\n": typeof types.DashboardProjectsPageWorkspaceQueryDocument,
|
||||
@@ -202,7 +202,6 @@ type Documents = {
|
||||
"\n query InviteUserSearch($input: UsersRetrievalInput!) {\n users(input: $input) {\n items {\n id\n name\n avatar\n }\n }\n }\n": typeof types.InviteUserSearchDocument,
|
||||
"\n mutation CreateNewRegion($input: CreateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n create(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n": typeof types.CreateNewRegionDocument,
|
||||
"\n mutation UpdateRegion($input: UpdateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n update(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n": typeof types.UpdateRegionDocument,
|
||||
"\n query PagesOnboardingDiscoverableWorkspaces_ActiveUser {\n activeUser {\n id\n ...PagesOnboarding_DiscoverableWorkspaces\n }\n }\n": typeof types.PagesOnboardingDiscoverableWorkspaces_ActiveUserDocument,
|
||||
"\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n id\n role\n ...LimitedUserAvatar\n }\n }\n }\n": typeof types.ProjectPageTeamInternals_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageTeamInternals_Workspace on Workspace {\n id\n team {\n items {\n id\n role\n user {\n id\n }\n }\n }\n }\n": typeof types.ProjectPageTeamInternals_WorkspaceFragmentDoc,
|
||||
"\n fragment ProjectDashboardItemNoModels on Project {\n id\n name\n createdAt\n updatedAt\n role\n team {\n id\n user {\n id\n name\n avatar\n }\n }\n ...ProjectPageModelsCardProject\n }\n": typeof types.ProjectDashboardItemNoModelsFragmentDoc,
|
||||
@@ -343,6 +342,8 @@ type Documents = {
|
||||
"\n subscription OnViewerUserActivityBroadcasted(\n $target: ViewerUpdateTrackingTarget!\n $sessionId: String!\n ) {\n viewerUserActivityBroadcasted(target: $target, sessionId: $sessionId) {\n userName\n userId\n user {\n ...LimitedUserAvatar\n }\n state\n status\n sessionId\n }\n }\n": typeof types.OnViewerUserActivityBroadcastedDocument,
|
||||
"\n subscription OnViewerCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n id\n type\n comment {\n id\n parent {\n id\n }\n ...ViewerCommentThread\n }\n }\n }\n": typeof types.OnViewerCommentsUpdatedDocument,
|
||||
"\n fragment LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\n }\n }\n": typeof types.LinkableCommentFragmentDoc,
|
||||
"\n fragment DiscoverableList_Discoverable on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n avatar\n }\n }\n }\n }\n": typeof types.DiscoverableList_DiscoverableFragmentDoc,
|
||||
"\n fragment DiscoverableList_Requests on User {\n workspaceJoinRequests {\n items {\n id\n status\n workspace {\n id\n name\n logo\n slug\n team {\n totalCount\n items {\n avatar\n }\n }\n }\n }\n }\n }\n": typeof types.DiscoverableList_RequestsFragmentDoc,
|
||||
"\n fragment UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n token\n workspaceId\n workspaceSlug\n user {\n id\n }\n }\n": typeof types.UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragmentDoc,
|
||||
"\n subscription OnWorkspaceProjectsUpdate($slug: String!) {\n workspaceProjectsUpdated(workspaceId: null, workspaceSlug: $slug) {\n projectId\n workspaceId\n type\n project {\n ...ProjectDashboardItem\n }\n }\n }\n ": typeof types.OnWorkspaceProjectsUpdateDocument,
|
||||
"\n fragment WorkspaceHasCustomDataResidency_Workspace on Workspace {\n id\n defaultRegion {\n id\n name\n }\n }\n": typeof types.WorkspaceHasCustomDataResidency_WorkspaceFragmentDoc,
|
||||
@@ -376,6 +377,8 @@ type Documents = {
|
||||
"\n query WorkspaceSsoCheck($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...WorkspaceSsoStatus_Workspace\n }\n activeUser {\n ...WorkspaceSsoStatus_User\n }\n }\n": typeof types.WorkspaceSsoCheckDocument,
|
||||
"\n query WorkspaceWizard($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...WorkspaceWizard_Workspace\n }\n }\n": typeof types.WorkspaceWizardDocument,
|
||||
"\n query WorkspaceWizardRegion {\n serverInfo {\n ...WorkspaceWizardStepRegion_ServerInfo\n }\n }\n": typeof types.WorkspaceWizardRegionDocument,
|
||||
"\n query DiscoverableWorkspaces_ActiveUser {\n activeUser {\n id\n ...DiscoverableList_Discoverable\n }\n }\n": typeof types.DiscoverableWorkspaces_ActiveUserDocument,
|
||||
"\n query DiscoverableWorkspacesRequests_ActiveUser {\n activeUser {\n id\n ...DiscoverableList_Requests\n }\n }\n": typeof types.DiscoverableWorkspacesRequests_ActiveUserDocument,
|
||||
"\n subscription onWorkspaceUpdated(\n $workspaceId: String\n $workspaceSlug: String\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceUpdated(workspaceId: $workspaceId, workspaceSlug: $workspaceSlug) {\n id\n workspace {\n id\n ...WorkspaceProjectList_Workspace\n }\n }\n }\n": typeof types.OnWorkspaceUpdatedDocument,
|
||||
"\n query LegacyBranchRedirectMetadata($streamId: String!, $branchName: String!) {\n project(id: $streamId) {\n modelByName(name: $branchName) {\n id\n }\n }\n }\n": typeof types.LegacyBranchRedirectMetadataDocument,
|
||||
"\n query LegacyViewerCommitRedirectMetadata($streamId: String!, $commitId: String!) {\n project(id: $streamId) {\n version(id: $commitId) {\n id\n model {\n id\n }\n }\n }\n }\n": typeof types.LegacyViewerCommitRedirectMetadataDocument,
|
||||
@@ -385,7 +388,6 @@ type Documents = {
|
||||
"\n fragment AutomateFunctionPage_AutomateFunction on AutomateFunction {\n id\n name\n description\n logo\n supportedSourceApps\n tags\n ...AutomateFunctionPageHeader_Function\n ...AutomateFunctionPageInfo_AutomateFunction\n ...AutomateAutomationCreateDialog_AutomateFunction\n creator {\n id\n }\n }\n": typeof types.AutomateFunctionPage_AutomateFunctionFragmentDoc,
|
||||
"\n query AutomateFunctionPage($functionId: ID!) {\n automateFunction(id: $functionId) {\n ...AutomateFunctionPage_AutomateFunction\n }\n activeUser {\n workspaces {\n items {\n ...AutomateFunctionCreateDialog_Workspace\n ...AutomateFunctionEditDialog_Workspace\n }\n }\n }\n }\n": typeof types.AutomateFunctionPageDocument,
|
||||
"\n query AutomateFunctionPageWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...AutomateFunctionPageHeader_Workspace\n }\n }\n": typeof types.AutomateFunctionPageWorkspaceDocument,
|
||||
"\n fragment PagesOnboarding_DiscoverableWorkspaces on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n }\n }\n": typeof types.PagesOnboarding_DiscoverableWorkspacesFragmentDoc,
|
||||
"\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...ProjectsMoveToWorkspaceDialog_Project\n }\n": typeof types.ProjectPageProjectFragmentDoc,
|
||||
"\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n": typeof types.ProjectPageAutomationPage_AutomationFragmentDoc,
|
||||
"\n fragment ProjectPageAutomationPage_Project on Project {\n id\n workspaceId\n ...ProjectPageAutomationHeader_Project\n }\n": typeof types.ProjectPageAutomationPage_ProjectFragmentDoc,
|
||||
@@ -483,7 +485,7 @@ const documents: Documents = {
|
||||
"\n fragment ProjectsDashboardFilledProject on ProjectCollection {\n items {\n ...ProjectDashboardItem\n }\n }\n": types.ProjectsDashboardFilledProjectFragmentDoc,
|
||||
"\n fragment ProjectsDashboardFilledUser on UserProjectCollection {\n items {\n ...ProjectDashboardItem\n }\n }\n": types.ProjectsDashboardFilledUserFragmentDoc,
|
||||
"\n fragment ProjectsDashboardHeaderProjects_User on User {\n projectInvites {\n ...ProjectsInviteBanner\n }\n }\n": types.ProjectsDashboardHeaderProjects_UserFragmentDoc,
|
||||
"\n fragment ProjectsDashboardHeaderWorkspaces_User on User {\n discoverableWorkspaces {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace\n }\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n": types.ProjectsDashboardHeaderWorkspaces_UserFragmentDoc,
|
||||
"\n fragment ProjectsDashboardHeaderWorkspaces_User on User {\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n": types.ProjectsDashboardHeaderWorkspaces_UserFragmentDoc,
|
||||
"\n fragment ProjectsDeleteDialog_Project on Project {\n id\n name\n role\n models(limit: 0) {\n totalCount\n }\n workspace {\n slug\n id\n }\n versions(limit: 0) {\n totalCount\n }\n }\n": types.ProjectsDeleteDialog_ProjectFragmentDoc,
|
||||
"\n fragment ProjectsHiddenProjectWarning_User on User {\n id\n expiredSsoSessions {\n id\n slug\n name\n logo\n }\n }\n": types.ProjectsHiddenProjectWarning_UserFragmentDoc,
|
||||
"\n fragment ProjectsMoveToWorkspaceDialog_Workspace on Workspace {\n id\n role\n name\n logo\n ...WorkspaceHasCustomDataResidency_Workspace\n ...ProjectsWorkspaceSelect_Workspace\n }\n": types.ProjectsMoveToWorkspaceDialog_WorkspaceFragmentDoc,
|
||||
@@ -529,7 +531,6 @@ const documents: Documents = {
|
||||
"\n fragment WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...BillingAlert_Workspace\n slug\n readOnly\n }\n": types.WorkspaceHeader_WorkspaceFragmentDoc,
|
||||
"\n fragment WorkspaceInviteBanner_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": types.WorkspaceInviteBanner_PendingWorkspaceCollaboratorFragmentDoc,
|
||||
"\n fragment WorkspaceInviteBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceId\n workspaceName\n token\n user {\n id\n name\n ...LimitedUserAvatar\n }\n title\n email\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": types.WorkspaceInviteBlock_PendingWorkspaceCollaboratorFragmentDoc,
|
||||
"\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace on LimitedWorkspace {\n id\n name\n slug\n description\n logo\n }\n": types.WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspaceFragmentDoc,
|
||||
"\n fragment WorkspaceJoinRequestApproveDialog_WorkspaceJoinRequest on WorkspaceJoinRequest {\n id\n user {\n id\n name\n }\n workspace {\n id\n }\n }\n": types.WorkspaceJoinRequestApproveDialog_WorkspaceJoinRequestFragmentDoc,
|
||||
"\n fragment WorkspaceSidebarAbout_Workspace on Workspace {\n ...WorkspaceDashboardAbout_Workspace\n }\n": types.WorkspaceSidebarAbout_WorkspaceFragmentDoc,
|
||||
"\n fragment WorkspaceSidebarSecurity_Workspace on Workspace {\n ...WorkspaceSecurity_Workspace\n }\n": types.WorkspaceSidebarSecurity_WorkspaceFragmentDoc,
|
||||
@@ -544,6 +545,7 @@ const documents: Documents = {
|
||||
"\n query AuthRegisterPanel($token: String) {\n serverInfo {\n inviteOnly\n authStrategies {\n id\n }\n ...AuthStategiesServerInfoFragment\n ...ServerTermsOfServicePrivacyPolicyFragment\n }\n serverInviteByToken(token: $token) {\n id\n email\n }\n }\n": types.AuthRegisterPanelDocument,
|
||||
"\n query AuthLoginPanelWorkspaceInvite($token: String) {\n workspaceInvite(token: $token) {\n id\n email\n ...AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator\n ...AuthLoginWithEmailBlock_PendingWorkspaceCollaborator\n }\n }\n": types.AuthLoginPanelWorkspaceInviteDocument,
|
||||
"\n query AuthorizableAppMetadata($id: String!) {\n app(id: $id) {\n id\n name\n description\n trustByDefault\n redirectUrl\n scopes {\n name\n description\n }\n author {\n name\n id\n avatar\n }\n }\n }\n": types.AuthorizableAppMetadataDocument,
|
||||
"\n query ActiveUserWorkspaceExistenceCheck {\n activeUser {\n id\n verified\n isOnboardingFinished\n versions(limit: 0) {\n totalCount\n }\n workspaces(limit: 0) {\n totalCount\n }\n discoverableWorkspaces {\n id\n }\n workspaceJoinRequests(limit: 0) {\n totalCount\n }\n }\n }\n": types.ActiveUserWorkspaceExistenceCheckDocument,
|
||||
"\n fragment FunctionRunStatusForSummary on AutomateFunctionRun {\n id\n status\n }\n": types.FunctionRunStatusForSummaryFragmentDoc,
|
||||
"\n fragment TriggeredAutomationsStatusSummary on TriggeredAutomationsStatus {\n id\n automationRuns {\n id\n functionRuns {\n id\n ...FunctionRunStatusForSummary\n }\n }\n }\n": types.TriggeredAutomationsStatusSummaryFragmentDoc,
|
||||
"\n fragment AutomationRunDetails on AutomateRun {\n id\n status\n functionRuns {\n ...FunctionRunStatusForSummary\n statusMessage\n }\n trigger {\n ... on VersionCreatedTrigger {\n version {\n id\n }\n model {\n id\n }\n }\n }\n createdAt\n updatedAt\n }\n": types.AutomationRunDetailsFragmentDoc,
|
||||
@@ -565,7 +567,7 @@ const documents: Documents = {
|
||||
"\n query ServerInfoBlobSizeLimit {\n serverInfo {\n configuration {\n blobSizeLimitBytes\n }\n }\n }\n": types.ServerInfoBlobSizeLimitDocument,
|
||||
"\n query ServerInfoAllScopes {\n serverInfo {\n scopes {\n name\n description\n }\n }\n }\n": types.ServerInfoAllScopesDocument,
|
||||
"\n query ProjectModelsSelectorValues($projectId: String!, $cursor: String) {\n project(id: $projectId) {\n id\n models(limit: 100, cursor: $cursor) {\n cursor\n totalCount\n items {\n ...CommonModelSelectorModel\n }\n }\n }\n }\n": types.ProjectModelsSelectorValuesDocument,
|
||||
"\n query MainServerInfoData {\n serverInfo {\n adminContact\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n }\n }\n": types.MainServerInfoDataDocument,
|
||||
"\n query MainServerInfoData {\n serverInfo {\n adminContact\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n configuration {\n isEmailEnabled\n }\n }\n }\n": types.MainServerInfoDataDocument,
|
||||
"\n mutation DashboardRequestToJoinWorkspace($input: WorkspaceRequestToJoinInput!) {\n workspaceMutations {\n requestToJoin(input: $input)\n }\n }\n": types.DashboardRequestToJoinWorkspaceDocument,
|
||||
"\n query DashboardProjectsPageQuery {\n activeUser {\n id\n projects(limit: 3) {\n items {\n ...DashboardProjectCard_Project\n }\n }\n ...ProjectsDashboardHeaderProjects_User\n }\n }\n": types.DashboardProjectsPageQueryDocument,
|
||||
"\n query DashboardProjectsPageWorkspaceQuery {\n activeUser {\n id\n ...ProjectsDashboardHeaderWorkspaces_User\n }\n }\n": types.DashboardProjectsPageWorkspaceQueryDocument,
|
||||
@@ -589,7 +591,6 @@ const documents: Documents = {
|
||||
"\n query InviteUserSearch($input: UsersRetrievalInput!) {\n users(input: $input) {\n items {\n id\n name\n avatar\n }\n }\n }\n": types.InviteUserSearchDocument,
|
||||
"\n mutation CreateNewRegion($input: CreateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n create(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n": types.CreateNewRegionDocument,
|
||||
"\n mutation UpdateRegion($input: UpdateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n update(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n": types.UpdateRegionDocument,
|
||||
"\n query PagesOnboardingDiscoverableWorkspaces_ActiveUser {\n activeUser {\n id\n ...PagesOnboarding_DiscoverableWorkspaces\n }\n }\n": types.PagesOnboardingDiscoverableWorkspaces_ActiveUserDocument,
|
||||
"\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n id\n role\n ...LimitedUserAvatar\n }\n }\n }\n": types.ProjectPageTeamInternals_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageTeamInternals_Workspace on Workspace {\n id\n team {\n items {\n id\n role\n user {\n id\n }\n }\n }\n }\n": types.ProjectPageTeamInternals_WorkspaceFragmentDoc,
|
||||
"\n fragment ProjectDashboardItemNoModels on Project {\n id\n name\n createdAt\n updatedAt\n role\n team {\n id\n user {\n id\n name\n avatar\n }\n }\n ...ProjectPageModelsCardProject\n }\n": types.ProjectDashboardItemNoModelsFragmentDoc,
|
||||
@@ -730,6 +731,8 @@ const documents: Documents = {
|
||||
"\n subscription OnViewerUserActivityBroadcasted(\n $target: ViewerUpdateTrackingTarget!\n $sessionId: String!\n ) {\n viewerUserActivityBroadcasted(target: $target, sessionId: $sessionId) {\n userName\n userId\n user {\n ...LimitedUserAvatar\n }\n state\n status\n sessionId\n }\n }\n": types.OnViewerUserActivityBroadcastedDocument,
|
||||
"\n subscription OnViewerCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n id\n type\n comment {\n id\n parent {\n id\n }\n ...ViewerCommentThread\n }\n }\n }\n": types.OnViewerCommentsUpdatedDocument,
|
||||
"\n fragment LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\n }\n }\n": types.LinkableCommentFragmentDoc,
|
||||
"\n fragment DiscoverableList_Discoverable on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n avatar\n }\n }\n }\n }\n": types.DiscoverableList_DiscoverableFragmentDoc,
|
||||
"\n fragment DiscoverableList_Requests on User {\n workspaceJoinRequests {\n items {\n id\n status\n workspace {\n id\n name\n logo\n slug\n team {\n totalCount\n items {\n avatar\n }\n }\n }\n }\n }\n }\n": types.DiscoverableList_RequestsFragmentDoc,
|
||||
"\n fragment UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n token\n workspaceId\n workspaceSlug\n user {\n id\n }\n }\n": types.UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragmentDoc,
|
||||
"\n subscription OnWorkspaceProjectsUpdate($slug: String!) {\n workspaceProjectsUpdated(workspaceId: null, workspaceSlug: $slug) {\n projectId\n workspaceId\n type\n project {\n ...ProjectDashboardItem\n }\n }\n }\n ": types.OnWorkspaceProjectsUpdateDocument,
|
||||
"\n fragment WorkspaceHasCustomDataResidency_Workspace on Workspace {\n id\n defaultRegion {\n id\n name\n }\n }\n": types.WorkspaceHasCustomDataResidency_WorkspaceFragmentDoc,
|
||||
@@ -763,6 +766,8 @@ const documents: Documents = {
|
||||
"\n query WorkspaceSsoCheck($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...WorkspaceSsoStatus_Workspace\n }\n activeUser {\n ...WorkspaceSsoStatus_User\n }\n }\n": types.WorkspaceSsoCheckDocument,
|
||||
"\n query WorkspaceWizard($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...WorkspaceWizard_Workspace\n }\n }\n": types.WorkspaceWizardDocument,
|
||||
"\n query WorkspaceWizardRegion {\n serverInfo {\n ...WorkspaceWizardStepRegion_ServerInfo\n }\n }\n": types.WorkspaceWizardRegionDocument,
|
||||
"\n query DiscoverableWorkspaces_ActiveUser {\n activeUser {\n id\n ...DiscoverableList_Discoverable\n }\n }\n": types.DiscoverableWorkspaces_ActiveUserDocument,
|
||||
"\n query DiscoverableWorkspacesRequests_ActiveUser {\n activeUser {\n id\n ...DiscoverableList_Requests\n }\n }\n": types.DiscoverableWorkspacesRequests_ActiveUserDocument,
|
||||
"\n subscription onWorkspaceUpdated(\n $workspaceId: String\n $workspaceSlug: String\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceUpdated(workspaceId: $workspaceId, workspaceSlug: $workspaceSlug) {\n id\n workspace {\n id\n ...WorkspaceProjectList_Workspace\n }\n }\n }\n": types.OnWorkspaceUpdatedDocument,
|
||||
"\n query LegacyBranchRedirectMetadata($streamId: String!, $branchName: String!) {\n project(id: $streamId) {\n modelByName(name: $branchName) {\n id\n }\n }\n }\n": types.LegacyBranchRedirectMetadataDocument,
|
||||
"\n query LegacyViewerCommitRedirectMetadata($streamId: String!, $commitId: String!) {\n project(id: $streamId) {\n version(id: $commitId) {\n id\n model {\n id\n }\n }\n }\n }\n": types.LegacyViewerCommitRedirectMetadataDocument,
|
||||
@@ -772,7 +777,6 @@ const documents: Documents = {
|
||||
"\n fragment AutomateFunctionPage_AutomateFunction on AutomateFunction {\n id\n name\n description\n logo\n supportedSourceApps\n tags\n ...AutomateFunctionPageHeader_Function\n ...AutomateFunctionPageInfo_AutomateFunction\n ...AutomateAutomationCreateDialog_AutomateFunction\n creator {\n id\n }\n }\n": types.AutomateFunctionPage_AutomateFunctionFragmentDoc,
|
||||
"\n query AutomateFunctionPage($functionId: ID!) {\n automateFunction(id: $functionId) {\n ...AutomateFunctionPage_AutomateFunction\n }\n activeUser {\n workspaces {\n items {\n ...AutomateFunctionCreateDialog_Workspace\n ...AutomateFunctionEditDialog_Workspace\n }\n }\n }\n }\n": types.AutomateFunctionPageDocument,
|
||||
"\n query AutomateFunctionPageWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...AutomateFunctionPageHeader_Workspace\n }\n }\n": types.AutomateFunctionPageWorkspaceDocument,
|
||||
"\n fragment PagesOnboarding_DiscoverableWorkspaces on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n }\n }\n": types.PagesOnboarding_DiscoverableWorkspacesFragmentDoc,
|
||||
"\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...ProjectsMoveToWorkspaceDialog_Project\n }\n": types.ProjectPageProjectFragmentDoc,
|
||||
"\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n": types.ProjectPageAutomationPage_AutomationFragmentDoc,
|
||||
"\n fragment ProjectPageAutomationPage_Project on Project {\n id\n workspaceId\n ...ProjectPageAutomationHeader_Project\n }\n": types.ProjectPageAutomationPage_ProjectFragmentDoc,
|
||||
@@ -1133,7 +1137,7 @@ export function graphql(source: "\n fragment ProjectsDashboardHeaderProjects_Us
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ProjectsDashboardHeaderWorkspaces_User on User {\n discoverableWorkspaces {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace\n }\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n"): (typeof documents)["\n fragment ProjectsDashboardHeaderWorkspaces_User on User {\n discoverableWorkspaces {\n ...WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace\n }\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n"];
|
||||
export function graphql(source: "\n fragment ProjectsDashboardHeaderWorkspaces_User on User {\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n"): (typeof documents)["\n fragment ProjectsDashboardHeaderWorkspaces_User on User {\n workspaceInvites {\n ...WorkspaceInviteBanner_PendingWorkspaceCollaborator\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1314,10 +1318,6 @@ export function graphql(source: "\n fragment WorkspaceInviteBanner_PendingWorks
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment WorkspaceInviteBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceId\n workspaceName\n token\n user {\n id\n name\n ...LimitedUserAvatar\n }\n title\n email\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n"): (typeof documents)["\n fragment WorkspaceInviteBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceId\n workspaceName\n token\n user {\n id\n name\n ...LimitedUserAvatar\n }\n title\n email\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace on LimitedWorkspace {\n id\n name\n slug\n description\n logo\n }\n"): (typeof documents)["\n fragment WorkspaceInviteDiscoverableWorkspaceBanner_LimitedWorkspace on LimitedWorkspace {\n id\n name\n slug\n description\n logo\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1374,6 +1374,10 @@ export function graphql(source: "\n query AuthLoginPanelWorkspaceInvite($token:
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query AuthorizableAppMetadata($id: String!) {\n app(id: $id) {\n id\n name\n description\n trustByDefault\n redirectUrl\n scopes {\n name\n description\n }\n author {\n name\n id\n avatar\n }\n }\n }\n"): (typeof documents)["\n query AuthorizableAppMetadata($id: String!) {\n app(id: $id) {\n id\n name\n description\n trustByDefault\n redirectUrl\n scopes {\n name\n description\n }\n author {\n name\n id\n avatar\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query ActiveUserWorkspaceExistenceCheck {\n activeUser {\n id\n verified\n isOnboardingFinished\n versions(limit: 0) {\n totalCount\n }\n workspaces(limit: 0) {\n totalCount\n }\n discoverableWorkspaces {\n id\n }\n workspaceJoinRequests(limit: 0) {\n totalCount\n }\n }\n }\n"): (typeof documents)["\n query ActiveUserWorkspaceExistenceCheck {\n activeUser {\n id\n verified\n isOnboardingFinished\n versions(limit: 0) {\n totalCount\n }\n workspaces(limit: 0) {\n totalCount\n }\n discoverableWorkspaces {\n id\n }\n workspaceJoinRequests(limit: 0) {\n totalCount\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1461,7 +1465,7 @@ export function graphql(source: "\n query ProjectModelsSelectorValues($projectI
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query MainServerInfoData {\n serverInfo {\n adminContact\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n }\n }\n"): (typeof documents)["\n query MainServerInfoData {\n serverInfo {\n adminContact\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n }\n }\n"];
|
||||
export function graphql(source: "\n query MainServerInfoData {\n serverInfo {\n adminContact\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n configuration {\n isEmailEnabled\n }\n }\n }\n"): (typeof documents)["\n query MainServerInfoData {\n serverInfo {\n adminContact\n canonicalUrl\n company\n description\n guestModeEnabled\n inviteOnly\n name\n termsOfService\n version\n automateUrl\n configuration {\n isEmailEnabled\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1554,10 +1558,6 @@ export function graphql(source: "\n mutation CreateNewRegion($input: CreateServ
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation UpdateRegion($input: UpdateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n update(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateRegion($input: UpdateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n update(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query PagesOnboardingDiscoverableWorkspaces_ActiveUser {\n activeUser {\n id\n ...PagesOnboarding_DiscoverableWorkspaces\n }\n }\n"): (typeof documents)["\n query PagesOnboardingDiscoverableWorkspaces_ActiveUser {\n activeUser {\n id\n ...PagesOnboarding_DiscoverableWorkspaces\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -2118,6 +2118,14 @@ export function graphql(source: "\n subscription OnViewerCommentsUpdated($targe
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\n }\n }\n"): (typeof documents)["\n fragment LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment DiscoverableList_Discoverable on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n avatar\n }\n }\n }\n }\n"): (typeof documents)["\n fragment DiscoverableList_Discoverable on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n avatar\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment DiscoverableList_Requests on User {\n workspaceJoinRequests {\n items {\n id\n status\n workspace {\n id\n name\n logo\n slug\n team {\n totalCount\n items {\n avatar\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n fragment DiscoverableList_Requests on User {\n workspaceJoinRequests {\n items {\n id\n status\n workspace {\n id\n name\n logo\n slug\n team {\n totalCount\n items {\n avatar\n }\n }\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -2250,6 +2258,14 @@ export function graphql(source: "\n query WorkspaceWizard($workspaceId: String!
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query WorkspaceWizardRegion {\n serverInfo {\n ...WorkspaceWizardStepRegion_ServerInfo\n }\n }\n"): (typeof documents)["\n query WorkspaceWizardRegion {\n serverInfo {\n ...WorkspaceWizardStepRegion_ServerInfo\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query DiscoverableWorkspaces_ActiveUser {\n activeUser {\n id\n ...DiscoverableList_Discoverable\n }\n }\n"): (typeof documents)["\n query DiscoverableWorkspaces_ActiveUser {\n activeUser {\n id\n ...DiscoverableList_Discoverable\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query DiscoverableWorkspacesRequests_ActiveUser {\n activeUser {\n id\n ...DiscoverableList_Requests\n }\n }\n"): (typeof documents)["\n query DiscoverableWorkspacesRequests_ActiveUser {\n activeUser {\n id\n ...DiscoverableList_Requests\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -2286,10 +2302,6 @@ export function graphql(source: "\n query AutomateFunctionPage($functionId: ID!
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query AutomateFunctionPageWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...AutomateFunctionPageHeader_Workspace\n }\n }\n"): (typeof documents)["\n query AutomateFunctionPageWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...AutomateFunctionPageHeader_Workspace\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment PagesOnboarding_DiscoverableWorkspaces on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n }\n }\n"): (typeof documents)["\n fragment PagesOnboarding_DiscoverableWorkspaces on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12,7 +12,6 @@ export const loginRoute = '/authn/login'
|
||||
export const registerRoute = '/authn/register'
|
||||
export const ssoLoginRoute = '/authn/sso'
|
||||
export const forgottenPasswordRoute = '/authn/forgotten-password'
|
||||
export const onboardingRoute = '/onboarding'
|
||||
export const verifyEmailRoute = '/verify-email'
|
||||
export const verifyEmailCountdownRoute = '/verify-email?source=registration'
|
||||
export const serverManagementRoute = '/server-management'
|
||||
@@ -24,6 +23,8 @@ export const defaultZapierWebhookUrl =
|
||||
'https://hooks.zapier.com/hooks/catch/12120532/2m4okri/'
|
||||
export const guideBillingUrl = 'https://speckle.guide/workspaces/billing.html'
|
||||
|
||||
export const onboardingRoute = '/onboarding'
|
||||
|
||||
export const settingsUserRoutes = {
|
||||
profile: '/settings/user/profile',
|
||||
notifications: '/settings/user/notifications',
|
||||
@@ -136,7 +137,9 @@ export const workspaceRoute = (slug: string) => `/workspaces/${slug}`
|
||||
export const workspaceSsoRoute = (slug: string) => `/workspaces/${slug}/sso`
|
||||
|
||||
export const workspaceCreateRoute = (slug?: string) =>
|
||||
slug ? `/workspaces/${slug}/create` : '/workspaces/create'
|
||||
slug ? `/workspaces/${slug}/create` : '/workspaces/actions/create'
|
||||
|
||||
export const workspaceJoinRoute = '/workspaces/actions/join'
|
||||
|
||||
export const workspaceFunctionsRoute = (slug: string) => `/workspaces/${slug}/functions`
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ export const mainServerInfoDataQuery = graphql(`
|
||||
termsOfService
|
||||
version
|
||||
automateUrl
|
||||
configuration {
|
||||
isEmailEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
|
||||
export const PagesOnboardingDiscoverableWorkspaces = graphql(`
|
||||
query PagesOnboardingDiscoverableWorkspaces_ActiveUser {
|
||||
activeUser {
|
||||
id
|
||||
...PagesOnboarding_DiscoverableWorkspaces
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -164,6 +164,13 @@ export function useUserEmails() {
|
||||
navigateTo(settingsUserRoutes.emails)
|
||||
}
|
||||
|
||||
modifyObjectField(
|
||||
apollo.cache,
|
||||
getCacheId('User', activeUserId.value),
|
||||
'discoverableWorkspaces',
|
||||
({ helpers: { evict } }) => evict()
|
||||
)
|
||||
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Email verified',
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useQuery, useMutation, useApolloClient } from '@vue/apollo-composable'
|
||||
import {
|
||||
discoverableWorkspacesQuery,
|
||||
discoverableWorkspacesRequestsQuery
|
||||
} from '../graphql/queries'
|
||||
import { dashboardRequestToJoinWorkspaceMutation } from '~/lib/dashboard/graphql/mutations'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import {
|
||||
convertThrowIntoFetchResult,
|
||||
getFirstErrorMessage
|
||||
} from '~~/lib/common/helpers/graphql'
|
||||
|
||||
graphql(`
|
||||
fragment DiscoverableList_Discoverable on User {
|
||||
discoverableWorkspaces {
|
||||
id
|
||||
name
|
||||
logo
|
||||
description
|
||||
slug
|
||||
team {
|
||||
totalCount
|
||||
items {
|
||||
avatar
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
graphql(`
|
||||
fragment DiscoverableList_Requests on User {
|
||||
workspaceJoinRequests {
|
||||
items {
|
||||
id
|
||||
status
|
||||
workspace {
|
||||
id
|
||||
name
|
||||
logo
|
||||
slug
|
||||
team {
|
||||
totalCount
|
||||
items {
|
||||
avatar
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const useDiscoverableWorkspaces = () => {
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
|
||||
const { result: discoverableResult, loading: discoverableLoading } = useQuery(
|
||||
discoverableWorkspacesQuery,
|
||||
undefined,
|
||||
{ enabled: isWorkspacesEnabled }
|
||||
)
|
||||
const {
|
||||
result: requestsResult,
|
||||
refetch,
|
||||
loading: joinRequestsLoading
|
||||
} = useQuery(discoverableWorkspacesRequestsQuery, undefined, {
|
||||
enabled: isWorkspacesEnabled
|
||||
})
|
||||
|
||||
const { mutate: requestToJoin } = useMutation(dashboardRequestToJoinWorkspaceMutation)
|
||||
|
||||
const mixpanel = useMixpanel()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const apollo = useApolloClient().client
|
||||
|
||||
const discoverableWorkspaces = computed(
|
||||
() => discoverableResult.value?.activeUser?.discoverableWorkspaces
|
||||
)
|
||||
|
||||
const workspaceJoinRequests = computed(
|
||||
() => requestsResult.value?.activeUser?.workspaceJoinRequests
|
||||
)
|
||||
|
||||
const discoverableWorkspacesAndJoinRequests = computed(() => {
|
||||
const joinRequests =
|
||||
workspaceJoinRequests.value?.items?.map((request) => ({
|
||||
...request.workspace,
|
||||
requestStatus: request.status
|
||||
})) || []
|
||||
|
||||
const discoverable =
|
||||
discoverableWorkspaces.value?.map((workspace) => ({
|
||||
...workspace,
|
||||
requestStatus: null
|
||||
})) || []
|
||||
|
||||
return [...joinRequests, ...discoverable]
|
||||
})
|
||||
|
||||
const hasDiscoverableWorkspaces = computed(
|
||||
() => discoverableWorkspaces.value && discoverableWorkspaces.value?.length > 0
|
||||
)
|
||||
|
||||
const hasDiscoverableJoinRequests = computed(
|
||||
() => workspaceJoinRequests.value && workspaceJoinRequests.value?.items.length > 0
|
||||
)
|
||||
|
||||
const hasDiscoverableWorkspacesOrJoinRequests = computed(() => {
|
||||
const requests = discoverableWorkspacesAndJoinRequests.value
|
||||
return requests && requests.length > 0
|
||||
})
|
||||
|
||||
const discoverableWorkspacesCount = computed(
|
||||
() => discoverableWorkspaces.value?.length || 0
|
||||
)
|
||||
|
||||
const discoverableJoinRequestsCount = computed(
|
||||
() => workspaceJoinRequests.value?.items.length || 0
|
||||
)
|
||||
|
||||
const discoverableWorkspacesAndJoinRequestsCount = computed(
|
||||
() => discoverableWorkspacesCount.value + discoverableJoinRequestsCount.value
|
||||
)
|
||||
|
||||
const processRequest = async (accept: boolean, workspaceId: string) => {
|
||||
const cache = apollo.cache
|
||||
|
||||
if (accept) {
|
||||
const result = await requestToJoin({
|
||||
input: { workspaceId }
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (result?.data) {
|
||||
cache.evict({
|
||||
id: getCacheId('LimitedWorkspace', workspaceId)
|
||||
})
|
||||
refetch()
|
||||
|
||||
mixpanel.track('Workspace Join Request Sent', {
|
||||
workspaceId,
|
||||
location: 'onboarding',
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: workspaceId
|
||||
})
|
||||
|
||||
triggerNotification({
|
||||
title: 'Request sent',
|
||||
description: 'Your request to join the workspace has been sent.',
|
||||
type: ToastNotificationType.Success
|
||||
})
|
||||
} else {
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
title: 'Failed to send request',
|
||||
description: errorMessage,
|
||||
type: ToastNotificationType.Danger
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loading = computed(() => {
|
||||
return discoverableLoading.value || joinRequestsLoading.value
|
||||
})
|
||||
|
||||
return {
|
||||
hasDiscoverableWorkspaces,
|
||||
hasDiscoverableJoinRequests,
|
||||
hasDiscoverableWorkspacesOrJoinRequests,
|
||||
discoverableJoinRequestsCount,
|
||||
discoverableWorkspacesCount,
|
||||
discoverableWorkspacesAndJoinRequestsCount,
|
||||
discoverableWorkspaces,
|
||||
workspaceJoinRequests,
|
||||
discoverableWorkspacesAndJoinRequests,
|
||||
processRequest,
|
||||
loading,
|
||||
refetch
|
||||
}
|
||||
}
|
||||
@@ -123,3 +123,21 @@ export const workspaceWizardRegionQuery = graphql(`
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const discoverableWorkspacesQuery = graphql(`
|
||||
query DiscoverableWorkspaces_ActiveUser {
|
||||
activeUser {
|
||||
id
|
||||
...DiscoverableList_Discoverable
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const discoverableWorkspacesRequestsQuery = graphql(`
|
||||
query DiscoverableWorkspacesRequests_ActiveUser {
|
||||
activeUser {
|
||||
id
|
||||
...DiscoverableList_Requests
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { homeRoute, verifyEmailRoute } from '~/lib/common/helpers/route'
|
||||
import { mainServerInfoDataQuery } from '~/lib/core/composables/server'
|
||||
import { activeUserQuery } from '~~/lib/auth/composables/activeUser'
|
||||
import { useApolloClientFromNuxt } from '~~/lib/common/composables/graphql'
|
||||
import { convertThrowIntoFetchResult } from '~~/lib/common/helpers/graphql'
|
||||
@@ -7,7 +8,18 @@ import { convertThrowIntoFetchResult } from '~~/lib/common/helpers/graphql'
|
||||
* Redirect user to /verify-email, if they haven't done it yet
|
||||
*/
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const isAuthPage = to.path.startsWith('/authn/')
|
||||
if (isAuthPage) return
|
||||
|
||||
const client = useApolloClientFromNuxt()
|
||||
const { data: emailData } = await client
|
||||
.query({
|
||||
query: mainServerInfoDataQuery
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (!emailData?.serverInfo.configuration.isEmailEnabled) return
|
||||
|
||||
const { data } = await client
|
||||
.query({
|
||||
query: activeUserQuery
|
||||
@@ -16,11 +28,8 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
|
||||
if (!data?.activeUser?.id) return
|
||||
|
||||
const isAuthPage = to.path.startsWith('/authn/')
|
||||
const isVerifyEmailPage = to.path === verifyEmailRoute
|
||||
|
||||
if (isAuthPage) return
|
||||
|
||||
const hasUnverifiedEmails = data.activeUser.emails.some((email) => !email.verified)
|
||||
|
||||
if (hasUnverifiedEmails) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mainServerInfoDataQuery } from '~/lib/core/composables/server'
|
||||
import { activeUserQuery } from '~~/lib/auth/composables/activeUser'
|
||||
import { useApolloClientFromNuxt } from '~~/lib/common/composables/graphql'
|
||||
import { convertThrowIntoFetchResult } from '~~/lib/common/helpers/graphql'
|
||||
@@ -7,6 +8,9 @@ import { homeRoute, onboardingRoute } from '~~/lib/common/helpers/route'
|
||||
* Redirect user to /onboarding, if they haven't done it yet
|
||||
*/
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const isAuthPage = to.path.startsWith('/authn/')
|
||||
if (isAuthPage) return
|
||||
|
||||
const client = useApolloClientFromNuxt()
|
||||
const { data } = await client
|
||||
.query({
|
||||
@@ -14,15 +18,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
|
||||
const isAuthPage = to.path.startsWith('/authn/')
|
||||
if (isAuthPage) return
|
||||
|
||||
// Ignore if not logged in
|
||||
if (!data?.activeUser?.id) return
|
||||
|
||||
// Ignore if user has not verified their email yet
|
||||
if (!data?.activeUser?.verified) return
|
||||
|
||||
const isOnboardingFinished = data?.activeUser?.isOnboardingFinished
|
||||
const isGoingToOnboarding = to.path === onboardingRoute
|
||||
const shouldRedirectToOnboarding =
|
||||
@@ -30,6 +25,19 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
!isGoingToOnboarding &&
|
||||
to.query['skiponboarding'] !== 'true'
|
||||
|
||||
// Ignore if not logged in
|
||||
if (!data?.activeUser?.id) return
|
||||
|
||||
const { data: emailData } = await client
|
||||
.query({
|
||||
query: mainServerInfoDataQuery
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
|
||||
// Ignore if user has not verified their email yet
|
||||
if (!data?.activeUser?.verified && emailData?.serverInfo.configuration.isEmailEnabled)
|
||||
return
|
||||
|
||||
if (shouldRedirectToOnboarding) {
|
||||
return navigateTo(onboardingRoute)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { activeUserWorkspaceExistenceCheckQuery } from '~/lib/auth/graphql/queries'
|
||||
import { mainServerInfoDataQuery } from '~/lib/core/composables/server'
|
||||
import { useApolloClientFromNuxt } from '~~/lib/common/composables/graphql'
|
||||
import { convertThrowIntoFetchResult } from '~~/lib/common/helpers/graphql'
|
||||
import { workspaceCreateRoute, workspaceJoinRoute } from '~~/lib/common/helpers/route'
|
||||
|
||||
/**
|
||||
* Redirect user to /workspaces/actions/join or /workspaces/actions/create, if they have no workspaces
|
||||
*/
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const isAuthPage = to.path.startsWith('/authn/')
|
||||
if (isAuthPage) return
|
||||
|
||||
const isOnboardingForced = useIsOnboardingForced()
|
||||
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
|
||||
if (!isWorkspacesEnabled.value) return
|
||||
if (!isWorkspaceNewPlansEnabled.value) return
|
||||
|
||||
const client = useApolloClientFromNuxt()
|
||||
const { data } = await client
|
||||
.query({
|
||||
query: activeUserWorkspaceExistenceCheckQuery
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
|
||||
// Ignore if not logged in
|
||||
if (!data?.activeUser?.id) return
|
||||
|
||||
const { data: emailData } = await client
|
||||
.query({
|
||||
query: mainServerInfoDataQuery
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
|
||||
// Ignore if user has not verified their email yet
|
||||
if (!data?.activeUser?.verified && emailData?.serverInfo.configuration.isEmailEnabled)
|
||||
return
|
||||
|
||||
// Ignore if user has not completed onboarding yet
|
||||
if (isOnboardingForced.value && !data?.activeUser?.isOnboardingFinished) return
|
||||
|
||||
const isMemberOfWorkspace = (data?.activeUser?.workspaces?.totalCount ?? 0) > 0
|
||||
const hasLegacyProjects = (data?.activeUser?.versions?.totalCount ?? 0) > 0
|
||||
const hasDiscoverableWorkspaces =
|
||||
(data?.activeUser?.discoverableWorkspaces?.length ?? 0) > 0 ||
|
||||
(data?.activeUser?.workspaceJoinRequests?.totalCount ?? 0) > 0
|
||||
|
||||
const isGoingToJoinWorkspace = to.path === workspaceJoinRoute
|
||||
const isGoingToCreateWorkspace = to.path === workspaceCreateRoute()
|
||||
|
||||
if (!isMemberOfWorkspace && !hasLegacyProjects) {
|
||||
if (
|
||||
hasDiscoverableWorkspaces &&
|
||||
!isGoingToJoinWorkspace &&
|
||||
!isGoingToCreateWorkspace
|
||||
) {
|
||||
return navigateTo(workspaceJoinRoute)
|
||||
}
|
||||
if (!hasDiscoverableWorkspaces && !isGoingToCreateWorkspace) {
|
||||
return navigateTo(workspaceCreateRoute())
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { activeUserWorkspaceExistenceCheckQuery } from '~/lib/auth/graphql/queries'
|
||||
import { useApolloClientFromNuxt } from '~~/lib/common/composables/graphql'
|
||||
import { convertThrowIntoFetchResult } from '~~/lib/common/helpers/graphql'
|
||||
import { workspaceCreateRoute } from '~~/lib/common/helpers/route'
|
||||
|
||||
/**
|
||||
* Redirect user to /workspaces/actions/create, if they have no discoverable workspaces
|
||||
*/
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
|
||||
if (!isWorkspacesEnabled.value) return
|
||||
|
||||
const client = useApolloClientFromNuxt()
|
||||
const { data } = await client
|
||||
.query({
|
||||
query: activeUserWorkspaceExistenceCheckQuery
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
|
||||
const hasDiscoverableWorkspaces =
|
||||
(data?.activeUser?.discoverableWorkspaces?.length ?? 0) > 0 ||
|
||||
(data?.activeUser?.workspaceJoinRequests?.totalCount ?? 0) > 0
|
||||
|
||||
const isGoingToCreateWorkspace = to.path === workspaceCreateRoute()
|
||||
|
||||
if (!hasDiscoverableWorkspaces && !isGoingToCreateWorkspace) {
|
||||
return navigateTo(workspaceCreateRoute())
|
||||
}
|
||||
})
|
||||
@@ -19,29 +19,14 @@
|
||||
</FormButton>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="isLoading">
|
||||
<div class="py-12 flex flex-col items-center gap-2">
|
||||
<CommonLoadingIcon />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="flex flex-col items-center justify-center p-4 max-w-lg mx-auto">
|
||||
<h1 class="text-heading-xl text-forefround mb-2 font-normal">
|
||||
{{ currentStage === 'join' ? 'Join your teammates' : 'Tell us about yourself' }}
|
||||
<div class="flex flex-col items-center justify-center p-4 max-w-lg mx-auto">
|
||||
<h1 class="text-heading-xl text-foreground mb-2 font-normal">
|
||||
Tell us about yourself
|
||||
</h1>
|
||||
<p class="text-center text-body-sm text-foreground-2 mb-8">
|
||||
{{
|
||||
currentStage === 'join'
|
||||
? 'We found a workspace that matches your email domain'
|
||||
: 'Your answers will help us improve'
|
||||
}}
|
||||
Your answers will help us improve
|
||||
</p>
|
||||
|
||||
<OnboardingJoinTeammates
|
||||
v-if="currentStage === 'join' && discoverableWorkspaces.length > 0"
|
||||
:workspaces="discoverableWorkspaces"
|
||||
@next="currentStage = 'questions'"
|
||||
/>
|
||||
<OnboardingQuestionsForm v-else />
|
||||
<OnboardingQuestionsForm />
|
||||
</div>
|
||||
</HeaderWithEmptyPage>
|
||||
</template>
|
||||
@@ -49,23 +34,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useProcessOnboarding } from '~~/lib/auth/composables/onboarding'
|
||||
import { useAuthManager } from '~/lib/auth/composables/auth'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import { CommonLoadingIcon } from '@speckle/ui-components'
|
||||
import { PagesOnboardingDiscoverableWorkspaces } from '~/lib/onboarding/graphql/queries'
|
||||
import { until } from '@vueuse/core'
|
||||
|
||||
graphql(`
|
||||
fragment PagesOnboarding_DiscoverableWorkspaces on User {
|
||||
discoverableWorkspaces {
|
||||
id
|
||||
name
|
||||
logo
|
||||
description
|
||||
slug
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
useHead({
|
||||
title: 'Welcome to Speckle'
|
||||
@@ -80,32 +48,4 @@ const isOnboardingForced = useIsOnboardingForced()
|
||||
|
||||
const { setUserOnboardingComplete } = useProcessOnboarding()
|
||||
const { logout } = useAuthManager()
|
||||
|
||||
const isLoading = ref(true)
|
||||
const currentStage = ref<'join' | 'questions'>('questions')
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
|
||||
const { result, loading } = useQuery(PagesOnboardingDiscoverableWorkspaces, undefined, {
|
||||
enabled: isWorkspacesEnabled.value
|
||||
})
|
||||
|
||||
const discoverableWorkspaces = computed(
|
||||
() => result.value?.activeUser?.discoverableWorkspaces || []
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
// If workspaces feature is disabled, go straight to questions
|
||||
if (!isWorkspacesEnabled.value) {
|
||||
currentStage.value = 'questions'
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for query to complete
|
||||
await until(loading).toBe(false)
|
||||
const hasWorkspaces =
|
||||
(result.value?.activeUser?.discoverableWorkspaces?.length ?? 0) > 0
|
||||
currentStage.value = hasWorkspaces ? 'join' : 'questions'
|
||||
isLoading.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col items-center justify-center p-4">
|
||||
<h1 class="text-heading-xl text-forefround mb-6 font-normal">
|
||||
<h1 class="text-heading-xl text-foreground mb-6 font-normal">
|
||||
{{ isPrimaryEmail ? 'Verify your email' : 'Verify additional email' }}
|
||||
</h1>
|
||||
<p class="text-center text-body-sm text-foreground">
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!registeredThisSession" class="w-full max-w-sm mx-auto mt-8">
|
||||
<CommonAlert color="neutral" size="xs">
|
||||
<CommonAlert color="neutral" size="xs" hide-icon>
|
||||
<template #title>Why am I seeing this?</template>
|
||||
<template #description>
|
||||
This server now requires you to verify all email addresses before you can
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div>
|
||||
<WorkspaceJoinPage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: [
|
||||
'requires-workspaces-enabled',
|
||||
'auth',
|
||||
'require-discoverable-workspaces'
|
||||
],
|
||||
layout: 'empty'
|
||||
})
|
||||
useHead({
|
||||
title: 'Join a workspace'
|
||||
})
|
||||
</script>
|
||||
+27
-12
@@ -10,7 +10,7 @@ import 'express-async-errors'
|
||||
import cookieParser from 'cookie-parser'
|
||||
|
||||
import { createTerminus } from '@godaddy/terminus'
|
||||
import Metrics from '@/observability'
|
||||
import Metrics, { initPrometheusRegistry } from '@/observability'
|
||||
import {
|
||||
startupLogger,
|
||||
shutdownLogger,
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
sanitizeHeaders
|
||||
} from '@/observability/components/express/expressLogging'
|
||||
|
||||
import { errorMetricsMiddleware } from '@/observability/components/express/metrics/errorMetrics'
|
||||
import prometheusClient from 'prom-client'
|
||||
import { errorMetricsMiddlewareFactory } from '@/observability/components/express/metrics/errorMetrics'
|
||||
import prometheusClient, { Registry } from 'prom-client'
|
||||
|
||||
import { ApolloServer } from '@apollo/server'
|
||||
import { expressMiddleware } from '@apollo/server/express4'
|
||||
@@ -104,9 +104,11 @@ const isWsServer = (server: http.Server | MockWsServer): server is MockWsServer
|
||||
* is that graphql-ws uses an entirely different protocol, so the client-side has to change as well, and so old clients
|
||||
* will be unable to use any WebSocket/subscriptions functionality with the updated server
|
||||
*/
|
||||
export function buildApolloSubscriptionServer(
|
||||
export function buildApolloSubscriptionServer(params: {
|
||||
server: http.Server | MockWsServer
|
||||
): SubscriptionServer {
|
||||
registers?: Registry[]
|
||||
}): SubscriptionServer {
|
||||
const { server, registers } = params
|
||||
const httpServer = isWsServer(server) ? undefined : server
|
||||
const mockServer = isWsServer(server) ? server : undefined
|
||||
|
||||
@@ -119,7 +121,9 @@ export function buildApolloSubscriptionServer(
|
||||
metricConnectedClients,
|
||||
metricSubscriptionTotalOperations,
|
||||
metricSubscriptionTotalResponses
|
||||
} = initApolloSubscriptionMonitoring()
|
||||
} = initApolloSubscriptionMonitoring({
|
||||
registers: registers ?? [prometheusClient.register]
|
||||
})
|
||||
|
||||
const getHeaders = (params: {
|
||||
connContext?: PossiblyMockedConnectionContext
|
||||
@@ -257,7 +261,7 @@ export async function buildApolloServer(options?: {
|
||||
schema,
|
||||
plugins: [
|
||||
statusCodePlugin,
|
||||
loggingPluginFactory({ register: prometheusClient.register }),
|
||||
loggingPluginFactory({ registers: [prometheusClient.register] }),
|
||||
ApolloServerPluginLandingPageLocalDefault({
|
||||
embed: true,
|
||||
includeCookies: true
|
||||
@@ -306,6 +310,8 @@ export async function init() {
|
||||
startupLogger.info('🖼️ Serving for frontend-2...')
|
||||
|
||||
const app = express()
|
||||
const promRegister = initPrometheusRegistry() // has to be called before both Metrics and Modules are initialized
|
||||
|
||||
app.disable('x-powered-by')
|
||||
|
||||
// Moves things along automatically on restart.
|
||||
@@ -353,11 +359,14 @@ export async function init() {
|
||||
// Metrics relies on 'regions' table in the database, so much be initialized after migrations in the main database ("migrateDbToLatest({ region: 'main'," etc..)
|
||||
// It also relies on the regional knex clients, which will initialize and run migrations in the respective regions.
|
||||
// It must be initialized after the multiregion module is initialized in ModulesSetup.init
|
||||
await Metrics(app)
|
||||
await Metrics({ app, registry: promRegister })
|
||||
|
||||
// Init HTTP server & subscription server
|
||||
const server = http.createServer(app)
|
||||
const subscriptionServer = buildApolloSubscriptionServer(server)
|
||||
const subscriptionServer = buildApolloSubscriptionServer({
|
||||
server,
|
||||
registers: [promRegister]
|
||||
})
|
||||
|
||||
// Initialize graphql server
|
||||
const graphqlServer = await buildApolloServer({
|
||||
@@ -371,12 +380,13 @@ export async function init() {
|
||||
)
|
||||
|
||||
// At the very end adding default error handler middleware
|
||||
app.use(errorMetricsMiddleware)
|
||||
app.use(errorMetricsMiddlewareFactory({ promRegisters: [promRegister] }))
|
||||
app.use(defaultErrorHandler)
|
||||
|
||||
return {
|
||||
app,
|
||||
graphqlServer,
|
||||
registers: [promRegister],
|
||||
server,
|
||||
subscriptionServer,
|
||||
readinessCheck: healthchecks.isReady
|
||||
@@ -415,11 +425,13 @@ async function createFrontendProxy() {
|
||||
export async function startHttp(params: {
|
||||
server: http.Server
|
||||
app: Express
|
||||
registers?: Registry[]
|
||||
graphqlServer: ApolloServer<GraphQLContext>
|
||||
readinessCheck: ReadinessHandler
|
||||
customPortOverride?: number
|
||||
}) {
|
||||
const { server, app, graphqlServer, readinessCheck, customPortOverride } = params
|
||||
const { server, app, registers, graphqlServer, readinessCheck, customPortOverride } =
|
||||
params
|
||||
let bindAddress = getBindAddress() // defaults to 127.0.0.1
|
||||
let port = getPort() // defaults to 3000
|
||||
|
||||
@@ -437,7 +449,10 @@ export async function startHttp(params: {
|
||||
bindAddress = getBindAddress('0.0.0.0')
|
||||
}
|
||||
|
||||
monitorActiveConnections(server)
|
||||
monitorActiveConnections({
|
||||
httpServer: server,
|
||||
registers: registers ?? [prometheusClient.register]
|
||||
})
|
||||
|
||||
app.set('port', port)
|
||||
|
||||
|
||||
@@ -352,6 +352,13 @@ input AutomateAuthCodePayloadTest {
|
||||
action: String!
|
||||
}
|
||||
|
||||
"""
|
||||
Additional resources to validate user access to.
|
||||
"""
|
||||
input AutomateAuthCodeResources {
|
||||
workspaceId: String
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
automateFunctions(
|
||||
filter: AutomateFunctionsFilter
|
||||
@@ -366,7 +373,10 @@ extend type Query {
|
||||
"""
|
||||
Part of the automation/function creation handshake mechanism
|
||||
"""
|
||||
automateValidateAuthCode(payload: AutomateAuthCodePayloadTest!): Boolean!
|
||||
automateValidateAuthCode(
|
||||
payload: AutomateAuthCodePayloadTest!
|
||||
resources: AutomateAuthCodeResources
|
||||
): Boolean!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
|
||||
@@ -73,6 +73,10 @@ Server configuration.
|
||||
type ServerConfiguration {
|
||||
objectSizeLimitBytes: Int!
|
||||
objectMultipartUploadSizeLimitBytes: Int!
|
||||
"""
|
||||
Whether the email feature is enabled on this server
|
||||
"""
|
||||
isEmailEnabled: Boolean!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
|
||||
@@ -519,6 +519,15 @@ extend type User {
|
||||
@hasServerRole(role: SERVER_GUEST)
|
||||
@hasScope(scope: "workspace:read")
|
||||
@isOwner
|
||||
|
||||
"""
|
||||
The last-visited workspace for the given user
|
||||
"""
|
||||
activeWorkspace: Workspace
|
||||
"""
|
||||
Returns `true` if last visited project was "legacy" "personal project" outside of a workspace
|
||||
"""
|
||||
isProjectsActive: Boolean
|
||||
}
|
||||
|
||||
extend type Project {
|
||||
@@ -633,3 +642,7 @@ input AdminUpdateWorkspacePlanInput {
|
||||
type AdminMutations {
|
||||
updateWorkspacePlan(input: AdminUpdateWorkspacePlanInput!): Boolean!
|
||||
}
|
||||
|
||||
extend type ActiveUserMutations {
|
||||
setActiveWorkspace(slug: String, isProjectsActive: Boolean): Boolean!
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ const { logger } = require('../observability/logging')
|
||||
const { init, startHttp } = require('../app')
|
||||
|
||||
init()
|
||||
.then(({ app, graphqlServer, server, readinessCheck }) =>
|
||||
startHttp({ app, graphqlServer, server, readinessCheck })
|
||||
.then(({ app, graphqlServer, registers, server, readinessCheck }) =>
|
||||
startHttp({ app, graphqlServer, registers, server, readinessCheck })
|
||||
)
|
||||
.catch((err) => {
|
||||
logger.error(err, 'Failed to start server. Exiting with non-zero exit code...')
|
||||
|
||||
@@ -5,8 +5,8 @@ const { logger } = require('../dist/observability/logging')
|
||||
const { init, startHttp } = require('../dist/app')
|
||||
|
||||
init()
|
||||
.then(({ app, graphqlServer, server, readinessCheck }) =>
|
||||
startHttp({ app, graphqlServer, server, readinessCheck })
|
||||
.then(({ app, graphqlServer, registers, server, readinessCheck }) =>
|
||||
startHttp({ app, graphqlServer, registers, server, readinessCheck })
|
||||
)
|
||||
.catch((err) => {
|
||||
logger.error(err, 'Failed to start server. Exiting with non-zero exit code...')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Optional, SpeckleModule } from '@/modules/shared/helpers/typeHelper'
|
||||
import { publishNotification } from '@/modules/notifications/services/publication'
|
||||
import { activitiesLogger, moduleLogger } from '@/observability/logging'
|
||||
import { moduleLogger } from '@/observability/logging'
|
||||
import { weeklyEmailDigestEnabled } from '@/modules/shared/helpers/envHelper'
|
||||
import { EventBus, getEventBus } from '@/modules/shared/services/eventBus'
|
||||
import { sendActivityNotificationsFactory } from '@/modules/activitystream/services/summary'
|
||||
@@ -105,8 +105,8 @@ const scheduleWeeklyActivityNotifications = () => {
|
||||
cronExpression,
|
||||
'weeklyActivityNotification',
|
||||
//task should be locked for 10 minutes
|
||||
async (now: Date) => {
|
||||
activitiesLogger.info('Sending weekly activity digests notifications.')
|
||||
async (now: Date, { logger }) => {
|
||||
logger.info('Sending weekly activity digests notifications.')
|
||||
const end = now
|
||||
const start = new Date(end.getTime())
|
||||
start.setDate(start.getDate() - numberOfDays)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { authLogger, type Logger } from '@/observability/logging'
|
||||
import { maybeLoggerWithContext } from '@/observability/components/express/requestContext'
|
||||
import { loggerWithMaybeContext } from '@/observability/components/express/requestContext'
|
||||
import {
|
||||
addToMailchimpAudience,
|
||||
triggerMailchimpCustomerJourney
|
||||
@@ -16,7 +16,7 @@ import { mixpanel } from '@/modules/shared/utils/mixpanel'
|
||||
|
||||
const onUserCreatedFactory =
|
||||
() => async (payload: EventPayload<typeof UserEvents.Created>) => {
|
||||
const logger = maybeLoggerWithContext({ logger: authLogger })!
|
||||
const logger = loggerWithMaybeContext({ logger: authLogger })
|
||||
const { user, signUpCtx } = payload.payload
|
||||
|
||||
try {
|
||||
|
||||
@@ -28,7 +28,9 @@ import {
|
||||
retry,
|
||||
timeoutAt
|
||||
} from '@speckle/shared'
|
||||
import { has, isObjectLike } from 'lodash'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { Logger } from 'pino'
|
||||
import { has, isObjectLike, isEmpty } from 'lodash'
|
||||
|
||||
export type AuthCodePayloadWithOrigin = AuthCodePayload & { origin: string }
|
||||
|
||||
@@ -48,7 +50,7 @@ export type AutomationCreateResponse = {
|
||||
const getApiUrl = (
|
||||
path?: string,
|
||||
options?: Partial<{
|
||||
query: Record<string, string | number | boolean | undefined>
|
||||
query: Record<string, string[] | string | number | boolean | undefined>
|
||||
}>
|
||||
) => {
|
||||
const automateUrl = speckleAutomateUrl()
|
||||
@@ -63,8 +65,10 @@ const getApiUrl = (
|
||||
if (options?.query) {
|
||||
Object.entries(options.query).forEach(([key, val]) => {
|
||||
if (isNullOrUndefined(val)) return
|
||||
if (typeof val === 'object' && isEmpty(val)) return
|
||||
try {
|
||||
url.searchParams.append(key, val.toString())
|
||||
const urlValue = typeof val === 'object' ? val.join(',') : val.toString()
|
||||
url.searchParams.append(key, urlValue)
|
||||
} catch {
|
||||
console.log({ val })
|
||||
}
|
||||
@@ -74,22 +78,23 @@ const getApiUrl = (
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const invokeSafeJsonRequest = async <
|
||||
Response extends Record<string, unknown> = Record<string, unknown>
|
||||
>(
|
||||
...args: Parameters<typeof invokeRequest>
|
||||
): Promise<Response | null> => {
|
||||
const [{ url, method }] = args
|
||||
try {
|
||||
return await invokeJsonRequest<Response>(...args)
|
||||
} catch (e) {
|
||||
automateLogger.error(
|
||||
{ url, method, err: e },
|
||||
'Automate API request error suppressed.'
|
||||
)
|
||||
return null
|
||||
const invokeSafeJsonRequestFactory =
|
||||
<Response extends Record<string, unknown> = Record<string, unknown>>(deps: {
|
||||
logger: Logger
|
||||
}) =>
|
||||
async (...args: Parameters<typeof invokeRequest>): Promise<Response | null> => {
|
||||
const { logger } = deps
|
||||
const [{ url, method }] = args
|
||||
try {
|
||||
return await invokeJsonRequest<Response>({
|
||||
...args[0],
|
||||
requestId: logger.bindings?.()?.req?.id
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error({ url, method, err: e }, 'Automate API request error suppressed.')
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const invokeJsonRequest = async <R = Record<string, unknown>>(
|
||||
...args: Parameters<typeof invokeRequest>
|
||||
@@ -109,10 +114,11 @@ const invokeRequest = async (params: {
|
||||
url: string
|
||||
method?: RequestInit['method']
|
||||
body?: Record<string, unknown>
|
||||
requestId?: string
|
||||
token?: string
|
||||
retry?: boolean
|
||||
}) => {
|
||||
const { url, method = 'get', body, token } = params
|
||||
const { url, method = 'get', body, token, requestId } = params
|
||||
|
||||
const response = await retry(
|
||||
async () =>
|
||||
@@ -121,6 +127,7 @@ const invokeRequest = async (params: {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Request-Id': requestId ?? randomUUID(),
|
||||
...(token?.length ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: body && isObjectLike(body) ? JSON.stringify(body) : undefined
|
||||
@@ -387,158 +394,232 @@ export type GetFunctionResponse = FunctionWithVersionsSchemaType & {
|
||||
versionCursor: Nullable<string>
|
||||
}
|
||||
|
||||
export const getFunction = async (params: {
|
||||
functionId: string
|
||||
token?: string
|
||||
releases?: { cursor?: string; limit?: number; versionsFilter?: string }
|
||||
}) => {
|
||||
const { functionId, token } = params
|
||||
const query = Object.values(params.releases || {}).filter(isNonNullable).length
|
||||
? params.releases
|
||||
: undefined
|
||||
export const getFunctionFactory =
|
||||
(deps: { logger: Logger }) =>
|
||||
async (params: {
|
||||
functionId: string
|
||||
token?: string
|
||||
releases?: { cursor?: string; limit?: number; versionsFilter?: string }
|
||||
}) => {
|
||||
const { logger } = deps
|
||||
const { functionId, token } = params
|
||||
const query = Object.values(params.releases || {}).filter(isNonNullable).length
|
||||
? params.releases
|
||||
: undefined
|
||||
|
||||
const url = getApiUrl(`/api/v1/functions/${functionId}`, {
|
||||
query
|
||||
})
|
||||
const url = getApiUrl(`/api/v1/functions/${functionId}`, {
|
||||
query
|
||||
})
|
||||
|
||||
return await invokeSafeJsonRequest<GetFunctionResponse>({
|
||||
url,
|
||||
method: 'get',
|
||||
token
|
||||
})
|
||||
}
|
||||
return await invokeSafeJsonRequestFactory<GetFunctionResponse>({
|
||||
logger
|
||||
})({
|
||||
url,
|
||||
method: 'get',
|
||||
token
|
||||
})
|
||||
}
|
||||
|
||||
export type GetFunctionReleaseResponse = FunctionReleaseSchemaType
|
||||
|
||||
/**
|
||||
* TODO: Build optimized exec engine endpoint for this
|
||||
*/
|
||||
export const getFunctionReleases = async (params: {
|
||||
ids: Array<{ functionId: string; functionReleaseId: string }>
|
||||
}) => {
|
||||
const { ids } = params
|
||||
const results = await Promise.all(
|
||||
ids.map(async ({ functionId, functionReleaseId }) => {
|
||||
try {
|
||||
return await getFunctionRelease({ functionId, functionReleaseId })
|
||||
} catch (e) {
|
||||
if (e instanceof ExecutionEngineNetworkError) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
e instanceof ExecutionEngineFailedResponseError &&
|
||||
e.response.statusMessage === 'FunctionNotFound'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
export const getFunctionReleasesFactory =
|
||||
(deps: { logger: Logger }) =>
|
||||
async (params: { ids: Array<{ functionId: string; functionReleaseId: string }> }) => {
|
||||
const { logger } = deps
|
||||
const { ids } = params
|
||||
const results = await Promise.all(
|
||||
ids.map(async ({ functionId, functionReleaseId }) => {
|
||||
try {
|
||||
return await getFunctionReleaseFactory({ logger })({
|
||||
functionId,
|
||||
functionReleaseId
|
||||
})
|
||||
} catch (e) {
|
||||
if (e instanceof ExecutionEngineNetworkError) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
e instanceof ExecutionEngineFailedResponseError &&
|
||||
e.response.statusMessage === 'FunctionNotFound'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
throw e
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return results.filter(isNonNullable)
|
||||
}
|
||||
|
||||
export const getFunctionReleaseFactory =
|
||||
(deps: { logger: Logger }) =>
|
||||
async (params: { functionId: string; functionReleaseId: string }) => {
|
||||
const { logger } = deps
|
||||
const { functionId, functionReleaseId } = params
|
||||
const url = getApiUrl(
|
||||
`/api/v1/functions/${functionId}/versions/${functionReleaseId}`
|
||||
)
|
||||
|
||||
const result = await invokeSafeJsonRequestFactory<GetFunctionReleaseResponse>({
|
||||
logger
|
||||
})({
|
||||
url,
|
||||
method: 'get'
|
||||
})
|
||||
)
|
||||
|
||||
return results.filter(isNonNullable)
|
||||
}
|
||||
return result
|
||||
? {
|
||||
...result,
|
||||
functionId
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
export const getFunctionRelease = async (params: {
|
||||
functionId: string
|
||||
functionReleaseId: string
|
||||
}) => {
|
||||
const { functionId, functionReleaseId } = params
|
||||
const url = getApiUrl(`/api/v1/functions/${functionId}/versions/${functionReleaseId}`)
|
||||
|
||||
const result = await invokeSafeJsonRequest<GetFunctionReleaseResponse>({
|
||||
url,
|
||||
method: 'get'
|
||||
})
|
||||
|
||||
return result
|
||||
? {
|
||||
...result,
|
||||
functionId
|
||||
}
|
||||
: null
|
||||
export type GetFunctionsParams = {
|
||||
auth?: AuthCodePayload
|
||||
filters: {
|
||||
query?: string
|
||||
cursor?: string
|
||||
limit?: number
|
||||
requireRelease?: boolean
|
||||
includeFeatured?: boolean
|
||||
includeWorkspaces?: string[]
|
||||
includeUsers?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export type GetFunctionsResponse = {
|
||||
items: FunctionSchemaType[]
|
||||
cursor: Nullable<string>
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
export const getFunctionsFactory =
|
||||
(deps: { logger: Logger }) => async (params: GetFunctionsParams) => {
|
||||
const { logger } = deps
|
||||
|
||||
const url = getApiUrl(`/api/v2/functions`, {
|
||||
query: {
|
||||
requireRelease: true,
|
||||
...params.filters
|
||||
}
|
||||
})
|
||||
|
||||
const authToken = params.auth
|
||||
? Buffer.from(
|
||||
JSON.stringify({
|
||||
...params.auth,
|
||||
origin: getServerOrigin()
|
||||
})
|
||||
).toString('base64')
|
||||
: undefined
|
||||
|
||||
return await invokeSafeJsonRequestFactory<GetFunctionsResponse>({ logger })({
|
||||
url,
|
||||
method: 'get',
|
||||
token: authToken
|
||||
})
|
||||
}
|
||||
|
||||
export type GetPublicFunctionsResponse = {
|
||||
totalCount: number
|
||||
cursor: Nullable<string>
|
||||
items: FunctionWithVersionsSchemaType[]
|
||||
}
|
||||
|
||||
export const getPublicFunctions = async (params: {
|
||||
query?: {
|
||||
query?: string
|
||||
cursor?: string
|
||||
limit?: number
|
||||
functionsWithoutVersions?: boolean
|
||||
}
|
||||
}) => {
|
||||
const { query } = params
|
||||
const url = getApiUrl(`/api/v1/functions`, {
|
||||
query: {
|
||||
...query,
|
||||
featuredFunctionsOnly: true
|
||||
export const getPublicFunctionsFactory =
|
||||
(deps: { logger: Logger }) =>
|
||||
async (params: {
|
||||
query?: {
|
||||
query?: string
|
||||
cursor?: string
|
||||
limit?: number
|
||||
functionsWithoutVersions?: boolean
|
||||
}
|
||||
})
|
||||
}) => {
|
||||
const { logger } = deps
|
||||
const { query } = params
|
||||
const url = getApiUrl(`/api/v1/functions`, {
|
||||
query: {
|
||||
...query,
|
||||
featuredFunctionsOnly: true
|
||||
}
|
||||
})
|
||||
|
||||
return await invokeSafeJsonRequest<GetFunctionsResponse>({
|
||||
url,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
return await invokeSafeJsonRequestFactory<GetFunctionsResponse>({
|
||||
logger
|
||||
})({
|
||||
url,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
type GetUserFunctionsResponse = {
|
||||
functions: FunctionWithVersionsSchemaType[]
|
||||
}
|
||||
|
||||
export const getUserFunctions = async (params: {
|
||||
userId: string
|
||||
query?: {
|
||||
query?: string
|
||||
cursor?: string
|
||||
limit?: number
|
||||
}
|
||||
body: {
|
||||
speckleServerAuthenticationPayload: AuthCodePayloadWithOrigin
|
||||
}
|
||||
}) => {
|
||||
const { userId, query, body } = params
|
||||
const url = getApiUrl(`/api/v2/users/${userId}/functions`, { query })
|
||||
export const getUserFunctionsFactory =
|
||||
(deps: { logger: Logger }) =>
|
||||
async (params: {
|
||||
userId: string
|
||||
query?: {
|
||||
query?: string
|
||||
cursor?: string
|
||||
limit?: number
|
||||
}
|
||||
body: {
|
||||
speckleServerAuthenticationPayload: AuthCodePayloadWithOrigin
|
||||
}
|
||||
}) => {
|
||||
const { logger } = deps
|
||||
const { userId, query, body } = params
|
||||
const url = getApiUrl(`/api/v2/users/${userId}/functions`, { query })
|
||||
|
||||
return await invokeSafeJsonRequest<GetUserFunctionsResponse>({
|
||||
url,
|
||||
method: 'POST',
|
||||
body,
|
||||
retry: false
|
||||
})
|
||||
}
|
||||
return await invokeSafeJsonRequestFactory<GetUserFunctionsResponse>({
|
||||
logger
|
||||
})({
|
||||
url,
|
||||
method: 'POST',
|
||||
body,
|
||||
retry: false
|
||||
})
|
||||
}
|
||||
|
||||
type GetWorkspaceFunctionsResponse = {
|
||||
functions: FunctionWithVersionsSchemaType[]
|
||||
}
|
||||
|
||||
export const getWorkspaceFunctions = async (params: {
|
||||
workspaceId: string
|
||||
query?: {
|
||||
query?: string
|
||||
cursor?: string
|
||||
limit?: number
|
||||
}
|
||||
body: {
|
||||
speckleServerAuthenticationPayload: AuthCodePayloadWithOrigin
|
||||
}
|
||||
}) => {
|
||||
const { workspaceId, query, body } = params
|
||||
const url = getApiUrl(`/api/v2/workspaces/${workspaceId}/functions`, { query })
|
||||
export const getWorkspaceFunctionsFactory =
|
||||
(deps: { logger: Logger }) =>
|
||||
async (params: {
|
||||
workspaceId: string
|
||||
query?: {
|
||||
query?: string
|
||||
cursor?: string
|
||||
limit?: number
|
||||
}
|
||||
body: {
|
||||
speckleServerAuthenticationPayload: AuthCodePayloadWithOrigin
|
||||
}
|
||||
}) => {
|
||||
const { logger } = deps
|
||||
const { workspaceId, query, body } = params
|
||||
const url = getApiUrl(`/api/v2/workspaces/${workspaceId}/functions`, { query })
|
||||
|
||||
return await invokeSafeJsonRequest<GetWorkspaceFunctionsResponse>({
|
||||
url,
|
||||
method: 'POST',
|
||||
body,
|
||||
retry: false
|
||||
})
|
||||
}
|
||||
return await invokeSafeJsonRequestFactory<GetWorkspaceFunctionsResponse>({
|
||||
logger
|
||||
})({
|
||||
url,
|
||||
method: 'POST',
|
||||
body,
|
||||
retry: false
|
||||
})
|
||||
}
|
||||
|
||||
type UserGithubAuthStateResponse = {
|
||||
userHasAuthorizedGitHubApp: boolean
|
||||
|
||||
@@ -3,13 +3,13 @@ import {
|
||||
createFunctionWithoutVersion,
|
||||
triggerAutomationRun,
|
||||
updateFunction as execEngineUpdateFunction,
|
||||
getFunction,
|
||||
getFunctionRelease,
|
||||
getPublicFunctions,
|
||||
getFunctionReleases,
|
||||
getFunctionFactory,
|
||||
getFunctionReleaseFactory,
|
||||
getPublicFunctionsFactory,
|
||||
getFunctionReleasesFactory,
|
||||
getUserGithubAuthState,
|
||||
getUserGithubOrganizations,
|
||||
getUserFunctions
|
||||
getUserFunctionsFactory
|
||||
} from '@/modules/automate/clients/executionEngine'
|
||||
import {
|
||||
GetProjectAutomationsParams,
|
||||
@@ -459,10 +459,12 @@ export = (FF_AUTOMATE_MODULE_ENABLED
|
||||
}
|
||||
},
|
||||
AutomateFunction: {
|
||||
async releases(parent, args) {
|
||||
async releases(parent, args, context) {
|
||||
try {
|
||||
// TODO: Replace w/ dataloader batch call, when/if possible
|
||||
const fn = await getFunction({
|
||||
const fn = await getFunctionFactory({
|
||||
logger: context.log
|
||||
})({
|
||||
functionId: parent.id,
|
||||
releases:
|
||||
args?.cursor || args?.filter?.search || args?.limit
|
||||
@@ -567,10 +569,11 @@ export = (FF_AUTOMATE_MODULE_ENABLED
|
||||
async updateFunction(_parent, args, ctx) {
|
||||
const update = updateFunctionFactory({
|
||||
updateFunction: execEngineUpdateFunction,
|
||||
getFunction,
|
||||
getFunction: getFunctionFactory({ logger: ctx.log }),
|
||||
createStoredAuthCode: createStoredAuthCodeFactory({
|
||||
redis: getGenericRedis()
|
||||
})
|
||||
}),
|
||||
logger: ctx.log
|
||||
})
|
||||
return await update({ input: args.input, userId: ctx.userId! })
|
||||
}
|
||||
@@ -621,12 +624,12 @@ export = (FF_AUTOMATE_MODULE_ENABLED
|
||||
getAutomation: getAutomationFactory({ db: projectDb }),
|
||||
storeAutomationRevision: storeAutomationRevisionFactory({ db: projectDb }),
|
||||
getBranchesByIds: getBranchesByIdsFactory({ db: projectDb }),
|
||||
getFunctionRelease,
|
||||
getFunctionRelease: getFunctionReleaseFactory({ logger: ctx.log }),
|
||||
getEncryptionKeyPair,
|
||||
getFunctionInputDecryptor: getFunctionInputDecryptorFactory({
|
||||
buildDecryptor
|
||||
}),
|
||||
getFunctionReleases,
|
||||
getFunctionReleases: getFunctionReleasesFactory({ logger: ctx.log }),
|
||||
eventEmit: getEventBus().emit,
|
||||
validateStreamAccess
|
||||
})
|
||||
@@ -679,7 +682,7 @@ export = (FF_AUTOMATE_MODULE_ENABLED
|
||||
|
||||
const create = createTestAutomationFactory({
|
||||
getEncryptionKeyPair,
|
||||
getFunction,
|
||||
getFunction: getFunctionFactory({ logger: ctx.log }),
|
||||
storeAutomation: storeAutomationFactory({ db: projectDb }),
|
||||
storeAutomationRevision: storeAutomationRevisionFactory({ db: projectDb }),
|
||||
validateStreamAccess,
|
||||
@@ -730,15 +733,20 @@ export = (FF_AUTOMATE_MODULE_ENABLED
|
||||
}
|
||||
},
|
||||
Query: {
|
||||
async automateValidateAuthCode(_parent, args) {
|
||||
async automateValidateAuthCode(_parent, args, ctx) {
|
||||
const validate = validateStoredAuthCodeFactory({
|
||||
redis: getGenericRedis(),
|
||||
emit: getEventBus().emit
|
||||
emit: getEventBus().emit,
|
||||
logger: ctx.log
|
||||
})
|
||||
const payload = removeNullOrUndefinedKeys(args.payload)
|
||||
const resources = removeNullOrUndefinedKeys(args.resources ?? {})
|
||||
return await validate({
|
||||
...payload,
|
||||
action: args.payload.action as AuthCodePayloadAction
|
||||
payload: {
|
||||
...payload,
|
||||
action: args.payload.action as AuthCodePayloadAction
|
||||
},
|
||||
resources
|
||||
})
|
||||
},
|
||||
async automateFunction(_parent, { id }, ctx) {
|
||||
@@ -751,9 +759,11 @@ export = (FF_AUTOMATE_MODULE_ENABLED
|
||||
|
||||
return convertFunctionToGraphQLReturn(fn)
|
||||
},
|
||||
async automateFunctions(_parent, args) {
|
||||
async automateFunctions(_parent, args, ctx) {
|
||||
try {
|
||||
const res = await getPublicFunctions({
|
||||
const res = await getPublicFunctionsFactory({
|
||||
logger: ctx.log
|
||||
})({
|
||||
query: {
|
||||
query: args.filter?.search || undefined,
|
||||
cursor: args.cursor || undefined,
|
||||
@@ -805,7 +815,9 @@ export = (FF_AUTOMATE_MODULE_ENABLED
|
||||
action: AuthCodePayloadAction.ListUserFunctions
|
||||
})
|
||||
|
||||
const res = await getUserFunctions({
|
||||
const res = await getUserFunctionsFactory({
|
||||
logger: context.log
|
||||
})({
|
||||
userId: context.userId!,
|
||||
query: {
|
||||
query: args.filter?.search || undefined,
|
||||
|
||||
@@ -20,6 +20,8 @@ export type FunctionSchemaType = {
|
||||
speckleUserId: string
|
||||
speckleServerOrigin: string
|
||||
}>
|
||||
functionCreatorSpeckleUserId: Nullable<string>
|
||||
functionCreatorSpeckleServerOrigin: Nullable<string>
|
||||
workspaceIds: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { isNonNullable, Scopes } from '@speckle/shared'
|
||||
import { registerOrUpdateScopeFactory } from '@/modules/shared/repositories/scopes'
|
||||
import {
|
||||
getFunction,
|
||||
getFunctionFactory,
|
||||
triggerAutomationRun
|
||||
} from '@/modules/automate/clients/executionEngine'
|
||||
import logStreamRest from '@/modules/automate/rest/logStream'
|
||||
@@ -58,7 +58,7 @@ import { getEventBus } from '@/modules/shared/services/eventBus'
|
||||
import { VersionEvents } from '@/modules/core/domain/commits/events'
|
||||
import { AutomationEvents, AutomationRunEvents } from '@/modules/automate/domain/events'
|
||||
import { LogicError } from '@/modules/shared/errors'
|
||||
import { maybeLoggerWithContext } from '@/observability/components/express/requestContext'
|
||||
import { loggerWithMaybeContext } from '@/observability/components/express/requestContext'
|
||||
|
||||
const { FF_AUTOMATE_MODULE_ENABLED } = getFeatureFlags()
|
||||
let quitListeners: Optional<() => void> = undefined
|
||||
@@ -177,7 +177,7 @@ const initializeEventListeners = () => {
|
||||
getEventBus().listen(
|
||||
AutomationRunEvents.Created,
|
||||
async ({ payload: { manifests, run, automation } }) => {
|
||||
const logger = maybeLoggerWithContext({ logger: automateLogger })!
|
||||
const logger = loggerWithMaybeContext({ logger: automateLogger })
|
||||
const validatedManifests = manifests
|
||||
.map((manifest) => {
|
||||
if (isVersionCreatedTriggerManifest(manifest)) {
|
||||
@@ -267,7 +267,7 @@ const initializeEventListeners = () => {
|
||||
AutomationRunEvents.StatusUpdated,
|
||||
async ({ payload: { run, functionRun, automationId, projectId } }) => {
|
||||
if (!isFinished(run.status)) return
|
||||
const logger = maybeLoggerWithContext({ logger: automateLogger })!
|
||||
const logger = loggerWithMaybeContext({ logger: automateLogger })
|
||||
const projectDb = await getProjectDbClient({ projectId })
|
||||
const project = await getProjectFactory({ db: projectDb })({ projectId })
|
||||
|
||||
@@ -289,7 +289,7 @@ const initializeEventListeners = () => {
|
||||
|
||||
const fn = isTestEnv()
|
||||
? null
|
||||
: await getFunction({ functionId: functionRun.functionId })
|
||||
: await getFunctionFactory({ logger })({ functionId: functionRun.functionId })
|
||||
|
||||
const userEmail = await getUserEmailFromAutomationRunFactory({
|
||||
getFullAutomationRevisionMetadata: getFullAutomationRevisionMetadataFactory({
|
||||
@@ -321,7 +321,7 @@ const initializeEventListeners = () => {
|
||||
getEventBus().listen(
|
||||
AutomationRunEvents.Created,
|
||||
async ({ payload: { automation, run: automationRun, source, manifests } }) => {
|
||||
const logger = maybeLoggerWithContext({ logger: automateLogger })!
|
||||
const logger = loggerWithMaybeContext({ logger: automateLogger })
|
||||
const manifest = manifests.at(0)
|
||||
if (!manifest || !isVersionCreatedTriggerManifest(manifest)) {
|
||||
logger.error(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EventBus } from '@/modules/shared/services/eventBus'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import Redis from 'ioredis'
|
||||
import { get, has, isObjectLike } from 'lodash'
|
||||
import { Logger } from 'pino'
|
||||
|
||||
export enum AuthCodePayloadAction {
|
||||
CreateAutomation = 'createAutomation',
|
||||
@@ -19,7 +20,6 @@ export enum AuthCodePayloadAction {
|
||||
export type AuthCodePayload = {
|
||||
code: string
|
||||
userId: string
|
||||
workspaceId?: string
|
||||
action: AuthCodePayloadAction
|
||||
}
|
||||
|
||||
@@ -48,9 +48,15 @@ export const createStoredAuthCodeFactory =
|
||||
}
|
||||
|
||||
export const validateStoredAuthCodeFactory =
|
||||
(deps: { redis: Redis; emit: EventBus['emit'] }) =>
|
||||
async (payload: AuthCodePayload) => {
|
||||
const { redis, emit } = deps
|
||||
(deps: { redis: Redis; logger: Logger; emit: EventBus['emit'] }) =>
|
||||
async (params: {
|
||||
payload: AuthCodePayload
|
||||
resources?: {
|
||||
workspaceId?: string
|
||||
}
|
||||
}) => {
|
||||
const { redis, logger, emit } = deps
|
||||
const { payload, resources } = params
|
||||
|
||||
const potentialPayloadString = await redis.get(payload.code)
|
||||
const potentialPayload: unknown = potentialPayloadString
|
||||
@@ -58,6 +64,17 @@ export const validateStoredAuthCodeFactory =
|
||||
: null
|
||||
const formattedPayload = isPayload(potentialPayload) ? potentialPayload : null
|
||||
|
||||
logger.info(
|
||||
{
|
||||
payloadString: potentialPayloadString,
|
||||
payload: {
|
||||
...formattedPayload,
|
||||
code: null
|
||||
}
|
||||
},
|
||||
'Validating execution engine request with provided auth payload.'
|
||||
)
|
||||
|
||||
if (
|
||||
!formattedPayload ||
|
||||
formattedPayload.code !== payload.code ||
|
||||
@@ -67,10 +84,11 @@ export const validateStoredAuthCodeFactory =
|
||||
throw new AutomateAuthCodeHandshakeError('Invalid automate auth payload')
|
||||
}
|
||||
|
||||
if (payload.workspaceId) {
|
||||
// Token is valid, confirm user is authorized to access specified resources.
|
||||
if (resources?.workspaceId) {
|
||||
emit({
|
||||
eventName: 'workspace.authorized',
|
||||
payload: { userId: payload.userId, workspaceId: payload.workspaceId }
|
||||
payload: { userId: payload.userId, workspaceId: resources?.workspaceId }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ import { getServerOrigin } from '@/modules/shared/helpers/envHelper'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import {
|
||||
createAutomation as clientCreateAutomation,
|
||||
getFunction,
|
||||
getFunctionRelease,
|
||||
getFunctionReleases
|
||||
getFunctionFactory,
|
||||
getFunctionReleaseFactory,
|
||||
getFunctionReleasesFactory
|
||||
} from '@/modules/automate/clients/executionEngine'
|
||||
import { Automate, Roles, removeNullOrUndefinedKeys } from '@speckle/shared'
|
||||
import { AuthCodePayloadAction } from '@/modules/automate/services/authCode'
|
||||
@@ -139,7 +139,7 @@ export const createAutomationFactory =
|
||||
|
||||
export type CreateTestAutomationDeps = {
|
||||
getEncryptionKeyPair: GetEncryptionKeyPair
|
||||
getFunction: typeof getFunction
|
||||
getFunction: ReturnType<typeof getFunctionFactory>
|
||||
storeAutomation: StoreAutomation
|
||||
storeAutomationRevision: StoreAutomationRevision
|
||||
validateStreamAccess: ValidateStreamAccess
|
||||
@@ -356,7 +356,7 @@ const validateNewTriggerDefinitions =
|
||||
}
|
||||
|
||||
type ValidateNewRevisionFunctionsDeps = {
|
||||
getFunctionRelease: typeof getFunctionRelease
|
||||
getFunctionRelease: ReturnType<typeof getFunctionReleaseFactory>
|
||||
}
|
||||
|
||||
const validateNewRevisionFunctions =
|
||||
@@ -399,7 +399,7 @@ export type CreateAutomationRevisionDeps = {
|
||||
storeAutomationRevision: StoreAutomationRevision
|
||||
getEncryptionKeyPair: GetEncryptionKeyPair
|
||||
getFunctionInputDecryptor: FunctionInputDecryptor
|
||||
getFunctionReleases: typeof getFunctionReleases
|
||||
getFunctionReleases: ReturnType<typeof getFunctionReleasesFactory>
|
||||
validateStreamAccess: ValidateStreamAccess
|
||||
eventEmit: EventBusEmit
|
||||
} & ValidateNewTriggerDefinitionsDeps &
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
CreateFunctionBody,
|
||||
ExecutionEngineFunctionTemplateId,
|
||||
createFunction,
|
||||
getFunction,
|
||||
getFunctionFactory,
|
||||
updateFunction as updateExecEngineFunction
|
||||
} from '@/modules/automate/clients/executionEngine'
|
||||
import {
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
speckleAutomateUrl
|
||||
} from '@/modules/shared/helpers/envHelper'
|
||||
import { getFunctionsMarketplaceUrl } from '@/modules/core/helpers/routeHelper'
|
||||
import { automateLogger } from '@/observability/logging'
|
||||
import { automateLogger, Logger } from '@/observability/logging'
|
||||
import { CreateStoredAuthCode } from '@/modules/automate/domain/operations'
|
||||
import { GetUser } from '@/modules/core/domain/users/operations'
|
||||
import { noop } from 'lodash'
|
||||
@@ -89,6 +89,14 @@ const cleanFunctionLogo = (logo: MaybeNullOrUndefined<string>): Nullable<string>
|
||||
export const convertFunctionToGraphQLReturn = (
|
||||
fn: FunctionSchemaType
|
||||
): AutomateFunctionGraphQLReturn => {
|
||||
const functionCreator: FunctionSchemaType['functionCreator'] =
|
||||
fn.functionCreatorSpeckleUserId && fn.functionCreatorSpeckleServerOrigin
|
||||
? {
|
||||
speckleUserId: fn.functionCreatorSpeckleUserId,
|
||||
speckleServerOrigin: fn.functionCreatorSpeckleServerOrigin
|
||||
}
|
||||
: fn.functionCreator
|
||||
|
||||
const ret: AutomateFunctionGraphQLReturn = {
|
||||
id: fn.functionId,
|
||||
name: fn.functionName,
|
||||
@@ -98,7 +106,7 @@ export const convertFunctionToGraphQLReturn = (
|
||||
logo: cleanFunctionLogo(fn.logo),
|
||||
tags: fn.tags,
|
||||
supportedSourceApps: fn.supportedSourceApps,
|
||||
functionCreator: fn.functionCreator,
|
||||
functionCreator,
|
||||
workspaceIds: fn.workspaceIds
|
||||
}
|
||||
|
||||
@@ -187,17 +195,18 @@ export const createFunctionFromTemplateFactory =
|
||||
|
||||
export type UpdateFunctionDeps = {
|
||||
updateFunction: typeof updateExecEngineFunction
|
||||
getFunction: typeof getFunction
|
||||
getFunction: ReturnType<typeof getFunctionFactory>
|
||||
createStoredAuthCode: CreateStoredAuthCode
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
export const updateFunctionFactory =
|
||||
(deps: UpdateFunctionDeps) =>
|
||||
async (params: { input: UpdateAutomateFunctionInput; userId: string }) => {
|
||||
const { updateFunction, createStoredAuthCode } = deps
|
||||
const { updateFunction, createStoredAuthCode, logger } = deps
|
||||
const { input, userId } = params
|
||||
|
||||
const existingFn = await getFunction({ functionId: input.id })
|
||||
const existingFn = await getFunctionFactory({ logger })({ functionId: input.id })
|
||||
if (!existingFn) {
|
||||
throw new AutomateFunctionUpdateError('Function not found')
|
||||
}
|
||||
|
||||
@@ -288,7 +288,15 @@ export const StreamFavorites = buildTableHelper('stream_favorites', [
|
||||
export const UsersMeta = buildMetaTableHelper(
|
||||
'users_meta',
|
||||
['userId', 'key', 'value', 'createdAt', 'updatedAt'],
|
||||
['isOnboardingFinished', 'foo', 'bar', 'onboardingStreamId'],
|
||||
[
|
||||
'isOnboardingFinished',
|
||||
'onboardingStreamId',
|
||||
'activeWorkspace',
|
||||
'isProjectsActive',
|
||||
// Used in tests
|
||||
'foo',
|
||||
'bar'
|
||||
],
|
||||
'userId'
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ScheduledTask } from '@/modules/core/domain/scheduledTasks/types'
|
||||
import cron from 'node-cron'
|
||||
import type { ScheduledTask } from '@/modules/core/domain/scheduledTasks/types'
|
||||
import type { Logger } from '@/observability/logging'
|
||||
import type { ScheduledTask as CronScheduledTask } from 'node-cron'
|
||||
|
||||
export type AcquireTaskLock = (
|
||||
scheduledTask: ScheduledTask
|
||||
@@ -10,6 +11,6 @@ export type ReleaseTaskLock = (args: { taskName: string }) => Promise<void>
|
||||
export type ScheduleExecution = (
|
||||
cronExpression: string,
|
||||
taskName: string,
|
||||
callback: (scheduledTime: Date) => Promise<void>,
|
||||
callback: (scheduledTime: Date, context: { logger: Logger }) => Promise<void>,
|
||||
lockTimeout?: number
|
||||
) => cron.ScheduledTask
|
||||
) => CronScheduledTask
|
||||
|
||||
@@ -71,8 +71,8 @@ import {
|
||||
getRevisionsTriggerDefinitionsFactory
|
||||
} from '@/modules/automate/repositories/automations'
|
||||
import {
|
||||
getFunction,
|
||||
getFunctionReleases
|
||||
getFunctionFactory,
|
||||
getFunctionReleasesFactory
|
||||
} from '@/modules/automate/clients/executionEngine'
|
||||
import {
|
||||
FunctionReleaseSchemaType,
|
||||
@@ -96,6 +96,7 @@ import {
|
||||
CommitWithStreamBranchId,
|
||||
CommitWithStreamBranchMetadata
|
||||
} from '@/modules/core/domain/commits/types'
|
||||
import { logger } from '@/observability/logging'
|
||||
|
||||
declare module '@/modules/core/loaders' {
|
||||
interface ModularizedDataLoaders extends ReturnType<typeof dataLoadersDefinition> {}
|
||||
@@ -618,7 +619,7 @@ const dataLoadersDefinition = defineRequestDataloaders(
|
||||
const results = await Promise.all(
|
||||
fnIds.map(async (fnId) => {
|
||||
try {
|
||||
return await getFunction({ functionId: fnId })
|
||||
return await getFunctionFactory({ logger })({ functionId: fnId })
|
||||
} catch (e) {
|
||||
const isNotFound =
|
||||
e instanceof ExecutionEngineFailedResponseError &&
|
||||
@@ -642,7 +643,7 @@ const dataLoadersDefinition = defineRequestDataloaders(
|
||||
>(
|
||||
async (keys) => {
|
||||
const results = keyBy(
|
||||
await getFunctionReleases({
|
||||
await getFunctionReleasesFactory({ logger })({
|
||||
ids: keys.map(([fnId, fnReleaseId]) => ({
|
||||
functionId: fnId,
|
||||
functionReleaseId: fnReleaseId
|
||||
|
||||
@@ -44,6 +44,7 @@ export type ActiveUserMutations = {
|
||||
emailMutations: UserEmailMutations;
|
||||
/** Mark onboarding as complete */
|
||||
finishOnboarding: Scalars['Boolean']['output'];
|
||||
setActiveWorkspace: Scalars['Boolean']['output'];
|
||||
/** Edit a user's profile */
|
||||
update: User;
|
||||
};
|
||||
@@ -54,6 +55,12 @@ export type ActiveUserMutationsFinishOnboardingArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type ActiveUserMutationsSetActiveWorkspaceArgs = {
|
||||
isProjectsActive?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
slug?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type ActiveUserMutationsUpdateArgs = {
|
||||
user: UserUpdateInput;
|
||||
};
|
||||
@@ -265,6 +272,11 @@ export type AutomateAuthCodePayloadTest = {
|
||||
workspaceId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
/** Additional resources to validate user access to. */
|
||||
export type AutomateAuthCodeResources = {
|
||||
workspaceId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type AutomateFunction = {
|
||||
__typename?: 'AutomateFunction';
|
||||
/** Only returned if user is a part of this speckle server */
|
||||
@@ -2747,6 +2759,7 @@ export type QueryAutomateFunctionsArgs = {
|
||||
|
||||
export type QueryAutomateValidateAuthCodeArgs = {
|
||||
payload: AutomateAuthCodePayloadTest;
|
||||
resources?: InputMaybe<AutomateAuthCodeResources>;
|
||||
};
|
||||
|
||||
|
||||
@@ -2952,6 +2965,8 @@ export type ServerAutomateInfo = {
|
||||
export type ServerConfiguration = {
|
||||
__typename?: 'ServerConfiguration';
|
||||
blobSizeLimitBytes: Scalars['Int']['output'];
|
||||
/** Whether the email feature is enabled on this server */
|
||||
isEmailEnabled: Scalars['Boolean']['output'];
|
||||
objectMultipartUploadSizeLimitBytes: Scalars['Int']['output'];
|
||||
objectSizeLimitBytes: Scalars['Int']['output'];
|
||||
};
|
||||
@@ -3715,6 +3730,8 @@ export type UpgradePlanInput = {
|
||||
*/
|
||||
export type User = {
|
||||
__typename?: 'User';
|
||||
/** The last-visited workspace for the given user */
|
||||
activeWorkspace?: Maybe<Workspace>;
|
||||
/**
|
||||
* All the recent activity from this user in chronological order
|
||||
* @deprecated Part of the old API surface and will be removed in the future.
|
||||
@@ -3762,6 +3779,8 @@ export type User = {
|
||||
id: Scalars['ID']['output'];
|
||||
/** Whether post-sign up onboarding has been finished or skipped entirely */
|
||||
isOnboardingFinished?: Maybe<Scalars['Boolean']['output']>;
|
||||
/** Returns `true` if last visited project was "legacy" "personal project" outside of a workspace */
|
||||
isProjectsActive?: Maybe<Scalars['Boolean']['output']>;
|
||||
name: Scalars['String']['output'];
|
||||
notificationPreferences: Scalars['JSONObject']['output'];
|
||||
profiles?: Maybe<Scalars['JSONObject']['output']>;
|
||||
@@ -4938,6 +4957,7 @@ export type ResolversTypes = {
|
||||
ArchiveCommentInput: ArchiveCommentInput;
|
||||
AuthStrategy: ResolverTypeWrapper<AuthStrategy>;
|
||||
AutomateAuthCodePayloadTest: AutomateAuthCodePayloadTest;
|
||||
AutomateAuthCodeResources: AutomateAuthCodeResources;
|
||||
AutomateFunction: ResolverTypeWrapper<AutomateFunctionGraphQLReturn>;
|
||||
AutomateFunctionCollection: ResolverTypeWrapper<Omit<AutomateFunctionCollection, 'items'> & { items: Array<ResolversTypes['AutomateFunction']> }>;
|
||||
AutomateFunctionRelease: ResolverTypeWrapper<AutomateFunctionReleaseGraphQLReturn>;
|
||||
@@ -5251,6 +5271,7 @@ export type ResolversParentTypes = {
|
||||
ArchiveCommentInput: ArchiveCommentInput;
|
||||
AuthStrategy: AuthStrategy;
|
||||
AutomateAuthCodePayloadTest: AutomateAuthCodePayloadTest;
|
||||
AutomateAuthCodeResources: AutomateAuthCodeResources;
|
||||
AutomateFunction: AutomateFunctionGraphQLReturn;
|
||||
AutomateFunctionCollection: Omit<AutomateFunctionCollection, 'items'> & { items: Array<ResolversParentTypes['AutomateFunction']> };
|
||||
AutomateFunctionRelease: AutomateFunctionReleaseGraphQLReturn;
|
||||
@@ -5545,6 +5566,7 @@ export type IsOwnerDirectiveResolver<Result, Parent, ContextType = GraphQLContex
|
||||
export type ActiveUserMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ActiveUserMutations'] = ResolversParentTypes['ActiveUserMutations']> = {
|
||||
emailMutations?: Resolver<ResolversTypes['UserEmailMutations'], ParentType, ContextType>;
|
||||
finishOnboarding?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, Partial<ActiveUserMutationsFinishOnboardingArgs>>;
|
||||
setActiveWorkspace?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, Partial<ActiveUserMutationsSetActiveWorkspaceArgs>>;
|
||||
update?: Resolver<ResolversTypes['User'], ParentType, ContextType, RequireFields<ActiveUserMutationsUpdateArgs, 'user'>>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
@@ -6524,6 +6546,7 @@ export type ServerAutomateInfoResolvers<ContextType = GraphQLContext, ParentType
|
||||
|
||||
export type ServerConfigurationResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ServerConfiguration'] = ResolversParentTypes['ServerConfiguration']> = {
|
||||
blobSizeLimitBytes?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
isEmailEnabled?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
|
||||
objectMultipartUploadSizeLimitBytes?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
objectSizeLimitBytes?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
@@ -6756,6 +6779,7 @@ export type TriggeredAutomationsStatusResolvers<ContextType = GraphQLContext, Pa
|
||||
};
|
||||
|
||||
export type UserResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['User'] = ResolversParentTypes['User']> = {
|
||||
activeWorkspace?: Resolver<Maybe<ResolversTypes['Workspace']>, ParentType, ContextType>;
|
||||
activity?: Resolver<Maybe<ResolversTypes['ActivityCollection']>, ParentType, ContextType, RequireFields<UserActivityArgs, 'limit'>>;
|
||||
apiTokens?: Resolver<Array<ResolversTypes['ApiToken']>, ParentType, ContextType>;
|
||||
authorizedApps?: Resolver<Maybe<Array<ResolversTypes['ServerAppListItem']>>, ParentType, ContextType>;
|
||||
@@ -6776,6 +6800,7 @@ export type UserResolvers<ContextType = GraphQLContext, ParentType extends Resol
|
||||
hasPendingVerification?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
isOnboardingFinished?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
isProjectsActive?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
notificationPreferences?: Resolver<ResolversTypes['JSONObject'], ParentType, ContextType>;
|
||||
profiles?: Resolver<Maybe<ResolversTypes['JSONObject']>, ParentType, ContextType>;
|
||||
|
||||
@@ -29,14 +29,14 @@ const isFieldNode = (node: SelectionNode): node is FieldNode => node.kind === 'F
|
||||
let metricCallCount: Counter<string>
|
||||
|
||||
export const loggingPluginFactory: (deps: {
|
||||
register: Registry
|
||||
registers: Registry[]
|
||||
}) => ApolloServerPlugin<GraphQLContext> = (deps) => ({
|
||||
serverWillStart: async () => {
|
||||
deps.register.removeSingleMetric('speckle_server_apollo_calls')
|
||||
deps.registers.forEach((r) => r.removeSingleMetric('speckle_server_apollo_calls'))
|
||||
metricCallCount = new Counter({
|
||||
name: 'speckle_server_apollo_calls',
|
||||
help: 'Number of calls',
|
||||
registers: [deps.register],
|
||||
registers: deps.registers,
|
||||
labelNames: ['actionName']
|
||||
})
|
||||
},
|
||||
|
||||
@@ -92,6 +92,7 @@ export type ServerInfo = ServerConfigRecord & {
|
||||
configuration: {
|
||||
objectSizeLimitBytes: number
|
||||
objectMultipartUploadSizeLimitBytes: number
|
||||
isEmailEnabled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
getServerMovedFrom,
|
||||
getServerMovedTo,
|
||||
getServerOrigin,
|
||||
getServerVersion
|
||||
getServerVersion,
|
||||
isEmailEnabled
|
||||
} from '@/modules/shared/helpers/envHelper'
|
||||
import { Knex } from 'knex'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
@@ -67,7 +68,8 @@ export const getServerInfoFactory =
|
||||
canonicalUrl: getServerOrigin(),
|
||||
configuration: {
|
||||
objectSizeLimitBytes: getMaximumObjectSizeMB() * 1024 * 1024,
|
||||
objectMultipartUploadSizeLimitBytes: getFileSizeLimitMB() * 1024 * 1024
|
||||
objectMultipartUploadSizeLimitBytes: getFileSizeLimitMB() * 1024 * 1024,
|
||||
isEmailEnabled: isEmailEnabled()
|
||||
},
|
||||
...(movedTo || movedFrom
|
||||
? {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import cron from 'node-cron'
|
||||
import crs from 'crypto-random-string'
|
||||
import { InvalidArgumentError } from '@/modules/shared/errors'
|
||||
import { taskSchedulerLogger as logger } from '@/observability/logging'
|
||||
import { type Logger, taskSchedulerLogger as logger } from '@/observability/logging'
|
||||
import {
|
||||
AcquireTaskLock,
|
||||
ReleaseTaskLock,
|
||||
@@ -11,11 +12,12 @@ export const scheduledCallbackWrapper = async (
|
||||
scheduledTime: Date,
|
||||
taskName: string,
|
||||
lockTimeout: number,
|
||||
callback: (scheduledTime: Date) => Promise<void>,
|
||||
callback: (scheduledTime: Date, context: { logger: Logger }) => Promise<void>,
|
||||
acquireLock: AcquireTaskLock,
|
||||
releaseTaskLock: ReleaseTaskLock
|
||||
) => {
|
||||
const boundLogger = logger.child({ taskName })
|
||||
const taskId = crs({ length: 10 })
|
||||
const boundLogger = logger.child({ taskName, taskId })
|
||||
// try to acquire the task lock with the function name and a new expiration date
|
||||
const lockExpiresAt = new Date(scheduledTime.getTime() + lockTimeout)
|
||||
const lock = await acquireLock({ taskName, lockExpiresAt })
|
||||
@@ -31,7 +33,7 @@ export const scheduledCallbackWrapper = async (
|
||||
{ scheduledTime },
|
||||
'Executing scheduled function {taskName} at {scheduledTime}'
|
||||
)
|
||||
await callback(scheduledTime)
|
||||
await callback(scheduledTime, { logger: boundLogger })
|
||||
// update lock as succeeded
|
||||
const finishDate = new Date()
|
||||
boundLogger.info(
|
||||
@@ -59,7 +61,7 @@ export const scheduleExecutionFactory =
|
||||
(
|
||||
cronExpression: string,
|
||||
taskName: string,
|
||||
callback: (scheduledTime: Date) => Promise<void>,
|
||||
callback: (scheduledTime: Date, context: { logger: Logger }) => Promise<void>,
|
||||
lockTimeout = 60 * 1000
|
||||
): cron.ScheduledTask => {
|
||||
const expressionValid = cron.validate(cronExpression)
|
||||
|
||||
+152
-92
@@ -1,26 +1,26 @@
|
||||
/* istanbul ignore file */
|
||||
const expect = require('chai').expect
|
||||
const request = require('supertest')
|
||||
import { expect } from 'chai'
|
||||
import request from 'supertest'
|
||||
|
||||
const { beforeEachContext, initializeTestServer } = require(`@/test/hooks`)
|
||||
const { generateManyObjects } = require(`@/test/helpers`)
|
||||
import { beforeEachContext, initializeTestServer } from '@/test/hooks'
|
||||
import { generateManyObjects } from '@/test/helpers'
|
||||
|
||||
const { Roles, Scopes } = require('@speckle/shared')
|
||||
const cryptoRandomString = require('crypto-random-string')
|
||||
const { db } = require('@/db/knex')
|
||||
const {
|
||||
import { Roles, Scopes } from '@speckle/shared'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import { db } from '@/db/knex'
|
||||
import {
|
||||
validateStreamAccessFactory,
|
||||
isStreamCollaboratorFactory,
|
||||
removeStreamCollaboratorFactory,
|
||||
addOrUpdateStreamCollaboratorFactory
|
||||
} = require('@/modules/core/services/streams/access')
|
||||
const { authorizeResolver } = require('@/modules/shared')
|
||||
const {
|
||||
} from '@/modules/core/services/streams/access'
|
||||
import { authorizeResolver } from '@/modules/shared'
|
||||
import {
|
||||
getStreamFactory,
|
||||
revokeStreamPermissionsFactory,
|
||||
grantStreamPermissionsFactory
|
||||
} = require('@/modules/core/repositories/streams')
|
||||
const {
|
||||
} from '@/modules/core/repositories/streams'
|
||||
import {
|
||||
getUserFactory,
|
||||
legacyGetPaginatedUsersFactory,
|
||||
storeUserFactory,
|
||||
@@ -28,43 +28,38 @@ const {
|
||||
storeUserAclFactory,
|
||||
isLastAdminUserFactory,
|
||||
updateUserServerRoleFactory
|
||||
} = require('@/modules/core/repositories/users')
|
||||
const {
|
||||
} from '@/modules/core/repositories/users'
|
||||
import {
|
||||
findEmailFactory,
|
||||
createUserEmailFactory,
|
||||
ensureNoPrimaryEmailForUserFactory
|
||||
} = require('@/modules/core/repositories/userEmails')
|
||||
const {
|
||||
requestNewEmailVerificationFactory
|
||||
} = require('@/modules/emails/services/verification/request')
|
||||
const {
|
||||
deleteOldAndInsertNewVerificationFactory
|
||||
} = require('@/modules/emails/repositories')
|
||||
const { renderEmail } = require('@/modules/emails/services/emailRendering')
|
||||
const { sendEmail } = require('@/modules/emails/services/sending')
|
||||
const {
|
||||
} from '@/modules/core/repositories/userEmails'
|
||||
import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request'
|
||||
import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories'
|
||||
import { renderEmail } from '@/modules/emails/services/emailRendering'
|
||||
import { sendEmail } from '@/modules/emails/services/sending'
|
||||
import {
|
||||
createUserFactory,
|
||||
changeUserRoleFactory
|
||||
} = require('@/modules/core/services/users/management')
|
||||
const {
|
||||
validateAndCreateUserEmailFactory
|
||||
} = require('@/modules/core/services/userEmails')
|
||||
const {
|
||||
finalizeInvitedServerRegistrationFactory
|
||||
} = require('@/modules/serverinvites/services/processing')
|
||||
const {
|
||||
} from '@/modules/core/services/users/management'
|
||||
import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails'
|
||||
import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing'
|
||||
import {
|
||||
deleteServerOnlyInvitesFactory,
|
||||
updateAllInviteTargetsFactory
|
||||
} = require('@/modules/serverinvites/repositories/serverInvites')
|
||||
const { createPersonalAccessTokenFactory } = require('@/modules/core/services/tokens')
|
||||
const {
|
||||
} from '@/modules/serverinvites/repositories/serverInvites'
|
||||
import { createPersonalAccessTokenFactory } from '@/modules/core/services/tokens'
|
||||
import {
|
||||
storeApiTokenFactory,
|
||||
storeTokenScopesFactory,
|
||||
storeTokenResourceAccessDefinitionsFactory,
|
||||
storePersonalApiTokenFactory
|
||||
} = require('@/modules/core/repositories/tokens')
|
||||
const { getServerInfoFactory } = require('@/modules/core/repositories/server')
|
||||
const { getEventBus } = require('@/modules/shared/services/eventBus')
|
||||
} from '@/modules/core/repositories/tokens'
|
||||
import { getServerInfoFactory } from '@/modules/core/repositories/server'
|
||||
import { getEventBus } from '@/modules/shared/services/eventBus'
|
||||
import { Express } from 'express'
|
||||
import { Server } from 'http'
|
||||
import { omit } from 'lodash'
|
||||
|
||||
const getUser = getUserFactory({ db })
|
||||
const getStream = getStreamFactory({ db })
|
||||
@@ -125,9 +120,9 @@ const createPersonalAccessToken = createPersonalAccessTokenFactory({
|
||||
storePersonalApiToken: storePersonalApiTokenFactory({ db })
|
||||
})
|
||||
|
||||
let app
|
||||
let server
|
||||
let sendRequest
|
||||
let app: Express
|
||||
let server: Server
|
||||
let sendRequest: Awaited<ReturnType<typeof initializeTestServer>>['sendRequest']
|
||||
|
||||
const changeUserRole = changeUserRoleFactory({
|
||||
getServerInfo,
|
||||
@@ -137,16 +132,22 @@ const changeUserRole = changeUserRoleFactory({
|
||||
|
||||
describe('GraphQL API Core @core-api', () => {
|
||||
const userA = {
|
||||
id: '',
|
||||
token: '',
|
||||
name: 'd1',
|
||||
email: 'd.1@speckle.systems',
|
||||
password: 'wowwowwowwowwow'
|
||||
}
|
||||
const userB = {
|
||||
id: '',
|
||||
token: '',
|
||||
name: 'd2',
|
||||
email: 'd.2@speckle.systems',
|
||||
password: 'wowwowwowwowwow'
|
||||
}
|
||||
const userC = {
|
||||
id: '',
|
||||
token: '',
|
||||
name: 'd3',
|
||||
email: 'd.3@speckle.systems',
|
||||
password: 'wowwowwowwowwow'
|
||||
@@ -263,30 +264,64 @@ describe('GraphQL API Core @core-api', () => {
|
||||
})
|
||||
|
||||
// the stream ids
|
||||
let ts1
|
||||
let ts2
|
||||
let ts3
|
||||
let ts4
|
||||
let ts5
|
||||
let ts6
|
||||
let ts1: string
|
||||
let ts2: string
|
||||
let ts3: string
|
||||
let ts4: string
|
||||
let ts5: string
|
||||
let ts6: string
|
||||
|
||||
// some api tokens
|
||||
let token1
|
||||
let token2
|
||||
let token3
|
||||
let token1: string
|
||||
let token2: string
|
||||
let token3: string
|
||||
|
||||
// object ids
|
||||
let objIds
|
||||
let objIds: string[]
|
||||
|
||||
// some commits
|
||||
const c1 = {}
|
||||
const c2 = {}
|
||||
const c1 = {
|
||||
id: '',
|
||||
message: '',
|
||||
streamId: '',
|
||||
objectId: '',
|
||||
branchName: '',
|
||||
previousCommitIds: []
|
||||
}
|
||||
const c2 = {
|
||||
id: '',
|
||||
message: '',
|
||||
streamId: '',
|
||||
objectId: '',
|
||||
branchName: '',
|
||||
previousCommitIds: new Array<string>()
|
||||
}
|
||||
|
||||
// some branches
|
||||
let b1 = {}
|
||||
let b2 = {}
|
||||
let b3 = {}
|
||||
let b4 = {}
|
||||
let b1 = {
|
||||
id: '',
|
||||
streamId: '',
|
||||
name: '',
|
||||
description: ''
|
||||
}
|
||||
let b2 = {
|
||||
id: '',
|
||||
streamId: '',
|
||||
name: '',
|
||||
description: ''
|
||||
}
|
||||
let b3 = {
|
||||
id: '',
|
||||
streamId: '',
|
||||
name: '',
|
||||
description: ''
|
||||
}
|
||||
let b4 = {
|
||||
id: '',
|
||||
streamId: '',
|
||||
name: '',
|
||||
description: ''
|
||||
}
|
||||
|
||||
describe('Mutations', () => {
|
||||
describe('Users & Api tokens', () => {
|
||||
@@ -358,6 +393,8 @@ describe('GraphQL API Core @core-api', () => {
|
||||
|
||||
it('Should delete my account', async () => {
|
||||
const userDelete = {
|
||||
id: '',
|
||||
token: '',
|
||||
name: 'delete',
|
||||
email: `${cryptoRandomString({ length: 10 })}@example.org`,
|
||||
password: 'wowwowwowwowwow'
|
||||
@@ -468,6 +505,7 @@ describe('GraphQL API Core @core-api', () => {
|
||||
describe('User deletion', () => {
|
||||
it('Only admins can delete user', async () => {
|
||||
const userDelete = {
|
||||
id: '',
|
||||
name: 'delete',
|
||||
email: `${cryptoRandomString({ length: 10 })}@example.org`,
|
||||
password: 'wowwowwowwowwow'
|
||||
@@ -484,6 +522,7 @@ describe('GraphQL API Core @core-api', () => {
|
||||
|
||||
it('Admin can delete user', async () => {
|
||||
const userDelete = {
|
||||
id: '',
|
||||
name: 'delete',
|
||||
email: 'd3l3t3@speckle.systems',
|
||||
password: 'wowwowwowwowwow'
|
||||
@@ -572,7 +611,7 @@ describe('GraphQL API Core @core-api', () => {
|
||||
expect(res).to.be.json
|
||||
expect(res.body.errors).to.be.ok
|
||||
expect(res.body.data.streamUpdatePermission).to.be.not.ok
|
||||
expect(res.body.errors.map((e) => e.message).join('|')).to.contain(
|
||||
expect(res.body.errors.map((e: Error) => e.message).join('|')).to.contain(
|
||||
"Cannot grant permissions to users who aren't collaborators already"
|
||||
)
|
||||
})
|
||||
@@ -763,16 +802,18 @@ describe('GraphQL API Core @core-api', () => {
|
||||
query:
|
||||
'{ adminStreams( visibility: "private" ) { totalCount items { id name isPublic } } }'
|
||||
})
|
||||
expect(streamResults.body.data.adminStreams.items).to.satisfy((streams) =>
|
||||
streams.every((stream) => !stream.isPublic)
|
||||
expect(streamResults.body.data.adminStreams.items).to.satisfy(
|
||||
(streams: { isPublic: boolean }[]) =>
|
||||
streams.every((stream) => !stream.isPublic)
|
||||
)
|
||||
|
||||
streamResults = await sendRequest(userA.token, {
|
||||
query:
|
||||
'{ adminStreams( visibility: "public" ) { totalCount items { id name isPublic } } }'
|
||||
})
|
||||
expect(streamResults.body.data.adminStreams.items).to.satisfy((streams) =>
|
||||
streams.every((stream) => stream.isPublic)
|
||||
expect(streamResults.body.data.adminStreams.items).to.satisfy(
|
||||
(streams: { isPublic: boolean }[]) =>
|
||||
streams.every((stream) => stream.isPublic)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -782,7 +823,7 @@ describe('GraphQL API Core @core-api', () => {
|
||||
})
|
||||
expect(streamResults.body.data.adminStreams.totalCount).to.equal(5)
|
||||
const streamIds = streamResults.body.data.adminStreams.items.map(
|
||||
(stream) => stream.id
|
||||
(stream: { id: string }) => stream.id
|
||||
)
|
||||
const res = await sendRequest(userA.token, {
|
||||
query: 'mutation ( $ids: [String!] ){ streamsDelete( ids: $ids )}',
|
||||
@@ -848,11 +889,11 @@ describe('GraphQL API Core @core-api', () => {
|
||||
let res = await sendRequest(userA.token, {
|
||||
query:
|
||||
'mutation( $myCommit: CommitCreateInput! ) { commitCreate( commit: $myCommit ) }',
|
||||
variables: { myCommit: c1 }
|
||||
variables: { myCommit: omit(c1, 'id') }
|
||||
})
|
||||
|
||||
expect(res).to.be.json
|
||||
expect(res.body.errors).to.not.exist
|
||||
expect(res.body.errors, JSON.stringify(res.body.errors)).to.not.exist
|
||||
expect(res.body.data).to.have.property('commitCreate')
|
||||
expect(res.body.data.commitCreate).to.be.a('string')
|
||||
c1.id = res.body.data.commitCreate
|
||||
@@ -866,10 +907,10 @@ describe('GraphQL API Core @core-api', () => {
|
||||
res = await sendRequest(userA.token, {
|
||||
query:
|
||||
'mutation( $myCommit: CommitCreateInput! ) { commitCreate( commit: $myCommit ) }',
|
||||
variables: { myCommit: c2 }
|
||||
variables: { myCommit: omit(c2, 'id') }
|
||||
})
|
||||
expect(res).to.be.json
|
||||
expect(res.body.errors).to.not.exist
|
||||
expect(res.body.errors, JSON.stringify(res.body.errors)).to.not.exist
|
||||
expect(res.body.data).to.have.property('commitCreate')
|
||||
expect(res.body.data.commitCreate).to.be.a('string')
|
||||
|
||||
@@ -961,6 +1002,7 @@ describe('GraphQL API Core @core-api', () => {
|
||||
|
||||
it('Should create several branches', async () => {
|
||||
b1 = {
|
||||
id: '',
|
||||
streamId: ts1,
|
||||
name: 'dim/dev',
|
||||
description: 'dimitries development branch'
|
||||
@@ -969,15 +1011,16 @@ describe('GraphQL API Core @core-api', () => {
|
||||
const res1 = await sendRequest(userA.token, {
|
||||
query:
|
||||
'mutation( $branch:BranchCreateInput! ) { branchCreate( branch:$branch ) }',
|
||||
variables: { branch: b1 }
|
||||
variables: { branch: omit(b1, 'id') }
|
||||
})
|
||||
expect(res1).to.be.json
|
||||
expect(res1.body.errors).to.not.exist
|
||||
expect(res1.body.errors, JSON.stringify(res1.body.errors)).to.not.exist
|
||||
expect(res1.body.data).to.have.property('branchCreate')
|
||||
expect(res1.body.data.branchCreate).to.be.a('string')
|
||||
b1.id = res1.body.data.branchCreate
|
||||
|
||||
b2 = {
|
||||
id: '',
|
||||
streamId: ts1,
|
||||
name: 'dim/dev/api-surgery',
|
||||
description: 'another branch'
|
||||
@@ -992,12 +1035,13 @@ describe('GraphQL API Core @core-api', () => {
|
||||
const res2 = await sendRequest(userB.token, {
|
||||
query:
|
||||
'mutation( $branch:BranchCreateInput! ) { branchCreate( branch:$branch ) }',
|
||||
variables: { branch: b2 }
|
||||
variables: { branch: omit(b2, 'id') }
|
||||
})
|
||||
expect(res2.body.errors).to.not.exist
|
||||
expect(res2.body.errors, JSON.stringify(res2.body.errors)).to.not.exist
|
||||
b2.id = res2.body.data.branchCreate
|
||||
|
||||
b3 = {
|
||||
id: '',
|
||||
streamId: ts1,
|
||||
name: 'userB/dev/api',
|
||||
description: 'more branches branch'
|
||||
@@ -1005,14 +1049,15 @@ describe('GraphQL API Core @core-api', () => {
|
||||
const res3 = await sendRequest(userB.token, {
|
||||
query:
|
||||
'mutation( $branch:BranchCreateInput! ) { branchCreate( branch:$branch ) }',
|
||||
variables: { branch: b3 }
|
||||
variables: { branch: omit(b3, 'id') }
|
||||
})
|
||||
expect(res3.body.errors).to.not.exist
|
||||
expect(res3.body.errors, JSON.stringify(res3.body.errors)).to.not.exist
|
||||
b3.id = res3.body.data.branchCreate
|
||||
})
|
||||
|
||||
it('Should update a branch', async () => {
|
||||
const b1 = {
|
||||
id: '',
|
||||
streamId: ts1,
|
||||
name: 'randomupdateablebranch',
|
||||
description: 'dimitries development branch'
|
||||
@@ -1020,8 +1065,10 @@ describe('GraphQL API Core @core-api', () => {
|
||||
const b1Res = await sendRequest(userA.token, {
|
||||
query:
|
||||
'mutation( $branch:BranchCreateInput! ) { branchCreate( branch:$branch ) }',
|
||||
variables: { branch: b1 }
|
||||
variables: { branch: omit(b1, 'id') }
|
||||
})
|
||||
expect(b1Res).to.be.json
|
||||
expect(b1Res.body.errors, JSON.stringify(b1Res.body)).to.not.exist
|
||||
b1.id = b1Res.body.data.branchCreate
|
||||
|
||||
const payload = {
|
||||
@@ -1043,6 +1090,7 @@ describe('GraphQL API Core @core-api', () => {
|
||||
|
||||
it('Should delete a branch', async () => {
|
||||
const b1 = {
|
||||
id: '',
|
||||
streamId: ts1,
|
||||
name: 'randomudeletablebranch',
|
||||
description: 'dimitries development branch'
|
||||
@@ -1050,8 +1098,10 @@ describe('GraphQL API Core @core-api', () => {
|
||||
const b1Res = await sendRequest(userA.token, {
|
||||
query:
|
||||
'mutation( $branch:BranchCreateInput! ) { branchCreate( branch:$branch ) }',
|
||||
variables: { branch: b1 }
|
||||
variables: { branch: omit(b1, 'id') }
|
||||
})
|
||||
expect(b1Res).to.be.json
|
||||
expect(b1Res.body.errors, JSON.stringify(b1Res.body.errors)).to.not.exist
|
||||
b1.id = b1Res.body.data.branchCreate
|
||||
|
||||
// give C some access permissions
|
||||
@@ -1109,11 +1159,12 @@ describe('GraphQL API Core @core-api', () => {
|
||||
})
|
||||
|
||||
it('Should commit to a non-main branch as well...', async () => {
|
||||
const cc = {}
|
||||
cc.message = 'what a message for a second commit'
|
||||
cc.streamId = ts1
|
||||
cc.objectId = objIds[3]
|
||||
cc.branchName = 'userB/dev/api'
|
||||
const cc = {
|
||||
message: 'what a message for a second commit',
|
||||
streamId: ts1,
|
||||
objectId: objIds[3],
|
||||
branchName: 'userB/dev/api'
|
||||
}
|
||||
|
||||
const res = await sendRequest(userB.token, {
|
||||
query:
|
||||
@@ -1121,7 +1172,7 @@ describe('GraphQL API Core @core-api', () => {
|
||||
variables: { myCommit: cc }
|
||||
})
|
||||
expect(res).to.be.json
|
||||
expect(res.body.errors).to.not.exist
|
||||
expect(res.body.errors, JSON.stringify(res.body.errors)).to.not.exist
|
||||
expect(res.body.data).to.have.property('commitCreate')
|
||||
expect(res.body.data.commitCreate).to.be.a('string')
|
||||
})
|
||||
@@ -1132,10 +1183,13 @@ describe('GraphQL API Core @core-api', () => {
|
||||
query:
|
||||
'mutation { streamCreate(stream: { name: "TS (u C) private", description: "sup my dudes", isPublic:false } ) }'
|
||||
})
|
||||
expect(res).to.be.json
|
||||
expect(res.body.errors, JSON.stringify(res.body.errors)).to.not.exist
|
||||
ts6 = res.body.data.streamCreate
|
||||
|
||||
// user B creates branch on private stream
|
||||
b4 = {
|
||||
id: '',
|
||||
streamId: ts3,
|
||||
name: 'izz/secret',
|
||||
description: 'a private branch on a private stream'
|
||||
@@ -1143,10 +1197,10 @@ describe('GraphQL API Core @core-api', () => {
|
||||
const res1 = await sendRequest(userB.token, {
|
||||
query:
|
||||
'mutation( $branch:BranchCreateInput! ) { branchCreate( branch:$branch ) }',
|
||||
variables: { branch: b4 }
|
||||
variables: { branch: omit(b4, 'id') }
|
||||
})
|
||||
expect(res1).to.be.json
|
||||
expect(res1.body.errors).to.not.exist
|
||||
expect(res1.body.errors, JSON.stringify(res1.body.errors)).to.not.exist
|
||||
expect(res1.body.data).to.have.property('branchCreate')
|
||||
expect(res1.body.data.branchCreate).to.be.a('string')
|
||||
b4.id = res1.body.data.branchCreate
|
||||
@@ -1241,7 +1295,9 @@ describe('GraphQL API Core @core-api', () => {
|
||||
expect(res2.body.data.user.streams.items.length).to.equal(3)
|
||||
|
||||
const streams = res2.body.data.user.streams.items
|
||||
const s1 = streams.find((s) => s.name === 'TS1 (u A) Private UPDATED')
|
||||
const s1 = streams.find(
|
||||
(s: { name: string }) => s.name === 'TS1 (u A) Private UPDATED'
|
||||
)
|
||||
expect(s1).to.exist
|
||||
})
|
||||
|
||||
@@ -1427,9 +1483,11 @@ describe('GraphQL API Core @core-api', () => {
|
||||
expect(stream.name).to.equal('TS1 (u A) Private UPDATED')
|
||||
expect(stream.collaborators).to.have.lengthOf(2)
|
||||
|
||||
const d2User = stream.collaborators.find((c) => c.name === 'd2')
|
||||
const d2User = stream.collaborators.find(
|
||||
(c: { name: string }) => c.name === 'd2'
|
||||
)
|
||||
const testUserUpdated = stream.collaborators.find(
|
||||
(c) => c.name === 'test user updated'
|
||||
(c: { name: string }) => c.name === 'test user updated'
|
||||
)
|
||||
expect(d2User).to.be.ok
|
||||
expect(testUserUpdated).to.be.ok
|
||||
@@ -1556,7 +1614,7 @@ describe('GraphQL API Core @core-api', () => {
|
||||
)
|
||||
})
|
||||
|
||||
let commitList
|
||||
let commitList: { id: string }[]
|
||||
|
||||
it('should retrieve all stream commits', async () => {
|
||||
const query = `
|
||||
@@ -1633,8 +1691,8 @@ describe('GraphQL API Core @core-api', () => {
|
||||
})
|
||||
|
||||
describe('Objects', () => {
|
||||
let myCommit
|
||||
let myObjs
|
||||
let myCommit: { id: string; name: string }
|
||||
let myObjs: { name: string }[]
|
||||
|
||||
before(async () => {
|
||||
const { commit, objs } = generateManyObjects(100, 'noise__')
|
||||
@@ -1656,7 +1714,7 @@ describe('GraphQL API Core @core-api', () => {
|
||||
expect(objIds.length).to.equal(101) // +1 for the actual "commit" object
|
||||
})
|
||||
|
||||
it("should get an object's subojects objects", async () => {
|
||||
it("should get an object's sub-objects' objects", async () => {
|
||||
const first = await sendRequest(userA.token, {
|
||||
query: `
|
||||
query {
|
||||
@@ -1846,11 +1904,13 @@ describe('GraphQL API Core @core-api', () => {
|
||||
|
||||
describe('Archived role access validation', () => {
|
||||
const archivedUser = {
|
||||
id: '',
|
||||
token: '',
|
||||
name: 'Mark von Archival',
|
||||
email: 'archi@speckle.systems',
|
||||
password: 'i"ll be back, just wait'
|
||||
}
|
||||
let streamId
|
||||
let streamId: string
|
||||
before(async () => {
|
||||
archivedUser.id = await createUser(archivedUser)
|
||||
archivedUser.token = `Bearer ${await createPersonalAccessToken(
|
||||
@@ -25,6 +25,7 @@ export type ActiveUserMutations = {
|
||||
emailMutations: UserEmailMutations;
|
||||
/** Mark onboarding as complete */
|
||||
finishOnboarding: Scalars['Boolean']['output'];
|
||||
setActiveWorkspace: Scalars['Boolean']['output'];
|
||||
/** Edit a user's profile */
|
||||
update: User;
|
||||
};
|
||||
@@ -35,6 +36,12 @@ export type ActiveUserMutationsFinishOnboardingArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type ActiveUserMutationsSetActiveWorkspaceArgs = {
|
||||
isProjectsActive?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
slug?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type ActiveUserMutationsUpdateArgs = {
|
||||
user: UserUpdateInput;
|
||||
};
|
||||
@@ -246,6 +253,11 @@ export type AutomateAuthCodePayloadTest = {
|
||||
workspaceId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
/** Additional resources to validate user access to. */
|
||||
export type AutomateAuthCodeResources = {
|
||||
workspaceId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type AutomateFunction = {
|
||||
__typename?: 'AutomateFunction';
|
||||
/** Only returned if user is a part of this speckle server */
|
||||
@@ -2728,6 +2740,7 @@ export type QueryAutomateFunctionsArgs = {
|
||||
|
||||
export type QueryAutomateValidateAuthCodeArgs = {
|
||||
payload: AutomateAuthCodePayloadTest;
|
||||
resources?: InputMaybe<AutomateAuthCodeResources>;
|
||||
};
|
||||
|
||||
|
||||
@@ -2933,6 +2946,8 @@ export type ServerAutomateInfo = {
|
||||
export type ServerConfiguration = {
|
||||
__typename?: 'ServerConfiguration';
|
||||
blobSizeLimitBytes: Scalars['Int']['output'];
|
||||
/** Whether the email feature is enabled on this server */
|
||||
isEmailEnabled: Scalars['Boolean']['output'];
|
||||
objectMultipartUploadSizeLimitBytes: Scalars['Int']['output'];
|
||||
objectSizeLimitBytes: Scalars['Int']['output'];
|
||||
};
|
||||
@@ -3696,6 +3711,8 @@ export type UpgradePlanInput = {
|
||||
*/
|
||||
export type User = {
|
||||
__typename?: 'User';
|
||||
/** The last-visited workspace for the given user */
|
||||
activeWorkspace?: Maybe<Workspace>;
|
||||
/**
|
||||
* All the recent activity from this user in chronological order
|
||||
* @deprecated Part of the old API surface and will be removed in the future.
|
||||
@@ -3743,6 +3760,8 @@ export type User = {
|
||||
id: Scalars['ID']['output'];
|
||||
/** Whether post-sign up onboarding has been finished or skipped entirely */
|
||||
isOnboardingFinished?: Maybe<Scalars['Boolean']['output']>;
|
||||
/** Returns `true` if last visited project was "legacy" "personal project" outside of a workspace */
|
||||
isProjectsActive?: Maybe<Scalars['Boolean']['output']>;
|
||||
name: Scalars['String']['output'];
|
||||
notificationPreferences: Scalars['JSONObject']['output'];
|
||||
profiles?: Maybe<Scalars['JSONObject']['output']>;
|
||||
|
||||
@@ -69,7 +69,7 @@ export const createCheckoutSessionFactory =
|
||||
})
|
||||
|
||||
const cancel_url = isCreateFlow
|
||||
? `${frontendOrigin}/workspaces/create?workspaceId=${workspaceId}&payment_status=canceled&session_id={CHECKOUT_SESSION_ID}`
|
||||
? `${frontendOrigin}/workspaces/actions/create?workspaceId=${workspaceId}&payment_status=canceled&session_id={CHECKOUT_SESSION_ID}`
|
||||
: `${resultUrl.toString()}&payment_status=canceled&session_id={CHECKOUT_SESSION_ID}`
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WorkspaceSeat } from '@/modules/gatekeeper/domain/billing'
|
||||
import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
|
||||
import { Workspace } from '@/modules/workspacesCore/domain/types'
|
||||
import {
|
||||
@@ -30,3 +31,7 @@ export type GetWorkspacePlanByProjectId = ({
|
||||
}: {
|
||||
projectId: string
|
||||
}) => Promise<WorkspacePlan | null>
|
||||
|
||||
export type CreateWorkspaceSeat = (
|
||||
args: Pick<WorkspaceSeat, 'workspaceId' | 'userId' | 'type'>
|
||||
) => Promise<void>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getFeatureFlags, getFrontendOrigin } from '@/modules/shared/helpers/envHelper'
|
||||
import { Resolvers } from '@/modules/core/graph/generated/graphql'
|
||||
import type { Resolvers } from '@/modules/core/graph/generated/graphql'
|
||||
import { authorizeResolver } from '@/modules/shared'
|
||||
import { Roles, throwUncoveredError } from '@speckle/shared'
|
||||
import { ensureError, Roles, throwUncoveredError } from '@speckle/shared'
|
||||
import {
|
||||
countWorkspaceRoleWithOptionalProjectRoleFactory,
|
||||
getWorkspaceFactory
|
||||
@@ -35,6 +35,9 @@ import { calculateSubscriptionSeats } from '@/modules/gatekeeper/domain/billing'
|
||||
import { WorkspacePaymentMethod } from '@/test/graphql/generated/graphql'
|
||||
import { LogicError, NotImplementedError } from '@/modules/shared/errors'
|
||||
import { isNewPlanType } from '@/modules/gatekeeper/helpers/plans'
|
||||
import { extendLoggerComponent } from '@/observability/logging'
|
||||
import { OperationName, OperationStatus } from '@/observability/domain/fields'
|
||||
import { logWithErr } from '@/observability/utils/logLevels'
|
||||
|
||||
const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } =
|
||||
getFeatureFlags()
|
||||
@@ -138,8 +141,15 @@ export = FF_GATEKEEPER_MODULE_ENABLED
|
||||
return true
|
||||
},
|
||||
createCheckoutSession: async (parent, args, ctx) => {
|
||||
let logger = extendLoggerComponent(
|
||||
ctx.log,
|
||||
'gatekeeper',
|
||||
'resolvers',
|
||||
'createCheckoutSession'
|
||||
).child(OperationName('createCheckoutSession'))
|
||||
const { workspaceId, workspacePlan, billingInterval, isCreateFlow } =
|
||||
args.input
|
||||
logger = logger.child({ workspaceId, workspacePlan })
|
||||
const workspace = await getWorkspaceFactory({ db })({ workspaceId })
|
||||
|
||||
if (!workspace) throw new WorkspaceNotFoundError()
|
||||
@@ -159,22 +169,37 @@ export = FF_GATEKEEPER_MODULE_ENABLED
|
||||
|
||||
const countRole = countWorkspaceRoleWithOptionalProjectRoleFactory({ db })
|
||||
|
||||
const session = await startCheckoutSessionFactory({
|
||||
getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory({ db }),
|
||||
getWorkspacePlan: getWorkspacePlanFactory({ db }),
|
||||
countRole,
|
||||
createCheckoutSession,
|
||||
saveCheckoutSession: saveCheckoutSessionFactory({ db }),
|
||||
deleteCheckoutSession: deleteCheckoutSessionFactory({ db })
|
||||
})({
|
||||
workspacePlan,
|
||||
workspaceId,
|
||||
workspaceSlug: workspace.slug,
|
||||
isCreateFlow: isCreateFlow || false,
|
||||
billingInterval
|
||||
})
|
||||
|
||||
return session
|
||||
try {
|
||||
logger.info(OperationStatus.start, '[{operationName} ({operationStatus})]')
|
||||
const session = await startCheckoutSessionFactory({
|
||||
getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory({ db }),
|
||||
getWorkspacePlan: getWorkspacePlanFactory({ db }),
|
||||
countRole,
|
||||
createCheckoutSession,
|
||||
saveCheckoutSession: saveCheckoutSessionFactory({ db }),
|
||||
deleteCheckoutSession: deleteCheckoutSessionFactory({ db })
|
||||
})({
|
||||
workspacePlan,
|
||||
workspaceId,
|
||||
workspaceSlug: workspace.slug,
|
||||
isCreateFlow: isCreateFlow || false,
|
||||
billingInterval
|
||||
})
|
||||
logger.info(
|
||||
{ ...OperationStatus.success, sessionId: session.id },
|
||||
'[{operationName} ({operationStatus})]'
|
||||
)
|
||||
return session
|
||||
} catch (err) {
|
||||
const e = ensureError(err, 'Unknown error creating checkout session')
|
||||
logWithErr(
|
||||
logger,
|
||||
e,
|
||||
{ ...OperationStatus.failure },
|
||||
'[{operationName} ({operationStatus})]'
|
||||
)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
upgradePlan: async (_parent, args, ctx) => {
|
||||
const { workspaceId, workspacePlan, billingInterval } = args.input
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import cron from 'node-cron'
|
||||
import { logger, moduleLogger } from '@/observability/logging'
|
||||
import { moduleLogger } from '@/observability/logging'
|
||||
import { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
|
||||
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
|
||||
import { validateModuleLicense } from '@/modules/gatekeeper/services/validateLicense'
|
||||
@@ -59,7 +59,6 @@ const scheduleWorkspaceSubscriptionDownscale = ({
|
||||
const stripe = getStripeClient()
|
||||
|
||||
const manageSubscriptionDownscale = manageSubscriptionDownscaleFactory({
|
||||
logger,
|
||||
downscaleWorkspaceSubscription: downscaleWorkspaceSubscriptionFactory({
|
||||
countWorkspaceRole: countWorkspaceRoleWithOptionalProjectRoleFactory({ db }),
|
||||
getWorkspacePlan: getWorkspacePlanFactory({ db }),
|
||||
@@ -76,8 +75,8 @@ const scheduleWorkspaceSubscriptionDownscale = ({
|
||||
return scheduleExecution(
|
||||
cronExpression,
|
||||
'WorkspaceSubscriptionDownscale',
|
||||
async () => {
|
||||
await manageSubscriptionDownscale()
|
||||
async (_scheduledTime, { logger }) => {
|
||||
await manageSubscriptionDownscale({ logger })
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -98,36 +97,41 @@ const scheduleWorkspaceTrialEmails = ({
|
||||
// const cronExpression = '*/5 * * * * *'
|
||||
// every day at noon
|
||||
const cronExpression = '0 12 * * *'
|
||||
return scheduleExecution(cronExpression, 'WorkspaceTrialEmails', async () => {
|
||||
const getWorkspacesByPlanAge = getWorkspacesByPlanAgeFactory({ db })
|
||||
const trialValidForDays = 31
|
||||
const trialWorkspacesExpireIn3Days = await getWorkspacesByPlanAge({
|
||||
daysTillExpiry: 3,
|
||||
planValidFor: trialValidForDays,
|
||||
plan: 'starter',
|
||||
status: 'trial'
|
||||
})
|
||||
if (trialWorkspacesExpireIn3Days.length) {
|
||||
await Promise.all(
|
||||
trialWorkspacesExpireIn3Days.map((workspace) =>
|
||||
sendWorkspaceTrialEmail({ workspace, expiresInDays: 3 })
|
||||
return scheduleExecution(
|
||||
cronExpression,
|
||||
'WorkspaceTrialEmails',
|
||||
async (_scheduledTime, { logger }) => {
|
||||
logger.info('Sending workspace trial emails.')
|
||||
const getWorkspacesByPlanAge = getWorkspacesByPlanAgeFactory({ db })
|
||||
const trialValidForDays = 31
|
||||
const trialWorkspacesExpireIn3Days = await getWorkspacesByPlanAge({
|
||||
daysTillExpiry: 3,
|
||||
planValidFor: trialValidForDays,
|
||||
plan: 'starter',
|
||||
status: 'trial'
|
||||
})
|
||||
if (trialWorkspacesExpireIn3Days.length) {
|
||||
await Promise.all(
|
||||
trialWorkspacesExpireIn3Days.map((workspace) =>
|
||||
sendWorkspaceTrialEmail({ workspace, expiresInDays: 3 })
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
const trialWorkspacesExpireToday = await getWorkspacesByPlanAge({
|
||||
daysTillExpiry: 0,
|
||||
planValidFor: trialValidForDays,
|
||||
plan: 'starter',
|
||||
status: 'trial'
|
||||
})
|
||||
if (trialWorkspacesExpireToday.length) {
|
||||
await Promise.all(
|
||||
trialWorkspacesExpireToday.map((workspace) =>
|
||||
sendWorkspaceTrialEmail({ workspace, expiresInDays: 0 })
|
||||
}
|
||||
const trialWorkspacesExpireToday = await getWorkspacesByPlanAge({
|
||||
daysTillExpiry: 0,
|
||||
planValidFor: trialValidForDays,
|
||||
plan: 'starter',
|
||||
status: 'trial'
|
||||
})
|
||||
if (trialWorkspacesExpireToday.length) {
|
||||
await Promise.all(
|
||||
trialWorkspacesExpireToday.map((workspace) =>
|
||||
sendWorkspaceTrialEmail({ workspace, expiresInDays: 0 })
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const scheduleWorkspaceTrialExpiry = ({
|
||||
@@ -139,24 +143,28 @@ const scheduleWorkspaceTrialExpiry = ({
|
||||
}) => {
|
||||
const changeExpiredStatuses = changeExpiredTrialWorkspacePlanStatusesFactory({ db })
|
||||
const cronExpression = '*/5 * * * *'
|
||||
return scheduleExecution(cronExpression, 'WorkspaceTrialExpiry', async () => {
|
||||
const expiredWorkspacePlans = await changeExpiredStatuses({ numberOfDays: 31 })
|
||||
return scheduleExecution(
|
||||
cronExpression,
|
||||
'WorkspaceTrialExpiry',
|
||||
async (_scheduledTime, { logger }) => {
|
||||
const expiredWorkspacePlans = await changeExpiredStatuses({ numberOfDays: 31 })
|
||||
|
||||
if (expiredWorkspacePlans.length) {
|
||||
logger.info(
|
||||
{ workspaceIds: expiredWorkspacePlans.map((p) => p.workspaceId) },
|
||||
'Workspace trial expired for {workspaceIds}.'
|
||||
)
|
||||
await Promise.all(
|
||||
expiredWorkspacePlans.map(async (plan) => {
|
||||
emit({
|
||||
eventName: 'gatekeeper.workspace-trial-expired',
|
||||
payload: { workspaceId: plan.workspaceId }
|
||||
if (expiredWorkspacePlans.length) {
|
||||
logger.info(
|
||||
{ workspaceIds: expiredWorkspacePlans.map((p) => p.workspaceId) },
|
||||
'Workspace trial expired for {workspaceIds}.'
|
||||
)
|
||||
await Promise.all(
|
||||
expiredWorkspacePlans.map(async (plan) => {
|
||||
emit({
|
||||
eventName: 'gatekeeper.workspace-trial-expired',
|
||||
payload: { workspaceId: plan.workspaceId }
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
let scheduledTasks: cron.ScheduledTask[] = []
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WorkspaceSeat } from '@/modules/gatekeeper/domain/billing'
|
||||
import { CreateWorkspaceSeat } from '@/modules/gatekeeper/domain/operations'
|
||||
import { Knex } from 'knex'
|
||||
|
||||
const tables = {
|
||||
@@ -17,3 +18,17 @@ export const countSeatsByTypeInWorkspaceFactory =
|
||||
.count('id')
|
||||
return parseInt(count.toString())
|
||||
}
|
||||
|
||||
export const createWorkspaceSeatFactory =
|
||||
({ db }: { db: Knex }): CreateWorkspaceSeat =>
|
||||
async ({ userId, workspaceId, type }) => {
|
||||
await tables
|
||||
.workspaceSeats(db)
|
||||
.insert({
|
||||
workspaceId,
|
||||
userId,
|
||||
type
|
||||
})
|
||||
.onConflict(['workspaceId', 'userId'])
|
||||
.merge()
|
||||
}
|
||||
|
||||
@@ -97,11 +97,7 @@ export const startCheckoutSessionFactory =
|
||||
if (workspaceCheckoutSession.paymentStatus === 'paid')
|
||||
// this is should not be possible, but its better to be checking it here, than double charging the customer
|
||||
throw new WorkspaceAlreadyPaidError()
|
||||
if (
|
||||
new Date().getTime() - workspaceCheckoutSession.createdAt.getTime() >
|
||||
1000
|
||||
// 10 * 60 * 1000
|
||||
) {
|
||||
if (new Date().getTime() - workspaceCheckoutSession.createdAt.getTime() > 1000) {
|
||||
await deleteCheckoutSession({
|
||||
checkoutSessionId: workspaceCheckoutSession.id
|
||||
})
|
||||
|
||||
@@ -328,7 +328,6 @@ export const downscaleWorkspaceSubscriptionFactory =
|
||||
|
||||
export const manageSubscriptionDownscaleFactory =
|
||||
({
|
||||
logger,
|
||||
getWorkspaceSubscriptions,
|
||||
downscaleWorkspaceSubscription,
|
||||
updateWorkspaceSubscription
|
||||
@@ -336,9 +335,9 @@ export const manageSubscriptionDownscaleFactory =
|
||||
getWorkspaceSubscriptions: GetWorkspaceSubscriptions
|
||||
downscaleWorkspaceSubscription: DownscaleWorkspaceSubscription
|
||||
updateWorkspaceSubscription: UpsertWorkspaceSubscription
|
||||
logger: Logger
|
||||
}) =>
|
||||
async () => {
|
||||
async (context: { logger: Logger }) => {
|
||||
const { logger } = context
|
||||
const subscriptions = await getWorkspaceSubscriptions()
|
||||
for (const workspaceSubscription of subscriptions) {
|
||||
const log = logger.child({ workspaceId: workspaceSubscription.workspaceId })
|
||||
|
||||
@@ -875,7 +875,6 @@ describe('subscriptions @gatekeeper', () => {
|
||||
})
|
||||
let updatedWorkspaceSubscription: WorkspaceSubscription | undefined = undefined
|
||||
await manageSubscriptionDownscaleFactory({
|
||||
logger,
|
||||
getWorkspaceSubscriptions: async () => [testWorkspaceSubscription],
|
||||
downscaleWorkspaceSubscription: async () => {
|
||||
throw new Error('kabumm')
|
||||
@@ -883,7 +882,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
updateWorkspaceSubscription: async ({ workspaceSubscription }) => {
|
||||
updatedWorkspaceSubscription = workspaceSubscription
|
||||
}
|
||||
})()
|
||||
})({ logger })
|
||||
|
||||
const updatedBillingCycleEnd = new Date(2035, 0, 5)
|
||||
expect(updatedWorkspaceSubscription).deep.equal({
|
||||
@@ -898,7 +897,6 @@ describe('subscriptions @gatekeeper', () => {
|
||||
})
|
||||
let updatedWorkspaceSubscription: WorkspaceSubscription | undefined = undefined
|
||||
await manageSubscriptionDownscaleFactory({
|
||||
logger,
|
||||
getWorkspaceSubscriptions: async () => [testWorkspaceSubscription],
|
||||
downscaleWorkspaceSubscription: async () => {
|
||||
throw new Error('kabumm')
|
||||
@@ -906,7 +904,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
updateWorkspaceSubscription: async ({ workspaceSubscription }) => {
|
||||
updatedWorkspaceSubscription = workspaceSubscription
|
||||
}
|
||||
})()
|
||||
})({ logger })
|
||||
|
||||
const updatedBillingCycleEnd = new Date(2035, 11, 5)
|
||||
expect(updatedWorkspaceSubscription).deep.equal({
|
||||
|
||||
@@ -58,7 +58,8 @@ describe('Activity digest notifications @notifications', () => {
|
||||
guestModeEnabled: false,
|
||||
configuration: {
|
||||
objectMultipartUploadSizeLimitBytes: 1000000,
|
||||
objectSizeLimitBytes: 1000000
|
||||
objectSizeLimitBytes: 1000000,
|
||||
isEmailEnabled: true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Stream } from '@/modules/core/domain/streams/types'
|
||||
import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types'
|
||||
import { ServerRegion } from '@/modules/multiregion/domain/types'
|
||||
import { SetOptional } from 'type-fest'
|
||||
import { WorkspaceSeat, WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing'
|
||||
|
||||
/** Workspace */
|
||||
|
||||
@@ -385,6 +386,10 @@ export type CopyProjectObjects = (params: {
|
||||
export type CopyProjectAutomations = (params: {
|
||||
projectIds: string[]
|
||||
}) => Promise<Record<string, number>>
|
||||
|
||||
export type AssignWorkspaceSeat = (
|
||||
params: Pick<WorkspaceSeat, 'userId' | 'workspaceId'> & { type?: WorkspaceSeatType }
|
||||
) => Promise<void>
|
||||
export type CopyProjectComments = (params: {
|
||||
projectIds: string[]
|
||||
}) => Promise<Record<string, number>>
|
||||
@@ -394,3 +399,10 @@ export type CopyProjectWebhooks = (params: {
|
||||
export type CopyProjectBlobs = (params: {
|
||||
projectIds: string[]
|
||||
}) => Promise<Record<string, number>>
|
||||
|
||||
export type SetUserActiveWorkspace = (args: {
|
||||
userId: string
|
||||
workspaceSlug: string | null
|
||||
/** Is the user in a "personal project" outside of a workspace? */
|
||||
isProjectsActive?: boolean
|
||||
}) => Promise<void>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { BaseError } from '@/modules/shared/errors'
|
||||
|
||||
export class InvalidWorkspaceSeatTypeError extends BaseError {
|
||||
static defaultMessage = 'Workspace seat type is invalid'
|
||||
static code = 'INDALID_WORKSPACE_SEAT_TYPE_ERROR'
|
||||
static statusCode = 400
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
upsertProjectRoleFactory
|
||||
} from '@/modules/core/repositories/streams'
|
||||
import {
|
||||
AssignWorkspaceSeat,
|
||||
CountWorkspaceRoleWithOptionalProjectRole,
|
||||
GetDefaultRegion,
|
||||
GetWorkspace,
|
||||
@@ -71,7 +72,8 @@ import { getBaseTrackingProperties, getClient } from '@/modules/shared/utils/mix
|
||||
import {
|
||||
calculateSubscriptionSeats,
|
||||
GetWorkspacePlan,
|
||||
GetWorkspaceSubscription
|
||||
GetWorkspaceSubscription,
|
||||
WorkspaceSeatType
|
||||
} from '@/modules/gatekeeper/domain/billing'
|
||||
import { getWorkspacePlanProductId } from '@/modules/gatekeeper/stripe'
|
||||
import { Workspace } from '@/modules/workspacesCore/domain/types'
|
||||
@@ -81,6 +83,11 @@ import {
|
||||
getWorkspacePlanFactory,
|
||||
getWorkspaceSubscriptionFactory
|
||||
} from '@/modules/gatekeeper/repositories/billing'
|
||||
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
|
||||
import { assignWorkspaceSeatFactory } from '@/modules/workspaces/services/workspaceSeat'
|
||||
import { createWorkspaceSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat'
|
||||
|
||||
const { FF_WORKSPACES_NEW_PLANS_ENABLED } = getFeatureFlags()
|
||||
|
||||
export const onProjectCreatedFactory =
|
||||
({
|
||||
@@ -222,22 +229,26 @@ export const onWorkspaceRoleUpdatedFactory =
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping,
|
||||
queryAllWorkspaceProjects,
|
||||
deleteProjectRole,
|
||||
upsertProjectRole
|
||||
upsertProjectRole,
|
||||
assignWorkspaceSeat
|
||||
}: {
|
||||
getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
|
||||
queryAllWorkspaceProjects: QueryAllWorkspaceProjects
|
||||
deleteProjectRole: DeleteProjectRole
|
||||
upsertProjectRole: UpsertProjectRole
|
||||
assignWorkspaceSeat: AssignWorkspaceSeat
|
||||
}) =>
|
||||
async ({
|
||||
userId,
|
||||
role,
|
||||
workspaceId,
|
||||
seatType,
|
||||
flags
|
||||
}: {
|
||||
userId: string
|
||||
role: WorkspaceRoles
|
||||
workspaceId: string
|
||||
seatType?: WorkspaceSeatType
|
||||
flags?: {
|
||||
skipProjectRoleUpdatesFor: string[]
|
||||
}
|
||||
@@ -276,6 +287,10 @@ export const onWorkspaceRoleUpdatedFactory =
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (FF_WORKSPACES_NEW_PLANS_ENABLED) {
|
||||
await assignWorkspaceSeat({ userId, workspaceId, type: seatType })
|
||||
}
|
||||
}
|
||||
|
||||
export const workspaceTrackingFactory =
|
||||
@@ -452,6 +467,21 @@ const emitWorkspaceGraphqlSubscriptionsFactory =
|
||||
}
|
||||
}
|
||||
|
||||
const onWorkspaceCreatedFactory =
|
||||
({ assignWorkspaceSeat }: { assignWorkspaceSeat: AssignWorkspaceSeat }) =>
|
||||
async ({
|
||||
workspace,
|
||||
createdByUserId
|
||||
}: {
|
||||
workspace: Workspace
|
||||
createdByUserId: string
|
||||
}) => {
|
||||
if (!FF_WORKSPACES_NEW_PLANS_ENABLED) {
|
||||
return
|
||||
}
|
||||
await assignWorkspaceSeat({ userId: createdByUserId, workspaceId: workspace.id })
|
||||
}
|
||||
|
||||
export const initializeEventListenersFactory =
|
||||
({ db }: { db: Knex }) =>
|
||||
() => {
|
||||
@@ -534,10 +564,24 @@ export const initializeEventListenersFactory =
|
||||
}),
|
||||
queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ getStreams }),
|
||||
deleteProjectRole: deleteProjectRoleFactory({ db: trx }),
|
||||
upsertProjectRole: upsertProjectRoleFactory({ db: trx })
|
||||
upsertProjectRole: upsertProjectRoleFactory({ db: trx }),
|
||||
assignWorkspaceSeat: assignWorkspaceSeatFactory({
|
||||
createWorkspaceSeat: createWorkspaceSeatFactory({ db: trx }),
|
||||
getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db: trx })
|
||||
})
|
||||
})
|
||||
await withTransaction(onWorkspaceRoleUpdated(payload), trx)
|
||||
}),
|
||||
eventBus.listen(WorkspaceEvents.Created, async ({ payload }) => {
|
||||
const trx = await db.transaction()
|
||||
const onWorkspaceCreated = onWorkspaceCreatedFactory({
|
||||
assignWorkspaceSeat: assignWorkspaceSeatFactory({
|
||||
createWorkspaceSeat: createWorkspaceSeatFactory({ db: trx }),
|
||||
getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db: trx })
|
||||
})
|
||||
})
|
||||
await withTransaction(onWorkspaceCreated(payload), trx)
|
||||
}),
|
||||
eventBus.listen('**', emitWorkspaceGraphqlSubscriptions)
|
||||
]
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ import { getInvitationTargetUsersFactory } from '@/modules/serverinvites/service
|
||||
import { authorizeResolver } from '@/modules/shared'
|
||||
import {
|
||||
getFeatureFlags,
|
||||
getServerOrigin,
|
||||
isRateLimiterEnabled
|
||||
} from '@/modules/shared/helpers/envHelper'
|
||||
import { getEventBus } from '@/modules/shared/services/eventBus'
|
||||
@@ -169,17 +168,12 @@ import {
|
||||
listWorkspaceSsoMembershipsFactory
|
||||
} from '@/modules/workspaces/repositories/sso'
|
||||
import { getDecryptor } from '@/modules/workspaces/helpers/sso'
|
||||
import { getWorkspaceFunctions } from '@/modules/automate/clients/executionEngine'
|
||||
import { getFunctionsFactory } from '@/modules/automate/clients/executionEngine'
|
||||
import {
|
||||
ExecutionEngineFailedResponseError,
|
||||
ExecutionEngineNetworkError
|
||||
} from '@/modules/automate/errors/executionEngine'
|
||||
import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regions'
|
||||
import {
|
||||
AuthCodePayloadAction,
|
||||
createStoredAuthCodeFactory
|
||||
} from '@/modules/automate/services/authCode'
|
||||
import { getGenericRedis } from '@/modules/shared/redis/redis'
|
||||
import { convertFunctionToGraphQLReturn } from '@/modules/automate/services/functionManagement'
|
||||
import {
|
||||
getWorkspacePlanFactory,
|
||||
@@ -202,6 +196,13 @@ import { OperationTypeNode } from 'graphql'
|
||||
import { updateWorkspacePlanFactory } from '@/modules/gatekeeper/services/workspacePlans'
|
||||
import { GetWorkspaceCollaboratorsArgs } from '@/modules/workspaces/domain/operations'
|
||||
import { WorkspaceTeamMember } from '@/modules/workspaces/domain/types'
|
||||
import { UsersMeta } from '@/modules/core/dbSchema'
|
||||
import { setUserActiveWorkspaceFactory } from '@/modules/workspaces/repositories/users'
|
||||
import { getGenericRedis } from '@/modules/shared/redis/redis'
|
||||
import {
|
||||
AuthCodePayloadAction,
|
||||
createStoredAuthCodeFactory
|
||||
} from '@/modules/automate/services/authCode'
|
||||
|
||||
const eventBus = getEventBus()
|
||||
const getServerInfo = getServerInfoFactory({ db })
|
||||
@@ -1079,6 +1080,13 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
},
|
||||
automateFunctions: async (parent, args, context) => {
|
||||
try {
|
||||
await authorizeResolver(
|
||||
context.userId,
|
||||
parent.id,
|
||||
Roles.Workspace.Member,
|
||||
context.resourceAccessRules
|
||||
)
|
||||
|
||||
const authCode = await createStoredAuthCodeFactory({
|
||||
redis: getGenericRedis()
|
||||
})({
|
||||
@@ -1086,14 +1094,18 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
action: AuthCodePayloadAction.ListWorkspaceFunctions
|
||||
})
|
||||
|
||||
const res = await getWorkspaceFunctions({
|
||||
workspaceId: parent.id,
|
||||
query: removeNullOrUndefinedKeys(args),
|
||||
body: {
|
||||
speckleServerAuthenticationPayload: {
|
||||
...authCode,
|
||||
origin: getServerOrigin()
|
||||
}
|
||||
const res = await getFunctionsFactory({
|
||||
logger: context.log
|
||||
})({
|
||||
auth: authCode,
|
||||
filters: {
|
||||
query: args.filter?.search ?? undefined,
|
||||
cursor: args.cursor ?? undefined,
|
||||
limit: args.limit,
|
||||
requireRelease: true,
|
||||
includeFeatured: true,
|
||||
includeWorkspaces: [parent.id],
|
||||
includeUsers: []
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1105,12 +1117,12 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
}
|
||||
}
|
||||
|
||||
const items = res.functions.map(convertFunctionToGraphQLReturn)
|
||||
const items = res.items.map(convertFunctionToGraphQLReturn)
|
||||
|
||||
return {
|
||||
cursor: undefined,
|
||||
totalCount: res.functions.length,
|
||||
items
|
||||
items,
|
||||
cursor: res.cursor,
|
||||
totalCount: res.totalCount
|
||||
}
|
||||
} catch (e) {
|
||||
const isNotFound =
|
||||
@@ -1296,6 +1308,26 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
})
|
||||
|
||||
return await getInvites(parent.id)
|
||||
},
|
||||
async activeWorkspace(parent, _args, ctx) {
|
||||
const metaVal = await ctx.loaders.users.getUserMeta.load({
|
||||
userId: parent.id,
|
||||
key: UsersMeta.metaKey.activeWorkspace
|
||||
})
|
||||
|
||||
if (!metaVal?.value) return null
|
||||
|
||||
return await getWorkspaceBySlugFactory({ db })({
|
||||
workspaceSlug: metaVal.value
|
||||
})
|
||||
},
|
||||
async isProjectsActive(parent, _args, ctx) {
|
||||
const metaVal = await ctx.loaders.users.getUserMeta.load({
|
||||
userId: parent.id,
|
||||
key: UsersMeta.metaKey.isProjectsActive
|
||||
})
|
||||
|
||||
return !!metaVal?.value
|
||||
}
|
||||
},
|
||||
Project: {
|
||||
@@ -1386,6 +1418,31 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
return team
|
||||
}
|
||||
},
|
||||
ActiveUserMutations: {
|
||||
async setActiveWorkspace(_parent, args, ctx) {
|
||||
const userId = ctx.userId
|
||||
if (!userId) return false
|
||||
|
||||
await Promise.all([
|
||||
ctx.loaders.users.getUserMeta.clear({
|
||||
userId,
|
||||
key: UsersMeta.metaKey.activeWorkspace
|
||||
}),
|
||||
ctx.loaders.users.getUserMeta.clear({
|
||||
userId,
|
||||
key: UsersMeta.metaKey.isProjectsActive
|
||||
})
|
||||
])
|
||||
|
||||
await setUserActiveWorkspaceFactory({ db })({
|
||||
userId,
|
||||
workspaceSlug: args.slug ?? null,
|
||||
isProjectsActive: !!args.isProjectsActive
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
Subscription: {
|
||||
workspaceProjectsUpdated: {
|
||||
subscribe: filteredSubscribe(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Users } from '@/modules/core/dbSchema'
|
||||
import { metaHelpers } from '@/modules/core/helpers/meta'
|
||||
import { SetUserActiveWorkspace } from '@/modules/workspaces/domain/operations'
|
||||
import { Knex } from 'knex'
|
||||
|
||||
export const setUserActiveWorkspaceFactory =
|
||||
(deps: { db: Knex }): SetUserActiveWorkspace =>
|
||||
async ({ userId, workspaceSlug, isProjectsActive = false }) => {
|
||||
const meta = metaHelpers(Users, deps.db)
|
||||
await Promise.all([
|
||||
meta.set(userId, 'activeWorkspace', workspaceSlug),
|
||||
meta.set(userId, 'isProjectsActive', isProjectsActive)
|
||||
])
|
||||
}
|
||||
@@ -147,6 +147,10 @@ export const approveWorkspaceJoinRequestFactory =
|
||||
await upsertWorkspaceRole({ userId, workspaceId, role, createdAt: new Date() })
|
||||
|
||||
await emit({ eventName: WorkspaceEvents.Updated, payload: { workspace } })
|
||||
await emit({
|
||||
eventName: WorkspaceEvents.RoleUpdated,
|
||||
payload: { workspaceId, userId, role }
|
||||
})
|
||||
|
||||
await sendWorkspaceJoinRequestApprovedEmail({
|
||||
workspace,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing'
|
||||
import { CreateWorkspaceSeat } from '@/modules/gatekeeper/domain/operations'
|
||||
import { NotFoundError } from '@/modules/shared/errors'
|
||||
import {
|
||||
AssignWorkspaceSeat,
|
||||
GetWorkspaceRoleForUser
|
||||
} from '@/modules/workspaces/domain/operations'
|
||||
import { InvalidWorkspaceSeatTypeError } from '@/modules/workspaces/errors/workspaceSeat'
|
||||
import { Roles, WorkspaceRoles } from '@speckle/shared'
|
||||
import { z } from 'zod'
|
||||
|
||||
const getDefaultWorkspaceSeatTypeByWorkspaceRole = ({
|
||||
workspaceRole
|
||||
}: {
|
||||
workspaceRole: WorkspaceRoles
|
||||
}): WorkspaceSeatType => {
|
||||
if (workspaceRole === Roles.Workspace.Admin) {
|
||||
return 'editor'
|
||||
}
|
||||
return 'viewer'
|
||||
}
|
||||
|
||||
const WorkspaceRoleWorkspaceSeatTypeMapping = z.union([
|
||||
z.object({
|
||||
workspaceRole: z.literal(Roles.Workspace.Admin),
|
||||
workspaceSeatType: z.literal('editor')
|
||||
}),
|
||||
z.object({
|
||||
workspaceRole: z.literal(Roles.Workspace.Member),
|
||||
workspaceSeatType: z.union([z.literal('editor'), z.literal('viewer')])
|
||||
}),
|
||||
z.object({
|
||||
workspaceRole: z.literal(Roles.Workspace.Guest),
|
||||
workspaceSeatType: z.union([z.literal('editor'), z.literal('viewer')])
|
||||
})
|
||||
])
|
||||
|
||||
type WorkspaceRoleWorkspaceSeatTypeMapping = z.infer<
|
||||
typeof WorkspaceRoleWorkspaceSeatTypeMapping
|
||||
>
|
||||
|
||||
export const isWorkspaceRoleWorkspaceSeatTypeValid = ({
|
||||
workspaceRole,
|
||||
workspaceSeatType
|
||||
}: {
|
||||
workspaceRole: WorkspaceRoles
|
||||
workspaceSeatType: WorkspaceSeatType
|
||||
}): boolean => {
|
||||
return WorkspaceRoleWorkspaceSeatTypeMapping.safeParse({
|
||||
workspaceRole,
|
||||
workspaceSeatType
|
||||
}).success
|
||||
}
|
||||
|
||||
export const assignWorkspaceSeatFactory =
|
||||
({
|
||||
createWorkspaceSeat,
|
||||
getWorkspaceRoleForUser
|
||||
}: {
|
||||
createWorkspaceSeat: CreateWorkspaceSeat
|
||||
getWorkspaceRoleForUser: GetWorkspaceRoleForUser
|
||||
}): AssignWorkspaceSeat =>
|
||||
async ({ workspaceId, userId, type }) => {
|
||||
const workspaceAcl = await getWorkspaceRoleForUser({ workspaceId, userId })
|
||||
if (!workspaceAcl) {
|
||||
throw new NotFoundError('User does not have a role in the workspace')
|
||||
}
|
||||
if (!type) {
|
||||
return await createWorkspaceSeat({
|
||||
workspaceId,
|
||||
userId,
|
||||
type: type
|
||||
? type
|
||||
: getDefaultWorkspaceSeatTypeByWorkspaceRole({
|
||||
workspaceRole: workspaceAcl.role
|
||||
})
|
||||
})
|
||||
}
|
||||
if (
|
||||
!isWorkspaceRoleWorkspaceSeatTypeValid({
|
||||
workspaceRole: workspaceAcl.role,
|
||||
workspaceSeatType: type
|
||||
})
|
||||
) {
|
||||
throw new InvalidWorkspaceSeatTypeError(
|
||||
`User with workspace role ${workspaceAcl.role} cannot have a seat of type ${type}`,
|
||||
{
|
||||
info: {
|
||||
workspaceId,
|
||||
userId
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return await createWorkspaceSeat({
|
||||
workspaceId,
|
||||
userId,
|
||||
type
|
||||
})
|
||||
}
|
||||
@@ -246,10 +246,6 @@ export const assignToWorkspace = async (
|
||||
user: BasicTestUser,
|
||||
role?: WorkspaceRoles
|
||||
) => {
|
||||
if (!FF_WORKSPACES_MODULE_ENABLED) {
|
||||
return // Just skip
|
||||
}
|
||||
|
||||
const updateWorkspaceRole = updateWorkspaceRoleFactory({
|
||||
getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }),
|
||||
findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createRandomEmail } from '@/modules/core/helpers/testHelpers'
|
||||
import {
|
||||
BasicTestWorkspace,
|
||||
createTestWorkspace
|
||||
} from '@/modules/workspaces/tests/helpers/creation'
|
||||
import { BasicTestUser, createTestUser } from '@/test/authHelper'
|
||||
import {
|
||||
SetUserActiveWorkspaceDocument,
|
||||
UserActiveResourcesDocument
|
||||
} from '@/test/graphql/generated/graphql'
|
||||
import { testApolloServer, TestApolloServer } from '@/test/graphqlHelper'
|
||||
import { beforeEachContext } from '@/test/hooks'
|
||||
import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper'
|
||||
import { expect } from 'chai'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
|
||||
describe('ActiveUserMutations.setActiveWorkspace', () => {
|
||||
let apollo: TestApolloServer
|
||||
|
||||
const user: BasicTestUser = {
|
||||
id: '',
|
||||
name: 'John Legacy Speckle',
|
||||
email: createRandomEmail()
|
||||
}
|
||||
|
||||
const workspace: BasicTestWorkspace = {
|
||||
id: '',
|
||||
ownerId: '',
|
||||
name: 'My Workspace',
|
||||
slug: ''
|
||||
}
|
||||
|
||||
const project: BasicTestStream = {
|
||||
id: '',
|
||||
ownerId: '',
|
||||
name: 'My Project',
|
||||
isPublic: true
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await beforeEachContext()
|
||||
await createTestUser(user)
|
||||
await createTestWorkspace(workspace, user)
|
||||
await createTestStream(project, user)
|
||||
|
||||
apollo = await testApolloServer({ authUserId: user.id })
|
||||
})
|
||||
|
||||
it('should accurately report active workspace', async () => {
|
||||
const resA = await apollo.execute(SetUserActiveWorkspaceDocument, {
|
||||
slug: workspace.slug
|
||||
})
|
||||
expect(resA).to.not.haveGraphQLErrors()
|
||||
|
||||
const resB = await apollo.execute(UserActiveResourcesDocument, {})
|
||||
expect(resB).to.not.haveGraphQLErrors()
|
||||
|
||||
expect(resB?.data?.activeUser?.activeWorkspace?.id).to.equal(workspace.id)
|
||||
})
|
||||
|
||||
it('should accurately report if last visited project is not a workspace project', async () => {
|
||||
const resA = await apollo.execute(SetUserActiveWorkspaceDocument, {
|
||||
slug: null,
|
||||
isProjectsActive: true
|
||||
})
|
||||
expect(resA).to.not.haveGraphQLErrors()
|
||||
|
||||
const resB = await apollo.execute(UserActiveResourcesDocument, {})
|
||||
expect(resB).to.not.haveGraphQLErrors()
|
||||
|
||||
expect(resB?.data?.activeUser?.isProjectsActive).to.be.true
|
||||
})
|
||||
|
||||
it('should allow values to be cleared with null input', async () => {
|
||||
const resA = await apollo.execute(SetUserActiveWorkspaceDocument, {
|
||||
slug: workspace.slug
|
||||
})
|
||||
expect(resA).to.not.haveGraphQLErrors()
|
||||
const resB = await apollo.execute(SetUserActiveWorkspaceDocument, { slug: null })
|
||||
expect(resB).to.not.haveGraphQLErrors()
|
||||
|
||||
const resC = await apollo.execute(UserActiveResourcesDocument, {})
|
||||
expect(resC).to.not.haveGraphQLErrors()
|
||||
|
||||
expect(resC.data?.activeUser?.activeWorkspace).to.be.null
|
||||
})
|
||||
|
||||
it('should return null if workspace is not found or was deleted', async () => {
|
||||
const resA = await apollo.execute(SetUserActiveWorkspaceDocument, {
|
||||
slug: cryptoRandomString({ length: 9 })
|
||||
})
|
||||
expect(resA).to.not.haveGraphQLErrors()
|
||||
|
||||
const resB = await apollo.execute(UserActiveResourcesDocument, {})
|
||||
expect(resB).to.not.haveGraphQLErrors()
|
||||
|
||||
expect(resB?.data?.activeUser?.activeWorkspace).to.be.null
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
import { db } from '@/db/knex'
|
||||
import {
|
||||
createRandomEmail,
|
||||
createRandomString
|
||||
} from '@/modules/core/helpers/testHelpers'
|
||||
import { createWorkspaceSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat'
|
||||
import { NotFoundError } from '@/modules/shared/errors'
|
||||
import { InvalidWorkspaceSeatTypeError } from '@/modules/workspaces/errors/workspaceSeat'
|
||||
import { getWorkspaceRoleForUserFactory } from '@/modules/workspaces/repositories/workspaces'
|
||||
import { assignWorkspaceSeatFactory } from '@/modules/workspaces/services/workspaceSeat'
|
||||
import {
|
||||
assignToWorkspace,
|
||||
BasicTestWorkspace,
|
||||
createTestWorkspace
|
||||
} from '@/modules/workspaces/tests/helpers/creation'
|
||||
import { expectToThrow } from '@/test/assertionHelper'
|
||||
import { BasicTestUser, createTestUser } from '@/test/authHelper'
|
||||
import { Roles } from '@speckle/shared'
|
||||
import { expect } from 'chai'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
|
||||
describe('Workspace workspaceSeat services', () => {
|
||||
describe('assignWorkspaceSeatFactory', () => {
|
||||
it('should throw an error if user is not a member of the workspace', async () => {
|
||||
const workspaceAdmin: BasicTestUser = {
|
||||
id: createRandomString(),
|
||||
name: createRandomString(),
|
||||
email: createRandomEmail(),
|
||||
role: Roles.Server.Admin,
|
||||
verified: true
|
||||
}
|
||||
await createTestUser(workspaceAdmin)
|
||||
|
||||
const workspace: BasicTestWorkspace = {
|
||||
id: createRandomString(),
|
||||
slug: createRandomString(),
|
||||
ownerId: workspaceAdmin.id,
|
||||
name: cryptoRandomString({ length: 6 }),
|
||||
description: cryptoRandomString({ length: 12 })
|
||||
}
|
||||
await createTestWorkspace(workspace, workspaceAdmin)
|
||||
|
||||
const user: BasicTestUser = {
|
||||
id: createRandomString(),
|
||||
name: createRandomString(),
|
||||
email: createRandomEmail(),
|
||||
role: Roles.Server.User,
|
||||
verified: true
|
||||
}
|
||||
await createTestUser(user)
|
||||
|
||||
const err = await expectToThrow(() =>
|
||||
assignWorkspaceSeatFactory({
|
||||
createWorkspaceSeat: createWorkspaceSeatFactory({ db }),
|
||||
getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db })
|
||||
})({ userId: user.id, workspaceId: workspace.id, type: 'editor' })
|
||||
)
|
||||
|
||||
expect(err.name).to.eq(NotFoundError.name)
|
||||
})
|
||||
it('should assign a workspace seat with the default type if none is provided', async () => {
|
||||
const workspaceAdmin: BasicTestUser = {
|
||||
id: createRandomString(),
|
||||
name: createRandomString(),
|
||||
email: createRandomEmail(),
|
||||
role: Roles.Server.Admin,
|
||||
verified: true
|
||||
}
|
||||
await createTestUser(workspaceAdmin)
|
||||
|
||||
const workspace: BasicTestWorkspace = {
|
||||
id: createRandomString(),
|
||||
slug: createRandomString(),
|
||||
ownerId: workspaceAdmin.id,
|
||||
name: cryptoRandomString({ length: 6 }),
|
||||
description: cryptoRandomString({ length: 12 })
|
||||
}
|
||||
await createTestWorkspace(workspace, workspaceAdmin)
|
||||
|
||||
const user: BasicTestUser = {
|
||||
id: createRandomString(),
|
||||
name: createRandomString(),
|
||||
email: createRandomEmail(),
|
||||
role: Roles.Server.User,
|
||||
verified: true
|
||||
}
|
||||
await createTestUser(user)
|
||||
|
||||
await assignToWorkspace(workspace, user, Roles.Workspace.Member)
|
||||
|
||||
await assignWorkspaceSeatFactory({
|
||||
createWorkspaceSeat: createWorkspaceSeatFactory({ db }),
|
||||
getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db })
|
||||
})({ userId: user.id, workspaceId: workspace.id })
|
||||
|
||||
const workspaceSeat = await db('workspace_seats')
|
||||
.where({ userId: user.id, workspaceId: workspace.id })
|
||||
.first()
|
||||
|
||||
expect(workspaceSeat.type).to.eq('viewer')
|
||||
})
|
||||
it('should assign a workspace seat with the provided type', async () => {
|
||||
const workspaceAdmin: BasicTestUser = {
|
||||
id: createRandomString(),
|
||||
name: createRandomString(),
|
||||
email: createRandomEmail(),
|
||||
role: Roles.Server.Admin,
|
||||
verified: true
|
||||
}
|
||||
await createTestUser(workspaceAdmin)
|
||||
|
||||
const workspace: BasicTestWorkspace = {
|
||||
id: createRandomString(),
|
||||
slug: createRandomString(),
|
||||
ownerId: workspaceAdmin.id,
|
||||
name: cryptoRandomString({ length: 6 }),
|
||||
description: cryptoRandomString({ length: 12 })
|
||||
}
|
||||
await createTestWorkspace(workspace, workspaceAdmin)
|
||||
|
||||
const user: BasicTestUser = {
|
||||
id: createRandomString(),
|
||||
name: createRandomString(),
|
||||
email: createRandomEmail(),
|
||||
role: Roles.Server.User,
|
||||
verified: true
|
||||
}
|
||||
await createTestUser(user)
|
||||
|
||||
await assignToWorkspace(workspace, user, Roles.Workspace.Member)
|
||||
|
||||
await assignWorkspaceSeatFactory({
|
||||
createWorkspaceSeat: createWorkspaceSeatFactory({ db }),
|
||||
getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db })
|
||||
})({ userId: user.id, workspaceId: workspace.id, type: 'editor' })
|
||||
|
||||
const workspaceSeat = await db('workspace_seats')
|
||||
.where({ userId: user.id, workspaceId: workspace.id })
|
||||
.first()
|
||||
|
||||
expect(workspaceSeat.type).to.eq('editor')
|
||||
})
|
||||
it('should throw an error if seat type is not compatible with workspace role', async () => {
|
||||
const workspaceAdmin: BasicTestUser = {
|
||||
id: createRandomString(),
|
||||
name: createRandomString(),
|
||||
email: createRandomEmail(),
|
||||
role: Roles.Server.Admin,
|
||||
verified: true
|
||||
}
|
||||
await createTestUser(workspaceAdmin)
|
||||
|
||||
const workspace: BasicTestWorkspace = {
|
||||
id: createRandomString(),
|
||||
slug: createRandomString(),
|
||||
ownerId: workspaceAdmin.id,
|
||||
name: cryptoRandomString({ length: 6 }),
|
||||
description: cryptoRandomString({ length: 12 })
|
||||
}
|
||||
await createTestWorkspace(workspace, workspaceAdmin)
|
||||
|
||||
const user: BasicTestUser = {
|
||||
id: createRandomString(),
|
||||
name: createRandomString(),
|
||||
email: createRandomEmail(),
|
||||
role: Roles.Server.User,
|
||||
verified: true
|
||||
}
|
||||
await createTestUser(user)
|
||||
|
||||
await assignToWorkspace(workspace, user, Roles.Workspace.Admin)
|
||||
|
||||
const err = await expectToThrow(() =>
|
||||
assignWorkspaceSeatFactory({
|
||||
createWorkspaceSeat: createWorkspaceSeatFactory({ db }),
|
||||
getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db })
|
||||
})({ userId: user.id, workspaceId: workspace.id, type: 'viewer' })
|
||||
)
|
||||
|
||||
expect(err.name).to.eq(InvalidWorkspaceSeatTypeError.name)
|
||||
})
|
||||
it('should update seat type on role change', async () => {
|
||||
const workspaceAdmin: BasicTestUser = {
|
||||
id: createRandomString(),
|
||||
name: createRandomString(),
|
||||
email: createRandomEmail(),
|
||||
role: Roles.Server.Admin,
|
||||
verified: true
|
||||
}
|
||||
await createTestUser(workspaceAdmin)
|
||||
|
||||
const workspace: BasicTestWorkspace = {
|
||||
id: createRandomString(),
|
||||
slug: createRandomString(),
|
||||
ownerId: workspaceAdmin.id,
|
||||
name: cryptoRandomString({ length: 6 }),
|
||||
description: cryptoRandomString({ length: 12 })
|
||||
}
|
||||
await createTestWorkspace(workspace, workspaceAdmin)
|
||||
|
||||
const user: BasicTestUser = {
|
||||
id: createRandomString(),
|
||||
name: createRandomString(),
|
||||
email: createRandomEmail(),
|
||||
role: Roles.Server.User,
|
||||
verified: true
|
||||
}
|
||||
await createTestUser(user)
|
||||
|
||||
await assignToWorkspace(workspace, user, Roles.Workspace.Member)
|
||||
const workspaceSeat = await db('workspace_seats')
|
||||
.where({ userId: user.id, workspaceId: workspace.id })
|
||||
.first()
|
||||
|
||||
expect(workspaceSeat.type).to.eq('viewer')
|
||||
|
||||
// Change workspace role
|
||||
await assignToWorkspace(workspace, user, Roles.Workspace.Admin)
|
||||
|
||||
const workspaceSeatUpdated = await db('workspace_seats')
|
||||
.where({ userId: user.id, workspaceId: workspace.id })
|
||||
.first()
|
||||
|
||||
expect(workspaceSeatUpdated.type).to.eq('editor')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -84,7 +84,8 @@ describe('Event handlers', () => {
|
||||
},
|
||||
upsertProjectRole: async () => {
|
||||
expect.fail()
|
||||
}
|
||||
},
|
||||
assignWorkspaceSeat: async () => undefined
|
||||
})({
|
||||
role: Roles.Workspace.Guest,
|
||||
userId: cryptoRandomString({ length: 10 }),
|
||||
@@ -123,7 +124,8 @@ describe('Event handlers', () => {
|
||||
storedRoles.push(args)
|
||||
trackProjectUpdate = trackProjectUpdate || options?.trackProjectUpdate
|
||||
return {} as StreamRecord
|
||||
}
|
||||
},
|
||||
assignWorkspaceSeat: async () => undefined
|
||||
})({
|
||||
role: Roles.Workspace.Member,
|
||||
userId,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { isWorkspaceRoleWorkspaceSeatTypeValid } from '@/modules/workspaces/services/workspaceSeat'
|
||||
import { Roles } from '@speckle/shared'
|
||||
import { expect } from 'chai'
|
||||
|
||||
describe('Workspace workspaceSeat services', () => {
|
||||
describe('isWorkspaceRoleWorkspaceSeatTypeValid', () => {
|
||||
it('should return true if the role is admin and seat type is editor', () => {
|
||||
const result = isWorkspaceRoleWorkspaceSeatTypeValid({
|
||||
workspaceRole: Roles.Workspace.Admin,
|
||||
workspaceSeatType: 'editor'
|
||||
})
|
||||
expect(result).to.be.true
|
||||
})
|
||||
it('should return false if the role is admin and seat type is viewer', () => {
|
||||
const result = isWorkspaceRoleWorkspaceSeatTypeValid({
|
||||
workspaceRole: Roles.Workspace.Admin,
|
||||
workspaceSeatType: 'viewer' as 'editor'
|
||||
})
|
||||
expect(result).to.be.false
|
||||
})
|
||||
it('should return true if the role is member and seat type is editor', () => {
|
||||
const result = isWorkspaceRoleWorkspaceSeatTypeValid({
|
||||
workspaceRole: Roles.Workspace.Member,
|
||||
workspaceSeatType: 'editor'
|
||||
})
|
||||
expect(result).to.be.true
|
||||
})
|
||||
it('should return true if the role is member and seat type is viewer', () => {
|
||||
const result = isWorkspaceRoleWorkspaceSeatTypeValid({
|
||||
workspaceRole: Roles.Workspace.Member,
|
||||
workspaceSeatType: 'viewer'
|
||||
})
|
||||
expect(result).to.be.true
|
||||
})
|
||||
it('should return true if the role is guest and seat type is editor', () => {
|
||||
const result = isWorkspaceRoleWorkspaceSeatTypeValid({
|
||||
workspaceRole: Roles.Workspace.Guest,
|
||||
workspaceSeatType: 'editor'
|
||||
})
|
||||
expect(result).to.be.true
|
||||
})
|
||||
it('should return true if the role is member and seat type is viewer', () => {
|
||||
const result = isWorkspaceRoleWorkspaceSeatTypeValid({
|
||||
workspaceRole: Roles.Workspace.Guest,
|
||||
workspaceSeatType: 'viewer'
|
||||
})
|
||||
expect(result).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,7 @@
|
||||
/* eslint-disable camelcase */
|
||||
import type { GraphQLContext } from '@/modules/shared/helpers/typeHelper'
|
||||
import type { ExecutionParams } from 'subscriptions-transport-ws'
|
||||
import {
|
||||
shouldLogAsInfoLevel,
|
||||
shouldLogAsWarnLevel
|
||||
} from '@/observability/utils/logLevels'
|
||||
import { logWithErr } from '@/observability/utils/logLevels'
|
||||
import { BaseError } from '@/modules/shared/errors'
|
||||
import { GraphQLError } from 'graphql'
|
||||
import { redactSensitiveVariables } from '@/observability/utils/redact'
|
||||
@@ -108,13 +105,7 @@ export function logSubscriptionOperation(params: {
|
||||
if (error instanceof BaseError) {
|
||||
errorLogger = errorLogger.child({ ...error.info() })
|
||||
}
|
||||
if (shouldLogAsInfoLevel(error)) {
|
||||
errorLogger.info({ err: error }, errMsg)
|
||||
} else if (shouldLogAsWarnLevel(error)) {
|
||||
errorLogger.warn({ err: error }, errMsg)
|
||||
} else {
|
||||
errorLogger.error({ err: error }, errMsg)
|
||||
}
|
||||
logWithErr(errorLogger, error, errMsg)
|
||||
}
|
||||
} else if (response?.data) {
|
||||
logger.info('GQL subscription event {graphql_operation_name} emitted')
|
||||
|
||||
+25
-22
@@ -1,15 +1,14 @@
|
||||
import prometheusClient from 'prom-client'
|
||||
import { Counter, Gauge, Registry } from 'prom-client'
|
||||
|
||||
let apolloSubscriptionMonitoringIsInitialized = false
|
||||
|
||||
let metricConnectCounter: prometheusClient.Counter<string>
|
||||
let metricConnectedClients: prometheusClient.Gauge<string>
|
||||
let metricSubscriptionTotalOperations: prometheusClient.Counter<'subscriptionType'>
|
||||
let metricSubscriptionTotalResponses: prometheusClient.Counter<
|
||||
'subscriptionType' | 'status'
|
||||
>
|
||||
let metricConnectCounter: Counter<string>
|
||||
let metricConnectedClients: Gauge<string>
|
||||
let metricSubscriptionTotalOperations: Counter<'subscriptionType'>
|
||||
let metricSubscriptionTotalResponses: Counter<'subscriptionType' | 'status'>
|
||||
|
||||
export const initApolloSubscriptionMonitoring = () => {
|
||||
export const initApolloSubscriptionMonitoring = (params: { registers: Registry[] }) => {
|
||||
const { registers } = params
|
||||
if (apolloSubscriptionMonitoringIsInitialized)
|
||||
return {
|
||||
metricConnectCounter,
|
||||
@@ -19,33 +18,37 @@ export const initApolloSubscriptionMonitoring = () => {
|
||||
}
|
||||
|
||||
// Init metrics
|
||||
prometheusClient.register.removeSingleMetric('speckle_server_apollo_connect')
|
||||
metricConnectCounter = new prometheusClient.Counter({
|
||||
registers.forEach((r) => r.removeSingleMetric('speckle_server_apollo_connect'))
|
||||
metricConnectCounter = new Counter({
|
||||
name: 'speckle_server_apollo_connect',
|
||||
help: 'Number of connects'
|
||||
help: 'Number of connects',
|
||||
registers
|
||||
})
|
||||
prometheusClient.register.removeSingleMetric('speckle_server_apollo_clients')
|
||||
metricConnectedClients = new prometheusClient.Gauge({
|
||||
registers.forEach((r) => r.removeSingleMetric('speckle_server_apollo_clients'))
|
||||
metricConnectedClients = new Gauge({
|
||||
name: 'speckle_server_apollo_clients',
|
||||
help: 'Number of currently connected clients'
|
||||
help: 'Number of currently connected clients',
|
||||
registers
|
||||
})
|
||||
|
||||
prometheusClient.register.removeSingleMetric(
|
||||
'speckle_server_apollo_graphql_total_subscription_operations'
|
||||
registers.forEach((r) =>
|
||||
r.removeSingleMetric('speckle_server_apollo_graphql_total_subscription_operations')
|
||||
)
|
||||
metricSubscriptionTotalOperations = new prometheusClient.Counter({
|
||||
metricSubscriptionTotalOperations = new Counter({
|
||||
name: 'speckle_server_apollo_graphql_total_subscription_operations',
|
||||
help: 'Number of total subscription operations served by this instance',
|
||||
labelNames: ['subscriptionType'] as const
|
||||
labelNames: ['subscriptionType'] as const,
|
||||
registers
|
||||
})
|
||||
|
||||
prometheusClient.register.removeSingleMetric(
|
||||
'speckle_server_apollo_graphql_total_subscription_responses'
|
||||
registers.forEach((r) =>
|
||||
r.removeSingleMetric('speckle_server_apollo_graphql_total_subscription_responses')
|
||||
)
|
||||
metricSubscriptionTotalResponses = new prometheusClient.Counter({
|
||||
metricSubscriptionTotalResponses = new Counter({
|
||||
name: 'speckle_server_apollo_graphql_total_subscription_responses',
|
||||
help: 'Number of total subscription responses served by this instance',
|
||||
labelNames: ['subscriptionType', 'status'] as const
|
||||
labelNames: ['subscriptionType', 'status'] as const,
|
||||
registers
|
||||
})
|
||||
|
||||
apolloSubscriptionMonitoringIsInitialized = true
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
/* istanbul ignore file */
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import prometheusClient from 'prom-client'
|
||||
import type express from 'express'
|
||||
import { Counter, type Registry } from 'prom-client'
|
||||
import type { ErrorRequestHandler } from 'express'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let metricErrorCount: Nullable<prometheusClient.Counter<any>> = null
|
||||
let metricErrorCount: Nullable<Counter<'route'>> = null
|
||||
|
||||
export const errorMetricsMiddleware: express.ErrorRequestHandler = (
|
||||
err,
|
||||
req,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
export const errorMetricsMiddlewareFactory: (params: {
|
||||
promRegisters: Registry[]
|
||||
}) => ErrorRequestHandler = (params) => (err, req, res, next) => {
|
||||
if (metricErrorCount === null) {
|
||||
metricErrorCount = new prometheusClient.Counter({
|
||||
params.promRegisters.forEach((register) => {
|
||||
register.removeSingleMetric('speckle_server_request_errors')
|
||||
})
|
||||
metricErrorCount = new Counter({
|
||||
name: 'speckle_server_request_errors',
|
||||
help: 'Number of requests that threw exceptions',
|
||||
labelNames: ['route']
|
||||
labelNames: ['route'],
|
||||
registers: params.promRegisters
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -40,14 +40,11 @@ export const enterNewRequestContext = (params: { reqId: string }) => {
|
||||
|
||||
export const getRequestContext = () => storage?.getStore()
|
||||
|
||||
export const maybeLoggerWithContext = ({ logger }: { logger?: Logger }) => {
|
||||
export const loggerWithMaybeContext = ({ logger }: { logger: Logger }) => {
|
||||
const reqCtx = getRequestContext()
|
||||
return logger?.child({
|
||||
...(reqCtx
|
||||
? {
|
||||
req: { id: reqCtx.requestId },
|
||||
dbMetrics: reqCtx.dbMetrics
|
||||
}
|
||||
: {})
|
||||
if (!reqCtx) return logger
|
||||
return logger.child({
|
||||
req: { id: reqCtx.requestId },
|
||||
dbMetrics: reqCtx.dbMetrics
|
||||
})
|
||||
}
|
||||
|
||||
@@ -41,15 +41,17 @@ type MetricConfig = {
|
||||
}
|
||||
|
||||
export const heapSizeAndUsed = (
|
||||
registry: Registry,
|
||||
registers: Registry[],
|
||||
config: MetricConfig = {}
|
||||
): Metric => {
|
||||
const registers = registry ? [registry] : undefined
|
||||
const namePrefix = config.prefix ?? ''
|
||||
const labels = config.labels ?? {}
|
||||
const labelNames = Object.keys(labels)
|
||||
const buckets = { ...DEFAULT_NODEJS_HEAP_SIZE_BUCKETS, ...config.buckets }
|
||||
|
||||
registers.forEach((r) => {
|
||||
r.removeSingleMetric(namePrefix + NODEJS_HEAP_SIZE_TOTAL)
|
||||
})
|
||||
const heapSizeTotal = new Histogram({
|
||||
name: namePrefix + NODEJS_HEAP_SIZE_TOTAL,
|
||||
help: 'Process heap size from Node.js in bytes. This data is collected at a higher frequency than Prometheus scrapes, and is presented as a Histogram.',
|
||||
@@ -57,6 +59,10 @@ export const heapSizeAndUsed = (
|
||||
buckets: buckets.NODEJS_HEAP_SIZE_TOTAL,
|
||||
labelNames
|
||||
})
|
||||
|
||||
registers.forEach((r) => {
|
||||
r.removeSingleMetric(namePrefix + NODEJS_HEAP_SIZE_USED)
|
||||
})
|
||||
const heapSizeUsed = new Histogram({
|
||||
name: namePrefix + NODEJS_HEAP_SIZE_USED,
|
||||
help: 'Process heap size used from Node.js in bytes. This data is collected at a higher frequency than Prometheus scrapes, and is presented as a Histogram.',
|
||||
@@ -64,6 +70,10 @@ export const heapSizeAndUsed = (
|
||||
buckets: buckets.NODEJS_HEAP_SIZE_USED,
|
||||
labelNames
|
||||
})
|
||||
|
||||
registers.forEach((r) => {
|
||||
r.removeSingleMetric(namePrefix + NODEJS_EXTERNAL_MEMORY)
|
||||
})
|
||||
const externalMemUsed = new Histogram({
|
||||
name: namePrefix + NODEJS_EXTERNAL_MEMORY,
|
||||
help: 'Node.js external memory size in bytes. This data is collected at a higher frequency than Prometheus scrapes, and is presented as a Histogram.',
|
||||
|
||||
+8
-6
@@ -24,23 +24,25 @@ type HighFrequencyMonitor = {
|
||||
}
|
||||
|
||||
export const initHighFrequencyMonitoring = (params: {
|
||||
register: Registry
|
||||
registers: Registry[]
|
||||
collectionPeriodMilliseconds: number
|
||||
config: MetricConfig
|
||||
}): HighFrequencyMonitor => {
|
||||
const { register, collectionPeriodMilliseconds } = params
|
||||
const { registers, collectionPeriodMilliseconds } = params
|
||||
const config = params.config
|
||||
const registers = register ? [register] : undefined
|
||||
const namePrefix = config.prefix ?? ''
|
||||
const labels = config.labels ?? {}
|
||||
const labelNames = Object.keys(labels)
|
||||
|
||||
const metrics = [
|
||||
processCpuTotal(register, config),
|
||||
heapSizeAndUsed(register, config),
|
||||
knexConnections(register, config)
|
||||
processCpuTotal(registers, config),
|
||||
heapSizeAndUsed(registers, config),
|
||||
knexConnections(registers, config)
|
||||
]
|
||||
|
||||
registers.forEach((r) => {
|
||||
r.removeSingleMetric(namePrefix + 'self_monitor_time_high_frequency')
|
||||
})
|
||||
const selfMonitor = new Histogram({
|
||||
name: namePrefix + 'self_monitor_time_high_frequency',
|
||||
help: 'The time taken to collect all of the high frequency metrics, seconds.',
|
||||
|
||||
+22
-2
@@ -36,13 +36,18 @@ type MetricConfig = {
|
||||
>
|
||||
}
|
||||
|
||||
export const knexConnections = (registry: Registry, config: MetricConfig): Metric => {
|
||||
const registers = registry ? [registry] : undefined
|
||||
export const knexConnections = (
|
||||
registers: Registry[],
|
||||
config: MetricConfig
|
||||
): Metric => {
|
||||
const namePrefix = config.prefix ?? ''
|
||||
const labels = config.labels ?? {}
|
||||
const labelNames = [...Object.keys(labels), 'region']
|
||||
const buckets = { ...DEFAULT_KNEX_TOTAL_BUCKETS, ...config.buckets }
|
||||
|
||||
registers.forEach((r) => {
|
||||
r.removeSingleMetric(namePrefix + KNEX_CONNECTIONS_FREE)
|
||||
})
|
||||
const knexConnectionsFree = new Histogram({
|
||||
name: namePrefix + KNEX_CONNECTIONS_FREE,
|
||||
help: 'Number of free DB connections. This data is collected at a higher frequency than Prometheus scrapes, and is presented as a Histogram.',
|
||||
@@ -51,6 +56,9 @@ export const knexConnections = (registry: Registry, config: MetricConfig): Metri
|
||||
labelNames
|
||||
})
|
||||
|
||||
registers.forEach((r) => {
|
||||
r.removeSingleMetric(namePrefix + KNEX_CONNECTIONS_USED)
|
||||
})
|
||||
const knexConnectionsUsed = new Histogram({
|
||||
name: namePrefix + KNEX_CONNECTIONS_USED,
|
||||
help: 'Number of used DB connections',
|
||||
@@ -59,6 +67,9 @@ export const knexConnections = (registry: Registry, config: MetricConfig): Metri
|
||||
labelNames
|
||||
})
|
||||
|
||||
registers.forEach((r) => {
|
||||
r.removeSingleMetric(namePrefix + KNEX_PENDING_ACQUIRES)
|
||||
})
|
||||
const knexPendingAcquires = new Histogram({
|
||||
name: namePrefix + KNEX_PENDING_ACQUIRES,
|
||||
help: 'Number of pending DB connection aquires',
|
||||
@@ -67,6 +78,9 @@ export const knexConnections = (registry: Registry, config: MetricConfig): Metri
|
||||
labelNames
|
||||
})
|
||||
|
||||
registers.forEach((r) => {
|
||||
r.removeSingleMetric(namePrefix + KNEX_PENDING_CREATES)
|
||||
})
|
||||
const knexPendingCreates = new Histogram({
|
||||
name: namePrefix + KNEX_PENDING_CREATES,
|
||||
help: 'Number of pending DB connection creates',
|
||||
@@ -75,6 +89,9 @@ export const knexConnections = (registry: Registry, config: MetricConfig): Metri
|
||||
labelNames
|
||||
})
|
||||
|
||||
registers.forEach((r) => {
|
||||
r.removeSingleMetric(namePrefix + KNEX_PENDING_VALIDATIONS)
|
||||
})
|
||||
const knexPendingValidations = new Histogram({
|
||||
name: namePrefix + KNEX_PENDING_VALIDATIONS,
|
||||
help: 'Number of pending DB connection validations. This is a state between pending acquisition and acquiring a connection.',
|
||||
@@ -83,6 +100,9 @@ export const knexConnections = (registry: Registry, config: MetricConfig): Metri
|
||||
labelNames
|
||||
})
|
||||
|
||||
registers.forEach((r) => {
|
||||
r.removeSingleMetric(namePrefix + KNEX_REMAINING_CAPACITY)
|
||||
})
|
||||
const knexRemainingCapacity = new Histogram({
|
||||
name: namePrefix + KNEX_REMAINING_CAPACITY,
|
||||
help: 'Remaining capacity of the DB connection pool',
|
||||
|
||||
@@ -41,15 +41,17 @@ type MetricConfig = {
|
||||
}
|
||||
|
||||
export const processCpuTotal = (
|
||||
registry: Registry,
|
||||
registers: Registry[],
|
||||
config: MetricConfig = {}
|
||||
): Metric => {
|
||||
const registers = registry ? [registry] : undefined
|
||||
const namePrefix = config.prefix ?? ''
|
||||
const labels = config.labels ?? {}
|
||||
const labelNames = Object.keys(labels)
|
||||
const buckets = { ...DEFAULT_CPU_TOTAL_BUCKETS, ...config.buckets }
|
||||
|
||||
registers.forEach((r) => {
|
||||
r.removeSingleMetric(namePrefix + PROCESS_CPU_USER_SECONDS)
|
||||
})
|
||||
const cpuUserUsageHistogram = new Histogram({
|
||||
name: namePrefix + PROCESS_CPU_USER_SECONDS,
|
||||
help: 'Total user CPU time spent in seconds. This data is collected at a higher frequency than Prometheus scrapes, and is presented as a Histogram.',
|
||||
@@ -57,6 +59,9 @@ export const processCpuTotal = (
|
||||
buckets: buckets.PROCESS_CPU_USER_SECONDS,
|
||||
registers
|
||||
})
|
||||
registers.forEach((r) => {
|
||||
r.removeSingleMetric(namePrefix + PROCESS_CPU_SYSTEM_SECONDS)
|
||||
})
|
||||
const cpuSystemUsageHistogram = new Histogram({
|
||||
name: namePrefix + PROCESS_CPU_SYSTEM_SECONDS,
|
||||
help: 'Total system CPU time spent in seconds. This data is collected at a higher frequency than Prometheus scrapes, and is presented as a Histogram.',
|
||||
@@ -64,6 +69,9 @@ export const processCpuTotal = (
|
||||
buckets: buckets.PROCESS_CPU_SYSTEM_SECONDS,
|
||||
labelNames
|
||||
})
|
||||
registers.forEach((r) => {
|
||||
r.removeSingleMetric(namePrefix + PROCESS_CPU_SECONDS)
|
||||
})
|
||||
const cpuUsageHistogram = new Histogram({
|
||||
name: namePrefix + PROCESS_CPU_SECONDS,
|
||||
help: 'Total user and system CPU time spent in seconds. This data is collected at a higher frequency than Prometheus scrapes, and is presented as a Histogram.',
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
/* istanbul ignore file */
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import prometheusClient from 'prom-client'
|
||||
import type http from 'http'
|
||||
import { Registry, Gauge } from 'prom-client'
|
||||
import type { Server } from 'http'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let metricActiveConnections: Nullable<prometheusClient.Gauge<any>> = null
|
||||
let metricActiveConnections: Nullable<Gauge<any>> = null
|
||||
|
||||
export const monitorActiveConnections = (httpServer: http.Server) => {
|
||||
export const monitorActiveConnections = (params: {
|
||||
httpServer: Server
|
||||
registers: Registry[]
|
||||
}) => {
|
||||
const { httpServer, registers } = params
|
||||
if (metricActiveConnections !== null) {
|
||||
prometheusClient.register.removeSingleMetric('speckle_server_active_connections')
|
||||
registers.forEach((r) => r.removeSingleMetric('speckle_server_active_connections'))
|
||||
}
|
||||
|
||||
metricActiveConnections = new prometheusClient.Gauge({
|
||||
metricActiveConnections = new Gauge({
|
||||
name: 'speckle_server_active_connections',
|
||||
help: 'Number of active http connections',
|
||||
async collect() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import prometheusClient, { type Registry } from 'prom-client'
|
||||
import { type Registry, Summary, Counter, Gauge, Histogram } from 'prom-client'
|
||||
import { numberOfFreeConnections } from '@/modules/shared/helpers/dbHelper'
|
||||
import { type Knex } from 'knex'
|
||||
import { Logger } from 'pino'
|
||||
@@ -7,12 +7,12 @@ import { omit } from 'lodash'
|
||||
import { getRequestContext } from '@/observability/components/express/requestContext'
|
||||
import { collectLongTrace } from '@speckle/shared'
|
||||
|
||||
let metricQueryDuration: prometheusClient.Summary<string>
|
||||
let metricQueryErrors: prometheusClient.Counter<string>
|
||||
let metricConnectionAcquisitionDuration: prometheusClient.Histogram<string>
|
||||
let metricConnectionPoolErrors: prometheusClient.Counter<string>
|
||||
let metricConnectionInUseDuration: prometheusClient.Histogram<string>
|
||||
let metricConnectionPoolReapingDuration: prometheusClient.Histogram<string>
|
||||
let metricQueryDuration: Summary<string>
|
||||
let metricQueryErrors: Counter<string>
|
||||
let metricConnectionAcquisitionDuration: Histogram<string>
|
||||
let metricConnectionPoolErrors: Counter<string>
|
||||
let metricConnectionInUseDuration: Histogram<string>
|
||||
let metricConnectionPoolReapingDuration: Histogram<string>
|
||||
const initializedRegions: string[] = []
|
||||
let initializedPollingMetrics = false
|
||||
|
||||
@@ -20,13 +20,16 @@ export const initKnexPrometheusMetrics = async (params: {
|
||||
getAllDbClients: () => Promise<
|
||||
Array<{ client: Knex; isMain: boolean; regionKey: string }>
|
||||
>
|
||||
register: Registry
|
||||
registers: Registry[]
|
||||
logger: Logger
|
||||
}) => {
|
||||
const { registers } = params
|
||||
if (!initializedPollingMetrics) {
|
||||
initializedPollingMetrics = true
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
|
||||
registers.forEach((r) => r.removeSingleMetric('speckle_server_knex_free'))
|
||||
new Gauge({
|
||||
registers,
|
||||
name: 'speckle_server_knex_free',
|
||||
labelNames: ['region'],
|
||||
help: 'Number of free DB connections',
|
||||
@@ -40,8 +43,9 @@ export const initKnexPrometheusMetrics = async (params: {
|
||||
}
|
||||
})
|
||||
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
registers.forEach((r) => r.removeSingleMetric('speckle_server_knex_used'))
|
||||
new Gauge({
|
||||
registers,
|
||||
name: 'speckle_server_knex_used',
|
||||
labelNames: ['region'],
|
||||
help: 'Number of used DB connections',
|
||||
@@ -55,8 +59,9 @@ export const initKnexPrometheusMetrics = async (params: {
|
||||
}
|
||||
})
|
||||
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
registers.forEach((r) => r.removeSingleMetric('speckle_server_knex_pending'))
|
||||
new Gauge({
|
||||
registers,
|
||||
name: 'speckle_server_knex_pending',
|
||||
labelNames: ['region'],
|
||||
help: 'Number of pending DB connection aquires',
|
||||
@@ -70,8 +75,11 @@ export const initKnexPrometheusMetrics = async (params: {
|
||||
}
|
||||
})
|
||||
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
registers.forEach((r) =>
|
||||
r.removeSingleMetric('speckle_server_knex_pending_creates')
|
||||
)
|
||||
new Gauge({
|
||||
registers,
|
||||
name: 'speckle_server_knex_pending_creates',
|
||||
labelNames: ['region'],
|
||||
help: 'Number of pending DB connection creates',
|
||||
@@ -85,8 +93,11 @@ export const initKnexPrometheusMetrics = async (params: {
|
||||
}
|
||||
})
|
||||
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
registers.forEach((r) =>
|
||||
r.removeSingleMetric('speckle_server_knex_pending_validations')
|
||||
)
|
||||
new Gauge({
|
||||
registers,
|
||||
name: 'speckle_server_knex_pending_validations',
|
||||
labelNames: ['region'],
|
||||
help: 'Number of pending DB connection validations. This is a state between pending acquisition and acquiring a connection.',
|
||||
@@ -100,8 +111,11 @@ export const initKnexPrometheusMetrics = async (params: {
|
||||
}
|
||||
})
|
||||
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
registers.forEach((r) =>
|
||||
r.removeSingleMetric('speckle_server_knex_remaining_capacity')
|
||||
)
|
||||
new Gauge({
|
||||
registers,
|
||||
name: 'speckle_server_knex_remaining_capacity',
|
||||
labelNames: ['region'],
|
||||
help: 'Remaining capacity of the DB connection pool',
|
||||
@@ -115,43 +129,57 @@ export const initKnexPrometheusMetrics = async (params: {
|
||||
}
|
||||
})
|
||||
|
||||
metricQueryDuration = new prometheusClient.Summary({
|
||||
registers: [params.register],
|
||||
registers.forEach((r) => r.removeSingleMetric('speckle_server_knex_query_duration'))
|
||||
metricQueryDuration = new Summary({
|
||||
registers,
|
||||
labelNames: ['sqlMethod', 'sqlNumberBindings', 'region'],
|
||||
name: 'speckle_server_knex_query_duration',
|
||||
help: 'Summary of the DB query durations in seconds'
|
||||
})
|
||||
|
||||
metricQueryErrors = new prometheusClient.Counter({
|
||||
registers: [params.register],
|
||||
registers.forEach((r) => r.removeSingleMetric('speckle_server_knex_query_errors'))
|
||||
metricQueryErrors = new Counter({
|
||||
registers,
|
||||
labelNames: ['sqlMethod', 'sqlNumberBindings', 'region'],
|
||||
name: 'speckle_server_knex_query_errors',
|
||||
help: 'Number of DB queries with errors'
|
||||
})
|
||||
|
||||
metricConnectionAcquisitionDuration = new prometheusClient.Histogram({
|
||||
registers: [params.register],
|
||||
registers.forEach((r) =>
|
||||
r.removeSingleMetric('speckle_server_knex_connection_acquisition_duration')
|
||||
)
|
||||
metricConnectionAcquisitionDuration = new Histogram({
|
||||
registers,
|
||||
name: 'speckle_server_knex_connection_acquisition_duration',
|
||||
labelNames: ['region'],
|
||||
help: 'Summary of the DB connection acquisition duration, from request to acquire connection from pool until successfully acquired, in seconds'
|
||||
})
|
||||
|
||||
metricConnectionPoolErrors = new prometheusClient.Counter({
|
||||
registers: [params.register],
|
||||
registers.forEach((r) =>
|
||||
r.removeSingleMetric('speckle_server_knex_connection_acquisition_errors')
|
||||
)
|
||||
metricConnectionPoolErrors = new Counter({
|
||||
registers,
|
||||
name: 'speckle_server_knex_connection_acquisition_errors',
|
||||
labelNames: ['region'],
|
||||
help: 'Number of DB connection pool acquisition errors'
|
||||
})
|
||||
|
||||
metricConnectionInUseDuration = new prometheusClient.Histogram({
|
||||
registers: [params.register],
|
||||
registers.forEach((r) =>
|
||||
r.removeSingleMetric('speckle_server_knex_connection_usage_duration')
|
||||
)
|
||||
metricConnectionInUseDuration = new Histogram({
|
||||
registers,
|
||||
name: 'speckle_server_knex_connection_usage_duration',
|
||||
labelNames: ['region'],
|
||||
help: 'Summary of the DB connection duration, from successful acquisition of connection from pool until release back to pool, in seconds'
|
||||
})
|
||||
|
||||
metricConnectionPoolReapingDuration = new prometheusClient.Histogram({
|
||||
registers: [params.register],
|
||||
registers.forEach((r) =>
|
||||
r.removeSingleMetric('speckle_server_knex_connection_pool_reaping_duration')
|
||||
)
|
||||
metricConnectionPoolReapingDuration = new Histogram({
|
||||
registers,
|
||||
name: 'speckle_server_knex_connection_pool_reaping_duration',
|
||||
labelNames: ['region'],
|
||||
help: 'Summary of the DB connection pool reaping duration, in seconds. Reaping is the process of removing idle connections from the pool.'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Helper constants for log fields.
|
||||
* Intended to be used as values when logging.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Operation status values.
|
||||
* Intended to be used with the `operationStatus` field when logging.
|
||||
* Helps to avoid typos and ensure consistency.
|
||||
*/
|
||||
const STATUS = {
|
||||
START: 'start',
|
||||
SUCCESS: 'success',
|
||||
FAILURE: 'failure'
|
||||
} as const
|
||||
|
||||
export const OperationStatus = {
|
||||
start: {
|
||||
operationStatus: STATUS.START
|
||||
},
|
||||
success: {
|
||||
operationStatus: STATUS.SUCCESS
|
||||
},
|
||||
failure: {
|
||||
operationStatus: STATUS.FAILURE
|
||||
}
|
||||
} as const
|
||||
|
||||
export const OperationName = (name: string) => ({
|
||||
operationName: name
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
/* istanbul ignore file */
|
||||
import prometheusClient from 'prom-client'
|
||||
import prometheusClient, { Registry } from 'prom-client'
|
||||
import promBundle from 'express-prom-bundle'
|
||||
|
||||
import { initKnexPrometheusMetrics } from '@/observability/components/knex/knexMonitoring'
|
||||
@@ -10,18 +10,34 @@ import type express from 'express'
|
||||
import { getAllRegisteredDbClients } from '@/modules/multiregion/utils/dbSelector'
|
||||
|
||||
let prometheusInitialized = false
|
||||
let prometheusRegistryInitialized = false
|
||||
|
||||
export default async function (app: express.Express) {
|
||||
if (!prometheusInitialized) {
|
||||
prometheusInitialized = true
|
||||
/**
|
||||
* This has to be called prior to using Prometheus
|
||||
* @returns The registry of Prometheus metrics which will be served
|
||||
*/
|
||||
export function initPrometheusRegistry() {
|
||||
if (!prometheusRegistryInitialized) {
|
||||
prometheusRegistryInitialized = true
|
||||
prometheusClient.register.clear()
|
||||
prometheusClient.register.setDefaultLabels({
|
||||
project: 'speckle-server',
|
||||
app: 'server'
|
||||
})
|
||||
prometheusClient.collectDefaultMetrics()
|
||||
}
|
||||
|
||||
return prometheusClient.register
|
||||
}
|
||||
|
||||
export default async function (params: { app: express.Express; registry: Registry }) {
|
||||
const { app, registry } = params
|
||||
if (!prometheusInitialized) {
|
||||
prometheusInitialized = true
|
||||
prometheusClient.collectDefaultMetrics({
|
||||
register: registry
|
||||
})
|
||||
const highfrequencyMonitoring = initHighFrequencyMonitoring({
|
||||
register: prometheusClient.register,
|
||||
registers: [registry],
|
||||
collectionPeriodMilliseconds: highFrequencyMetricsCollectionPeriodMs(),
|
||||
config: {
|
||||
getDbClients: getAllRegisteredDbClients
|
||||
@@ -30,7 +46,7 @@ export default async function (app: express.Express) {
|
||||
highfrequencyMonitoring.start()
|
||||
|
||||
await initKnexPrometheusMetrics({
|
||||
register: prometheusClient.register,
|
||||
registers: [registry],
|
||||
getAllDbClients: getAllRegisteredDbClients,
|
||||
logger
|
||||
})
|
||||
@@ -41,7 +57,8 @@ export default async function (app: express.Express) {
|
||||
includePath: true,
|
||||
httpDurationMetricName: 'speckle_server_request_duration',
|
||||
metricType: 'summary',
|
||||
autoregister: false
|
||||
autoregister: false,
|
||||
promRegistry: registry
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -49,8 +66,8 @@ export default async function (app: express.Express) {
|
||||
// Expose prometheus metrics
|
||||
app.get('/metrics', async (req, res, next) => {
|
||||
try {
|
||||
res.set('Content-Type', prometheusClient.register.contentType)
|
||||
res.end(await prometheusClient.register.metrics())
|
||||
res.set('Content-Type', registry.contentType)
|
||||
res.end(await registry.metrics())
|
||||
} catch (ex: unknown) {
|
||||
res.status(500).end(ex instanceof Error ? ex.message : `${ex}`)
|
||||
next(ex)
|
||||
|
||||
@@ -35,6 +35,7 @@ export const testLogger = extendLoggerComponent(logger, 'test')
|
||||
export const fileUploadsLogger = extendLoggerComponent(logger, 'file-uploads')
|
||||
export const emailLogger = extendLoggerComponent(logger, 'email')
|
||||
export const taskSchedulerLogger = extendLoggerComponent(logger, 'task-scheduler')
|
||||
export const previewLogger = extendLoggerComponent(logger, 'preview')
|
||||
|
||||
export type Logger = typeof logger
|
||||
export { extendLoggerComponent, Observability }
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEachContext, initializeTestServer } from '@/test/hooks'
|
||||
import { expect } from 'chai'
|
||||
|
||||
describe('Observability', () => {
|
||||
describe('Metrics', () => {
|
||||
let serverAddress: string
|
||||
before(async () => {
|
||||
const ctx = await beforeEachContext()
|
||||
;({ serverAddress } = await initializeTestServer(ctx))
|
||||
})
|
||||
|
||||
describe('Register metrics', () => {
|
||||
let metricsPageBody = ''
|
||||
before(async () => {
|
||||
const metricsResponse = await fetch(`${serverAddress}/metrics`, {
|
||||
method: 'GET'
|
||||
})
|
||||
metricsPageBody = await metricsResponse.text()
|
||||
})
|
||||
const testCases = [
|
||||
'speckle_server_apollo_calls',
|
||||
'speckle_server_request_duration',
|
||||
'speckle_server_request_errors',
|
||||
'speckle_server_active_connections',
|
||||
'speckle_server_apollo_connect',
|
||||
'speckle_server_apollo_clients',
|
||||
'speckle_server_apollo_graphql_total_subscription_operations',
|
||||
'speckle_server_apollo_graphql_total_subscription_responses',
|
||||
'speckle_server_active_connections',
|
||||
'speckle_server_knex_free',
|
||||
'speckle_server_knex_used',
|
||||
'speckle_server_knex_pending',
|
||||
'speckle_server_knex_pending_creates',
|
||||
'speckle_server_knex_pending_validations',
|
||||
'speckle_server_knex_remaining_capacity',
|
||||
'speckle_server_knex_query_duration',
|
||||
'speckle_server_knex_query_errors',
|
||||
'speckle_server_knex_connection_acquisition_duration',
|
||||
'speckle_server_knex_connection_acquisition_errors',
|
||||
'speckle_server_knex_connection_usage_duration',
|
||||
'speckle_server_knex_connection_pool_reaping_duration',
|
||||
'nodejs_heap_size_total_bytes_high_frequency',
|
||||
'nodejs_heap_size_used_bytes_high_frequency',
|
||||
'nodejs_external_memory_bytes_high_frequency',
|
||||
'self_monitor_time_high_frequency',
|
||||
'knex_connections_free_high_frequency',
|
||||
'knex_connections_used_high_frequency',
|
||||
'knex_pending_acquires_high_frequency',
|
||||
'knex_pending_creates_high_frequency',
|
||||
'knex_pending_validations_high_frequency',
|
||||
'knex_remaining_capacity_high_frequency',
|
||||
'process_cpu_user_seconds_total_high_frequency',
|
||||
'process_cpu_system_seconds_total_high_frequency',
|
||||
'process_cpu_seconds_total_high_frequency'
|
||||
]
|
||||
|
||||
testCases.forEach((testCase) =>
|
||||
it(`should register metric ${testCase}`, async () => {
|
||||
const re = new RegExp(String.raw`(^${testCase}.*)\}\s([\d]+)$`, 'gm')
|
||||
const match = [...metricsPageBody.matchAll(re)]
|
||||
if (!match) {
|
||||
expect(match).not.to.be.null
|
||||
return '' //HACK force correct type below
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,28 @@
|
||||
import { BaseError } from '@/modules/shared/errors'
|
||||
import { isUserGraphqlError } from '@/modules/shared/helpers/graphqlHelper'
|
||||
import { ApolloError } from '@apollo/client/core'
|
||||
import { ensureError } from '@speckle/shared'
|
||||
import { GraphQLError } from 'graphql'
|
||||
import type { Logger } from 'pino'
|
||||
|
||||
interface LogFn {
|
||||
(logger: Logger, e: unknown, obj?: unknown, msg?: string, ...args: unknown[]): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the provided error to determine which log level to use, and adds the error to the logger instance.
|
||||
* @param logger The logger instance
|
||||
* @param e The error which will be used to determine the log level. It will be added to the logger instance.
|
||||
* @param obj The object providing additional context to the log message (see Pino documentation https://github.com/pinojs/pino/blob/main/docs/api.md#logging-method-parameters)
|
||||
* @param msg The message to log (see Pino documentation https://github.com/pinojs/pino/blob/main/docs/api.md#logging-method-parameters)
|
||||
* @param args Additional arguments to log (see Pino documentation https://github.com/pinojs/pino/blob/main/docs/api.md#logging-method-parameters)
|
||||
*/
|
||||
export const logWithErr: LogFn = (logger, e, obj, msg?, ...args) => {
|
||||
const err = ensureError(e)
|
||||
if (shouldLogAsInfoLevel(err)) return logger.child({ err }).info(obj, msg, ...args)
|
||||
if (shouldLogAsWarnLevel(err)) return logger.child({ err }).warn(obj, msg, ...args)
|
||||
return logger.child({ err }).error(obj, msg, ...args)
|
||||
}
|
||||
|
||||
export const shouldLogAsInfoLevel = (err: unknown): boolean => {
|
||||
if (err instanceof GraphQLError) {
|
||||
@@ -22,7 +43,7 @@ export const shouldLogAsInfoLevel = (err: unknown): boolean => {
|
||||
return err instanceof ApolloError
|
||||
}
|
||||
|
||||
export const shouldLogAsWarnLevel = (err: unknown): boolean => {
|
||||
const shouldLogAsWarnLevel = (err: unknown): boolean => {
|
||||
if (!(err instanceof GraphQLError)) return false
|
||||
|
||||
if (err.message.startsWith('Cannot return null for non-nullable field')) return true
|
||||
|
||||
@@ -26,6 +26,7 @@ export type ActiveUserMutations = {
|
||||
emailMutations: UserEmailMutations;
|
||||
/** Mark onboarding as complete */
|
||||
finishOnboarding: Scalars['Boolean']['output'];
|
||||
setActiveWorkspace: Scalars['Boolean']['output'];
|
||||
/** Edit a user's profile */
|
||||
update: User;
|
||||
};
|
||||
@@ -36,6 +37,12 @@ export type ActiveUserMutationsFinishOnboardingArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type ActiveUserMutationsSetActiveWorkspaceArgs = {
|
||||
isProjectsActive?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
slug?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type ActiveUserMutationsUpdateArgs = {
|
||||
user: UserUpdateInput;
|
||||
};
|
||||
@@ -247,6 +254,11 @@ export type AutomateAuthCodePayloadTest = {
|
||||
workspaceId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
/** Additional resources to validate user access to. */
|
||||
export type AutomateAuthCodeResources = {
|
||||
workspaceId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type AutomateFunction = {
|
||||
__typename?: 'AutomateFunction';
|
||||
/** Only returned if user is a part of this speckle server */
|
||||
@@ -2729,6 +2741,7 @@ export type QueryAutomateFunctionsArgs = {
|
||||
|
||||
export type QueryAutomateValidateAuthCodeArgs = {
|
||||
payload: AutomateAuthCodePayloadTest;
|
||||
resources?: InputMaybe<AutomateAuthCodeResources>;
|
||||
};
|
||||
|
||||
|
||||
@@ -2934,6 +2947,8 @@ export type ServerAutomateInfo = {
|
||||
export type ServerConfiguration = {
|
||||
__typename?: 'ServerConfiguration';
|
||||
blobSizeLimitBytes: Scalars['Int']['output'];
|
||||
/** Whether the email feature is enabled on this server */
|
||||
isEmailEnabled: Scalars['Boolean']['output'];
|
||||
objectMultipartUploadSizeLimitBytes: Scalars['Int']['output'];
|
||||
objectSizeLimitBytes: Scalars['Int']['output'];
|
||||
};
|
||||
@@ -3697,6 +3712,8 @@ export type UpgradePlanInput = {
|
||||
*/
|
||||
export type User = {
|
||||
__typename?: 'User';
|
||||
/** The last-visited workspace for the given user */
|
||||
activeWorkspace?: Maybe<Workspace>;
|
||||
/**
|
||||
* All the recent activity from this user in chronological order
|
||||
* @deprecated Part of the old API surface and will be removed in the future.
|
||||
@@ -3744,6 +3761,8 @@ export type User = {
|
||||
id: Scalars['ID']['output'];
|
||||
/** Whether post-sign up onboarding has been finished or skipped entirely */
|
||||
isOnboardingFinished?: Maybe<Scalars['Boolean']['output']>;
|
||||
/** Returns `true` if last visited project was "legacy" "personal project" outside of a workspace */
|
||||
isProjectsActive?: Maybe<Scalars['Boolean']['output']>;
|
||||
name: Scalars['String']['output'];
|
||||
notificationPreferences: Scalars['JSONObject']['output'];
|
||||
profiles?: Maybe<Scalars['JSONObject']['output']>;
|
||||
@@ -5712,6 +5731,19 @@ export type RequestVerificationMutationVariables = Exact<{ [key: string]: never;
|
||||
|
||||
export type RequestVerificationMutation = { __typename?: 'Mutation', requestVerification: boolean };
|
||||
|
||||
export type UserActiveResourcesQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type UserActiveResourcesQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', isProjectsActive?: boolean | null, activeWorkspace?: { __typename?: 'Workspace', id: string, name: string } | null } | null };
|
||||
|
||||
export type SetUserActiveWorkspaceMutationVariables = Exact<{
|
||||
slug?: InputMaybe<Scalars['String']['input']>;
|
||||
isProjectsActive?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type SetUserActiveWorkspaceMutation = { __typename?: 'Mutation', activeUserMutations: { __typename?: 'ActiveUserMutations', setActiveWorkspace: boolean } };
|
||||
|
||||
export type CreateProjectVersionMutationVariables = Exact<{
|
||||
input: CreateVersionInput;
|
||||
}>;
|
||||
@@ -5981,6 +6013,8 @@ export const GetOtherUserDocument = {"kind":"Document","definitions":[{"kind":"O
|
||||
export const GetAdminUsersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminUsers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"25"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"query"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}},"defaultValue":{"kind":"NullValue"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"adminUsers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}},{"kind":"Argument","name":{"kind":"Name","value":"query"},"value":{"kind":"Variable","name":{"kind":"Name","value":"query"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"registeredUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetAdminUsersQuery, GetAdminUsersQueryVariables>;
|
||||
export const GetPendingEmailVerificationStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPendingEmailVerificationStatus"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasPendingVerification"}}]}}]}}]} as unknown as DocumentNode<GetPendingEmailVerificationStatusQuery, GetPendingEmailVerificationStatusQueryVariables>;
|
||||
export const RequestVerificationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RequestVerification"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"requestVerification"}}]}}]} as unknown as DocumentNode<RequestVerificationMutation, RequestVerificationMutationVariables>;
|
||||
export const UserActiveResourcesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UserActiveResources"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeWorkspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isProjectsActive"}}]}}]}}]} as unknown as DocumentNode<UserActiveResourcesQuery, UserActiveResourcesQueryVariables>;
|
||||
export const SetUserActiveWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetUserActiveWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"slug"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"isProjectsActive"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUserMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setActiveWorkspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"slug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"slug"}}},{"kind":"Argument","name":{"kind":"Name","value":"isProjectsActive"},"value":{"kind":"Variable","name":{"kind":"Name","value":"isProjectsActive"}}}]}]}}]}}]} as unknown as DocumentNode<SetUserActiveWorkspaceMutation, SetUserActiveWorkspaceMutationVariables>;
|
||||
export const CreateProjectVersionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateProjectVersion"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateVersionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"versionMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"sourceApplication"}},{"kind":"Field","name":{"kind":"Name","value":"model"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}}]}}]}}]}}]} as unknown as DocumentNode<CreateProjectVersionMutation, CreateProjectVersionMutationVariables>;
|
||||
export const MarkProjectVersionReceivedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"MarkProjectVersionReceived"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"MarkReceivedVersionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"versionMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"markReceived"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode<MarkProjectVersionReceivedMutation, MarkProjectVersionReceivedMutationVariables>;
|
||||
export const CreateWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TestWorkspace"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}}]} as unknown as DocumentNode<CreateWorkspaceMutation, CreateWorkspaceMutationVariables>;
|
||||
|
||||
@@ -124,6 +124,26 @@ const requestVerificationMutation = gql`
|
||||
}
|
||||
`
|
||||
|
||||
export const getUserActiveResources = gql`
|
||||
query UserActiveResources {
|
||||
activeUser {
|
||||
activeWorkspace {
|
||||
id
|
||||
name
|
||||
}
|
||||
isProjectsActive
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const setUserActiveWorkspaceMutation = gql`
|
||||
mutation SetUserActiveWorkspace($slug: String, $isProjectsActive: Boolean) {
|
||||
activeUserMutations {
|
||||
setActiveWorkspace(slug: $slug, isProjectsActive: $isProjectsActive)
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const getActiveUser = (apollo: ExecuteOperationServer) =>
|
||||
executeOperation<GetActiveUserQuery, GetActiveUserQueryVariables>(
|
||||
apollo,
|
||||
|
||||
@@ -290,7 +290,7 @@ export const testApolloSubscriptionServer = async () => {
|
||||
set(mockWsServer, 'removeListener', mockWsServer.off.bind(mockWsServer)) // backwards compat w/ subscriptions-transport-ws
|
||||
|
||||
const mockWs = MockSocket.WebSocket as unknown as ws.WebSocket
|
||||
const apolloSubServer = buildApolloSubscriptionServer(mockWsServer)
|
||||
const apolloSubServer = buildApolloSubscriptionServer({ server: mockWsServer })
|
||||
|
||||
// weakRef to ensure we dont prevent garbage collection
|
||||
const clients: WeakRef<SubscriptionClient>[] = []
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
/* istanbul ignore file */
|
||||
// const { logger } = require('@/observability/logging')
|
||||
const crypto = require('crypto')
|
||||
|
||||
function generateManyObjects(shitTon, noise) {
|
||||
shitTon = shitTon || 10000
|
||||
noise = noise || Math.random() * 100
|
||||
|
||||
const objs = []
|
||||
|
||||
const base = { name: 'base bastard 2', noise, __closure: {} }
|
||||
// objs.push( base )
|
||||
let k = 0
|
||||
|
||||
for (let i = 0; i < shitTon; i++) {
|
||||
const baby = {
|
||||
name: `mr. ${i}`,
|
||||
nest: { duck: i % 2 === 0, mallard: 'falsey', arr: [i + 42, i, i] },
|
||||
test: { value: i, secondValue: 'mallard ' + (i % 10) },
|
||||
similar: k,
|
||||
even: i % 2 === 0,
|
||||
objArr: [{ a: i }, { b: i * i }, { c: true }],
|
||||
noise,
|
||||
sortValueA: i,
|
||||
sortValueB: i * 0.42 * i
|
||||
}
|
||||
if (i % 3 === 0) k++
|
||||
|
||||
getAnIdForThisOnePlease(baby)
|
||||
|
||||
base.__closure[baby.id] = 1
|
||||
|
||||
objs.push(baby)
|
||||
}
|
||||
|
||||
getAnIdForThisOnePlease(base)
|
||||
return { commit: base, objs }
|
||||
}
|
||||
|
||||
function createManyObjects(num, noise) {
|
||||
num = num || 10000
|
||||
noise = noise || Math.random() * 100
|
||||
|
||||
const objs = []
|
||||
|
||||
const base = { name: 'base bastard 2', noise, __closure: {} }
|
||||
objs.push(base)
|
||||
|
||||
for (let i = 0; i < num; i++) {
|
||||
const baby = {
|
||||
name: `mr. ${i}`,
|
||||
nest: { duck: i % 2 === 0, mallard: 'falsey', arr: [i + 42, i, i] }
|
||||
}
|
||||
getAnIdForThisOnePlease(baby)
|
||||
base.__closure[baby.id] = 1
|
||||
objs.push(baby)
|
||||
}
|
||||
getAnIdForThisOnePlease(base)
|
||||
return objs
|
||||
}
|
||||
|
||||
exports.createManyObjects = createManyObjects
|
||||
|
||||
function getAnIdForThisOnePlease(obj) {
|
||||
obj.id = obj.id || crypto.createHash('md5').update(JSON.stringify(obj)).digest('hex')
|
||||
}
|
||||
|
||||
exports.generateManyObjects = generateManyObjects
|
||||
exports.getAnIdForThisOnePlease = getAnIdForThisOnePlease
|
||||
|
||||
exports.sleep = (ms) => {
|
||||
// logger.debug( `\t Sleeping ${ms}ms ` )
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the response body for errors. To be used in expect assertions.
|
||||
* Will throw an error if 'errors' exist.
|
||||
* @param {*} res
|
||||
*/
|
||||
function noErrors(res) {
|
||||
if (res.error) throw new Error(`Failed GraphQL request: ${JSON.stringify(res.error)}`)
|
||||
if ('errors' in res.body)
|
||||
throw new Error(`Failed GraphQL request: ${JSON.stringify(res.body.errors)}`)
|
||||
}
|
||||
exports.noErrors = noErrors
|
||||
@@ -0,0 +1,112 @@
|
||||
import crypto from 'crypto'
|
||||
import { get } from 'lodash'
|
||||
|
||||
/**
|
||||
* Generates an object containing the base object and an array of objects with an id. The base object will have a closure property which references all the other objects.
|
||||
* @description Differs from createManyObjects in that it returns an object with a 'commit' property (the base object) and a separate 'objs' property (an array of children objects). It also adds more properties to the objects.
|
||||
* @param shitTon the number of objects to generate
|
||||
* @param noise Any data to be added to the objects. Defaults to a random number between 0 and 100, inclusive
|
||||
* @returns An object. The 'commit' property is the base object with a closure property which references all the other ('children') objects. The 'objs' property is an array of children objects.
|
||||
*/
|
||||
export function generateManyObjects(shitTon: number, noise?: unknown) {
|
||||
shitTon = shitTon || 10000
|
||||
noise = noise || Math.random() * 100
|
||||
|
||||
const objs = []
|
||||
|
||||
const base: {
|
||||
id?: string
|
||||
name: string
|
||||
noise: unknown
|
||||
__closure: Record<string, number>
|
||||
} = { name: 'base bastard 2', noise, __closure: {} }
|
||||
let k = 0
|
||||
|
||||
for (let i = 0; i < shitTon; i++) {
|
||||
const baby = {
|
||||
name: `mr. ${i}`,
|
||||
nest: { duck: i % 2 === 0, mallard: 'falsey', arr: [i + 42, i, i] },
|
||||
test: { value: i, secondValue: 'mallard ' + (i % 10) },
|
||||
similar: k,
|
||||
even: i % 2 === 0,
|
||||
objArr: [{ a: i }, { b: i * i }, { c: true }],
|
||||
noise,
|
||||
sortValueA: i,
|
||||
sortValueB: i * 0.42 * i
|
||||
}
|
||||
if (i % 3 === 0) k++
|
||||
|
||||
if (!getAnIdForThisOnePlease(baby)) continue //this will never be true, but typescript now knows baby definitely has an id
|
||||
|
||||
base.__closure[baby.id] = 1
|
||||
|
||||
objs.push(baby)
|
||||
}
|
||||
|
||||
if (!getAnIdForThisOnePlease(base)) throw new Error('base object has no id') //this will never be true, but typescript now knows base definitely has an id
|
||||
return { commit: base, objs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a bunch of objects with an id. The first object in the array will have a closure property which references all the other objects.
|
||||
* @description Differs from generateManyObjects in that it returns an array of objects, including a base object (at index 0).
|
||||
* @param num The number of objects to create.
|
||||
* @param noise Any arbitrary data which will be added to the objects. Defaults to a random number between 0 and 100, inclusive.
|
||||
* @returns An array of objects, including a base object (at index 0) with a closure property which references all the other objects.
|
||||
*/
|
||||
export function createManyObjects(num: number, noise?: unknown) {
|
||||
num = num || 10000
|
||||
noise = noise || Math.random() * 100
|
||||
|
||||
const objs = []
|
||||
|
||||
const base: {
|
||||
__closure: Record<string, number>
|
||||
} & Record<string, unknown> = { name: 'base bastard 2', noise, __closure: {} }
|
||||
|
||||
for (let i = 0; i < num; i++) {
|
||||
const baby: Record<string, unknown> = {
|
||||
name: `mr. ${i}`,
|
||||
nest: { duck: i % 2 === 0, mallard: 'falsey', arr: [i + 42, i, i] }
|
||||
}
|
||||
|
||||
if (!getAnIdForThisOnePlease(baby)) continue //this will never be true, but typescript now knows baby definitely has an id
|
||||
base.__closure[baby.id] = 1
|
||||
objs.push(baby)
|
||||
}
|
||||
if (!getAnIdForThisOnePlease(base)) return objs //this will never be true, but typescript now knows base definitely has an id
|
||||
objs.unshift(base)
|
||||
return objs
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an 'id' property to an object if it doesn't already have one. The 'id' is a hash (md5) of the object.
|
||||
* @param obj This object is passed by reference and will be modified
|
||||
* @returns true. This is a hack to make typescript happy and eliminate the 'undefined' type from 'id'
|
||||
*/
|
||||
export function getAnIdForThisOnePlease(
|
||||
obj: Record<string, unknown>
|
||||
): obj is Record<'id', string> & Record<string, unknown> {
|
||||
obj.id = obj.id || crypto.createHash('md5').update(JSON.stringify(obj)).digest('hex')
|
||||
return true //HACK to make typescript happy and eliminate the 'undefined' type from 'id'
|
||||
}
|
||||
|
||||
export const sleep = (ms: number) => {
|
||||
// logger.debug( `\t Sleeping ${ms}ms ` )
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the response body for errors. To be used in expect assertions.
|
||||
* Will throw an error if 'errors' exist.
|
||||
* @param {*} res
|
||||
*/
|
||||
export function noErrors(res: unknown) {
|
||||
const e = get(res, 'error')
|
||||
if (e) throw new Error(`Failed GraphQL request: ${JSON.stringify(e)}`)
|
||||
const bodyErrors = get(res, 'body.errors')
|
||||
if (bodyErrors)
|
||||
throw new Error(`Failed GraphQL request: ${JSON.stringify(bodyErrors)}`)
|
||||
}
|
||||
@@ -420,3 +420,5 @@ export const blockedDomains: string[] = [
|
||||
'dontreg.com',
|
||||
'dontsendmespam.de'
|
||||
]
|
||||
|
||||
export const blockedSlugs: string[] = ['actions']
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user