Merge branch 'main' into iain/ratelimiter-should-respect-configuration

This commit is contained in:
Iain Sproat
2025-03-04 16:36:43 +00:00
107 changed files with 2650 additions and 1014 deletions
+1 -1
View File
@@ -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": {},
+17
View File
@@ -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())
}
})
+5 -65
View File
@@ -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>
+2 -2
View File
@@ -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
View File
@@ -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!
}
+2 -2
View File
@@ -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...')
+2 -2
View File
@@ -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[]
}
+6 -6
View File
@@ -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')
}
+9 -1
View File
@@ -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)
@@ -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
+54 -46
View File
@@ -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')
@@ -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.',
@@ -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.',
@@ -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
})
+27 -10
View File
@@ -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)
+1
View File
@@ -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>;
+20
View File
@@ -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,
+1 -1
View File
@@ -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>[] = []
-88
View File
@@ -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
+112
View File
@@ -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)}`)
}
+2
View File
@@ -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