Merge branch 'main' into iain/web-2732-observability-for-improved-reliability-core

This commit is contained in:
Iain Sproat
2025-04-16 14:58:00 +01:00
150 changed files with 3210 additions and 1346 deletions
+1 -1
View File
@@ -10,6 +10,6 @@ LAST_RELEASE="$(git describe --always --tags $(git rev-list --tags) | grep -E '^
# shellcheck disable=SC2034
NEXT_RELEASE="$(echo "${LAST_RELEASE}" | awk -F. -v OFS=. '{$NF += 1 ; print}')"
# shellcheck disable=SC2034
BRANCH_NAME_TRUNCATED="$(echo "${CIRCLE_BRANCH}" | cut -c -50 | sed 's/[^a-zA-Z0-9_.-]/_/g')" # docker has a 128 character tag limit, so ensuring the branch name will be short enough
BRANCH_NAME_TRUNCATED="$(echo "${CIRCLE_BRANCH}" | cut -c -50 | sed 's/[^a-zA-Z0-9.-]/-/g')" # docker has a 128 character tag limit, so ensuring the branch name will be short enough
# shellcheck disable=SC2034
COMMIT_SHA1_TRUNCATED="$(echo "${CIRCLE_SHA1}" | cut -c -7)"
+15
View File
@@ -789,6 +789,21 @@ jobs:
command: yarn test:single-run
working_directory: 'packages/shared'
- run:
name: Build
command: yarn build
working_directory: 'packages/shared'
- run:
name: Ensure ESM import works
command: node ./e2e/testEsm.mjs
working_directory: 'packages/shared'
- run:
name: Ensure CJS require works
command: node ./e2e/testCjs.cjs
working_directory: 'packages/shared'
test-objectsender:
docker: *docker-node-browsers-image
resource_class: large
+6
View File
@@ -15,5 +15,11 @@ if [[ "${CIRCLE_BRANCH}" == "main" ]]; then
exit 0
fi
# if branch name truncated contains an underscore, we should exit
if [[ "${BRANCH_NAME_TRUNCATED}" =~ "_" ]]; then
echo "Branch name contains an underscore, exiting"
exit 1
fi
echo "${NEXT_RELEASE}-branch.${BRANCH_NAME_TRUNCATED}.${CIRCLE_BUILD_NUM}-${COMMIT_SHA1_TRUNCATED}"
exit 0
+1 -1
View File
@@ -6,7 +6,7 @@ dist-*
coverage
.nyc_output
packages/server/reports*
packages/preview-service/public/render/**/*
packages/preview-service/public/**/*
packages/objectloader/examples/browser/objectloader.web.js
packages/viewer/example/speckleviewer.web.js
+1 -1
View File
@@ -54,7 +54,7 @@ Have you checked our [dev docs](https://speckle.guide/dev/)?
We have a detailed section on [deploying a Speckle server](https://speckle.guide/dev/server-setup.html). To get started developing locally, you can see the [Local development environment](https://speckle.guide/dev/server-local-dev.html) page.
## TL;DR
## TL;DR;
We're using yarn and its workspaces functionalities to manage the monorepo.
Make sure you are using [Node](https://nodejs.org/en) version 18.
@@ -588,6 +588,7 @@ export type CheckoutSession = {
export type CheckoutSessionInput = {
billingInterval: BillingInterval;
currency?: InputMaybe<Currency>;
isCreateFlow?: InputMaybe<Scalars['Boolean']['input']>;
workspaceId: Scalars['ID']['input'];
workspacePlan: PaidWorkspacePlans;
@@ -609,8 +610,9 @@ export type Comment = {
id: Scalars['String']['output'];
/** Parent thread, if there's any */
parent?: Maybe<Comment>;
permissions: CommentPermissionChecks;
/** Plain-text version of the comment text, ideal for previews */
rawText: Scalars['String']['output'];
rawText?: Maybe<Scalars['String']['output']>;
/** @deprecated Not actually implemented */
reactions?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
/** Gets the replies to this comment. */
@@ -620,7 +622,7 @@ export type Comment = {
/** Resources that this comment targets. Can be a mixture of either one stream, or multiple commits and objects. */
resources: Array<ResourceIdentifier>;
screenshot?: Maybe<Scalars['String']['output']>;
text: SmartTextEditorValue;
text?: Maybe<SmartTextEditorValue>;
/** The time this comment was last updated. Corresponds also to the latest reply to this comment, if any. */
updatedAt: Scalars['DateTime']['output'];
/** The last time you viewed this comment. Present only if an auth'ed request. Relevant only if a top level commit. */
@@ -742,6 +744,11 @@ export type CommentMutationsReplyArgs = {
input: CreateCommentReplyInput;
};
export type CommentPermissionChecks = {
__typename?: 'CommentPermissionChecks';
canArchive: PermissionCheckResult;
};
export type CommentReplyAuthorCollection = {
__typename?: 'CommentReplyAuthorCollection';
items: Array<LimitedUser>;
@@ -926,6 +933,17 @@ export type CreateVersionInput = {
totalChildrenCount?: InputMaybe<Scalars['Int']['input']>;
};
export enum Currency {
Gbp = 'gbp',
Usd = 'usd'
}
export type CurrencyBasedPrices = {
__typename?: 'CurrencyBasedPrices';
gbp: WorkspacePaidPlanPrices;
usd: WorkspacePaidPlanPrices;
};
export type DeleteModelInput = {
id: Scalars['ID']['input'];
projectId: Scalars['ID']['input'];
@@ -1240,6 +1258,7 @@ export type Model = {
name: Scalars['String']['output'];
/** Returns a list of versions that are being created from a file import */
pendingImportedVersions: Array<FileUpload>;
permissions: ModelPermissionChecks;
previewUrl?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];
version: Version;
@@ -1298,6 +1317,13 @@ export type ModelMutationsUpdateArgs = {
input: UpdateModelInput;
};
export type ModelPermissionChecks = {
__typename?: 'ModelPermissionChecks';
canCreateVersion: PermissionCheckResult;
canDelete: PermissionCheckResult;
canUpdate: PermissionCheckResult;
};
export type ModelVersionsFilter = {
/** Make sure these specified versions are always loaded first */
priorityIds?: InputMaybe<Array<Scalars['String']['input']>>;
@@ -1879,8 +1905,10 @@ export enum PaidWorkspacePlans {
Business = 'business',
Plus = 'plus',
Pro = 'pro',
ProUnlimited = 'proUnlimited',
Starter = 'starter',
Team = 'team'
Team = 'team',
TeamUnlimited = 'teamUnlimited'
}
export type PasswordStrengthCheckFeedback = {
@@ -2523,7 +2551,22 @@ export enum ProjectPendingVersionsUpdatedMessageType {
export type ProjectPermissionChecks = {
__typename?: 'ProjectPermissionChecks';
canBroadcastActivity: PermissionCheckResult;
canCreateComment: PermissionCheckResult;
canCreateModel: PermissionCheckResult;
canLeave: PermissionCheckResult;
canMoveToWorkspace: PermissionCheckResult;
canRead: PermissionCheckResult;
canReadSettings: PermissionCheckResult;
canReadWebhooks: PermissionCheckResult;
canRequestRender: PermissionCheckResult;
canUpdate: PermissionCheckResult;
canUpdateAllowPublicComments: PermissionCheckResult;
};
export type ProjectPermissionChecksCanMoveToWorkspaceArgs = {
workspaceId?: InputMaybe<Scalars['String']['input']>;
};
export type ProjectRole = {
@@ -2937,6 +2980,11 @@ export type Role = {
resourceTarget: Scalars['String']['output'];
};
export type RootPermissionChecks = {
__typename?: 'RootPermissionChecks';
canCreatePersonalProject: PermissionCheckResult;
};
/** Available scopes. */
export type Scope = {
__typename?: 'Scope';
@@ -3134,7 +3182,7 @@ export type ServerStats = {
export type ServerWorkspacesInfo = {
__typename?: 'ServerWorkspacesInfo';
/** Up-to-date prices for paid & non-invoiced Workspace plans */
planPrices: Array<WorkspacePlanPrice>;
planPrices?: Maybe<CurrencyBasedPrices>;
/**
* This is a backend control variable for the workspaces feature set.
* Since workspaces need a backend logic to be enabled, this is not enough as a feature flag.
@@ -3802,6 +3850,7 @@ export type User = {
isProjectsActive?: Maybe<Scalars['Boolean']['output']>;
name: Scalars['String']['output'];
notificationPreferences: Scalars['JSONObject']['output'];
permissions: RootPermissionChecks;
profiles?: Maybe<Scalars['JSONObject']['output']>;
/** Get pending project access request, that the user made */
projectAccessRequest?: Maybe<ProjectAccessRequest>;
@@ -4113,8 +4162,9 @@ export type Version = {
message?: Maybe<Scalars['String']['output']>;
model: Model;
parents?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
permissions: VersionPermissionChecks;
previewUrl: Scalars['String']['output'];
referencedObject: Scalars['String']['output'];
referencedObject?: Maybe<Scalars['String']['output']>;
sourceApplication?: Maybe<Scalars['String']['output']>;
totalChildrenCount?: Maybe<Scalars['Int']['output']>;
};
@@ -4190,6 +4240,12 @@ export type VersionMutationsUpdateArgs = {
input: UpdateVersionInput;
};
export type VersionPermissionChecks = {
__typename?: 'VersionPermissionChecks';
canReceive: PermissionCheckResult;
canUpdate: PermissionCheckResult;
};
export type ViewerResourceGroup = {
__typename?: 'ViewerResourceGroup';
/** Resource identifier used to refer to a collection of resource items */
@@ -4347,6 +4403,8 @@ export type Workspace = {
name: Scalars['String']['output'];
permissions: WorkspacePermissionChecks;
plan?: Maybe<WorkspacePlan>;
/** Shows the plan prices localized for the given workspace */
planPrices?: Maybe<WorkspacePaidPlanPrices>;
projects: ProjectCollection;
/** A Workspace is marked as readOnly if its trial period is finished or a paid plan is subscribed but payment has failed */
readOnly: Scalars['Boolean']['output'];
@@ -4696,6 +4754,14 @@ export type WorkspaceMutationsUpdateSeatTypeArgs = {
input: WorkspaceUpdateSeatTypeInput;
};
export type WorkspacePaidPlanPrices = {
__typename?: 'WorkspacePaidPlanPrices';
pro: WorkspacePlanPrice;
proUnlimited: WorkspacePlanPrice;
team: WorkspacePlanPrice;
teamUnlimited: WorkspacePlanPrice;
};
export enum WorkspacePaymentMethod {
Billing = 'billing',
Invoice = 'invoice',
@@ -4705,6 +4771,12 @@ export enum WorkspacePaymentMethod {
export type WorkspacePermissionChecks = {
__typename?: 'WorkspacePermissionChecks';
canCreateProject: PermissionCheckResult;
canMoveProjectToWorkspace: PermissionCheckResult;
};
export type WorkspacePermissionChecksCanMoveProjectToWorkspaceArgs = {
projectId?: InputMaybe<Scalars['String']['input']>;
};
export type WorkspacePlan = {
@@ -4718,9 +4790,8 @@ export type WorkspacePlan = {
export type WorkspacePlanPrice = {
__typename?: 'WorkspacePlanPrice';
id: Scalars['String']['output'];
monthly?: Maybe<Price>;
yearly?: Maybe<Price>;
monthly: Price;
yearly: Price;
};
export enum WorkspacePlanStatuses {
@@ -4746,9 +4817,13 @@ export enum WorkspacePlans {
Plus = 'plus',
PlusInvoiced = 'plusInvoiced',
Pro = 'pro',
ProUnlimited = 'proUnlimited',
ProUnlimitedInvoiced = 'proUnlimitedInvoiced',
Starter = 'starter',
StarterInvoiced = 'starterInvoiced',
Team = 'team',
TeamUnlimited = 'teamUnlimited',
TeamUnlimitedInvoiced = 'teamUnlimitedInvoiced',
Unlimited = 'unlimited'
}
@@ -4900,6 +4975,7 @@ export type WorkspaceSubscription = {
__typename?: 'WorkspaceSubscription';
billingInterval: BillingInterval;
createdAt: Scalars['DateTime']['output'];
currency: Currency;
currentBillingCycleEnd: Scalars['DateTime']['output'];
seats: WorkspaceSubscriptionSeats;
updatedAt: Scalars['DateTime']['output'];
@@ -1,15 +1,13 @@
<template>
<div>
<template v-if="showBillingAlert">
<CommonAlert :color="alertColor" :actions="actions">
<template #title>
{{ title }}
</template>
<template #description>
{{ description }}
</template>
</CommonAlert>
</template>
<CommonAlert :color="alertColor" :actions="actions">
<template #title>
{{ title }}
</template>
<template #description>
{{ description }}
</template>
</CommonAlert>
</div>
</template>
@@ -21,10 +19,14 @@ import {
} from '~/lib/common/generated/gql/graphql'
import { useBillingActions } from '~/lib/billing/composables/actions'
import type { AlertAction, AlertColor } from '@speckle/ui-components'
import { type MaybeNullOrUndefined, Roles } from '@speckle/shared'
import { settingsWorkspaceRoutes } from '~/lib/common/helpers/route'
graphql(`
fragment BillingAlert_Workspace on Workspace {
id
role
slug
plan {
name
status
@@ -38,20 +40,14 @@ graphql(`
`)
const props = defineProps<{
workspace: BillingAlert_WorkspaceFragment
actions?: Array<AlertAction>
condensed?: boolean
workspace: MaybeNullOrUndefined<BillingAlert_WorkspaceFragment>
hideSettingsLinks?: boolean
}>()
const { billingPortalRedirect } = useBillingActions()
const planStatus = computed(() => props.workspace.plan?.status)
const isPaymentFailed = computed(
() => planStatus.value === WorkspacePlanStatuses.PaymentFailed
)
const isScheduledForCancelation = computed(
() => planStatus.value === WorkspacePlanStatuses.CancelationScheduled
)
const planStatus = computed(() => props.workspace?.plan?.status)
const title = computed(() => {
switch (planStatus.value) {
case WorkspacePlanStatuses.CancelationScheduled:
@@ -64,24 +60,19 @@ const title = computed(() => {
return ''
}
})
const description = computed(() => {
switch (planStatus.value) {
case WorkspacePlanStatuses.CancelationScheduled:
return 'Once the current billing cycle ends your workspace will enter read-only mode. Renew your subscription to undo.'
case WorkspacePlanStatuses.Canceled:
return 'Your workspace has been cancelled and is in read-only mode. Subscribe to a plan to regain full access.'
return 'Your workspace has been cancelled and is in read-only mode. Resubscribe to a plan to regain full access.'
case WorkspacePlanStatuses.PaymentFailed:
return "Update your payment information now to ensure your workspace doesn't go into maintenance mode."
default:
return ''
}
})
const showBillingAlert = computed(
() =>
planStatus.value === WorkspacePlanStatuses.PaymentFailed ||
planStatus.value === WorkspacePlanStatuses.Canceled ||
planStatus.value === WorkspacePlanStatuses.CancelationScheduled
)
const alertColor = computed<AlertColor>(() => {
switch (planStatus.value) {
@@ -95,20 +86,32 @@ const alertColor = computed<AlertColor>(() => {
}
})
const actions = computed((): AlertAction[] => {
const actions: Array<AlertAction> = props.actions ?? []
const isWorkspaceGuest = computed(() => props.workspace?.role === Roles.Workspace.Guest)
if (isPaymentFailed.value) {
const actions = computed((): AlertAction[] => {
const actions: Array<AlertAction> = []
if (isWorkspaceGuest.value) return actions
if (planStatus.value === WorkspacePlanStatuses.PaymentFailed) {
actions.push({
title: 'Update payment information',
onClick: () => billingPortalRedirect(props.workspace.id),
disabled: !props.workspace.id
onClick: () => billingPortalRedirect(props.workspace?.id)
})
} else if (isScheduledForCancelation.value) {
}
if (planStatus.value === WorkspacePlanStatuses.CancelationScheduled) {
actions.push({
title: 'Renew subscription',
onClick: () => billingPortalRedirect(props.workspace.id),
disabled: !props.workspace.id
onClick: () => billingPortalRedirect(props.workspace?.id)
})
}
if (planStatus.value === WorkspacePlanStatuses.Canceled && !props.hideSettingsLinks) {
actions.push({
title: 'Resubscribe',
onClick: () =>
navigateTo(settingsWorkspaceRoutes.billing.route(props.workspace?.slug || ''))
})
}
@@ -12,7 +12,7 @@
<!-- New State -->
<CommonCard
class="!p-2 !pr-3 !border-blue-300 !bg-blue-50 dark:!border-blue-800 dark:!bg-blue-950"
class="!p-2 !pr-3 !border-blue-200 !bg-blue-50 dark:!border-blue-800 dark:!bg-blue-950"
>
<slot name="new-state" />
</CommonCard>
@@ -35,42 +35,10 @@
</LayoutSidebarMenuGroup>
<LayoutSidebarMenuGroup>
<template v-if="!isWorkspacesEnabled">
<NuxtLink :to="projectsRoute" @click="isOpenMobile = false">
<LayoutSidebarMenuGroupItem
label="Projects"
:active="isActive(projectsRoute)"
>
<template #icon>
<IconProjects class="size-4 text-foreground-2" />
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
</template>
<NuxtLink
v-if="activeWorkspaceSlug"
:to="workspaceRoute(activeWorkspaceSlug)"
@click="isOpenMobile = false"
>
<LayoutSidebarMenuGroupItem
label="Home"
:active="route.name === 'workspaces-slug'"
>
<template #icon>
<HomeIcon class="size-4 stroke-[1.5px]" />
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
<NuxtLink
v-else-if="isProjectsActive"
:to="projectsRoute"
@click="isOpenMobile = false"
>
<NuxtLink :to="projectsLink" @click="isOpenMobile = false">
<LayoutSidebarMenuGroupItem
label="Projects"
:active="isActive(projectsRoute)"
:active="route.name === 'workspaces-slug' || isActive(projectsRoute)"
>
<template #icon>
<IconProjects class="size-4 text-foreground-2" />
@@ -170,7 +138,6 @@ import {
} from '~/lib/common/helpers/route'
import { useRoute } from 'vue-router'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { HomeIcon } from '@heroicons/vue/24/outline'
import { useNavigation } from '~~/lib/navigation/composables/navigation'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
import { useBreakpoints } from '@vueuse/core'
@@ -178,13 +145,20 @@ import { useBreakpoints } from '@vueuse/core'
const { isLoggedIn } = useActiveUser()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const route = useRoute()
const { activeWorkspaceSlug, isProjectsActive } = useNavigation()
const { activeWorkspaceSlug } = useNavigation()
const breakpoints = useBreakpoints(TailwindBreakpoints)
const isMobile = breakpoints.smaller('lg')
const isOpenMobile = ref(false)
const showFeedbackDialog = ref(false)
const projectsLink = computed(() => {
return isWorkspacesEnabled.value
? projectsRoute
: activeWorkspaceSlug.value
? workspaceRoute(activeWorkspaceSlug.value)
: projectsRoute
})
const isActive = (...routes: string[]): boolean => {
return routes.some((routeTo) => route.path === routeTo)
}
@@ -54,14 +54,27 @@
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { XMarkIcon, BellIcon } from '@heroicons/vue/24/outline'
import { useQuery } from '@vue/apollo-composable'
import { navigationInvitesQuery } from '~~/lib/navigation/graphql/queries'
import {
navigationProjectInvitesQuery,
navigationWorkspaceInvitesQuery
} from '~~/lib/navigation/graphql/queries'
const menuButtonId = useId()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { result } = useQuery(navigationInvitesQuery)
const { result: projectInviteResult } = useQuery(navigationProjectInvitesQuery)
const { result: workspaceInviteResult } = useQuery(
navigationWorkspaceInvitesQuery,
null,
{ enabled: isWorkspacesEnabled.value }
)
const projectsInvites = computed(() => result.value?.activeUser?.projectInvites)
const workspacesInvites = computed(() => result.value?.activeUser?.workspaceInvites)
const projectsInvites = computed(
() => projectInviteResult.value?.activeUser?.projectInvites
)
const workspacesInvites = computed(
() => workspaceInviteResult.value?.activeUser?.workspaceInvites
)
const hasNotifications = computed(
() => projectsInvites.value?.length || workspacesInvites.value?.length
@@ -57,44 +57,11 @@
</div>
<ul class="flex flex-col gap-y-2 mt-4 pt-3 border-t border-outline-3">
<PricingTablePlanFeature
v-for="feature in commonFeatures"
:key="feature.displayName"
:display-name="feature.displayName"
:description="feature.description"
is-included
display-name="Unlimited editor and viewer seats"
description="Some tooltip text"
/>
<PricingTablePlanFeature
is-included
display-name="Unlimited guests"
description="Some tooltip text"
/>
<PricingTablePlanFeature
is-included
:display-name="`${planLimits.projectCount} project${
planLimits.projectCount === 1 ? '' : 's'
}`"
description="Some tooltip text"
/>
<PricingTablePlanFeature
is-included
:display-name="`${planLimits.modelCount} models per workspace`"
description="Some tooltip text"
/>
<PricingTablePlanFeature
is-included
:display-name="
planLimits.versionsHistory
? `${planLimits.versionsHistory} day version history`
: 'Full version history'
"
description="Some tooltip text"
/>
<PricingTablePlanFeature
is-included
:display-name="
planLimits.versionsHistory
? `${planLimits.versionsHistory} day comment history`
: 'Full comment history'
"
description="Some tooltip text"
/>
<PricingTablePlanFeature
v-for="(featureMetadata, feature) in WorkspacePlanFeaturesMetadata"
@@ -112,8 +79,7 @@ import {
type MaybeNullOrUndefined,
WorkspacePlans,
WorkspacePlanFeaturesMetadata,
WorkspacePlanConfigs,
WorkspacePlanBillingIntervals
WorkspacePlanConfigs
} from '@speckle/shared'
import {
type WorkspacePlan,
@@ -123,7 +89,6 @@ import {
} from '~/lib/common/generated/gql/graphql'
import { useWorkspacePlanPrices } from '~/lib/billing/composables/prices'
import { formatPrice, formatName } from '~/lib/billing/helpers/plan'
import { useBillingActions } from '~/lib/billing/composables/actions'
import type { SetupContext } from 'vue'
const emit = defineEmits<{
@@ -146,22 +111,59 @@ const isYearlyIntervalSelected = defineModel<boolean>('isYearlyIntervalSelected'
const slots: SetupContext['slots'] = useSlots()
const { prices } = useWorkspacePlanPrices()
const { redirectToCheckout } = useBillingActions()
const planLimits = computed(() => WorkspacePlanConfigs[props.plan].limits)
const planFeatures = computed(() => WorkspacePlanConfigs[props.plan].features)
const commonFeatures = shallowRef([
{
displayName: 'Unlimited editor and viewer seats',
description: 'Some tooltip text'
},
{
displayName: 'Unlimited guests',
description: 'Some tooltip text'
},
{
displayName: `${planLimits.value.projectCount} project${
planLimits.value.projectCount === 1 ? '' : 's'
}`,
description: 'Some tooltip text'
},
{
displayName: `${planLimits.value.modelCount} models per workspace`,
description: 'Some tooltip text'
},
{
displayName: planLimits.value.versionsHistory
? `${planLimits.value.versionsHistory.value} day version history`
: 'Full version history',
description: 'Some tooltip text'
},
{
displayName: planLimits.value.versionsHistory
? `${planLimits.value.versionsHistory.value} day comment history`
: 'Full comment history',
description: 'Some tooltip text'
}
])
const planPrice = computed(() => {
let basePrice = 0
if (props.plan === WorkspacePlans.Team || props.plan === WorkspacePlans.Pro) {
return formatPrice(
basePrice =
prices.value?.[props.currency || Currency.Usd]?.[props.plan]?.[
WorkspacePlanBillingIntervals.Monthly
]
)
isYearlyIntervalSelected.value
? BillingInterval.Yearly
: BillingInterval.Monthly
].amount || 0
}
return formatPrice({
amount: 0,
currency: props.currency || 'usd'
amount: basePrice
? isYearlyIntervalSelected.value
? basePrice / 12
: basePrice
: 0,
currency: props.currency || Currency.Usd
})
})
@@ -172,7 +174,9 @@ const canUpgradeToPlan = computed(() => {
const allowedUpgrades: Partial<Record<WorkspacePlans, WorkspacePlans[]>> = {
[WorkspacePlans.Free]: [WorkspacePlans.Team, WorkspacePlans.Pro],
[WorkspacePlans.Team]: [WorkspacePlans.Pro]
[WorkspacePlans.Team]: [WorkspacePlans.Pro],
[WorkspacePlans.TeamUnlimited]: [WorkspacePlans.Team, WorkspacePlans.Pro],
[WorkspacePlans.ProUnlimited]: [WorkspacePlans.Pro]
}
return allowedUpgrades[props.currentPlan.name]?.includes(props.plan)
@@ -192,7 +196,17 @@ const isCurrentPlan = computed(() => {
if (props.plan === WorkspacePlans.Free) {
return props.currentPlan?.name === props.plan
}
return isMatchingInterval.value && props.currentPlan?.name === props.plan
const isMatchingTier =
(props.currentPlan?.name === WorkspacePlans.TeamUnlimited &&
props.plan === WorkspacePlans.Team) ||
(props.currentPlan?.name === WorkspacePlans.ProUnlimited &&
props.plan === WorkspacePlans.Pro)
return (
isMatchingInterval.value &&
(props.currentPlan?.name === props.plan || isMatchingTier)
)
})
const isAnnualToMonthly = computed(() => {
@@ -223,6 +237,10 @@ const isSelectable = computed(() => {
)
return true
// Dont allow upgrades during cancelation
if (props.currentPlan?.status === WorkspacePlanStatuses.CancelationScheduled) {
return false
}
// Allow selection if switching from monthly to yearly for the same plan
if (isMonthlyToAnnual.value && props.currentPlan?.name === props.plan) return true
@@ -292,6 +310,10 @@ const buttonTooltip = computed(() => {
)
return undefined
if (props.currentPlan?.status === WorkspacePlanStatuses.CancelationScheduled) {
return 'You must renew your subscription first'
}
if (isDowngrade.value) {
return 'Downgrading is not supported at the moment. Please contact billing@speckle.systems.'
}
@@ -312,25 +334,16 @@ const buttonTooltip = computed(() => {
})
const badgeText = computed(() =>
props.currentPlan?.name === props.plan ? 'Current plan' : ''
props.currentPlan?.name === props.plan &&
props.currentPlan?.status !== WorkspacePlanStatuses.Canceled &&
props.currentPlan?.status !== WorkspacePlanStatuses.CancelationScheduled
? 'Current plan'
: ''
)
const handleUpgradeClick = () => {
if (!props.workspaceId) return
if (props.plan !== WorkspacePlans.Team && props.plan !== WorkspacePlans.Pro) return
if (props.hasSubscription) {
if (props.canUpgrade) {
emit('onUpgradeClick')
}
} else {
redirectToCheckout({
plan: props.plan,
cycle: isYearlyIntervalSelected.value
? BillingInterval.Yearly
: BillingInterval.Monthly,
workspaceId: props.workspaceId
})
}
emit('onUpgradeClick')
}
</script>
@@ -2,13 +2,22 @@
<div>
<Portal to="navigation">
<HeaderNavLink
v-if="project.workspace && isWorkspacesEnabled"
:to="workspaceRoute(project.workspace.slug)"
name="Home"
v-if="showWorkspaceLink"
:to="workspaceRoute(project.workspace?.slug)"
name="Projects"
:separator="false"
/>
<HeaderNavLink v-else :to="projectsRoute" name="Projects" :separator="false" />
<HeaderNavLink :to="projectRoute(project.id)" :name="project.name" />
<HeaderNavLink
v-else-if="!isWorkspacesEnabled"
:to="projectsRoute"
name="Projects"
:separator="false"
/>
<HeaderNavLink
:to="projectRoute(project.id)"
:name="project.name"
:separator="showWorkspaceLink || !isWorkspacesEnabled"
/>
<HeaderNavLink
v-if="props.project.model"
:to="modelVersionsRoute(project.id, props.project.model.id)"
@@ -46,6 +55,7 @@ graphql(`
id
slug
name
role
}
}
`)
@@ -55,4 +65,7 @@ const props = defineProps<{
}>()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const showWorkspaceLink = computed(
() => !!props.project.workspace?.role && isWorkspacesEnabled.value
)
</script>
@@ -1,16 +1,23 @@
<template>
<div>
<Portal to="navigation">
<template v-if="project.workspace && isWorkspacesEnabled">
<HeaderNavLink
:to="workspaceRoute(project.workspace.slug)"
name="Home"
:separator="false"
/>
</template>
<HeaderNavLink v-else :to="projectsRoute" name="Projects" :separator="false" />
<HeaderNavLink :to="projectRoute(project.id)" :name="project.name" />
<HeaderNavLink
v-if="showWorkspaceLink"
:to="workspaceRoute(project.workspace?.slug)"
name="Projects"
:separator="false"
/>
<HeaderNavLink
v-else-if="!isWorkspacesEnabled"
:to="projectsRoute"
name="Projects"
:separator="false"
/>
<HeaderNavLink
:to="projectRoute(project.id)"
:name="project.name"
:separator="showWorkspaceLink || !isWorkspacesEnabled"
/>
</Portal>
<div class="flex gap-x-3">
@@ -49,13 +56,17 @@ graphql(`
slug
name
logo
role
}
}
`)
defineProps<{
const props = defineProps<{
project: ProjectPageProjectHeaderFragment
}>()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const showWorkspaceLink = computed(
() => !!props.project.workspace?.role && isWorkspacesEnabled.value
)
</script>
@@ -1,42 +1,75 @@
<template>
<div>
<CommonCard class="!p-3 !bg-foundation mb-4">
<CommonCard class="pb-6 !bg-foundation mb-6">
<div class="flex flex-col sm:flex-row sm:gap-2 text-foreground">
<ExclamationCircleIcon class="h-8 w-8 m-1 text-warning shrink-0" />
<div class="flex flex-col gap-4">
<h3 class="text-heading mt-2">
{{
projectId
? `Move this project to a workspace or it will be deleted in (count) days.`
: `Move projects to a workspace or they will be deleted in (count) days.`
? `Move this project to a workspace`
: `Move your projects to a workspace`
}}
</h3>
<div class="text-body-xs max-w-3xl">
<p>
In our continuous effort to improve user experience, we are excited to
announce the rollout of several new features designed to simplify your
workflow and enhance navigation. Important facts:
<div class="text-body-xs">
<p class="mb-4">
How you work in Speckle is changing. We are
<span class="text-warning-darker align-top text-body"></span>
making workspaces the default way to work in Speckle, and
<span class="text-warning-darker align-top text-body"></span>
introducing new pricing including limits to the free plan.
</p>
<ul class="list-disc list-inside pl-2">
<li>These updates will include customizable dashboards,</li>
<li>Improved search functionality,</li>
<li>And a more user-friendly interface</li>
</ul>
</div>
<div class="flex gap-2 mt-2 mb-3">
<FormButton @click="$emit('moveProject', projectId)">
{{ projectId ? 'Move project' : 'Show projects to move' }}
</FormButton>
<div v-show="showDetails" class="mb-3">
<h3 class="font-medium text-warning-darker">By June 1st 2025</h3>
<p>Move your projects to a workspace to:</p>
<ul class="list-disc list-inside pl-2 mb-4">
<li>
<span class="font-medium">Create new projects and models</span>
(will be disabled for personal projects; existing projects and models
stay editable)
</li>
<li>
<span class="font-medium">Invite new project collaborators</span>
(new invites will be unavailable for personal projects)
</li>
<li>
<span class="font-medium">Preserve version and comment history</span>
(history is reduced to 7 days for personal projects)
</li>
</ul>
<h3 class="font-medium text-warning-darker">By Janury 1st 2026</h3>
<p>
All projects will be archived if not moved into a workspace. Don't
worry, we'll give you plenty of reminders before then.
</p>
</div>
<FormButton
color="outline"
:to="LearnMoreMoveProjectsUrl"
external
target="_blank"
text
color="primary"
size="sm"
class="mb-5"
:icon-right="showDetails ? ChevronUpIcon : ChevronDownIcon"
@click="showDetails = !showDetails"
>
Learn more
{{ showDetails ? 'Show less' : 'Show more' }}
</FormButton>
<div class="flex gap-2">
<FormButton @click="$emit('moveProject', projectId)">
{{ projectId ? 'Move project' : 'Move projects' }}
</FormButton>
<FormButton
color="outline"
:to="LearnMoreMoveProjectsUrl"
external
target="_blank"
>
Explore new pricing
</FormButton>
</div>
</div>
</div>
</div>
@@ -47,10 +80,13 @@
<script setup lang="ts">
import { ExclamationCircleIcon } from '@heroicons/vue/24/outline'
import { LearnMoreMoveProjectsUrl } from '~/lib/common/helpers/route'
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/20/solid'
defineEmits(['moveProject'])
defineProps<{
projectId?: string
}>()
const showDetails = ref(false)
</script>
@@ -8,7 +8,7 @@
>
<div class="flex flex-col">
<CommonBadge
v-if="!project.workspace?.id && isWorkspacesEnabled"
v-if="!project.workspace?.id && isWorkspacesEnabled && isOwner"
class="mb-2 max-w-max"
rounded
>
@@ -64,7 +64,7 @@
}}
</FormButton>
<FormButton
v-if="!project.workspace?.id && isWorkspacesEnabled"
v-if="!project.workspace?.id && isWorkspacesEnabled && isOwner"
size="sm"
color="outline"
@click="$emit('moveProject', project.id)"
@@ -107,6 +107,7 @@
</div>
</template>
<script lang="ts" setup>
import { Roles } from '@speckle/shared'
import { FormButton } from '@speckle/ui-components'
import type { ProjectDashboardItemFragment } from '~~/lib/common/generated/gql/graphql'
import {
@@ -132,6 +133,7 @@ const props = defineProps<{
const router = useRouter()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const isOwner = computed(() => props.project.role === Roles.Stream.Owner)
const projectId = computed(() => props.project.id)
const updatedAt = computed(() => {
return {
@@ -1,5 +1,12 @@
<template>
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0 flex flex-col gap-y-2 md:gap-y-4">
<BillingAlert
v-if="showBillingAlert"
class="mb-4"
:workspace="workspace"
hide-settings-links
/>
<SettingsSectionHeader
title="Billing and plans"
text="Update your payment information or switch plans according to your needs"
@@ -44,6 +51,7 @@ import { settingsWorkspaceBillingQuery } from '~/lib/settings/graphql/queries'
import type { WorkspaceRoles } from '@speckle/shared'
import { useWorkspacePlan } from '~~/lib/workspaces/composables/plan'
import { graphql } from '~/lib/common/generated/gql'
import { WorkspacePlanStatuses } from '~/lib/common/generated/gql/graphql'
graphql(`
fragment WorkspaceBillingPage_Workspace on Workspace {
@@ -52,6 +60,7 @@ graphql(`
subscription {
currency
}
...BillingAlert_Workspace
}
`)
@@ -74,6 +83,14 @@ const { result: workspaceResult } = useQuery(
)
const workspace = computed(() => workspaceResult.value?.workspaceBySlug)
const showBillingAlert = computed(
() =>
workspace.value?.plan?.status === WorkspacePlanStatuses.PaymentFailed ||
workspace.value?.plan?.status === WorkspacePlanStatuses.Canceled ||
workspace.value?.plan?.status === WorkspacePlanStatuses.CancelationScheduled
)
watch(
() => intervalIsYearly.value,
(newVal) => {
@@ -5,16 +5,28 @@
class="grid grid-cols-1 lg:grid-cols-3 divide-y divide-outline-3 lg:divide-y-0 lg:divide-x"
>
<div class="p-5 pt-4 flex flex-col">
<h3 class="text-body-xs text-foreground-2 pb-4">Current plan</h3>
<p class="text-heading-lg text-foreground capitalize">
{{ formatName(plan?.name) }}
<h3 class="text-body-xs text-foreground-2 pb-4">
{{ statusIsCanceled ? 'Plan' : 'Current plan' }}
</h3>
<p class="flex gap-x-2">
<span class="text-heading-lg text-foreground">
{{ formatName(plan?.name) }}
</span>
<span v-if="hasUnlimitedAddon" class="text-body-xs text-foreground-2">
including add-ons:
</span>
</p>
<div v-if="hasUnlimitedAddon" class="mt-1">
<CommonBadge rounded color="secondary">
Unlimited Projects & Models
</CommonBadge>
</div>
</div>
<div class="p-5 pt-4 flex flex-col">
<h3 class="text-body-xs text-foreground-2 pb-4">Billing period</h3>
<p class="text-heading-lg text-foreground inline-block">
<span v-if="isPaidPlan">
<span v-if="isPaidPlan && billingInterval">
{{ intervalIsYearly ? 'Yearly' : 'Monthly' }}
</span>
<span v-else>Not applicable</span>
@@ -22,11 +34,13 @@
</div>
<div class="p-5 pt-4 flex flex-col">
<h3 class="text-body-xs text-foreground-2 pb-4">Next payment due</h3>
<h3 class="text-body-xs text-foreground-2 pb-4">
{{ nextPaymentHeadingText }}
</h3>
<p class="text-heading-lg text-foreground capitalize">
{{
currentBillingCycleEnd
? dayjs(currentBillingCycleEnd).format('DD-MMMM-YYYY')
? dayjs(currentBillingCycleEnd).format('DD-MM-YYYY')
: 'Not applicable'
}}
</p>
@@ -66,9 +80,23 @@ const { billingPortalRedirect } = useBillingActions()
const route = useRoute()
const slug = computed(() => (route.params.slug as string) || '')
const { plan, isPaidPlan, intervalIsYearly, currentBillingCycleEnd } = useWorkspacePlan(
slug.value
)
const {
plan,
isPaidPlan,
intervalIsYearly,
currentBillingCycleEnd,
statusIsCanceled,
statusIsCancelationScheduled,
hasUnlimitedAddon,
billingInterval
} = useWorkspacePlan(slug.value)
const nextPaymentHeadingText = computed(() => {
if (statusIsCanceled.value) return 'Cancelled on'
if (statusIsCancelationScheduled.value) return 'Cancellation scheduled for'
return 'Next payment due '
})
const showBillingPortalLink = computed(
() =>
@@ -2,7 +2,7 @@
<template>
<div class="border border-outline-3 rounded-lg divide-y divide-outline-3">
<div
v-if="isPaidPlan"
v-if="isPaidPlan && !statusIsCanceled"
class="px-5 py-8 gap-y-6 flex flex-col sm:items-center sm:flex-row"
>
<div
@@ -96,7 +96,7 @@ const props = defineProps<{
const { projectCount, modelCount } = useWorkspaceUsage(props.slug)
const { limits } = useWorkspaceLimits(props.slug)
const { seats, isPaidPlan } = useWorkspacePlan(props.slug)
const { seats, isPaidPlan, statusIsCanceled } = useWorkspacePlan(props.slug)
const formatUsageText = (current: number, max: number, type: string) => {
return `${current} ${type}${current === 1 ? '' : 's'} used / ${max} included`
@@ -7,15 +7,15 @@
:buttons="[unlimitedAddOnButton]"
>
<template #subtitle>
<p class="text-body pt-1">
<span class="font-medium">{{ addonPrice }}</span>
per editor/month
<p class="text-foreground-3 text-body-sm pt-1">
{{ addonPrice }} per editor/month
</p>
<div class="flex items-center gap-x-2 mt-3 px-1">
<FormSwitch
v-model="isYearlyIntervalSelected"
:show-label="false"
name="billing-interval"
:disabled="hasUnlimitedAddon"
/>
<span class="text-body-2xs">Billed yearly</span>
<CommonBadge rounded color-classes="text-foreground-2 bg-primary-muted">
@@ -27,11 +27,16 @@
<SettingsWorkspacesBillingAddOnsCard
title="Extra data regions"
subtitle="Talk to us"
info="Access to almost all data residency regions."
disclaimer="Only on Business plan"
:buttons="[contactButton]"
/>
>
<template #subtitle>
<p class="text-foreground-3 text-body-sm pt-1">
{{ currency === Currency.Gbp ? '£' : '$' }}500 per region/year
</p>
</template>
</SettingsWorkspacesBillingAddOnsCard>
<SettingsWorkspacesBillingAddOnsCard
title="Priority support"
@@ -58,7 +63,8 @@ import { useWorkspacePlan } from '~~/lib/workspaces/composables/plan'
import { useWorkspaceAddonPrices } from '~/lib/billing/composables/prices'
import { formatPrice } from '~/lib/billing/helpers/plan'
import { PaidWorkspacePlansNew, type MaybeNullOrUndefined } from '@speckle/shared'
import { BillingInterval } from '~/lib/common/generated/gql/graphql'
import { BillingInterval, Currency } from '~/lib/common/generated/gql/graphql'
import { useActiveWorkspace } from '~/lib/workspaces/composables/activeWorkspace'
const props = defineProps<{
slug: string
@@ -68,22 +74,16 @@ const isYearlyIntervalSelected = defineModel<boolean>('isYearlyIntervalSelected'
default: false
})
const {
isBusinessPlan,
isPaidPlan,
currency,
plan,
intervalIsYearly,
hasUnlimitedAddon
} = useWorkspacePlan(props.slug)
const { isPaidPlan, currency, plan, intervalIsYearly, hasUnlimitedAddon } =
useWorkspacePlan(props.slug)
const { addonPrices } = useWorkspaceAddonPrices()
const { isAdmin } = useActiveWorkspace(props.slug)
const isUpgradeDialogOpen = ref(false)
const contactButton = computed(() => ({
text: 'Contact us',
id: 'contact-us',
disabled: !isBusinessPlan.value,
onClick: () => {
window.location.href = 'mailto:billing@speckle.systems'
}
@@ -95,7 +95,8 @@ const unlimitedAddOnButton = computed(() => ({
disabled:
!isPaidPlan.value ||
(!isYearlyIntervalSelected.value && intervalIsYearly.value) ||
hasUnlimitedAddon.value,
hasUnlimitedAddon.value ||
!isAdmin.value,
onClick: () => {
isUpgradeDialogOpen.value = true
}
@@ -1,16 +1,21 @@
<template>
<div>
<BillingTransitionCards>
<BillingTransitionCards class="mb-4">
<template #current-state>
<div class="p-2">
<p class="text-foreground text-body-3xs">
Current plan (billed {{ intervalIsYearly ? 'yearly' : 'monthly' }})
{{ statusIsCanceled ? 'Old plan' : 'Current plan' }}
<template v-if="!isFreePlan && !statusIsCanceled">
(billed {{ intervalIsYearly ? 'yearly' : 'monthly' }})
</template>
</p>
<div class="mt-2 flex justify-between items-center">
<h3 class="text-body">{{ formatName(plan?.name) }}</h3>
<p class="text-body-2xs">{{ currentEditorPrice }} editor seat/month</p>
<p v-if="!isFreePlan && !statusIsCanceled" class="text-body-2xs">
{{ currentEditorPrice }} editor seat/month
</p>
</div>
<template v-if="hasUnlimitedAddon">
<template v-if="hasUnlimitedAddon && !statusIsCanceled">
<div class="mt-2 flex justify-between items-center">
<CommonBadge
color-classes="bg-foundation border-blue-200 dark:border-blue-800 border"
@@ -68,8 +73,8 @@
</BillingTransitionCards>
<p
v-if="plan?.name && isPaidPlan(plan.name)"
class="text-foreground-2 text-body-2xs mt-6 mb-2"
v-if="plan?.name && isPaidPlan(plan.name) && !statusIsCanceled"
class="text-foreground-2 text-body-2xs my-2"
>
The amount you will be charged today will be prorated based on the time remaining
in your billing cycle. The prorated amount will be lower than the listed price.
@@ -99,9 +104,14 @@ const props = defineProps<{
billingInterval: BillingInterval
}>()
const { intervalIsYearly, plan, currency, hasUnlimitedAddon } = useWorkspacePlan(
props.slug
)
const {
intervalIsYearly,
plan,
currency,
hasUnlimitedAddon,
isFreePlan,
statusIsCanceled
} = useWorkspacePlan(props.slug)
const { prices: activeWorkspacePrices } = useActiveWorkspacePlanPrices()
const { prices } = useWorkspacePlanPrices()
const { addonPrices } = useWorkspaceAddonPrices()
@@ -51,8 +51,10 @@ const includeUnlimitedAddon = defineModel<AddonIncludedSelect | undefined>(
}
)
const { upgradePlan } = useBillingActions()
const { hasUnlimitedAddon, plan } = useWorkspacePlan(props.slug)
const { upgradePlan, redirectToCheckout } = useBillingActions()
const { hasUnlimitedAddon, plan, subscription, statusIsCanceled } = useWorkspacePlan(
props.slug
)
const { projectCount, modelCount } = useWorkspaceUsage(props.slug)
const showAddonSelect = ref<boolean>(true)
@@ -124,17 +126,25 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
const backButtonText = computed(() => (showAddonSelect.value ? 'Cancel' : 'Back'))
const nextButtonText = computed(() =>
showAddonSelect.value ? 'Continue' : 'Continue and upgrade'
showAddonSelect.value || statusIsCanceled.value ? 'Continue' : 'Continue and upgrade'
)
const onSubmit = () => {
if (!props.workspaceId) return
upgradePlan({
plan: finalNewPlan.value,
cycle: props.billingInterval,
workspaceId: props.workspaceId
})
if (!subscription.value || statusIsCanceled.value) {
redirectToCheckout({
plan: finalNewPlan.value,
cycle: props.billingInterval,
workspaceId: props.workspaceId
})
} else {
upgradePlan({
plan: finalNewPlan.value,
cycle: props.billingInterval,
workspaceId: props.workspaceId
})
}
isOpen.value = false
}
@@ -145,7 +155,7 @@ watch(
if (newVal) {
showAddonSelect.value = props.isChangingPlan && !isSamePlanWithAddon.value
// If the add-on is required or already included, set it to yes
if (usageExceedsNewPlanLimit.value) {
if (usageExceedsNewPlanLimit.value && props.isChangingPlan) {
includeUnlimitedAddon.value = 'yes'
} else {
includeUnlimitedAddon.value = undefined
@@ -1,65 +0,0 @@
<template>
<LayoutDialog v-model:open="open" max-width="xs" :buttons="dialogButtons">
<template #header>Leave workspace?</template>
<div class="flex flex-col gap-4 mb-4 -mt-1">
<p>
You will no longer have access to projects in the
<span class="font-medium">{{ workspace?.name }}</span>
workspace.
</p>
</div>
</LayoutDialog>
</template>
<script setup lang="ts">
import type { LayoutDialogButton } from '@speckle/ui-components'
import { useWorkspaceUpdateRole } from '~/lib/workspaces/composables/management'
import type { SettingsWorkspacesMembersTable_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
import { useActiveUser } from '~/lib/auth/composables/activeUser'
import type { MaybeNullOrUndefined } from '@speckle/shared'
import { useNavigation } from '~/lib/navigation/composables/navigation'
import { homeRoute } from '~/lib/common/helpers/route'
const props = defineProps<{
workspace: MaybeNullOrUndefined<SettingsWorkspacesMembersTable_WorkspaceFragment>
isOnlyAdmin: boolean
}>()
const emit = defineEmits<{
(e: 'success'): void
}>()
const open = defineModel<boolean>('open', { required: true })
const { activeUser } = useActiveUser()
const updateUserRole = useWorkspaceUpdateRole()
const { mutateActiveWorkspaceSlug } = useNavigation()
const router = useRouter()
const handleConfirm = async () => {
if (!props.workspace?.id || !activeUser.value?.id) return
await updateUserRole({
userId: activeUser.value.id,
role: null,
workspaceId: props.workspace.id
})
mutateActiveWorkspaceSlug(null)
router.push(homeRoute)
open.value = false
emit('success')
}
const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Cancel',
props: { color: 'outline' },
onClick: () => (open.value = false)
},
{
text: 'Leave',
onClick: handleConfirm
}
])
</script>
@@ -55,11 +55,10 @@
@success="onDialogSuccess"
/>
<SettingsWorkspacesMembersActionsLeaveWorkspaceDialog
<SettingsWorkspacesGeneralLeaveDialog
v-if="dialogToShow.leaveWorkspace"
v-model:open="showDialog"
:workspace="workspace"
:is-only-admin="hasSingleAdmin"
/>
<SettingsWorkspacesMembersActionsProjectPermissionsDialog
@@ -2,25 +2,47 @@
<BillingTransitionCards :current-state="currentSeat" :new-state="newSeat">
<template #current-state>
<div class="flex items-center justify-between">
<div>
<div class="text-heading-sm">{{ currentSeat.title }}</div>
<div class="text-body-2xs">{{ currentSeat.description }}</div>
<div class="flex items-center gap-2">
<div
class="bg-foundation rounded-full border border-outline-3 h-10 w-10 flex items-center justify-center"
>
<component :is="currentSeat.icon" class="w-5 h-5 text-foreground-2" />
</div>
<div>
<div class="text-body-2xs text-foreground">{{ currentSeat.title }}</div>
<div class="text-body-3xs text-foreground-2">
{{ currentSeat.description }}
</div>
</div>
</div>
<div class="text-body-3xs font-medium text-foreground-2">Current</div>
</div>
</template>
<template #new-state>
<div class="flex items-center justify-between">
<div>
<div class="text-heading-sm">{{ newSeat.title }}</div>
<div class="text-body-2xs">{{ newSeat.description }}</div>
<div class="flex items-center gap-2">
<div
class="bg-foundation rounded-full border border-blue-200 h-10 w-10 flex items-center justify-center"
>
<component :is="newSeat.icon" class="w-5 h-5 text-foreground-2" />
</div>
<div>
<div class="text-body-2xs text-foreground">{{ newSeat.title }}</div>
<div class="text-body-3xs text-foreground-2">{{ newSeat.description }}</div>
</div>
</div>
<div v-if="isUpgrading" class="ml-auto flex items-center gap-1 font-medium">
<template v-if="hasAvailableSeat || isFreePlan">
<div class="line-through text-foreground-2">{{ seatPrice }}/month</div>
<template v-if="hasAvailableSeat">
<div class="line-through text-foreground-2">
{{ seatPrice }}/{{ billingInterval }}
</div>
<div class="text-primary">Free</div>
</template>
<template v-else-if="isFreePlan || isUnlimitedPlan">
<div class="text-primary">Free</div>
</template>
<template v-else>
<div class="text-primary">{{ seatPrice }}/month</div>
<div class="text-primary">{{ seatPrice }}/{{ billingInterval }}</div>
</template>
</div>
<div v-else class="ml-auto text-primary font-medium">Free</div>
@@ -39,6 +61,7 @@ const props = defineProps<{
isGuest: boolean
hasAvailableSeat: boolean
seatPrice: string
billingInterval: 'monthly' | 'yearly'
}>()
const editorDescription = computed(() =>
@@ -23,13 +23,13 @@
<CommonCard class="!py-3">
<p class="text-body-xs font-medium text-foreground">
{{
isFreePlan || isUnlimitedPlan
? 'Seat upgrade required'
isFreePlan || hasAvailableEditorSeats || isUnlimitedPlan
? 'Seat change required'
: 'Seat purchase required'
}}
</p>
<p class="text-body-2xs text-foreground mb-4">
All admins need to be on a paid Editor seat.
<p class="text-body-2xs text-foreground mb-4 mt-2">
Admins have to be on an Editor seat.
</p>
<SeatTransitionCards
:is-upgrading="true"
@@ -38,6 +38,7 @@
:is-guest="false"
:has-available-seat="hasAvailableEditorSeats"
:seat-price="editorSeatPriceFormatted"
:billing-interval="intervalIsYearly ? 'yearly' : 'monthly'"
/>
<template v-if="needsEditorUpgrade && !isFreePlan && !isUnlimitedPlan">
<p
@@ -47,38 +48,23 @@
You have an unused Editor seat that is already paid for, so the change
will not incur any charges.
</p>
<p v-else class="text-foreground-2 text-body-xs mt-4">
Note that the Editor seat is a paid seat type and this change will incur
additional charges to your subscription.
<p v-else class="text-foreground-2 text-body-xs mt-4 leading-5">
You'll be charged immediately for the partial period from today until your
plan renewal on {{ currentBillingCycleEnd }} ({{
editorSeatPriceFormatted
}}/{{ intervalIsYearly ? 'year' : 'month' }} adjusted for the remaining
time).
</p>
</template>
</CommonCard>
</template>
<p class="text-foreground-2 text-body-2xs">
{{ roleInfo }} Learn more about
<NuxtLink
:to="LearnMoreRolesSeatsUrl"
target="_blank"
class="text-foreground-2 underline"
>
workspace roles.
</NuxtLink>
</p>
<p v-if="isPaidPlan" class="text-foreground-2 text-body-xs mt-3">
Note that the Editor seat is a paid seat type if your workspace is subscribed to
one of the paid plans.
</p>
</div>
</LayoutDialog>
</template>
<script setup lang="ts">
import type { LayoutDialogButton } from '@speckle/ui-components'
import { LearnMoreRolesSeatsUrl } from '~/lib/common/helpers/route'
import { Roles, SeatTypes } from '@speckle/shared'
import { WorkspaceRoleDescriptions } from '~/lib/settings/helpers/constants'
import { useWorkspaceUpdateRole } from '~/lib/workspaces/composables/management'
import { useWorkspacePlan } from '~/lib/workspaces/composables/plan'
import SeatTransitionCards from './SeatTransitionCards.vue'
@@ -106,8 +92,9 @@ const {
hasAvailableEditorSeats,
isFreePlan,
isUnlimitedPlan,
isPaidPlan,
editorSeatPriceFormatted
editorSeatPriceFormatted,
intervalIsYearly,
currentBillingCycleEnd
} = useWorkspacePlan(props.workspace?.slug || '')
const needsEditorUpgrade = computed(() => {
@@ -128,7 +115,7 @@ const title = computed(() => {
const buttonText = computed(() => {
switch (props.action) {
case 'make':
return needsEditorUpgrade.value ? 'Upgrade and make admin' : 'Make an admin'
return needsEditorUpgrade.value ? 'Confirm and pay' : 'Make an admin'
case 'remove':
return 'Revoke admin access'
default:
@@ -147,12 +134,6 @@ const mainMessage = computed(() => {
}
})
const roleInfo = computed(() => {
return props.action === 'make'
? undefined
: WorkspaceRoleDescriptions[Roles.Workspace.Member]
})
const handleConfirm = async () => {
if (!props.workspace?.id) return
@@ -11,18 +11,12 @@
:is-guest="user.role === Roles.Workspace.Guest"
:has-available-seat="hasAvailableEditorSeats"
:seat-price="editorSeatPriceFormatted"
:billing-interval="intervalIsYearly ? 'yearly' : 'monthly'"
/>
<p v-if="billingMessage" class="text-foreground-2 text-body-xs mt-4">
{{ billingMessage }}
</p>
<NuxtLink
:to="LearnMoreRolesSeatsUrl"
class="text-foreground-2 text-body-xs underline mt-3"
>
Learn more about seats
</NuxtLink>
</div>
</LayoutDialog>
</template>
@@ -36,7 +30,6 @@ import {
} from '@speckle/shared'
import { useWorkspaceUpdateSeatType } from '~/lib/workspaces/composables/management'
import { useWorkspacePlan } from '~/lib/workspaces/composables/plan'
import { LearnMoreRolesSeatsUrl } from '~/lib/common/helpers/route'
import SeatTransitionCards from './SeatTransitionCards.vue'
import type {
SettingsWorkspacesMembersActionsMenu_UserFragment,
@@ -74,7 +67,7 @@ const billingMessage = computed(() => {
if (isUpgrading.value) {
return hasAvailableEditorSeats.value
? 'You have an unused Editor seat that is already paid for, so the change will not incur any charges.'
: `This adds an extra Editor seat to your subscription, increasing your total billing by ${editorSeatPriceFormatted.value}/${annualOrMonthly.value}.`
: `You'll be charged immediately for the partial period from today until your plan renewal on ${currentBillingCycleEnd.value} (${editorSeatPriceFormatted.value}/${annualOrMonthly.value} adjusted for the remaining time).`
} else {
return isPaidPlan.value
? `The Editor seat will still be paid for until your plan renews on ${currentBillingCycleEnd.value}. You can freely reassign it to another person.`
@@ -110,7 +103,11 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
onClick: () => (open.value = false)
},
{
text: isUpgrading.value ? 'Confirm and upgrade' : 'Confirm and downgrade',
text: isUpgrading.value
? isFreePlan.value || hasAvailableEditorSeats.value || isUnlimitedPlan.value
? 'Upgrade seat'
: 'Confirm and pay'
: 'Downgrade seat',
props: {
color: 'primary'
},
@@ -8,7 +8,7 @@
<template v-if="project?.workspace && isWorkspacesEnabled">
<HeaderNavLink
:to="workspaceRoute(project?.workspace.slug)"
name="Home"
name="Projects"
:separator="false"
/>
</template>
@@ -28,7 +28,7 @@
</template>
<template v-else>
<CommonTiptapTextEditor
v-if="comment.text.doc"
v-if="comment?.text?.doc"
:model-value="comment.text.doc"
:schema-options="{ multiLine: false }"
:project-id="projectId"
@@ -124,7 +124,7 @@ const onDownloadClick = async () => {
}
}
const attachmentList = computed(() => props.attachments.text.attachments || [])
const attachmentList = computed(() => props.attachments?.text?.attachments || [])
const dialogButtons = computed((): Optional<LayoutDialogButton[]> => {
if (!dialogAttachment.value) return undefined
@@ -62,7 +62,9 @@ const mixpanel = useMixpanel()
const { logout } = useAuthManager()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { result } = useQuery(activeUserWorkspaceExistenceCheckQuery)
const { result } = useQuery(activeUserWorkspaceExistenceCheckQuery, null, {
enabled: isWorkspacesEnabled.value
})
const isCancelDialogOpen = ref(false)
@@ -0,0 +1,151 @@
<template>
<svg
width="259"
height="231"
viewBox="0 0 259 231"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.49495 103.869L166.728 50.7911C170.436 49.5552 175.387 51.0542 177.786 54.1393L253.207 151.108C255.606 154.193 254.546 157.696 250.838 158.932L91.6048 212.01C87.897 213.246 82.946 211.747 80.5464 208.662L5.12605 111.693C2.72652 108.608 3.78711 105.105 7.49495 103.869Z"
stroke="currentColor"
class="stroke-outline-3"
/>
<path
d="M7.49495 79.8688L166.728 26.7911C170.436 25.5552 175.387 27.0542 177.786 30.1393L253.207 127.108C255.606 130.193 254.546 133.696 250.838 134.932L91.6048 188.01C87.897 189.246 82.946 187.747 80.5464 184.662L5.12605 87.6928C2.72652 84.6077 3.78711 81.1047 7.49495 79.8688Z"
fill="currentColor"
class="fill-foundation-page"
/>
<path
d="M7.49495 79.8688L166.728 26.7911C170.436 25.5552 175.387 27.0542 177.786 30.1393L253.207 127.108C255.606 130.193 254.546 133.696 250.838 134.932L91.6048 188.01C87.897 189.246 82.946 187.747 80.5464 184.662L5.12605 87.6928C2.72652 84.6077 3.78711 81.1047 7.49495 79.8688Z"
stroke="currentColor"
class="stroke-outline-2"
/>
<path
d="M5.12605 63.6928C2.72652 60.6077 3.78711 57.1047 7.49495 55.8688L166.728 2.7911C170.436 1.55516 175.387 3.0542 177.786 6.1393L253.207 103.108C255.606 106.193 254.546 109.696 250.838 110.932L91.6048 164.01C87.897 165.246 82.946 163.747 80.5464 160.662L5.12605 63.6928Z"
fill="currentColor"
class="fill-foundation-page"
/>
<path
d="M5.12605 63.6928C2.72652 60.6077 3.78711 57.1047 7.49495 55.8688L166.728 2.7911C170.436 1.55516 175.387 3.0542 177.786 6.1393L253.207 103.108C255.606 106.193 254.546 109.696 250.838 110.932L91.6048 164.01C87.897 165.246 82.946 163.747 80.5464 160.662L5.12605 63.6928Z"
stroke="currentColor"
class="stroke-outline-5"
/>
<g clip-path="url(#clip0_2756_30947)">
<rect
width="167"
height="90"
rx="8"
transform="matrix(0.948683 -0.316228 0.613941 0.789352 22.3242 74.2852)"
fill="currentColor"
class="fill-foundation-2"
/>
<g clip-path="url(#clip1_2756_30947)">
<path
d="M101.22 114.809L140.11 109.324L122.02 86.0658L88.6736 98.6774L101.22 114.809Z"
fill="currentColor"
stroke="currentColor"
class="stroke-outline-5 fill-foundation-page"
stroke-linejoin="round"
/>
<path
d="M182.269 84.635L140.11 109.325L122.02 86.0664L172.064 71.5134L182.269 84.635Z"
fill="currentColor"
stroke="currentColor"
class="stroke-outline-5 fill-foundation-2"
stroke-linejoin="round"
/>
<path
d="M104.422 71.1033L133.433 53.254L149.158 73.4714L116.35 86.4403L104.422 71.1033Z"
fill="currentColor"
stroke="currentColor"
class="stroke-outline-5 fill-foundation-page"
stroke-linejoin="round"
/>
<path
d="M177.531 82.4527L139.875 101.358L131.213 90.221L172.233 75.6399L177.531 82.4527Z"
fill="currentColor"
stroke="currentColor"
class="stroke-outline-5 fill-foundation-page"
stroke-linejoin="round"
/>
<path
d="M93.6025 96.8154L106.966 113.998"
stroke="currentColor"
class="stroke-outline-5"
stroke-miterlimit="10"
/>
<path
d="M99.2197 94.6914L113.518 113.075"
stroke="currentColor"
class="stroke-outline-5"
stroke-miterlimit="10"
/>
<path
d="M105.677 92.249L121.049 112.013"
stroke="currentColor"
class="stroke-outline-5"
stroke-miterlimit="10"
/>
<path
d="M113.186 89.4092L129.806 110.778"
stroke="currentColor"
class="stroke-outline-5"
stroke-miterlimit="10"
/>
<path
d="M123.992 88.6017L173.992 72.6842L170.376 68.0345L117.498 80.252L123.992 88.6017Z"
fill="currentColor"
stroke="currentColor"
class="stroke-outline-5 fill-foundation-2"
stroke-linejoin="round"
/>
<path
d="M123.992 88.6018L88.9523 100.816L84.5128 95.1082L117.498 80.252L123.992 88.6018Z"
fill="currentColor"
stroke="currentColor"
class="stroke-outline-5 fill-foundation-page"
stroke-linejoin="round"
/>
<path
d="M166.935 68.8295L148.805 73.0177L133.434 53.2539L154.727 53.1339L166.935 68.8295Z"
fill="currentColor"
stroke="currentColor"
class="stroke-outline-5 fill-foundation-2"
stroke-linejoin="round"
/>
</g>
</g>
<rect
x="0.781312"
y="0.236562"
width="166"
height="89"
rx="7.5"
transform="matrix(0.948683 -0.316228 0.613941 0.789352 22.2191 74.5821)"
stroke="currentColorr"
class="stroke-outline-5"
/>
<defs>
<clipPath id="clip0_2756_30947">
<rect
width="167"
height="90"
rx="8"
transform="matrix(0.948683 -0.316228 0.613941 0.789352 22.3242 74.2852)"
fill="currentColor"
class="fill-foundation"
/>
</clipPath>
<clipPath id="clip1_2756_30947">
<rect
width="89.5004"
height="58.6479"
fill="currentColor"
transform="matrix(0.948683 -0.316228 0.613941 0.789352 68.7334 74.8203)"
class="fill-foundation"
/>
</clipPath>
</defs>
</svg>
</template>
@@ -14,7 +14,7 @@
<Portal v-if="workspace?.name" to="navigation">
<HeaderNavLink
:to="workspaceRoute(workspaceSlug)"
name="Home"
name="Projects"
:separator="false"
/>
</Portal>
@@ -46,15 +46,19 @@
<section
v-if="showEmptyState"
class="bg-foundation border border-outline-2 rounded-md h-96 flex flex-col items-center justify-center gap-4"
class="bg-foundation-page h-96 flex flex-col items-center justify-center gap-4"
>
<WorkspaceEmptyStateIllustration />
<span class="text-body-2xs text-foreground-2 text-center">
Workspace is empty
</span>
<WorkspaceHeaderAddProjectMenu
button-copy="Add your first project"
:workspace-name="workspace?.name || ''"
:workspace-slug="workspaceSlug"
:workspace-plan="workspace?.plan?.name ? workspace?.plan?.name : null"
:can-create-project="canCreateProject"
:can-move-project="canMoveProject"
:can-move-project-to-workspace="canMoveProjectToWorkspace"
@new-project="openNewProject = true"
@move-project="showMoveProjectsDialog = true"
/>
@@ -83,7 +87,7 @@
<script setup lang="ts">
import { MagnifyingGlassIcon, Squares2X2Icon } from '@heroicons/vue/24/outline'
import { useQuery, useQueryLoading } from '@vue/apollo-composable'
import { Roles, type Nullable, type Optional, type StreamRoles } from '@speckle/shared'
import type { Nullable, Optional, StreamRoles } from '@speckle/shared'
import {
workspacePageQuery,
workspaceProjectsQuery
@@ -91,10 +95,7 @@ import {
import { useDebouncedTextInput } from '@speckle/ui-components'
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
import { graphql } from '~~/lib/common/generated/gql'
import type {
FullPermissionCheckResultFragment,
WorkspaceProjectsQueryQueryVariables
} from '~~/lib/common/generated/gql/graphql'
import type { WorkspaceProjectsQueryQueryVariables } from '~~/lib/common/generated/gql/graphql'
import { workspaceRoute } from '~/lib/common/helpers/route'
import { useBillingActions } from '~/lib/billing/composables/actions'
import { useWorkspacesWizard } from '~/lib/workspaces/composables/wizard'
@@ -195,14 +196,9 @@ const { finalizeWizard } = useWorkspacesWizard()
const canCreateProject = computed(
() => initialQueryResult.value?.workspaceBySlug?.permissions.canCreateProject
)
const canMoveProject = computed((): FullPermissionCheckResultFragment => {
// TODO: Until we have a real resolver
return {
authorized: isWorkspaceAdmin.value,
message: isWorkspaceAdmin.value ? 'OK' : 'You must be a workspace admin',
code: isWorkspaceAdmin.value ? 'OK' : 'FORBIDDEN'
}
})
const canMoveProjectToWorkspace = computed(
() => initialQueryResult.value?.workspaceBySlug?.permissions.canMoveProjectToWorkspace
)
const projects = computed(() => query.result.value?.workspaceBySlug?.projects)
const workspaceInvite = computed(() => initialQueryResult.value?.workspaceInvite)
@@ -213,8 +209,6 @@ const showEmptyState = computed(() => {
return projects.value && !projects.value?.items?.length
})
const isWorkspaceAdmin = computed(() => workspace.value?.role === Roles.Workspace.Admin)
const showLoadingBar = computed(() => {
const isLoading = areQueriesLoading.value || (!!search.value && query.loading.value)
@@ -2,7 +2,7 @@
<WorkspaceCard :logo="workspace.logo ?? ''" :name="workspace.name">
<template #text>
<div class="flex flex-col gap-y-1">
<div class="text-body-2xs">
<div class="text-body-2xs line-clamp-3">
{{ workspace.description }}
</div>
<div class="text-body-2xs">{{ workspace.team?.totalCount }} members</div>
@@ -1,32 +1,55 @@
<template>
<LayoutMenu
v-model:open="showMenu"
:items="menuItems"
:menu-position="HorizontalDirection.Left"
:menu-id="menuId"
@click.stop.prevent
@chosen="onActionChosen"
>
<FormButton
color="outline"
:class="hideTextOnMobile ? 'hidden md:block' : ''"
@click="showMenu = !showMenu"
<div>
<LayoutMenu
v-model:open="showMenu"
:items="menuItems"
:menu-position="HorizontalDirection.Left"
:menu-id="menuId"
@click.stop.prevent
@chosen="onActionChosen"
>
<div class="flex items-center gap-1">
{{ buttonCopy || 'Add project' }}
<ChevronDownIcon class="h-3 w-3" />
<FormButton
color="outline"
:class="hideTextOnMobile ? 'hidden md:block' : ''"
@click="showMenu = !showMenu"
>
<div class="flex items-center gap-1">
{{ buttonCopy || 'Add project' }}
<ChevronDownIcon class="h-3 w-3" />
</div>
</FormButton>
<FormButton
color="outline"
:class="hideTextOnMobile ? 'md:hidden' : 'hidden'"
hide-text
:icon-left="PlusIcon"
@click="showMenu = !showMenu"
>
Add project
</FormButton>
</LayoutMenu>
<WorkspacePlanLimitReachedDialog
v-model:open="showLimitDialog"
subtitle="Upgrade your plan to move project"
>
<p class="text-body-xs text-foreground-2">
The workspace
<span class="font-bold">{{ workspaceName }}</span>
is on a {{ workspacePlan ? formatName(workspacePlan) : undefined }} plan with a
limit of 1 project and 5 models. Upgrade the workspace to add more projects.
</p>
<div class="flex justify-end gap-1">
<FormButton color="subtle" @click="showLimitDialog = false">Cancel</FormButton>
<FormButton
@click="
navigateTo(settingsWorkspaceRoutes.billing.route(props.workspaceSlug))
"
>
See plans
</FormButton>
</div>
</FormButton>
<FormButton
color="outline"
:class="hideTextOnMobile ? 'md:hidden' : 'hidden'"
hide-text
:icon-left="PlusIcon"
@click="showMenu = !showMenu"
>
Add project
</FormButton>
</LayoutMenu>
</WorkspacePlanLimitReachedDialog>
</div>
</template>
<script setup lang="ts">
@@ -34,6 +57,9 @@ import { ChevronDownIcon, PlusIcon } from '@heroicons/vue/24/outline'
import type { FullPermissionCheckResultFragment } from '~/lib/common/generated/gql/graphql'
import { HorizontalDirection } from '~~/lib/common/composables/window'
import type { LayoutMenuItem } from '~~/lib/layout/helpers/components'
import { formatName } from '~/lib/billing/helpers/plan'
import { settingsWorkspaceRoutes } from '~/lib/common/helpers/route'
import type { WorkspacePlans } from '@speckle/shared'
enum AddNewProjectActionTypes {
NewProject = 'new-project',
@@ -46,26 +72,34 @@ const emit = defineEmits<{
}>()
const props = defineProps<{
workspaceName: string
workspaceSlug: string
workspacePlan?: WorkspacePlans | null
hideTextOnMobile?: boolean
buttonCopy?: string
canCreateProject: FullPermissionCheckResultFragment | undefined
canMoveProject: FullPermissionCheckResultFragment | undefined
canMoveProjectToWorkspace: FullPermissionCheckResultFragment | undefined
}>()
const menuId = useId()
const showMenu = ref(false)
const showLimitDialog = ref(false)
const menuItems = computed<LayoutMenuItem[][]>(() => [
[
{
title: 'Create new project...',
id: AddNewProjectActionTypes.NewProject,
disabled: !props.canCreateProject?.authorized,
disabledTooltip: props.canCreateProject?.message
disabled: isDisabled.value,
disabledTooltip: isDisabled.value ? props.canCreateProject?.message : undefined
},
{
title: 'Move existing project...',
id: AddNewProjectActionTypes.MoveProject
id: AddNewProjectActionTypes.MoveProject,
disabled: isDisabled.value,
disabledTooltip: isDisabled.value
? props.canMoveProjectToWorkspace?.message
: undefined
}
]
])
@@ -73,6 +107,11 @@ const menuItems = computed<LayoutMenuItem[][]>(() => [
const onActionChosen = (params: { item: LayoutMenuItem; event: MouseEvent }) => {
const { item } = params
if (isLimitReached.value) {
showLimitDialog.value = true
return
}
switch (item.id) {
case AddNewProjectActionTypes.NewProject:
emit('new-project')
@@ -82,4 +121,12 @@ const onActionChosen = (params: { item: LayoutMenuItem; event: MouseEvent }) =>
break
}
}
const isLimitReached = computed(() => {
return props.canCreateProject?.code === 'WorkspaceLimitsReached'
})
const isDisabled = computed(() => {
return !props.canCreateProject?.authorized && !isLimitReached.value
})
</script>
@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col gap-3 lg:gap-4">
<div v-if="!isWorkspaceGuest">
<BillingAlert :workspace="workspaceInfo" :actions="billingAlertAction" />
<div v-if="!isWorkspaceGuest && showBillingAlert">
<BillingAlert :workspace="workspaceInfo" />
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-x-2">
@@ -21,9 +21,12 @@
<div class="flex gap-1.5 md:gap-2">
<WorkspaceHeaderAddProjectMenu
:workspace-name="workspaceInfo.name"
:workspace-slug="workspaceInfo.slug"
:workspace-plan="workspaceInfo.plan?.name ? workspaceInfo.plan?.name : null"
hide-text-on-mobile
:can-create-project="canCreateProject"
:can-move-project="canMoveProject"
:can-move-project-to-workspace="canMoveProjectToWorkspace"
@new-project="$emit('show-new-project-dialog')"
@move-project="$emit('show-move-projects-dialog')"
/>
@@ -58,11 +61,9 @@
import { graphql } from '~~/lib/common/generated/gql'
import {
WorkspacePlanStatuses,
type FullPermissionCheckResultFragment,
type WorkspaceHeader_WorkspaceFragment
} from '~~/lib/common/generated/gql/graphql'
import { Cog8ToothIcon } from '@heroicons/vue/24/outline'
import type { AlertAction } from '@speckle/ui-components'
import { Roles } from '@speckle/shared'
import { settingsWorkspaceRoutes } from '~/lib/common/helpers/route'
@@ -77,6 +78,9 @@ graphql(`
canCreateProject {
...FullPermissionCheckResult
}
canMoveProjectToWorkspace {
...FullPermissionCheckResult
}
}
}
`)
@@ -106,29 +110,13 @@ const isWorkspaceMember = computed(
const canCreateProject = computed(
() => props.workspaceInfo.permissions.canCreateProject
)
const canMoveProject = computed((): FullPermissionCheckResultFragment => {
// TODO: Until we have a real resolver
return {
authorized: isWorkspaceAdmin.value,
message: isWorkspaceAdmin.value ? 'OK' : 'You must be a workspace admin',
code: isWorkspaceAdmin.value ? 'OK' : 'FORBIDDEN'
}
})
const billingAlertAction = computed<Array<AlertAction>>(() => {
if (
isWorkspaceAdmin.value ||
props.workspaceInfo.plan?.status === WorkspacePlanStatuses.Expired
) {
return [
{
title: 'Subscribe',
onClick: () =>
navigateTo(settingsWorkspaceRoutes.billing.route(props.workspaceInfo.slug))
}
]
}
return []
})
const canMoveProjectToWorkspace = computed(
() => props.workspaceInfo.permissions.canMoveProjectToWorkspace
)
const showBillingAlert = computed(
() =>
props.workspaceInfo.plan?.status === WorkspacePlanStatuses.PaymentFailed ||
props.workspaceInfo.plan?.status === WorkspacePlanStatuses.Canceled ||
props.workspaceInfo.plan?.status === WorkspacePlanStatuses.CancelationScheduled
)
</script>
@@ -4,10 +4,7 @@
<WorkspaceMoveProjectSelectProject
v-if="!selectedProject"
:workspace-slug="workspaceSlug"
:can-move-to-workspace="canMoveToWorkspace"
:is-sso-required="isSsoRequired"
:is-limit-reached="isLimitReached"
:get-disabled-tooltip="getDisabledTooltip"
:project-permissions="projectResult?.project.permissions.canMoveToWorkspace"
@project-selected="onProjectSelected"
/>
@@ -15,10 +12,9 @@
<WorkspaceMoveProjectSelectWorkspace
v-if="selectedProject && activeDialog === 'workspace'"
:project="selectedProject"
:can-move-to-workspace="canMoveToWorkspace"
:is-sso-required="isSsoRequired"
:is-limit-reached="isLimitReached"
:get-disabled-tooltip="getDisabledTooltip"
:workspace-permissions="
workspaceResult?.workspaceBySlug.permissions.canMoveProjectToWorkspace
"
@workspace-selected="onWorkspaceSelected"
/>
@@ -56,7 +52,6 @@
import { useQuery } from '@vue/apollo-composable'
import { graphql } from '~~/lib/common/generated/gql'
import type {
FullPermissionCheckResultFragment,
WorkspaceMoveProjectManager_ProjectFragment,
WorkspaceMoveProjectManager_WorkspaceFragment
} from '~/lib/common/generated/gql/graphql'
@@ -82,6 +77,11 @@ graphql(`
graphql(`
fragment WorkspaceMoveProjectManager_Project on Project {
...WorkspaceMoveProjectManager_ProjectBase
permissions {
canMoveToWorkspace {
...FullPermissionCheckResult
}
}
workspace {
id
permissions {
@@ -136,43 +136,6 @@ const selectedWorkspace = ref<WorkspaceMoveProjectManager_WorkspaceFragment | nu
null
)
// Permission check computeds
const isSsoRequired = computed(
() => (permission: FullPermissionCheckResultFragment) => {
return permission?.code === 'WorkspaceSsoSessionNoAccess'
}
)
const isLimitReached = computed(
() => (permission: FullPermissionCheckResultFragment) => {
return permission?.code === 'WorkspaceLimitsReached'
}
)
const canMoveToWorkspace = computed(
() => (permission: FullPermissionCheckResultFragment) => {
return permission?.authorized && permission?.code === 'OK'
}
)
const getDisabledTooltip = computed(
() => (permission: FullPermissionCheckResultFragment) => {
if (permission?.code === 'WorkspaceLimitsReached') {
return undefined
}
if (permission?.code === 'WorkspaceSsoSessionNoAccess') {
return 'SSO login required to access this workspace'
}
if (!permission?.authorized) {
return permission?.message
}
return undefined
}
)
// Dialog states based on what we have
const activeDialog = computed(() => {
if (!selectedProject.value) return 'project'
@@ -37,7 +37,7 @@
</div>
</div>
<div
:key="`${project.id}-${project.workspace?.permissions?.canMoveProjectToWorkspace?.code}`"
:key="`${project.id}-${project.permissions.canMoveToWorkspace.code}`"
v-tippy="getProjectTooltip(project)"
>
<FormButton
@@ -65,7 +65,16 @@
<WorkspacePlanLimitReachedDialog
v-model:open="showLimitDialog"
subtitle="Upgrade your plan to move project"
></WorkspacePlanLimitReachedDialog>
>
<template v-if="limitReachedWorkspace">
<p class="text-body-xs text-foreground-2">
The workspace
<span class="font-bold">{{ limitReachedWorkspace.name }}</span>
is on a {{ formatName(limitReachedWorkspace.plan?.name) }} plan with a limit
of 1 project and 5 models. Upgrade the workspace to add more projects.
</p>
</template>
</WorkspacePlanLimitReachedDialog>
</div>
</template>
@@ -76,11 +85,13 @@ import {
useDebouncedTextInput
} from '@speckle/ui-components'
import type {
FullPermissionCheckResultFragment,
WorkspaceMoveProjectManager_ProjectFragment
PermissionCheckResult,
WorkspaceMoveProjectManager_ProjectFragment,
WorkspaceMoveProjectManager_WorkspaceFragment
} from '~~/lib/common/generated/gql/graphql'
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
import { workspaceMoveProjectManagerUserQuery } from '~/lib/workspaces/graphql/queries'
import { formatName } from '~/lib/billing/helpers/plan'
const search = defineModel<string>('search')
const { on, bind } = useDebouncedTextInput({ model: search })
@@ -91,12 +102,7 @@ const emit = defineEmits<{
const props = defineProps<{
workspaceSlug?: string
canMoveToWorkspace: (permission: FullPermissionCheckResultFragment) => boolean
isLimitReached: (permission: FullPermissionCheckResultFragment) => boolean
isSsoRequired: (permission: FullPermissionCheckResultFragment) => boolean
getDisabledTooltip: (
permission: FullPermissionCheckResultFragment
) => string | undefined
projectPermissions?: PermissionCheckResult
}>()
const {
@@ -122,6 +128,9 @@ const {
})
const showLimitDialog = ref(false)
const limitReachedWorkspace = ref<WorkspaceMoveProjectManager_WorkspaceFragment | null>(
null
)
const userProjects = computed(() => result.value?.activeUser?.projects.items || [])
const moveableProjects = computed(() => userProjects.value)
@@ -129,16 +138,27 @@ const hasMoveableProjects = computed(() => moveableProjects.value.length > 0)
const isProjectDisabled = computed(
() => (project: WorkspaceMoveProjectManager_ProjectFragment) => {
if (!props.workspaceSlug) {
if (project.permissions.canMoveToWorkspace.authorized) {
return false
}
return true
}
)
return !canMoveProject.value(project) && !isProjectLimitReached.value(project)
const getProjectTooltip = computed(
() => (project: WorkspaceMoveProjectManager_ProjectFragment) => {
if (project.permissions.canMoveToWorkspace.authorized) {
return undefined
}
return project.permissions.canMoveToWorkspace.message
}
)
const onMoveClick = (project: WorkspaceMoveProjectManager_ProjectFragment) => {
if (props.workspaceSlug && isProjectLimitReached.value(project)) {
if (props.workspaceSlug) {
limitReachedWorkspace.value = {
name: props.workspaceSlug
} as WorkspaceMoveProjectManager_WorkspaceFragment
showLimitDialog.value = true
return
}
@@ -147,38 +167,4 @@ const onMoveClick = (project: WorkspaceMoveProjectManager_ProjectFragment) => {
}
const showLoading = computed(() => loading.value && userProjects.value.length === 0)
const getProjectPermission = (project: WorkspaceMoveProjectManager_ProjectFragment) => {
return (
project.workspace?.permissions?.canMoveProjectToWorkspace || {
authorized: false,
code: '',
message: ''
}
)
}
const canMoveProject = computed(
() => (project: WorkspaceMoveProjectManager_ProjectFragment) => {
const permission = getProjectPermission(project)
return props.canMoveToWorkspace(permission)
}
)
const isProjectLimitReached = computed(
() => (project: WorkspaceMoveProjectManager_ProjectFragment) => {
const permission = getProjectPermission(project)
return props.isLimitReached(permission)
}
)
const getProjectTooltip = computed(
() => (project: WorkspaceMoveProjectManager_ProjectFragment) => {
const permission = getProjectPermission(project)
if (props.isLimitReached(permission)) {
return undefined
}
return props.getDisabledTooltip(permission)
}
)
</script>
@@ -26,7 +26,7 @@
<template #text>
<div class="flex flex-col gap-2 items-start">
<CommonBadge
v-if="isSsoRequired(ws.permissions?.canMoveProjectToWorkspace)"
v-if="isSsoRequired(ws)"
color="secondary"
class="capitalize"
rounded
@@ -80,9 +80,9 @@
<script setup lang="ts">
import { graphql } from '~~/lib/common/generated/gql'
import type {
PermissionCheckResult,
WorkspaceMoveProjectManager_ProjectFragment,
WorkspaceMoveProjectManager_WorkspaceFragment,
FullPermissionCheckResultFragment
WorkspaceMoveProjectManager_WorkspaceFragment
} from '~~/lib/common/generated/gql/graphql'
import { useQuery } from '@vue/apollo-composable'
import { UserAvatarGroup } from '@speckle/ui-components'
@@ -109,12 +109,7 @@ graphql(`
const props = defineProps<{
project: WorkspaceMoveProjectManager_ProjectFragment
canMoveToWorkspace: (permission: FullPermissionCheckResultFragment) => boolean
isLimitReached: (permission: FullPermissionCheckResultFragment) => boolean
isSsoRequired: (permission: FullPermissionCheckResultFragment) => boolean
getDisabledTooltip: (
permission: FullPermissionCheckResultFragment
) => string | undefined
workspacePermissions?: PermissionCheckResult
}>()
const emit = defineEmits<{
@@ -157,31 +152,38 @@ const isWorkspaceDisabled = computed(
return true
}
return (
!props.canMoveToWorkspace(workspace.permissions?.canMoveProjectToWorkspace) &&
!props.isLimitReached(workspace.permissions?.canMoveProjectToWorkspace)
)
const permission = workspace.permissions?.canMoveProjectToWorkspace
return !permission?.authorized && permission?.code !== 'WorkspaceLimitsReached'
}
)
const getWorkspaceTooltip = computed(
() => (workspace: WorkspaceMoveProjectManager_WorkspaceFragment) => {
if (workspace.permissions.canMoveProjectToWorkspace.authorized) {
return undefined
}
if (
workspace.permissions.canMoveProjectToWorkspace.code === 'WorkspaceLimitsReached'
) {
return undefined
}
if (!isWorkspaceAdmin.value(workspace)) {
return 'Only workspace administrators can move projects to this workspace'
}
return props.getDisabledTooltip(workspace.permissions?.canMoveProjectToWorkspace)
const permission = workspace.permissions?.canMoveProjectToWorkspace
return permission?.message
}
)
const sortedWorkspaces = computed(() => {
return [...workspaces.value].sort((a, b) => {
const aEnabled =
props.canMoveToWorkspace(a.permissions?.canMoveProjectToWorkspace) ||
props.isLimitReached(a.permissions?.canMoveProjectToWorkspace)
a.permissions?.canMoveProjectToWorkspace?.authorized ||
a.permissions?.canMoveProjectToWorkspace?.code === 'WorkspaceLimitsReached'
const bEnabled =
props.canMoveToWorkspace(b.permissions?.canMoveProjectToWorkspace) ||
props.isLimitReached(b.permissions?.canMoveProjectToWorkspace)
b.permissions?.canMoveProjectToWorkspace?.authorized ||
b.permissions?.canMoveProjectToWorkspace?.code === 'WorkspaceLimitsReached'
if (aEnabled && !bEnabled) return -1
if (!aEnabled && bEnabled) return 1
@@ -192,14 +194,24 @@ const sortedWorkspaces = computed(() => {
const handleWorkspaceClick = (
workspace: WorkspaceMoveProjectManager_WorkspaceFragment
) => {
if (props.isLimitReached(workspace.permissions?.canMoveProjectToWorkspace)) {
const permission = workspace.permissions?.canMoveProjectToWorkspace
if (permission?.code === 'WorkspaceLimitsReached') {
limitReachedWorkspace.value = workspace
showLimitDialog.value = true
return
}
if (props.canMoveToWorkspace(workspace.permissions?.canMoveProjectToWorkspace)) {
if (permission?.authorized) {
emit('workspace-selected', workspace)
}
}
const isSsoRequired = computed(
() => (workspace: WorkspaceMoveProjectManager_WorkspaceFragment) => {
return (
workspace.permissions?.canMoveProjectToWorkspace?.code ===
'WorkspaceSsoSessionNoAccess'
)
}
)
</script>
@@ -19,7 +19,7 @@
{{
plan === WorkspacePlans.Free && !isYearlySelected
? 'Get started for free'
: `Subscribe to ${startCase(plan)}`
: `Subscribe to ${formatName(plan)}`
}}
</FormButton>
</template>
@@ -39,7 +39,7 @@ import { BillingInterval } from '~/lib/common/generated/gql/graphql'
import { useWorkspacesWizard } from '~/lib/workspaces/composables/wizard'
import { useMixpanel } from '~/lib/core/composables/mp'
import { WorkspacePlans, type PaidWorkspacePlans } from '@speckle/shared'
import { startCase } from 'lodash'
import { formatName } from '~/lib/billing/helpers/plan'
const { goToNextStep, goToPreviousStep, state } = useWorkspacesWizard()
const mixpanel = useMixpanel()
@@ -40,7 +40,7 @@ type Documents = {
"\n fragment AutomateRunsTriggerStatusDialogRunsRows_AutomateRun on AutomateRun {\n id\n functionRuns {\n id\n ...AutomateRunsTriggerStatusDialogFunctionRun_AutomateFunctionRun\n }\n ...AutomationsStatusOrderedRuns_AutomationRun\n }\n": typeof types.AutomateRunsTriggerStatusDialogRunsRows_AutomateRunFragmentDoc,
"\n fragment AutomateViewerPanel_AutomateRun on AutomateRun {\n id\n functionRuns {\n id\n ...AutomateViewerPanelFunctionRunRow_AutomateFunctionRun\n }\n ...AutomationsStatusOrderedRuns_AutomationRun\n }\n": typeof types.AutomateViewerPanel_AutomateRunFragmentDoc,
"\n fragment AutomateViewerPanelFunctionRunRow_AutomateFunctionRun on AutomateFunctionRun {\n id\n results\n status\n statusMessage\n contextView\n function {\n id\n logo\n name\n }\n createdAt\n updatedAt\n }\n": typeof types.AutomateViewerPanelFunctionRunRow_AutomateFunctionRunFragmentDoc,
"\n fragment BillingAlert_Workspace on Workspace {\n id\n plan {\n name\n status\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n": typeof types.BillingAlert_WorkspaceFragmentDoc,
"\n fragment BillingAlert_Workspace on Workspace {\n id\n role\n slug\n plan {\n name\n status\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n": typeof types.BillingAlert_WorkspaceFragmentDoc,
"\n fragment CommonModelSelectorModel on Model {\n id\n name\n }\n": typeof types.CommonModelSelectorModelFragmentDoc,
"\n fragment FormSelectModels_Model on Model {\n id\n name\n }\n": typeof types.FormSelectModels_ModelFragmentDoc,
"\n fragment FormSelectProjects_Project on Project {\n id\n name\n }\n": typeof types.FormSelectProjects_ProjectFragmentDoc,
@@ -57,7 +57,7 @@ type Documents = {
"\n fragment InviteDialogProject_Project on Project {\n id\n name\n ...InviteDialogProjectWorkspaceMembers_Project\n workspace {\n id\n name\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n }\n }\n": typeof types.InviteDialogProject_ProjectFragmentDoc,
"\n fragment InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator on WorkspaceCollaborator {\n role\n id\n user {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n": typeof types.InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaboratorFragmentDoc,
"\n fragment InviteDialogProjectWorkspaceMembers_Project on Project {\n id\n ...ProjectPageTeamInternals_Project\n workspace {\n team {\n items {\n ...InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator\n }\n }\n }\n }\n": typeof types.InviteDialogProjectWorkspaceMembers_ProjectFragmentDoc,
"\n fragment ProjectModelPageHeaderProject on Project {\n id\n name\n model(id: $modelId) {\n id\n name\n description\n }\n workspace {\n id\n slug\n name\n }\n }\n": typeof types.ProjectModelPageHeaderProjectFragmentDoc,
"\n fragment ProjectModelPageHeaderProject on Project {\n id\n name\n model(id: $modelId) {\n id\n name\n description\n }\n workspace {\n id\n slug\n name\n role\n }\n }\n": typeof types.ProjectModelPageHeaderProjectFragmentDoc,
"\n fragment ProjectModelPageVersionsPagination on Project {\n id\n visibility\n model(id: $modelId) {\n id\n versions(limit: 16, cursor: $versionsCursor) {\n cursor\n totalCount\n items {\n ...ProjectModelPageVersionsCardVersion\n }\n }\n }\n ...ProjectsModelPageEmbed_Project\n }\n": typeof types.ProjectModelPageVersionsPaginationFragmentDoc,
"\n fragment ProjectModelPageVersionsProject on Project {\n ...ProjectPageProjectHeader\n model(id: $modelId) {\n id\n name\n pendingImportedVersions {\n ...PendingFileUpload\n }\n }\n ...ProjectModelPageVersionsPagination\n ...ProjectsModelPageEmbed_Project\n workspace {\n id\n readOnly\n }\n }\n": typeof types.ProjectModelPageVersionsProjectFragmentDoc,
"\n fragment ProjectModelPageDialogDeleteVersion on Version {\n id\n message\n }\n": typeof types.ProjectModelPageDialogDeleteVersionFragmentDoc,
@@ -65,7 +65,7 @@ type Documents = {
"\n fragment ProjectModelPageDialogMoveToVersion on Version {\n id\n message\n }\n": typeof types.ProjectModelPageDialogMoveToVersionFragmentDoc,
"\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n }\n": typeof types.ProjectsModelPageEmbed_ProjectFragmentDoc,
"\n fragment ProjectModelPageVersionsCardVersion on Version {\n id\n message\n authorUser {\n ...LimitedUserAvatar\n }\n createdAt\n previewUrl\n referencedObject\n sourceApplication\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n ...ProjectModelPageDialogDeleteVersion\n ...ProjectModelPageDialogMoveToVersion\n automationsStatus {\n ...AutomateRunsTriggerStatus_TriggeredAutomationsStatus\n }\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ProjectModelPageVersionsCardVersionFragmentDoc,
"\n fragment ProjectPageProjectHeader on Project {\n id\n name\n description\n workspace {\n id\n slug\n name\n logo\n }\n }\n": typeof types.ProjectPageProjectHeaderFragmentDoc,
"\n fragment ProjectPageProjectHeader on Project {\n id\n name\n description\n workspace {\n id\n slug\n name\n logo\n role\n }\n }\n": typeof types.ProjectPageProjectHeaderFragmentDoc,
"\n fragment ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunction on AutomationRevisionFunction {\n parameters\n release {\n id\n versionTag\n createdAt\n inputSchema\n function {\n id\n }\n }\n }\n": typeof types.ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunctionFragmentDoc,
"\n fragment ProjectPageAutomationFunctionSettingsDialog_AutomationRevision on AutomationRevision {\n id\n triggerDefinitions {\n ... on VersionCreatedTriggerDefinition {\n type\n model {\n id\n ...CommonModelSelectorModel\n }\n }\n }\n }\n": typeof types.ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFragmentDoc,
"\n fragment ProjectPageAutomationFunctions_Automation on Automation {\n id\n currentRevision {\n id\n ...ProjectPageAutomationFunctionSettingsDialog_AutomationRevision\n functions {\n release {\n id\n inputSchema\n function {\n id\n ...AutomationsFunctionsCard_AutomateFunction\n releases(limit: 1) {\n items {\n id\n }\n }\n }\n }\n ...ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunction\n }\n }\n }\n": typeof types.ProjectPageAutomationFunctions_AutomationFragmentDoc,
@@ -115,7 +115,7 @@ type Documents = {
"\n fragment SettingsWorkspaceGeneralDeleteDialog_Workspace on Workspace {\n id\n name\n }\n": typeof types.SettingsWorkspaceGeneralDeleteDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesGeneralEditAvatar_Workspace on Workspace {\n id\n logo\n name\n }\n": typeof types.SettingsWorkspacesGeneralEditAvatar_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesGeneralEditSlugDialog_Workspace on Workspace {\n id\n name\n slug\n }\n": typeof types.SettingsWorkspacesGeneralEditSlugDialog_WorkspaceFragmentDoc,
"\n fragment WorkspaceBillingPage_Workspace on Workspace {\n id\n role\n subscription {\n currency\n }\n }\n": typeof types.WorkspaceBillingPage_WorkspaceFragmentDoc,
"\n fragment WorkspaceBillingPage_Workspace on Workspace {\n id\n role\n subscription {\n currency\n }\n ...BillingAlert_Workspace\n }\n": typeof types.WorkspaceBillingPage_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n inviteId\n role\n title\n updatedAt\n user {\n id\n ...LimitedUserAvatar\n }\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n }\n": typeof types.SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment SettingsWorkspacesMembersInvitesTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n invitedTeam {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n": typeof types.SettingsWorkspacesMembersInvitesTable_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersRequestsTable_Workspace on Workspace {\n ...SettingsWorkspacesMembersTableHeader_Workspace\n id\n adminWorkspacesJoinRequests {\n totalCount\n items {\n ...WorkspaceJoinRequestApproveDialog_WorkspaceJoinRequest\n id\n createdAt\n status\n user {\n id\n avatar\n name\n }\n }\n }\n }\n": typeof types.SettingsWorkspacesMembersRequestsTable_WorkspaceFragmentDoc,
@@ -136,12 +136,12 @@ type Documents = {
"\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n": typeof types.ViewerModelVersionCardItemFragmentDoc,
"\n fragment WorkspaceProjectList_Workspace on Workspace {\n id\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...WorkspaceSecurity_Workspace\n ...WorkspaceHeader_Workspace\n ...BillingAlert_Workspace\n ...InviteDialogWorkspace_Workspace\n projects {\n ...WorkspaceProjectList_ProjectCollection\n }\n creationState {\n completed\n state\n }\n permissions {\n canCreateProject {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.WorkspaceProjectList_WorkspaceFragmentDoc,
"\n fragment WorkspaceProjectList_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...ProjectDashboardItem\n }\n cursor\n }\n": typeof types.WorkspaceProjectList_ProjectCollectionFragmentDoc,
"\n fragment WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...BillingAlert_Workspace\n slug\n readOnly\n permissions {\n canCreateProject {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.WorkspaceHeader_WorkspaceFragmentDoc,
"\n fragment WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...BillingAlert_Workspace\n slug\n readOnly\n permissions {\n canCreateProject {\n ...FullPermissionCheckResult\n }\n canMoveProjectToWorkspace {\n ...FullPermissionCheckResult\n }\n }\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 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 WorkspaceMoveProjectManager_ProjectBase on Project {\n id\n name\n modelCount: models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n }\n": typeof types.WorkspaceMoveProjectManager_ProjectBaseFragmentDoc,
"\n fragment WorkspaceMoveProjectManager_Project on Project {\n ...WorkspaceMoveProjectManager_ProjectBase\n workspace {\n id\n permissions {\n canMoveProjectToWorkspace(projectId: $projectId) {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": typeof types.WorkspaceMoveProjectManager_ProjectFragmentDoc,
"\n fragment WorkspaceMoveProjectManager_Project on Project {\n ...WorkspaceMoveProjectManager_ProjectBase\n permissions {\n canMoveToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n workspace {\n id\n permissions {\n canMoveProjectToWorkspace(projectId: $projectId) {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": typeof types.WorkspaceMoveProjectManager_ProjectFragmentDoc,
"\n fragment WorkspaceMoveProjectManager_Workspace on Workspace {\n id\n role\n name\n logo\n slug\n plan {\n name\n }\n permissions {\n canMoveProjectToWorkspace(projectId: $projectId) {\n ...FullPermissionCheckResult\n }\n }\n projects {\n totalCount\n }\n team {\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n": typeof types.WorkspaceMoveProjectManager_WorkspaceFragmentDoc,
"\n fragment WorkspaceMoveProjectSelectWorkspace_User on User {\n workspaces {\n items {\n ...WorkspaceMoveProjectManager_Workspace\n }\n }\n projects(cursor: $cursor, filter: $filter) {\n items {\n ...WorkspaceMoveProjectManager_Project\n }\n cursor\n totalCount\n }\n }\n": typeof types.WorkspaceMoveProjectSelectWorkspace_UserFragmentDoc,
"\n fragment WorkspaceSidebarAbout_Workspace on Workspace {\n ...WorkspaceDashboardAbout_Workspace\n }\n": typeof types.WorkspaceSidebarAbout_WorkspaceFragmentDoc,
@@ -213,7 +213,8 @@ type Documents = {
"\n mutation SetActiveWorkspace($slug: String, $isProjectsActive: Boolean) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug, isProjectsActive: $isProjectsActive)\n }\n }\n": typeof types.SetActiveWorkspaceDocument,
"\n query NavigationActiveWorkspace($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...UseNavigationActiveWorkspace_Workspace\n }\n }\n": typeof types.NavigationActiveWorkspaceDocument,
"\n query NavigationWorkspaceList {\n activeUser {\n id\n ...UseNavigationWorkspaceList_User\n }\n }\n": typeof types.NavigationWorkspaceListDocument,
"\n query NavigationInvites {\n activeUser {\n id\n projectInvites {\n ...HeaderNavNotificationsProjectInvite_PendingStreamCollaborator\n }\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n": typeof types.NavigationInvitesDocument,
"\n query NavigationProjectInvites {\n activeUser {\n id\n projectInvites {\n ...HeaderNavNotificationsProjectInvite_PendingStreamCollaborator\n }\n }\n }\n": typeof types.NavigationProjectInvitesDocument,
"\n query NavigationWorkspaceInvites {\n activeUser {\n id\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n": typeof types.NavigationWorkspaceInvitesDocument,
"\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 seatType\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 ProjectPageTeamDialog on Project {\n id\n name\n role\n allowPublicComments\n visibility\n team {\n id\n role\n user {\n ...LimitedUserAvatar\n role\n }\n }\n invitedTeam {\n id\n title\n inviteId\n role\n user {\n ...LimitedUserAvatar\n role\n }\n }\n ...ProjectsPageTeamDialogManagePermissions_Project\n }\n": typeof types.ProjectPageTeamDialogFragmentDoc,
@@ -458,7 +459,7 @@ const documents: Documents = {
"\n fragment AutomateRunsTriggerStatusDialogRunsRows_AutomateRun on AutomateRun {\n id\n functionRuns {\n id\n ...AutomateRunsTriggerStatusDialogFunctionRun_AutomateFunctionRun\n }\n ...AutomationsStatusOrderedRuns_AutomationRun\n }\n": types.AutomateRunsTriggerStatusDialogRunsRows_AutomateRunFragmentDoc,
"\n fragment AutomateViewerPanel_AutomateRun on AutomateRun {\n id\n functionRuns {\n id\n ...AutomateViewerPanelFunctionRunRow_AutomateFunctionRun\n }\n ...AutomationsStatusOrderedRuns_AutomationRun\n }\n": types.AutomateViewerPanel_AutomateRunFragmentDoc,
"\n fragment AutomateViewerPanelFunctionRunRow_AutomateFunctionRun on AutomateFunctionRun {\n id\n results\n status\n statusMessage\n contextView\n function {\n id\n logo\n name\n }\n createdAt\n updatedAt\n }\n": types.AutomateViewerPanelFunctionRunRow_AutomateFunctionRunFragmentDoc,
"\n fragment BillingAlert_Workspace on Workspace {\n id\n plan {\n name\n status\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n": types.BillingAlert_WorkspaceFragmentDoc,
"\n fragment BillingAlert_Workspace on Workspace {\n id\n role\n slug\n plan {\n name\n status\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n": types.BillingAlert_WorkspaceFragmentDoc,
"\n fragment CommonModelSelectorModel on Model {\n id\n name\n }\n": types.CommonModelSelectorModelFragmentDoc,
"\n fragment FormSelectModels_Model on Model {\n id\n name\n }\n": types.FormSelectModels_ModelFragmentDoc,
"\n fragment FormSelectProjects_Project on Project {\n id\n name\n }\n": types.FormSelectProjects_ProjectFragmentDoc,
@@ -475,7 +476,7 @@ const documents: Documents = {
"\n fragment InviteDialogProject_Project on Project {\n id\n name\n ...InviteDialogProjectWorkspaceMembers_Project\n workspace {\n id\n name\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n }\n }\n": types.InviteDialogProject_ProjectFragmentDoc,
"\n fragment InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator on WorkspaceCollaborator {\n role\n id\n user {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n": types.InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaboratorFragmentDoc,
"\n fragment InviteDialogProjectWorkspaceMembers_Project on Project {\n id\n ...ProjectPageTeamInternals_Project\n workspace {\n team {\n items {\n ...InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator\n }\n }\n }\n }\n": types.InviteDialogProjectWorkspaceMembers_ProjectFragmentDoc,
"\n fragment ProjectModelPageHeaderProject on Project {\n id\n name\n model(id: $modelId) {\n id\n name\n description\n }\n workspace {\n id\n slug\n name\n }\n }\n": types.ProjectModelPageHeaderProjectFragmentDoc,
"\n fragment ProjectModelPageHeaderProject on Project {\n id\n name\n model(id: $modelId) {\n id\n name\n description\n }\n workspace {\n id\n slug\n name\n role\n }\n }\n": types.ProjectModelPageHeaderProjectFragmentDoc,
"\n fragment ProjectModelPageVersionsPagination on Project {\n id\n visibility\n model(id: $modelId) {\n id\n versions(limit: 16, cursor: $versionsCursor) {\n cursor\n totalCount\n items {\n ...ProjectModelPageVersionsCardVersion\n }\n }\n }\n ...ProjectsModelPageEmbed_Project\n }\n": types.ProjectModelPageVersionsPaginationFragmentDoc,
"\n fragment ProjectModelPageVersionsProject on Project {\n ...ProjectPageProjectHeader\n model(id: $modelId) {\n id\n name\n pendingImportedVersions {\n ...PendingFileUpload\n }\n }\n ...ProjectModelPageVersionsPagination\n ...ProjectsModelPageEmbed_Project\n workspace {\n id\n readOnly\n }\n }\n": types.ProjectModelPageVersionsProjectFragmentDoc,
"\n fragment ProjectModelPageDialogDeleteVersion on Version {\n id\n message\n }\n": types.ProjectModelPageDialogDeleteVersionFragmentDoc,
@@ -483,7 +484,7 @@ const documents: Documents = {
"\n fragment ProjectModelPageDialogMoveToVersion on Version {\n id\n message\n }\n": types.ProjectModelPageDialogMoveToVersionFragmentDoc,
"\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n }\n": types.ProjectsModelPageEmbed_ProjectFragmentDoc,
"\n fragment ProjectModelPageVersionsCardVersion on Version {\n id\n message\n authorUser {\n ...LimitedUserAvatar\n }\n createdAt\n previewUrl\n referencedObject\n sourceApplication\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n ...ProjectModelPageDialogDeleteVersion\n ...ProjectModelPageDialogMoveToVersion\n automationsStatus {\n ...AutomateRunsTriggerStatus_TriggeredAutomationsStatus\n }\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ProjectModelPageVersionsCardVersionFragmentDoc,
"\n fragment ProjectPageProjectHeader on Project {\n id\n name\n description\n workspace {\n id\n slug\n name\n logo\n }\n }\n": types.ProjectPageProjectHeaderFragmentDoc,
"\n fragment ProjectPageProjectHeader on Project {\n id\n name\n description\n workspace {\n id\n slug\n name\n logo\n role\n }\n }\n": types.ProjectPageProjectHeaderFragmentDoc,
"\n fragment ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunction on AutomationRevisionFunction {\n parameters\n release {\n id\n versionTag\n createdAt\n inputSchema\n function {\n id\n }\n }\n }\n": types.ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunctionFragmentDoc,
"\n fragment ProjectPageAutomationFunctionSettingsDialog_AutomationRevision on AutomationRevision {\n id\n triggerDefinitions {\n ... on VersionCreatedTriggerDefinition {\n type\n model {\n id\n ...CommonModelSelectorModel\n }\n }\n }\n }\n": types.ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFragmentDoc,
"\n fragment ProjectPageAutomationFunctions_Automation on Automation {\n id\n currentRevision {\n id\n ...ProjectPageAutomationFunctionSettingsDialog_AutomationRevision\n functions {\n release {\n id\n inputSchema\n function {\n id\n ...AutomationsFunctionsCard_AutomateFunction\n releases(limit: 1) {\n items {\n id\n }\n }\n }\n }\n ...ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunction\n }\n }\n }\n": types.ProjectPageAutomationFunctions_AutomationFragmentDoc,
@@ -533,7 +534,7 @@ const documents: Documents = {
"\n fragment SettingsWorkspaceGeneralDeleteDialog_Workspace on Workspace {\n id\n name\n }\n": types.SettingsWorkspaceGeneralDeleteDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesGeneralEditAvatar_Workspace on Workspace {\n id\n logo\n name\n }\n": types.SettingsWorkspacesGeneralEditAvatar_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesGeneralEditSlugDialog_Workspace on Workspace {\n id\n name\n slug\n }\n": types.SettingsWorkspacesGeneralEditSlugDialog_WorkspaceFragmentDoc,
"\n fragment WorkspaceBillingPage_Workspace on Workspace {\n id\n role\n subscription {\n currency\n }\n }\n": types.WorkspaceBillingPage_WorkspaceFragmentDoc,
"\n fragment WorkspaceBillingPage_Workspace on Workspace {\n id\n role\n subscription {\n currency\n }\n ...BillingAlert_Workspace\n }\n": types.WorkspaceBillingPage_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n inviteId\n role\n title\n updatedAt\n user {\n id\n ...LimitedUserAvatar\n }\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n }\n": types.SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment SettingsWorkspacesMembersInvitesTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n invitedTeam {\n ...SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator\n }\n }\n": types.SettingsWorkspacesMembersInvitesTable_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersRequestsTable_Workspace on Workspace {\n ...SettingsWorkspacesMembersTableHeader_Workspace\n id\n adminWorkspacesJoinRequests {\n totalCount\n items {\n ...WorkspaceJoinRequestApproveDialog_WorkspaceJoinRequest\n id\n createdAt\n status\n user {\n id\n avatar\n name\n }\n }\n }\n }\n": types.SettingsWorkspacesMembersRequestsTable_WorkspaceFragmentDoc,
@@ -554,12 +555,12 @@ const documents: Documents = {
"\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n": types.ViewerModelVersionCardItemFragmentDoc,
"\n fragment WorkspaceProjectList_Workspace on Workspace {\n id\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...WorkspaceSecurity_Workspace\n ...WorkspaceHeader_Workspace\n ...BillingAlert_Workspace\n ...InviteDialogWorkspace_Workspace\n projects {\n ...WorkspaceProjectList_ProjectCollection\n }\n creationState {\n completed\n state\n }\n permissions {\n canCreateProject {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.WorkspaceProjectList_WorkspaceFragmentDoc,
"\n fragment WorkspaceProjectList_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...ProjectDashboardItem\n }\n cursor\n }\n": types.WorkspaceProjectList_ProjectCollectionFragmentDoc,
"\n fragment WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...BillingAlert_Workspace\n slug\n readOnly\n permissions {\n canCreateProject {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.WorkspaceHeader_WorkspaceFragmentDoc,
"\n fragment WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...BillingAlert_Workspace\n slug\n readOnly\n permissions {\n canCreateProject {\n ...FullPermissionCheckResult\n }\n canMoveProjectToWorkspace {\n ...FullPermissionCheckResult\n }\n }\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 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 WorkspaceMoveProjectManager_ProjectBase on Project {\n id\n name\n modelCount: models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n }\n": types.WorkspaceMoveProjectManager_ProjectBaseFragmentDoc,
"\n fragment WorkspaceMoveProjectManager_Project on Project {\n ...WorkspaceMoveProjectManager_ProjectBase\n workspace {\n id\n permissions {\n canMoveProjectToWorkspace(projectId: $projectId) {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": types.WorkspaceMoveProjectManager_ProjectFragmentDoc,
"\n fragment WorkspaceMoveProjectManager_Project on Project {\n ...WorkspaceMoveProjectManager_ProjectBase\n permissions {\n canMoveToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n workspace {\n id\n permissions {\n canMoveProjectToWorkspace(projectId: $projectId) {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": types.WorkspaceMoveProjectManager_ProjectFragmentDoc,
"\n fragment WorkspaceMoveProjectManager_Workspace on Workspace {\n id\n role\n name\n logo\n slug\n plan {\n name\n }\n permissions {\n canMoveProjectToWorkspace(projectId: $projectId) {\n ...FullPermissionCheckResult\n }\n }\n projects {\n totalCount\n }\n team {\n items {\n user {\n id\n name\n avatar\n }\n }\n }\n }\n": types.WorkspaceMoveProjectManager_WorkspaceFragmentDoc,
"\n fragment WorkspaceMoveProjectSelectWorkspace_User on User {\n workspaces {\n items {\n ...WorkspaceMoveProjectManager_Workspace\n }\n }\n projects(cursor: $cursor, filter: $filter) {\n items {\n ...WorkspaceMoveProjectManager_Project\n }\n cursor\n totalCount\n }\n }\n": types.WorkspaceMoveProjectSelectWorkspace_UserFragmentDoc,
"\n fragment WorkspaceSidebarAbout_Workspace on Workspace {\n ...WorkspaceDashboardAbout_Workspace\n }\n": types.WorkspaceSidebarAbout_WorkspaceFragmentDoc,
@@ -631,7 +632,8 @@ const documents: Documents = {
"\n mutation SetActiveWorkspace($slug: String, $isProjectsActive: Boolean) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug, isProjectsActive: $isProjectsActive)\n }\n }\n": types.SetActiveWorkspaceDocument,
"\n query NavigationActiveWorkspace($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...UseNavigationActiveWorkspace_Workspace\n }\n }\n": types.NavigationActiveWorkspaceDocument,
"\n query NavigationWorkspaceList {\n activeUser {\n id\n ...UseNavigationWorkspaceList_User\n }\n }\n": types.NavigationWorkspaceListDocument,
"\n query NavigationInvites {\n activeUser {\n id\n projectInvites {\n ...HeaderNavNotificationsProjectInvite_PendingStreamCollaborator\n }\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n": types.NavigationInvitesDocument,
"\n query NavigationProjectInvites {\n activeUser {\n id\n projectInvites {\n ...HeaderNavNotificationsProjectInvite_PendingStreamCollaborator\n }\n }\n }\n": types.NavigationProjectInvitesDocument,
"\n query NavigationWorkspaceInvites {\n activeUser {\n id\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n": types.NavigationWorkspaceInvitesDocument,
"\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 seatType\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 ProjectPageTeamDialog on Project {\n id\n name\n role\n allowPublicComments\n visibility\n team {\n id\n role\n user {\n ...LimitedUserAvatar\n role\n }\n }\n invitedTeam {\n id\n title\n inviteId\n role\n user {\n ...LimitedUserAvatar\n role\n }\n }\n ...ProjectsPageTeamDialogManagePermissions_Project\n }\n": types.ProjectPageTeamDialogFragmentDoc,
@@ -971,7 +973,7 @@ export function graphql(source: "\n fragment AutomateViewerPanelFunctionRunRow_
/**
* 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 BillingAlert_Workspace on Workspace {\n id\n plan {\n name\n status\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"): (typeof documents)["\n fragment BillingAlert_Workspace on Workspace {\n id\n plan {\n name\n status\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"];
export function graphql(source: "\n fragment BillingAlert_Workspace on Workspace {\n id\n role\n slug\n plan {\n name\n status\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"): (typeof documents)["\n fragment BillingAlert_Workspace on Workspace {\n id\n role\n slug\n plan {\n name\n status\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1039,7 +1041,7 @@ export function graphql(source: "\n fragment InviteDialogProjectWorkspaceMember
/**
* 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 ProjectModelPageHeaderProject on Project {\n id\n name\n model(id: $modelId) {\n id\n name\n description\n }\n workspace {\n id\n slug\n name\n }\n }\n"): (typeof documents)["\n fragment ProjectModelPageHeaderProject on Project {\n id\n name\n model(id: $modelId) {\n id\n name\n description\n }\n workspace {\n id\n slug\n name\n }\n }\n"];
export function graphql(source: "\n fragment ProjectModelPageHeaderProject on Project {\n id\n name\n model(id: $modelId) {\n id\n name\n description\n }\n workspace {\n id\n slug\n name\n role\n }\n }\n"): (typeof documents)["\n fragment ProjectModelPageHeaderProject on Project {\n id\n name\n model(id: $modelId) {\n id\n name\n description\n }\n workspace {\n id\n slug\n name\n role\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1071,7 +1073,7 @@ export function graphql(source: "\n fragment ProjectModelPageVersionsCardVersio
/**
* 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 ProjectPageProjectHeader on Project {\n id\n name\n description\n workspace {\n id\n slug\n name\n logo\n }\n }\n"): (typeof documents)["\n fragment ProjectPageProjectHeader on Project {\n id\n name\n description\n workspace {\n id\n slug\n name\n logo\n }\n }\n"];
export function graphql(source: "\n fragment ProjectPageProjectHeader on Project {\n id\n name\n description\n workspace {\n id\n slug\n name\n logo\n role\n }\n }\n"): (typeof documents)["\n fragment ProjectPageProjectHeader on Project {\n id\n name\n description\n workspace {\n id\n slug\n name\n logo\n role\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1271,7 +1273,7 @@ export function graphql(source: "\n fragment SettingsWorkspacesGeneralEditSlugD
/**
* 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 WorkspaceBillingPage_Workspace on Workspace {\n id\n role\n subscription {\n currency\n }\n }\n"): (typeof documents)["\n fragment WorkspaceBillingPage_Workspace on Workspace {\n id\n role\n subscription {\n currency\n }\n }\n"];
export function graphql(source: "\n fragment WorkspaceBillingPage_Workspace on Workspace {\n id\n role\n subscription {\n currency\n }\n ...BillingAlert_Workspace\n }\n"): (typeof documents)["\n fragment WorkspaceBillingPage_Workspace on Workspace {\n id\n role\n subscription {\n currency\n }\n ...BillingAlert_Workspace\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1355,7 +1357,7 @@ export function graphql(source: "\n fragment WorkspaceProjectList_ProjectCollec
/**
* 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 WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...BillingAlert_Workspace\n slug\n readOnly\n permissions {\n canCreateProject {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...BillingAlert_Workspace\n slug\n readOnly\n permissions {\n canCreateProject {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
export function graphql(source: "\n fragment WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...BillingAlert_Workspace\n slug\n readOnly\n permissions {\n canCreateProject {\n ...FullPermissionCheckResult\n }\n canMoveProjectToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...BillingAlert_Workspace\n slug\n readOnly\n permissions {\n canCreateProject {\n ...FullPermissionCheckResult\n }\n canMoveProjectToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1375,7 +1377,7 @@ export function graphql(source: "\n fragment WorkspaceMoveProjectManager_Projec
/**
* 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 WorkspaceMoveProjectManager_Project on Project {\n ...WorkspaceMoveProjectManager_ProjectBase\n workspace {\n id\n permissions {\n canMoveProjectToWorkspace(projectId: $projectId) {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceMoveProjectManager_Project on Project {\n ...WorkspaceMoveProjectManager_ProjectBase\n workspace {\n id\n permissions {\n canMoveProjectToWorkspace(projectId: $projectId) {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"];
export function graphql(source: "\n fragment WorkspaceMoveProjectManager_Project on Project {\n ...WorkspaceMoveProjectManager_ProjectBase\n permissions {\n canMoveToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n workspace {\n id\n permissions {\n canMoveProjectToWorkspace(projectId: $projectId) {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceMoveProjectManager_Project on Project {\n ...WorkspaceMoveProjectManager_ProjectBase\n permissions {\n canMoveToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n workspace {\n id\n permissions {\n canMoveProjectToWorkspace(projectId: $projectId) {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1663,7 +1665,11 @@ export function graphql(source: "\n query NavigationWorkspaceList {\n active
/**
* 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 NavigationInvites {\n activeUser {\n id\n projectInvites {\n ...HeaderNavNotificationsProjectInvite_PendingStreamCollaborator\n }\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n"): (typeof documents)["\n query NavigationInvites {\n activeUser {\n id\n projectInvites {\n ...HeaderNavNotificationsProjectInvite_PendingStreamCollaborator\n }\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n"];
export function graphql(source: "\n query NavigationProjectInvites {\n activeUser {\n id\n projectInvites {\n ...HeaderNavNotificationsProjectInvite_PendingStreamCollaborator\n }\n }\n }\n"): (typeof documents)["\n query NavigationProjectInvites {\n activeUser {\n id\n projectInvites {\n ...HeaderNavNotificationsProjectInvite_PendingStreamCollaborator\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 NavigationWorkspaceInvites {\n activeUser {\n id\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n"): (typeof documents)["\n query NavigationWorkspaceInvites {\n activeUser {\n id\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\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
@@ -129,7 +129,7 @@ export const publicAutomateFunctionsRoute = '/functions'
export const automateFunctionRoute = (functionId: string) =>
`${publicAutomateFunctionsRoute}/${functionId}`
export const workspaceRoute = (slug: string) => `/workspaces/${slug}`
export const workspaceRoute = (slug?: string) => `/workspaces/${slug}`
export const workspaceSsoRoute = (slug: string) => `/workspaces/${slug}/sso`
export const workspaceCreateRoute = (slug?: string) =>
@@ -213,4 +213,4 @@ export const doesRouteFitTarget = (fullPathA: string, fullPathB: string) => {
// Link to Workspace roles and seats documentation
// TODO: Add link when ready
export const LearnMoreRolesSeatsUrl = 'https://speckle.guide/'
export const LearnMoreMoveProjectsUrl = 'https://speckle.guide/'
export const LearnMoreMoveProjectsUrl = 'https://speckle.systems/pricing'
@@ -134,6 +134,9 @@ function createCache(): InMemoryCache {
},
User: {
fields: {
meta: {
merge: mergeAsObjectsFunction
},
timeline: {
keyArgs: ['after', 'before'],
merge: buildAbstractCollectionMergeFunction('ActivityCollection')
@@ -17,13 +17,21 @@ export const navigationWorkspaceListQuery = graphql(`
}
`)
export const navigationInvitesQuery = graphql(`
query NavigationInvites {
export const navigationProjectInvitesQuery = graphql(`
query NavigationProjectInvites {
activeUser {
id
projectInvites {
...HeaderNavNotificationsProjectInvite_PendingStreamCollaborator
}
}
}
`)
export const navigationWorkspaceInvitesQuery = graphql(`
query NavigationWorkspaceInvites {
activeUser {
id
workspaceInvites {
...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator
}
@@ -1,6 +1,7 @@
import { graphql } from '~/lib/common/generated/gql/gql'
import { useQuery } from '@vue/apollo-composable'
import { activeWorkspaceQuery } from '~/lib/workspaces/graphql/queries'
import { Roles } from '@speckle/shared'
graphql(`
fragment ActiveWorkspace_Workspace on Workspace {
@@ -24,8 +25,10 @@ export const useActiveWorkspace = (slug: string) => {
)
const activeWorkspace = computed(() => result.value?.workspaceBySlug)
const isAdmin = computed(() => activeWorkspace.value?.role === Roles.Workspace.Admin)
return {
activeWorkspace
activeWorkspace,
isAdmin
}
}
@@ -151,6 +151,26 @@ export const useDiscoverableWorkspaces = () => {
return id !== workspaceId
}
)
},
workspaceJoinRequests(existingRefs = []) {
// Add the workspace to join requests with Pending status
const workspace = discoverableWorkspaces.value?.find(
(w) => w.id === workspaceId
)
if (workspace) {
return {
...existingRefs,
items: [
...(existingRefs?.items || []),
{
id: workspaceId,
status: 'Pending',
workspace
}
]
}
}
return existingRefs
}
}
})
@@ -48,6 +48,7 @@ import { useLock } from '~/lib/common/composables/singleton'
import type { Get } from 'type-fest'
import type { ApolloCache } from '@apollo/client/core'
import { workspaceLastAdminCheckQuery } from '../graphql/queries'
import { useNavigation } from '~/lib/navigation/composables/navigation'
export const useInviteUserToWorkspace = () => {
const { activeUser } = useActiveUser()
@@ -369,6 +370,8 @@ export function useCreateWorkspace() {
const { triggerNotification } = useGlobalToast()
const { activeUser } = useActiveUser()
const router = useRouter()
const { mutateActiveWorkspaceSlug } = useNavigation()
return async (
input: WorkspaceCreateInput,
options?: Partial<{
@@ -420,6 +423,7 @@ export function useCreateWorkspace() {
if (options?.navigateOnSuccess === true) {
router.push(workspaceRoute(res.data?.workspaceMutations.create.slug))
mutateActiveWorkspaceSlug(res.data?.workspaceMutations.create.slug)
}
} else {
const err = getFirstErrorMessage(res.errors)
@@ -104,10 +104,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
})
.catch(convertThrowIntoFetchResult)
const workspaces =
workspaceExistenceData?.activeUser?.workspaces?.items.filter(
(w) => w.creationState?.completed !== false
) ?? []
const workspaces = workspaceExistenceData?.activeUser?.workspaces?.items ?? []
const hasWorkspaces = workspaces.length > 0
const hasDiscoverableWorkspaces =
(workspaceExistenceData?.activeUser?.discoverableWorkspaces?.length ?? 0) > 0 ||
@@ -21,8 +21,14 @@ export default defineNuxtRouteMiddleware(async (to) => {
mutateActiveWorkspaceSlug,
mutateIsProjectsActive
} = useNavigation()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
if (!isWorkspacesEnabled.value) {
mutateIsProjectsActive(true)
return navigateTo(projectsRoute)
}
const { data: workspaceExistenceData } = await client
.query({
query: activeUserWorkspaceExistenceCheckQuery
+6
View File
@@ -234,6 +234,12 @@ export default defineNuxtConfig({
to: 'https://www.speckle.systems/connectors',
statusCode: 301
}
},
'/workspaces': {
redirect: {
to: '/workspaces/actions/create',
statusCode: 301
}
}
},
@@ -64,10 +64,10 @@
</div>
<WorkspaceMoveProjectManager
v-if="project"
v-if="project && isWorkspacesEnabled"
v-model:open="showMoveDialog"
event-source="project-page"
:project-id="project.id"
:project-id="projectId"
/>
</div>
</template>
+1 -1
View File
@@ -315,9 +315,9 @@ export async function init() {
app.use(cookieParser())
app.use(DetermineRequestIdMiddleware)
app.use(LoggingExpressMiddleware)
app.use(initiateRequestContextMiddleware)
app.use(determineClientIpAddressMiddleware)
app.use(LoggingExpressMiddleware)
if (asyncRequestContextEnabled()) {
startupLogger.info('Async request context tracking enabled 👀')
@@ -139,11 +139,11 @@ type Comment {
authorId: String!
archived: Boolean!
screenshot: String
text: SmartTextEditorValue!
text: SmartTextEditorValue
"""
Plain-text version of the comment text, ideal for previews
"""
rawText: String!
rawText: String
"""
Resources that this comment targets. Can be a mixture of either one stream, or multiple commits and objects.
"""
@@ -80,13 +80,11 @@ type ProjectInviteMutations {
"""
create(projectId: ID!, input: ProjectInviteCreateInput!): Project!
@hasScope(scope: "users:invite")
@hasServerRole(role: SERVER_USER)
"""
Batch invite to project
"""
batchCreate(projectId: ID!, input: [ProjectInviteCreateInput!]!): Project!
@hasScope(scope: "users:invite")
@hasServerRole(role: SERVER_USER)
"""
Accept or decline a project invite
@@ -96,9 +94,7 @@ type ProjectInviteMutations {
"""
Cancel a pending stream invite. Can only be invoked by a project owner.
"""
cancel(projectId: ID!, inviteId: String!): Project!
@hasScope(scope: "users:invite")
@hasServerRole(role: SERVER_USER)
cancel(projectId: ID!, inviteId: String!): Project! @hasScope(scope: "users:invite")
}
type ProjectMutations {
@@ -0,0 +1,17 @@
type UserMeta {
newWorkspaceExplainerDismissed: Boolean!
legacyProjectsExplainerCollapsed: Boolean!
}
type UserMetaMutations {
setNewWorkspaceExplainerDismissed(value: Boolean!): Boolean!
setLegacyProjectsExplainerCollapsed(value: Boolean!): Boolean!
}
extend type User {
meta: UserMeta! @isOwner
}
extend type ActiveUserMutations {
meta: UserMetaMutations!
}
@@ -120,7 +120,7 @@ extend type ProjectInviteMutations {
createForWorkspace(
projectId: ID!
inputs: [WorkspaceProjectInviteCreateInput!]!
): Project! @hasScope(scope: "users:invite") @hasServerRole(role: SERVER_USER)
): Project! @hasScope(scope: "users:invite")
}
extend type Mutation {
@@ -229,14 +229,10 @@ input WorkspaceInviteResendInput {
type WorkspaceInviteMutations {
create(workspaceId: String!, input: WorkspaceInviteCreateInput!): Workspace!
@hasScope(scope: "users:invite")
@hasServerRole(role: SERVER_USER)
batchCreate(workspaceId: String!, input: [WorkspaceInviteCreateInput!]!): Workspace!
@hasScope(scope: "users:invite")
@hasServerRole(role: SERVER_USER)
use(input: WorkspaceInviteUseInput!): Boolean!
resend(input: WorkspaceInviteResendInput!): Boolean!
@hasScope(scope: "users:invite")
@hasServerRole(role: SERVER_USER)
resend(input: WorkspaceInviteResendInput!): Boolean! @hasScope(scope: "users:invite")
cancel(workspaceId: String!, inviteId: String!): Workspace!
@hasScope(scope: "users:invite")
@hasServerRole(role: SERVER_USER)
+2
View File
@@ -25,6 +25,7 @@ generates:
LimitedUser: '@/modules/core/helpers/graphTypes#LimitedUserGraphQLReturn'
User: '@/modules/core/helpers/graphTypes#UserGraphQLReturn'
ActiveUserMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn'
UserMetaMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn'
UserEmailMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn'
ProjectMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn'
ProjectInviteMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn'
@@ -91,6 +92,7 @@ generates:
ServerRegionItem: '@/modules/multiregion/helpers/graphTypes#ServerRegionItemGraphQLReturn'
Price: '@/modules/gatekeeperCore/helpers/graphTypes#PriceGraphQLReturn'
WorkspaceSubscription: '@/modules/gatekeeper/helpers/graphTypes#WorkspaceSubscriptionGraphQLReturn'
UserMeta: '@/modules/core/helpers/graphTypes#UserMetaGraphQLReturn'
ProjectPermissionChecks: '@/modules/core/helpers/graphTypes#ProjectPermissionChecksGraphQLReturn'
ModelPermissionChecks: '@/modules/core/helpers/graphTypes#ModelPermissionChecksGraphQLReturn'
VersionPermissionChecks: '@/modules/core/helpers/graphTypes#VersionPermissionChecksGraphQLReturn'
@@ -31,6 +31,7 @@ import {
import { authorizeResolver } from '@/modules/shared'
import { LogicError } from '@/modules/shared/errors'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { withOperationLogging } from '@/observability/domain/businessLogging'
const getUser = getUserFactory({ db })
const getStream = getStreamFactory({ db })
@@ -103,7 +104,18 @@ const resolvers: Resolvers = {
if (!userId) throw new LogicError('User ID unexpectedly false')
const { streamId } = args
return await requestStreamAccess(userId, streamId)
const logger = ctx.log.child({
streamId,
projectId: streamId
})
return await withOperationLogging(
async () => await requestStreamAccess(userId, streamId),
{
logger,
operationName: 'requestStreamAccess',
operationDescription: 'Request for stream access'
}
)
}
},
ProjectMutations: {
@@ -113,18 +125,39 @@ const resolvers: Resolvers = {
async create(_parent, args, ctx) {
const { userId } = ctx
const { projectId } = args
return await requestProjectAccess(userId!, projectId)
const logger = ctx.log.child({
projectId,
streamId: projectId // for legacy compatibility
})
return await withOperationLogging(
async () => await requestProjectAccess(userId!, projectId),
{
logger,
operationName: 'CreateProjectAccessRequest',
operationDescription: 'Create a request for project access'
}
)
},
async use(_parent, args, ctx) {
const { userId, resourceAccessRules } = ctx
const { requestId, accept, role } = args
const logger = ctx.log
const usedReq = await processPendingProjectRequest(
userId!,
requestId,
accept,
mapStreamRoleToValue(role),
resourceAccessRules
const usedReq = await withOperationLogging(
async () =>
await processPendingProjectRequest(
userId!,
requestId,
accept,
mapStreamRoleToValue(role),
resourceAccessRules
),
{
logger,
operationName: 'ProcessProjectAccessRequest',
operationDescription: 'Use a request for project access'
}
)
const project = await ctx.loaders.streams.getStream.load(usedReq.resourceId)
@@ -1,4 +1,3 @@
import { automateLogger } from '@/observability/logging'
import {
ExecutionEngineBadResponseBodyError,
type ExecutionEngineErrorResponse,
@@ -29,8 +28,9 @@ import {
timeoutAt
} from '@speckle/shared'
import { randomUUID } from 'crypto'
import { Logger } from 'pino'
import { automateLogger, type Logger } from '@/observability/logging'
import { has, isObjectLike, isEmpty } from 'lodash'
import { getRequestLogger } from '@/observability/components/express/requestContext'
export type AuthCodePayloadWithOrigin = AuthCodePayload & { origin: string }
@@ -51,8 +51,10 @@ const getApiUrl = (
path?: string,
options?: Partial<{
query: Record<string, string[] | string | number | boolean | undefined>
}>
}> & { logger?: Logger }
) => {
const logger = options?.logger || getRequestLogger() || automateLogger
const automateUrl = speckleAutomateUrl()
if (!automateUrl)
throw new MisconfiguredEnvironmentError(
@@ -70,7 +72,8 @@ const getApiUrl = (
const urlValue = typeof val === 'object' ? val.join(',') : val.toString()
url.searchParams.append(key, urlValue)
} catch {
console.log({ val })
logger.warn({ automateUrl: val }, 'Failed to parse query param')
//ignore
}
})
}
@@ -119,6 +122,7 @@ const invokeRequest = async (params: {
retry?: boolean
}) => {
const { url, method = 'get', body, token, requestId } = params
const logger = getRequestLogger() || automateLogger
const response = await retry(
async () =>
@@ -138,7 +142,7 @@ const invokeRequest = async (params: {
]),
params.retry !== false ? 3 : 1,
(i, error) => {
automateLogger.warn(
logger.warn(
{ url, method, err: error },
'Automate Execution Engine API call failed, retrying...'
)
@@ -408,7 +412,8 @@ export const getFunctionFactory =
: undefined
const url = getApiUrl(`/api/v1/functions/${functionId}`, {
query
query,
logger
})
return await invokeSafeJsonRequestFactory<GetFunctionResponse>({
@@ -462,7 +467,10 @@ export const getFunctionReleaseFactory =
const { logger } = deps
const { functionId, functionReleaseId } = params
const url = getApiUrl(
`/api/v1/functions/${functionId}/versions/${functionReleaseId}`
`/api/v1/functions/${functionId}/versions/${functionReleaseId}`,
{
logger
}
)
const result = await invokeSafeJsonRequestFactory<GetFunctionReleaseResponse>({
@@ -507,7 +515,8 @@ export const getFunctionsFactory =
query: {
requireRelease: true,
...params.filters
}
},
logger
})
const authToken = params.auth
@@ -548,7 +557,8 @@ export const getPublicFunctionsFactory =
query: {
...query,
featuredFunctionsOnly: true
}
},
logger
})
return await invokeSafeJsonRequestFactory<GetFunctionsResponse>({
@@ -578,7 +588,7 @@ export const getUserFunctionsFactory =
}) => {
const { logger } = deps
const { userId, query, body } = params
const url = getApiUrl(`/api/v2/users/${userId}/functions`, { query })
const url = getApiUrl(`/api/v2/users/${userId}/functions`, { query, logger })
return await invokeSafeJsonRequestFactory<GetUserFunctionsResponse>({
logger
@@ -609,7 +619,10 @@ export const getWorkspaceFunctionsFactory =
}) => {
const { logger } = deps
const { workspaceId, query, body } = params
const url = getApiUrl(`/api/v2/workspaces/${workspaceId}/functions`, { query })
const url = getApiUrl(`/api/v2/workspaces/${workspaceId}/functions`, {
query,
logger
})
return await invokeSafeJsonRequestFactory<GetWorkspaceFunctionsResponse>({
logger
@@ -73,10 +73,7 @@ import {
manuallyTriggerAutomationFactory,
triggerAutomationRevisionRunFactory
} from '@/modules/automate/services/trigger'
import {
reportFunctionRunStatusFactory,
ReportFunctionRunStatusDeps
} from '@/modules/automate/services/runsManagement'
import { reportFunctionRunStatusFactory } from '@/modules/automate/services/runsManagement'
import {
AutomationNotFoundError,
FunctionNotFoundError
@@ -125,6 +122,7 @@ import {
import { getEventBus } from '@/modules/shared/services/eventBus'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { BranchNotFoundError } from '@/modules/core/errors/branch'
import { withOperationLogging } from '@/observability/domain/businessLogging'
const { FF_AUTOMATE_MODULE_ENABLED } = getFeatureFlags()
@@ -533,54 +531,90 @@ export = (FF_AUTOMATE_MODULE_ENABLED
},
AutomateMutations: {
async createFunction(_parent, args, ctx) {
const logger = ctx.log
const create = createFunctionFromTemplateFactory({
createExecutionEngineFn: createFunction,
getUser: getUserFactory({ db }),
createStoredAuthCode: createStoredAuthCodeFactory({
redis: getGenericRedis()
})
}),
logger
})
return (await create({ input: args.input, userId: ctx.userId! }))
.graphqlReturn
const { graphqlReturn } = await withOperationLogging(
async () => await create({ input: args.input, userId: ctx.userId! }),
{
logger,
operationName: 'createFunction',
operationDescription: 'Create a new Automate function'
}
)
return graphqlReturn
},
async createFunctionWithoutVersion(_parent, args, ctx) {
const logger = ctx.log
const authCode = await createStoredAuthCodeFactory({
redis: getGenericRedis()
})({
userId: ctx.userId!,
action: AuthCodePayloadAction.CreateFunction
})
return await createFunctionWithoutVersion({
body: {
speckleServerAuthenticationPayload: {
...authCode,
origin: getServerOrigin()
},
functionName: args.input.name,
description: args.input.description,
repositoryUrl:
'https://github.com/specklesystems/speckle_automate_python_example',
supportedSourceApps: [],
tags: []
return await withOperationLogging(
async () =>
await createFunctionWithoutVersion({
body: {
speckleServerAuthenticationPayload: {
...authCode,
origin: getServerOrigin()
},
functionName: args.input.name,
description: args.input.description,
repositoryUrl:
'https://github.com/specklesystems/speckle_automate_python_example',
supportedSourceApps: [],
tags: []
}
}),
{
logger,
operationName: 'createFunctionWithoutVersion',
operationDescription: 'Create a new Automate function without version'
}
})
)
},
async updateFunction(_parent, args, ctx) {
const functionId = args.input.id
const logger = ctx.log.child({
functionId
})
const update = updateFunctionFactory({
updateFunction: execEngineUpdateFunction,
getFunction: getFunctionFactory({ logger: ctx.log }),
getFunction: getFunctionFactory({ logger }),
createStoredAuthCode: createStoredAuthCodeFactory({
redis: getGenericRedis()
}),
logger: ctx.log
})
})
return await update({ input: args.input, userId: ctx.userId! })
return await withOperationLogging(
async () => await update({ input: args.input, userId: ctx.userId! }),
{
logger,
operationName: 'updateFunction',
operationDescription: 'Update an Automate function'
}
)
}
},
ProjectAutomationMutations: {
async create(parent, { input }, ctx) {
const projectDb = await getProjectDbClient({ projectId: parent.projectId })
const projectId = parent.projectId
const logger = ctx.log.child({
projectId,
streamId: projectId //legacy
})
const projectDb = await getProjectDbClient({ projectId })
const create = createAutomationFactory({
createAuthCode: createStoredAuthCodeFactory({ redis: getGenericRedis() }),
@@ -591,17 +625,34 @@ export = (FF_AUTOMATE_MODULE_ENABLED
eventEmit: getEventBus().emit
})
return (
await create({
input,
userId: ctx.userId!,
projectId: parent.projectId,
userResourceAccessRules: ctx.resourceAccessRules
})
).automation
const { automation } = await withOperationLogging(
async () =>
await create({
input,
userId: ctx.userId!,
projectId,
userResourceAccessRules: ctx.resourceAccessRules
}),
{
logger,
operationName: 'createProjectAutomation',
operationDescription: 'Create a new Automation attached to a project'
}
)
return automation
},
async update(parent, { input }, ctx) {
const projectDb = await getProjectDbClient({ projectId: parent.projectId })
const projectId = parent.projectId
const automationId = input.id
const logger = ctx.log.child({
projectId,
streamId: projectId, //legacy
automationId
})
const projectDb = await getProjectDbClient({ projectId })
const update = validateAndUpdateAutomationFactory({
getAutomation: getAutomationFactory({ db: projectDb }),
@@ -610,15 +661,32 @@ export = (FF_AUTOMATE_MODULE_ENABLED
eventEmit: getEventBus().emit
})
return await update({
input,
userId: ctx.userId!,
projectId: parent.projectId,
userResourceAccessRules: ctx.resourceAccessRules
})
return await withOperationLogging(
async () =>
await update({
input,
userId: ctx.userId!,
projectId,
userResourceAccessRules: ctx.resourceAccessRules
}),
{
logger,
operationName: 'updateProjectAutomation',
operationDescription: 'Update an Automation attached to a project'
}
)
},
async createRevision(parent, { input }, ctx) {
const projectDb = await getProjectDbClient({ projectId: parent.projectId })
const projectId = parent.projectId
const automationId = input.automationId
const logger = ctx.log.child({
projectId,
streamId: projectId, //legacy
automationId
})
const projectDb = await getProjectDbClient({ projectId })
const create = createAutomationRevisionFactory({
getAutomation: getAutomationFactory({ db: projectDb }),
@@ -634,15 +702,31 @@ export = (FF_AUTOMATE_MODULE_ENABLED
validateStreamAccess
})
return await create({
input,
projectId: parent.projectId,
userId: ctx.userId!,
userResourceAccessRules: ctx.resourceAccessRules
})
return await withOperationLogging(
async () =>
await create({
input,
projectId,
userId: ctx.userId!,
userResourceAccessRules: ctx.resourceAccessRules
}),
{
logger,
operationName: 'createAutomationRevision',
operationDescription: 'Create a new Automation revision'
}
)
},
async trigger(parent, { automationId }, ctx) {
const projectDb = await getProjectDbClient({ projectId: parent.projectId })
const projectId = parent.projectId
const logger = ctx.log.child({
projectId,
streamId: projectId, //legacy
automationId
})
const projectDb = await getProjectDbClient({ projectId })
const trigger = manuallyTriggerAutomationFactory({
getAutomationTriggerDefinitions: getAutomationTriggerDefinitionsFactory({
@@ -668,17 +752,32 @@ export = (FF_AUTOMATE_MODULE_ENABLED
validateStreamAccess
})
const { automationRunId } = await trigger({
automationId,
userId: ctx.userId!,
userResourceAccessRules: ctx.resourceAccessRules,
projectId: parent.projectId
})
const { automationRunId } = await withOperationLogging(
async () =>
await trigger({
automationId,
userId: ctx.userId!,
userResourceAccessRules: ctx.resourceAccessRules,
projectId
}),
{
logger,
operationName: 'triggerProjectAutomation',
operationDescription: 'Trigger an Automation'
}
)
return automationRunId
},
async createTestAutomation(parent, { input }, ctx) {
const projectDb = await getProjectDbClient({ projectId: parent.projectId })
const projectId = parent.projectId
const logger = ctx.log.child({
projectId,
streamId: projectId //legacy
})
const projectDb = await getProjectDbClient({ projectId })
const create = createTestAutomationFactory({
getEncryptionKeyPair,
@@ -689,14 +788,30 @@ export = (FF_AUTOMATE_MODULE_ENABLED
eventEmit: getEventBus().emit
})
return await create({
input,
projectId: parent.projectId,
userId: ctx.userId!,
userResourceAccessRules: ctx.resourceAccessRules
})
return await withOperationLogging(
async () =>
await create({
input,
projectId,
userId: ctx.userId!,
userResourceAccessRules: ctx.resourceAccessRules
}),
{
logger,
operationName: 'createTestAutomation',
operationDescription: 'Create a new test Automation'
}
)
},
async createTestAutomationRun(parent, { automationId }, ctx) {
const projectId = parent.projectId
const logger = ctx.log.child({
projectId,
streamId: projectId, //legacy
automationId
})
const projectDb = await getProjectDbClient({ projectId: parent.projectId })
const create = createTestAutomationRunFactory({
@@ -725,11 +840,19 @@ export = (FF_AUTOMATE_MODULE_ENABLED
validateStreamAccess
})
return await create({
projectId: parent.projectId,
automationId,
userId: ctx.userId!
})
return await withOperationLogging(
async () =>
await create({
projectId: parent.projectId,
automationId,
userId: ctx.userId!
}),
{
logger,
operationName: 'createTestAutomationRun',
operationDescription: 'Create a new test Automation run'
}
)
}
},
Query: {
@@ -928,9 +1051,17 @@ export = (FF_AUTOMATE_MODULE_ENABLED
}
},
Mutation: {
async automateFunctionRunStatusReport(_parent, { input }) {
async automateFunctionRunStatusReport(_parent, { input }, ctx) {
const projectId = input.projectId
const functionRunId = input.functionRunId
const logger = ctx.log.child({
projectId,
streamId: projectId, //legacy
functionRunId
})
const projectDb = await getProjectDbClient({ projectId: input.projectId })
const deps: ReportFunctionRunStatusDeps = {
const reportFunctionRunStatus = reportFunctionRunStatusFactory({
getAutomationFunctionRunRecord: getFunctionRunFactory({
db: projectDb
}),
@@ -941,18 +1072,25 @@ export = (FF_AUTOMATE_MODULE_ENABLED
db: projectDb
}),
emitEvent: getEventBus().emit
}
})
const payload = {
...input,
contextView: input.contextView ?? null,
results: (input.results as Automate.AutomateTypes.ResultsSchema) ?? null,
runId: input.functionRunId,
status: mapGqlStatusToDbStatus(input.status),
statusMessage: input.statusMessage ?? null
}
const result = await reportFunctionRunStatusFactory(deps)(payload)
const result = await withOperationLogging(
async () =>
await reportFunctionRunStatus({
...input,
contextView: input.contextView ?? null,
results:
(input.results as Automate.AutomateTypes.ResultsSchema) ?? null,
runId: input.functionRunId,
status: mapGqlStatusToDbStatus(input.status),
statusMessage: input.statusMessage ?? null
}),
{
logger,
operationName: 'automateFunctionRunStatusReport',
operationDescription: 'Report the status of a function run'
}
)
return result
},
@@ -43,7 +43,7 @@ import {
speckleAutomateUrl
} from '@/modules/shared/helpers/envHelper'
import { getFunctionsMarketplaceUrl } from '@/modules/core/helpers/routeHelper'
import { automateLogger, Logger } from '@/observability/logging'
import type { Logger } from '@/observability/logging'
import { CreateStoredAuthCode } from '@/modules/automate/domain/operations'
import { GetUser } from '@/modules/core/domain/users/operations'
import { noop } from 'lodash'
@@ -132,13 +132,14 @@ export type CreateFunctionDeps = {
createStoredAuthCode: CreateStoredAuthCode
createExecutionEngineFn: typeof createFunction
getUser: GetUser
logger: Logger
}
export const createFunctionFromTemplateFactory =
(deps: CreateFunctionDeps) =>
async (params: { input: CreateAutomateFunctionInput; userId: string }) => {
const { input, userId } = params
const { createExecutionEngineFn, getUser, createStoredAuthCode } = deps
const { createExecutionEngineFn, getUser, createStoredAuthCode, logger } = deps
// Validate user
const user = await getUser(userId)
@@ -163,7 +164,7 @@ export const createFunctionFromTemplateFactory =
const created = await createExecutionEngineFn({ body })
if (isDevEnv() && created) {
automateLogger.info({ created }, `[dev] Created function #${created.functionId}`)
logger.info({ created }, `[dev] Created function #${created.functionId}`)
}
// Don't want to pull the function w/ another req, so we'll just return the input
@@ -197,16 +198,15 @@ export type UpdateFunctionDeps = {
updateFunction: typeof updateExecEngineFunction
getFunction: ReturnType<typeof getFunctionFactory>
createStoredAuthCode: CreateStoredAuthCode
logger: Logger
}
export const updateFunctionFactory =
(deps: UpdateFunctionDeps) =>
async (params: { input: UpdateAutomateFunctionInput; userId: string }) => {
const { updateFunction, createStoredAuthCode, logger } = deps
const { getFunction, updateFunction, createStoredAuthCode } = deps
const { input, userId } = params
const existingFn = await getFunctionFactory({ logger })({ functionId: input.id })
const existingFn = await getFunction({ functionId: input.id })
if (!existingFn) {
throw new AutomateFunctionUpdateError('Function not found')
}
@@ -239,8 +239,6 @@ export const updateFunctionFactory =
}
})
console.log(JSON.stringify(apiResult, null, 2))
return convertFunctionToGraphQLReturn(apiResult)
}
@@ -85,15 +85,13 @@ export const resolveStatusFromFunctionRunStatuses = (
return AutomationRunStatuses.succeeded
}
export type ReportFunctionRunStatusDeps = {
getAutomationFunctionRunRecord: GetFunctionRun
upsertAutomationFunctionRunRecord: UpsertAutomationFunctionRun
automationRunUpdater: UpdateAutomationRun
emitEvent: EventBusEmit
}
export const reportFunctionRunStatusFactory =
(deps: ReportFunctionRunStatusDeps) =>
(deps: {
getAutomationFunctionRunRecord: GetFunctionRun
upsertAutomationFunctionRunRecord: UpsertAutomationFunctionRun
automationRunUpdater: UpdateAutomationRun
emitEvent: EventBusEmit
}) =>
async (
params: Pick<
AutomationFunctionRunRecord,
@@ -81,6 +81,7 @@ import {
getCommitsAndTheirBranchIdsFactory,
getSpecificBranchCommitsFactory
} from '@/modules/core/repositories/commits'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import {
getBranchLatestCommitsFactory,
getStreamBranchesByNameFactory
@@ -90,7 +91,16 @@ import { getStreamFactory } from '@/modules/core/repositories/streams'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { Knex } from 'knex'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { StreamNotFoundError } from '@/modules/core/errors/stream'
import { isCreatedBeyondHistoryLimitCutoff, getProjectLimitDate } from '@speckle/shared'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { withOperationLogging } from '@/observability/domain/businessLogging'
import { Authz } from '@speckle/shared'
const { FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
const getPersonalProjectLimits = FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
? () => Promise.resolve(Authz.PersonalProjectsLimits)
: () => Promise.resolve(null)
// We can use the main DB for these
const getStream = getStreamFactory({ db })
@@ -203,15 +213,49 @@ export = {
/**
* Format comment.text for output, since it can have multiple formats
*/
text(parent) {
const commentText = parent?.text || ''
async text(parent, _args, ctx) {
const project = await ctx.loaders.streams.getStream.load(parent.streamId)
if (!project) {
throw new StreamNotFoundError('Project not found', {
info: { streamId: parent.streamId }
})
}
const isBeyondLimit = await isCreatedBeyondHistoryLimitCutoff({
getProjectLimitDate: getProjectLimitDate({
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
getPersonalProjectLimits
})
})({ entity: parent, limitType: 'commentHistory', project })
// null is for out of limits
if (isBeyondLimit) return null
// why is the text nullable in the DB record?
if (!parent.text) return ''
return {
...ensureCommentSchema(commentText),
...ensureCommentSchema(parent.text),
projectId: parent.streamId
}
},
rawText(parent) {
async rawText(parent, _args, ctx) {
const project = await ctx.loaders.streams.getStream.load(parent.streamId)
if (!project) {
throw new StreamNotFoundError('Project not found', {
info: { streamId: parent.streamId }
})
}
const isBeyondLimit = await isCreatedBeyondHistoryLimitCutoff({
getProjectLimitDate: getProjectLimitDate({
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
getPersonalProjectLimits
})
})({ entity: parent, limitType: 'commentHistory', project })
// null is for out of limits
if (isBeyondLimit) return null
// why us the text nullable in the DB?
const { doc } = ensureCommentSchema(parent.text || '')
return documentToBasicString(doc)
},
@@ -495,6 +539,11 @@ export = {
})
throwIfAuthNotOk(canCreate)
const logger = ctx.log.child({
projectId,
streamId: projectId //legacy
})
const projectDb = await getProjectDbClient({ projectId })
const getViewerResourceItemsUngrouped = buildGetViewerResourceItemsUngrouped({
@@ -517,16 +566,28 @@ export = {
emitEvent: getEventBus().emit
})
return await createCommentThreadAndNotify(args.input, ctx.userId!)
return await withOperationLogging(
async () => await createCommentThreadAndNotify(args.input, ctx.userId!),
{
operationName: 'createCommentThread',
operationDescription: 'Create comment thread',
logger
}
)
},
async reply(_parent, args, ctx) {
const projectId = args.input.projectId
const canCreateComment = await ctx.authPolicies.project.comment.canCreate({
userId: ctx.userId,
projectId: args.input.projectId
projectId
})
throwIfAuthNotOk(canCreateComment)
const logger = ctx.log.child({
projectId,
streamId: projectId //legacy
})
const projectDb = await getProjectDbClient({ projectId: args.input.projectId })
const projectDb = await getProjectDbClient({ projectId })
const getComment = getCommentFactory({ db: projectDb })
const validateInputAttachments = validateInputAttachmentsFactory({
getBlobs: getBlobsFactory({ db: projectDb })
@@ -549,18 +610,33 @@ export = {
})
})
return await createCommentReplyAndNotify(args.input, ctx.userId!)
return await withOperationLogging(
async () => await createCommentReplyAndNotify(args.input, ctx.userId!),
{
operationName: 'replyToComment',
operationDescription: 'Reply to comment',
logger
}
)
},
async edit(_parent, args, ctx) {
const projectId = args.input.projectId
const commentId = args.input.commentId
const canEditComment = await ctx.authPolicies.project.comment.canEdit({
projectId: args.input.projectId,
projectId,
userId: ctx.userId,
commentId: args.input.commentId
commentId
})
throwIfAuthNotOk(canEditComment)
const logger = ctx.log.child({
projectId,
streamId: projectId, //legacy
commentId
})
const projectDb = await getProjectDbClient({
projectId: args.input.projectId
projectId
})
const getComment = getCommentFactory({ db: projectDb })
const validateInputAttachments = validateInputAttachmentsFactory({
@@ -575,18 +651,28 @@ export = {
emitEvent: getEventBus().emit
})
return await editCommentAndNotify(args.input, ctx.userId!)
return await withOperationLogging(
async () => await editCommentAndNotify(args.input, ctx.userId!),
{ logger, operationName: 'editComment', operationDescription: 'Edit comment' }
)
},
async archive(_parent, args, ctx) {
const projectId = args.input.projectId
const commentId = args.input.commentId
const canArchive = await ctx.authPolicies.project.comment.canArchive({
userId: ctx.userId,
projectId: args.input.projectId,
commentId: args.input.commentId
projectId,
commentId
})
throwIfAuthNotOk(canArchive)
const logger = ctx.log.child({
projectId,
streamId: projectId, //legacy
commentId
})
const projectDb = await getProjectDbClient({
projectId: args.input.projectId
projectId
})
const getComment = getCommentFactory({ db: projectDb })
const getStream = getStreamFactory({ db: projectDb })
@@ -605,10 +691,14 @@ export = {
emitEvent: getEventBus().emit
})
await archiveCommentAndNotify(
args.input.commentId,
ctx.userId!,
args.input.archived
await withOperationLogging(
async () =>
await archiveCommentAndNotify(commentId, ctx.userId!, args.input.archived),
{
logger,
operationName: 'archiveComment',
operationDescription: 'Archive comment'
}
)
return true
}
@@ -675,13 +765,19 @@ export = {
},
async commentCreate(_parent, args, context) {
const projectId = args.input.streamId
const canCreate = await context.authPolicies.project.comment.canCreate({
userId: context.userId,
projectId: args.input.streamId
projectId
})
throwIfAuthNotOk(canCreate)
const projectDb = await getProjectDbClient({ projectId: args.input.streamId })
const logger = context.log.child({
projectId,
streamId: projectId //legacy
})
const projectDb = await getProjectDbClient({ projectId })
const getViewerResourcesFromLegacyIdentifiers =
buildGetViewerResourcesFromLegacyIdentifiers({ db: projectDb })
@@ -699,23 +795,39 @@ export = {
emitEvent: getEventBus().emit,
getViewerResourcesFromLegacyIdentifiers
})
const comment = await createComment({
userId: context.userId!,
input: args.input
})
const comment = await withOperationLogging(
async () =>
await createComment({
userId: context.userId!,
input: args.input
}),
{
operationName: 'createComment',
operationDescription: 'Create comment',
logger
}
)
return comment.id
},
async commentEdit(_parent, args, context) {
const projectId = args.input.streamId
const commentId = args.input.id
const canEdit = await context.authPolicies.project.comment.canEdit({
userId: context.userId,
projectId: args.input.streamId,
commentId: args.input.id
projectId,
commentId
})
throwIfAuthNotOk(canEdit)
const projectDb = await getProjectDbClient({ projectId: args.input.streamId })
const logger = context.log.child({
projectId,
streamId: projectId, //legacy
commentId
})
const projectDb = await getProjectDbClient({ projectId })
const editComment = editCommentFactory({
getComment: getCommentFactory({ db: projectDb }),
validateInputAttachments: validateInputAttachmentsFactory({
@@ -725,7 +837,10 @@ export = {
emitEvent: getEventBus().emit
})
await editComment({ userId: context.userId!, input: args.input })
await withOperationLogging(
async () => await editComment({ userId: context.userId!, input: args.input }),
{ operationName: 'editComment', operationDescription: 'Edit comment', logger }
)
return true
},
@@ -745,38 +860,59 @@ export = {
},
async commentArchive(_parent, args, context) {
const projectId = args.streamId
const commentId = args.commentId
const canArchive = await context.authPolicies.project.comment.canArchive({
userId: context.userId,
projectId: args.streamId,
commentId: args.commentId
projectId,
commentId
})
throwIfAuthNotOk(canArchive)
const projectDb = await getProjectDbClient({ projectId: args.streamId })
const logger = context.log.child({
projectId,
streamId: projectId, //legacy
commentId
})
const projectDb = await getProjectDbClient({ projectId })
const archiveComment = archiveCommentFactory({
getComment: getCommentFactory({ db: projectDb }),
getStream,
updateComment: updateCommentFactory({ db: projectDb }),
emitEvent: getEventBus().emit
})
await archiveComment({ ...args, userId: context.userId! }) // NOTE: permissions check inside service
await withOperationLogging(
async () => await archiveComment({ ...args, userId: context.userId! }), // NOTE: permissions check inside service
{
logger,
operationName: 'archiveComment',
operationDescription: 'Archive comment'
}
)
return true
},
async commentReply(_parent, args, context) {
const projectId = args.input.streamId
if (!context.userId)
throw new ForbiddenError('Only registered users can comment.')
const logger = context.log.child({
projectId,
streamId: projectId //legacy
})
const stream = await getStream({
streamId: args.input.streamId,
streamId: projectId,
userId: context.userId
})
if (!stream?.allowPublicComments && !stream?.role)
throw new ForbiddenError('You are not authorized.')
const projectDb = await getProjectDbClient({ projectId: args.input.streamId })
const projectDb = await getProjectDbClient({ projectId })
const createCommentReply = createCommentReplyFactory({
validateInputAttachments: validateInputAttachmentsFactory({
@@ -796,14 +932,22 @@ export = {
buildGetViewerResourcesFromLegacyIdentifiers({ db: projectDb })
})
})
const reply = await createCommentReply({
authorId: context.userId,
parentCommentId: args.input.parentComment,
streamId: args.input.streamId,
text: args.input.text as SmartTextEditorValueSchema,
data: args.input.data ?? null,
blobIds: args.input.blobIds
})
const reply = await withOperationLogging(
async () =>
await createCommentReply({
authorId: context.userId!,
parentCommentId: args.input.parentComment,
streamId: args.input.streamId,
text: args.input.text as SmartTextEditorValueSchema,
data: args.input.data ?? null,
blobIds: args.input.blobIds
}),
{
logger,
operationName: 'createCommentReply',
operationDescription: 'Create comment reply'
}
)
return reply.id
}
@@ -30,7 +30,11 @@ import {
purgeNotifications
} from '@/test/notificationsHelper'
import { NotificationType } from '@/modules/notifications/helpers/types'
import { EmailSendingServiceMock, CommentsRepositoryMock } from '@/test/mocks/global'
import {
EmailSendingServiceMock,
CommentsRepositoryMock,
StreamsRepositoryMock
} from '@/test/mocks/global'
import { createAuthedTestContext, ServerAndContext } from '@/test/graphqlHelper'
import {
checkStreamResourceAccessFactory,
@@ -125,6 +129,7 @@ import {
getViewerResourcesForCommentsFactory,
getViewerResourcesFromLegacyIdentifiersFactory
} from '@/modules/core/services/commit/viewerResources'
import { StreamRecord } from '@/modules/core/helpers/types'
type LegacyCommentRecord = CommentRecord & {
total_count: string
@@ -287,6 +292,7 @@ function generateRandomCommentText() {
const mailerMock = EmailSendingServiceMock
const commentRepoMock = CommentsRepositoryMock
const streamsRepoMock = StreamsRepositoryMock
describe('Comments @comments', () => {
let app: express.Express
@@ -366,6 +372,7 @@ describe('Comments @comments', () => {
after(() => {
notificationsState.destroy()
commentRepoMock.destroy()
streamsRepoMock.destroy()
})
afterEach(() => {
@@ -1688,8 +1695,8 @@ describe('Comments @comments', () => {
expect(errors?.length || 0).to.eq(0)
expect(data?.comment).to.be.ok
expect(data?.comment?.text.doc).to.be.null
expect(data?.comment?.text.attachments?.length).to.be.greaterThan(0)
expect(data?.comment?.text?.doc).to.be.null
expect(data?.comment?.text?.attachments?.length).to.be.greaterThan(0)
})
const unexpectedValDataset = [
@@ -1698,9 +1705,18 @@ describe('Comments @comments', () => {
]
unexpectedValDataset.forEach(({ display, value }) => {
it(`unexpected text value (${display}) in DB throw sanitized errors`, async () => {
streamsRepoMock.enable()
streamsRepoMock.mockFunction('getStreamsFactory', () => async () => [
{
id: stream.id,
workspaceId: ''
} as unknown as StreamRecord
])
const item = {
id: '1',
text: value
text: value,
streamId: stream.id,
createdAt: new Date()
} as unknown as LegacyCommentRecord
commentRepoMock.enable()
@@ -1710,12 +1726,13 @@ describe('Comments @comments', () => {
totalCount: 1
}))
const { data, errors } = await readComments()
const { errors } = await readComments()
expect(data?.comments).to.not.be.ok
expect((errors || []).map((e) => e.message).join(';')).to.contain(
'Unexpected comment schema format'
)
streamsRepoMock.disable()
streamsRepoMock.resetMockedFunctions()
})
})
})
@@ -112,7 +112,7 @@ describe('Project Comments', () => {
expect(res1).to.not.haveGraphQLErrors()
expect(threadId).to.be.ok
expect(res1.data?.commentMutations.create.rawText).to.equal(parentText)
expect(res1.data?.commentMutations.create.text.doc).to.be.ok
expect(res1.data?.commentMutations.create.text?.doc).to.be.ok
expect(res1.data?.commentMutations.create.authorId).to.equal(me.id)
expect(createEventFired).to.be.true
})
@@ -161,7 +161,7 @@ describe('Project Comments', () => {
expect(res2).to.not.haveGraphQLErrors()
expect(res2.data?.commentMutations.reply.rawText).to.equal(replyText)
expect(res2.data?.commentMutations.reply.text.doc).to.be.ok
expect(res2.data?.commentMutations.reply.text?.doc).to.be.ok
expect(res2.data?.commentMutations.reply.authorId).to.equal(me.id)
expect(replyEventFired).to.be.true
})
@@ -190,7 +190,7 @@ describe('Project Comments', () => {
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.commentMutations.edit.rawText).to.equal(newText)
expect(res.data?.commentMutations.edit.text.doc).to.be.ok
expect(res.data?.commentMutations.edit.text?.doc).to.be.ok
expect(res.data?.commentMutations.edit.authorId).to.equal(me.id)
expect(editEventFired).to.be.true
})
+2
View File
@@ -293,6 +293,8 @@ export const UsersMeta = buildMetaTableHelper(
'onboardingStreamId',
'activeWorkspace',
'isProjectsActive',
'newWorkspaceExplainerDismissed',
'legacyProjectsExplainerCollapsed',
// Used in tests
'foo',
'bar'
@@ -1,5 +1,5 @@
import { GraphQLResolveInfo, GraphQLScalarType, GraphQLScalarTypeConfig } from 'graphql';
import { StreamGraphQLReturn, CommitGraphQLReturn, ProjectGraphQLReturn, ObjectGraphQLReturn, VersionGraphQLReturn, ServerInviteGraphQLReturnType, ModelGraphQLReturn, ModelsTreeItemGraphQLReturn, MutationsObjectGraphQLReturn, LimitedUserGraphQLReturn, UserGraphQLReturn, GraphQLEmptyReturn, StreamCollaboratorGraphQLReturn, ProjectCollaboratorGraphQLReturn, ServerInfoGraphQLReturn, BranchGraphQLReturn, ProjectPermissionChecksGraphQLReturn, ModelPermissionChecksGraphQLReturn, VersionPermissionChecksGraphQLReturn, RootPermissionChecksGraphQLReturn } from '@/modules/core/helpers/graphTypes';
import { StreamGraphQLReturn, CommitGraphQLReturn, ProjectGraphQLReturn, ObjectGraphQLReturn, VersionGraphQLReturn, ServerInviteGraphQLReturnType, ModelGraphQLReturn, ModelsTreeItemGraphQLReturn, MutationsObjectGraphQLReturn, LimitedUserGraphQLReturn, UserGraphQLReturn, GraphQLEmptyReturn, StreamCollaboratorGraphQLReturn, ProjectCollaboratorGraphQLReturn, ServerInfoGraphQLReturn, BranchGraphQLReturn, UserMetaGraphQLReturn, ProjectPermissionChecksGraphQLReturn, ModelPermissionChecksGraphQLReturn, VersionPermissionChecksGraphQLReturn, RootPermissionChecksGraphQLReturn } from '@/modules/core/helpers/graphTypes';
import { StreamAccessRequestGraphQLReturn, ProjectAccessRequestGraphQLReturn } from '@/modules/accessrequests/helpers/graphTypes';
import { CommentReplyAuthorCollectionGraphQLReturn, CommentGraphQLReturn, CommentPermissionChecksGraphQLReturn } from '@/modules/comments/helpers/graphTypes';
import { PendingStreamCollaboratorGraphQLReturn } from '@/modules/serverinvites/helpers/graphTypes';
@@ -45,6 +45,7 @@ export type ActiveUserMutations = {
emailMutations: UserEmailMutations;
/** Mark onboarding as complete */
finishOnboarding: Scalars['Boolean']['output'];
meta: UserMetaMutations;
setActiveWorkspace: Scalars['Boolean']['output'];
/** Edit a user's profile */
update: User;
@@ -634,7 +635,7 @@ export type Comment = {
parent?: Maybe<Comment>;
permissions: CommentPermissionChecks;
/** Plain-text version of the comment text, ideal for previews */
rawText: Scalars['String']['output'];
rawText?: Maybe<Scalars['String']['output']>;
/** @deprecated Not actually implemented */
reactions?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
/** Gets the replies to this comment. */
@@ -644,7 +645,7 @@ export type Comment = {
/** Resources that this comment targets. Can be a mixture of either one stream, or multiple commits and objects. */
resources: Array<ResourceIdentifier>;
screenshot?: Maybe<Scalars['String']['output']>;
text: SmartTextEditorValue;
text?: Maybe<SmartTextEditorValue>;
/** The time this comment was last updated. Corresponds also to the latest reply to this comment, if any. */
updatedAt: Scalars['DateTime']['output'];
/** The last time you viewed this comment. Present only if an auth'ed request. Relevant only if a top level commit. */
@@ -3895,6 +3896,7 @@ export type User = {
isOnboardingFinished?: Maybe<Scalars['Boolean']['output']>;
/** Returns `true` if last visited project was "legacy" "personal project" outside of a workspace */
isProjectsActive?: Maybe<Scalars['Boolean']['output']>;
meta: UserMeta;
name: Scalars['String']['output'];
notificationPreferences: Scalars['JSONObject']['output'];
permissions: RootPermissionChecks;
@@ -4115,6 +4117,28 @@ export type UserGendoAiCredits = {
used: Scalars['Int']['output'];
};
export type UserMeta = {
__typename?: 'UserMeta';
legacyProjectsExplainerCollapsed: Scalars['Boolean']['output'];
newWorkspaceExplainerDismissed: Scalars['Boolean']['output'];
};
export type UserMetaMutations = {
__typename?: 'UserMetaMutations';
setLegacyProjectsExplainerCollapsed: Scalars['Boolean']['output'];
setNewWorkspaceExplainerDismissed: Scalars['Boolean']['output'];
};
export type UserMetaMutationsSetLegacyProjectsExplainerCollapsedArgs = {
value: Scalars['Boolean']['input'];
};
export type UserMetaMutationsSetNewWorkspaceExplainerDismissedArgs = {
value: Scalars['Boolean']['input'];
};
export type UserProjectCollection = {
__typename?: 'UserProjectCollection';
cursor?: Maybe<Scalars['String']['output']>;
@@ -5412,6 +5436,8 @@ export type ResolversTypes = {
UserEmail: ResolverTypeWrapper<UserEmail>;
UserEmailMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
UserGendoAICredits: ResolverTypeWrapper<UserGendoAiCredits>;
UserMeta: ResolverTypeWrapper<UserMetaGraphQLReturn>;
UserMetaMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
UserProjectCollection: ResolverTypeWrapper<Omit<UserProjectCollection, 'items'> & { items: Array<ResolversTypes['Project']> }>;
UserProjectsFilter: UserProjectsFilter;
UserProjectsUpdatedMessage: ResolverTypeWrapper<Omit<UserProjectsUpdatedMessage, 'project'> & { project?: Maybe<ResolversTypes['Project']> }>;
@@ -5725,6 +5751,8 @@ export type ResolversParentTypes = {
UserEmail: UserEmail;
UserEmailMutations: MutationsObjectGraphQLReturn;
UserGendoAICredits: UserGendoAiCredits;
UserMeta: UserMetaGraphQLReturn;
UserMetaMutations: MutationsObjectGraphQLReturn;
UserProjectCollection: Omit<UserProjectCollection, 'items'> & { items: Array<ResolversParentTypes['Project']> };
UserProjectsFilter: UserProjectsFilter;
UserProjectsUpdatedMessage: Omit<UserProjectsUpdatedMessage, 'project'> & { project?: Maybe<ResolversParentTypes['Project']> };
@@ -5840,6 +5868,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>>;
meta?: Resolver<ResolversTypes['UserMetaMutations'], ParentType, ContextType>;
setActiveWorkspace?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, Partial<ActiveUserMutationsSetActiveWorkspaceArgs>>;
update?: Resolver<ResolversTypes['User'], ParentType, ContextType, RequireFields<ActiveUserMutationsUpdateArgs, 'user'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@@ -6153,13 +6182,13 @@ export type CommentResolvers<ContextType = GraphQLContext, ParentType extends Re
id?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
parent?: Resolver<Maybe<ResolversTypes['Comment']>, ParentType, ContextType>;
permissions?: Resolver<ResolversTypes['CommentPermissionChecks'], ParentType, ContextType>;
rawText?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
rawText?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
reactions?: Resolver<Maybe<Array<Maybe<ResolversTypes['String']>>>, ParentType, ContextType>;
replies?: Resolver<ResolversTypes['CommentCollection'], ParentType, ContextType, RequireFields<CommentRepliesArgs, 'limit'>>;
replyAuthors?: Resolver<ResolversTypes['CommentReplyAuthorCollection'], ParentType, ContextType, RequireFields<CommentReplyAuthorsArgs, 'limit'>>;
resources?: Resolver<Array<ResolversTypes['ResourceIdentifier']>, ParentType, ContextType>;
screenshot?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
text?: Resolver<ResolversTypes['SmartTextEditorValue'], ParentType, ContextType>;
text?: Resolver<Maybe<ResolversTypes['SmartTextEditorValue']>, ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
viewedAt?: Resolver<Maybe<ResolversTypes['DateTime']>, ParentType, ContextType>;
viewerResources?: Resolver<Array<ResolversTypes['ViewerResourceItem']>, ParentType, ContextType>;
@@ -7141,6 +7170,7 @@ export type UserResolvers<ContextType = GraphQLContext, ParentType extends Resol
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
isOnboardingFinished?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
isProjectsActive?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
meta?: Resolver<ResolversTypes['UserMeta'], ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
notificationPreferences?: Resolver<ResolversTypes['JSONObject'], ParentType, ContextType>;
permissions?: Resolver<ResolversTypes['RootPermissionChecks'], ParentType, ContextType>;
@@ -7191,6 +7221,18 @@ export type UserGendoAiCreditsResolvers<ContextType = GraphQLContext, ParentType
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UserMetaResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['UserMeta'] = ResolversParentTypes['UserMeta']> = {
legacyProjectsExplainerCollapsed?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
newWorkspaceExplainerDismissed?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UserMetaMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['UserMetaMutations'] = ResolversParentTypes['UserMetaMutations']> = {
setLegacyProjectsExplainerCollapsed?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<UserMetaMutationsSetLegacyProjectsExplainerCollapsedArgs, 'value'>>;
setNewWorkspaceExplainerDismissed?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<UserMetaMutationsSetNewWorkspaceExplainerDismissedArgs, 'value'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UserProjectCollectionResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['UserProjectCollection'] = ResolversParentTypes['UserProjectCollection']> = {
cursor?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
items?: Resolver<Array<ResolversTypes['Project']>, ParentType, ContextType>;
@@ -7718,6 +7760,8 @@ export type Resolvers<ContextType = GraphQLContext> = {
UserEmail?: UserEmailResolvers<ContextType>;
UserEmailMutations?: UserEmailMutationsResolvers<ContextType>;
UserGendoAICredits?: UserGendoAiCreditsResolvers<ContextType>;
UserMeta?: UserMetaResolvers<ContextType>;
UserMetaMutations?: UserMetaMutationsResolvers<ContextType>;
UserProjectCollection?: UserProjectCollectionResolvers<ContextType>;
UserProjectsUpdatedMessage?: UserProjectsUpdatedMessageResolvers<ContextType>;
UserSearchResultCollection?: UserSearchResultCollectionResolvers<ContextType>;
@@ -27,8 +27,10 @@ import {
batchDeleteCommitsFactory,
batchMoveCommitsFactory
} from '@/modules/core/services/commit/batchCommitActions'
import { StreamInvalidAccessError } from '@/modules/core/errors/stream'
import { isNonNullable, MaybeNullOrUndefined, Roles } from '@speckle/shared'
import {
StreamInvalidAccessError,
StreamNotFoundError
} from '@/modules/core/errors/stream'
import {
throwIfResourceAccessNotAllowed,
toProjectIdWhitelist
@@ -81,10 +83,19 @@ import { getEventBus } from '@/modules/shared/services/eventBus'
import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { getDateFromLimitsFactory } from '@/modules/core/services/versions/limits'
import { withOperationLogging } from '@/observability/domain/businessLogging'
import {
Authz,
getProjectLimitDate,
isNonNullable,
MaybeNullOrUndefined,
Roles
} from '@speckle/shared'
const { FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
const getPersonalProjectLimits = FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
? () => Promise.resolve(Authz.PersonalProjectsLimits)
: () => Promise.resolve(null)
const getStreams = getStreamsFactory({ db })
@@ -210,12 +221,10 @@ export = {
async commits(parent, args, ctx) {
const projectDB = await getProjectDbClient({ projectId: parent.id })
const limitsDate = await getDateFromLimitsFactory({
environment: {
personalProjectsLimitEnabled: FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
},
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits
})({ workspaceId: parent.workspaceId })
const limitsDate = await getProjectLimitDate({
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
getPersonalProjectLimits
})({ limitType: 'versionsHistory', project: parent })
const getCommitsByStreamId = legacyGetPaginatedStreamCommitsPageFactory({
db: projectDB,
@@ -331,13 +340,17 @@ export = {
}
}
const stream = await ctx.loaders.streams.getStream.load(parent.streamId)
const limitsDate = await getDateFromLimitsFactory({
environment: {
personalProjectsLimitEnabled: FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
},
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits
})({ workspaceId: stream?.workspaceId })
const project = await ctx.loaders.streams.getStream.load(parent.streamId)
if (!project) {
throw new StreamNotFoundError('Project not found', {
info: { streamId: parent.streamId }
})
}
const limitsDate = await getProjectLimitDate({
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
getPersonalProjectLimits
})({ limitType: 'versionsHistory', project })
const getPaginatedBranchCommits = getPaginatedBranchCommitsFactory({
getSpecificBranchCommits: getSpecificBranchCommitsFactory({ db: projectDB }),
@@ -409,7 +409,7 @@ export = {
)
// Reset loader cache
await ctx.clearCache()
ctx.clearCache()
return ret
},
@@ -450,7 +450,7 @@ export = {
)
// Reset loader cache
await context.clearCache()
context.clearCache()
return true
},
@@ -17,7 +17,7 @@ import {
lookupUsersFactory,
bulkLookupUsersFactory
} from '@/modules/core/repositories/users'
import { UsersMeta } from '@/modules/core/dbSchema'
import { Users, UsersMeta } from '@/modules/core/dbSchema'
import { throwForNotHavingServerRole } from '@/modules/shared/authz'
import {
deleteAllUserInvitesFactory,
@@ -46,6 +46,7 @@ import {
} from '@/modules/shared/helpers/envHelper'
import { updateMailchimpMemberTags } from '@/modules/auth/services/mailchimp'
import { withOperationLogging } from '@/observability/domain/businessLogging'
import { metaHelpers } from '@/modules/core/helpers/meta'
const getUser = legacyGetUserFactory({ db })
const getUserByEmail = legacyGetUserByEmailFactory({ db })
@@ -202,6 +203,25 @@ export = {
key: UsersMeta.metaKey.isOnboardingFinished
})
return !!metaVal?.value
},
meta: async (parent) => ({
userId: parent.id
})
},
UserMeta: {
newWorkspaceExplainerDismissed: async (parent, _args, ctx) => {
const metaVal = await ctx.loaders.users.getUserMeta.load({
userId: parent.userId,
key: UsersMeta.metaKey.newWorkspaceExplainerDismissed
})
return !!metaVal?.value
},
legacyProjectsExplainerCollapsed: async (parent, _args, ctx) => {
const metaVal = await ctx.loaders.users.getUserMeta.load({
userId: parent.userId,
key: UsersMeta.metaKey.legacyProjectsExplainerCollapsed
})
return !!metaVal?.value
}
},
LimitedUser: {
@@ -331,17 +351,39 @@ export = {
return success
},
async update(_parent, args, context) {
const logger = context.log.child({
userIdToOperateOn: context.userId
})
return await withOperationLogging(
const logger = context.log
const newUser = await withOperationLogging(
async () => await updateUserAndNotify(context.userId!, args.user),
{
logger,
operationName: 'updateUser',
operationDescription: `Update user`
operationDescription: 'Update user'
}
)
return newUser
},
meta: () => ({})
},
UserMetaMutations: {
setLegacyProjectsExplainerCollapsed: async (_parent, args, ctx) => {
const meta = metaHelpers(Users, db)
const res = await meta.set(
ctx.userId!,
UsersMeta.metaKey.legacyProjectsExplainerCollapsed,
args.value
)
return !!res.value
},
setNewWorkspaceExplainerDismissed: async (_parent, args, ctx) => {
const meta = metaHelpers(Users, db)
const res = await meta.set(
ctx.userId!,
UsersMeta.metaKey.newWorkspaceExplainerDismissed,
args.value
)
return !!res.value
}
}
} as Resolvers
@@ -48,15 +48,22 @@ import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import coreModule from '@/modules/core'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { StreamNotFoundError } from '@/modules/core/errors/stream'
import { getLimitedReferencedObjectFactory } from '@/modules/core/services/versions/limits'
import { throwIfResourceAccessNotAllowed } from '@/modules/core/helpers/token'
import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { Version } from '@/modules/core/domain/commits/types'
import { GraphQLResolveInfo } from 'graphql'
import { withOperationLogging } from '@/observability/domain/businessLogging'
import {
Authz,
getProjectLimitDate,
isCreatedBeyondHistoryLimitCutoff
} from '@speckle/shared'
const { FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
const getPersonalProjectLimits = FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
? () => Promise.resolve(Authz.PersonalProjectsLimits)
: () => Promise.resolve(null)
/**
* Simple utility to check if version is inside a Model or a Project
@@ -127,12 +134,12 @@ export = {
})
}
const getLimitedReferencedObject = getLimitedReferencedObjectFactory({
environment: {
personalProjectsLimitEnabled: FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
},
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits
})
const isBeyondLimit = await isCreatedBeyondHistoryLimitCutoff({
getProjectLimitDate: getProjectLimitDate({
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
getPersonalProjectLimits
})
})({ entity: parent, limitType: 'versionsHistory', project })
let lastVersion: Version | null
if (getTypeFromPath(info) === 'Model') {
lastVersion = await ctx.loaders
@@ -144,10 +151,8 @@ export = {
.streams.getLastVersion.load(parent.streamId)
}
if (lastVersion?.id === parent.id) return parent.referencedObject
return await getLimitedReferencedObject({
version: parent,
workspaceId: project.workspaceId
})
if (isBeyondLimit) return null
return parent.referencedObject
}
},
Mutation: {
@@ -130,6 +130,8 @@ export type StreamCollaboratorGraphQLReturn = {
export type ServerInfoGraphQLReturn = ServerInfo
export type UserMetaGraphQLReturn = { userId: string }
export type ProjectCollaboratorGraphQLReturn = {
id: string
user: LimitedUserRecord
@@ -8,6 +8,9 @@ export function createRandomPassword(length?: number) {
return crs({ length: length ?? 10 })
}
/**
* @deprecated use the one in shared
*/
export function createRandomString(length?: number) {
return crs({ length: length ?? 10 })
}
@@ -131,6 +131,8 @@ export const deleteStreamAndNotifyFactory =
// TODO: this has been around since before my time, we should get rid of it...
// delay deletion by a bit so we can do auth checks
// (essentially: ensure authorizeResolver/authPolicies can retrieve the stream and
// validate a user's access in subscription field resolvers. we can do w/o it tho...)
await wait(250)
// Delete after event so we can do authz
@@ -38,7 +38,7 @@ export const scheduledCallbackWrapper = async (
const finishDate = new Date()
boundLogger.info(
{ durationSeconds: (finishDate.getTime() - scheduledTime.getTime()) / 1000 },
'Finished scheduled function {taskName} execution in {durationSeconds} seconds'
'Finished scheduled function {taskName} execution succeeded in {durationSeconds} seconds'
)
} catch (error) {
boundLogger.error(
@@ -1,68 +0,0 @@
import { Version } from '@/modules/core/domain/commits/types'
import { GetWorkspaceLimits } from '@speckle/shared/dist/commonjs/authz/domain/workspaces/operations'
import dayjs from 'dayjs'
export const PersonalProjectsLimits: {
versionHistory: { value: number; unit: 'week' }
} = {
versionHistory: {
value: 1,
unit: 'week'
}
}
export const getLimitedReferencedObjectFactory =
({
environment: { personalProjectsLimitEnabled },
getWorkspaceLimits
}: {
environment: { personalProjectsLimitEnabled: boolean }
getWorkspaceLimits: GetWorkspaceLimits
}) =>
async ({
version,
workspaceId
}: {
version: Pick<Version, 'referencedObject' | 'createdAt'>
workspaceId?: string | null
}) => {
const limitDate = await getDateFromLimitsFactory({
environment: { personalProjectsLimitEnabled },
getWorkspaceLimits
})({ workspaceId })
if (dayjs(limitDate).isAfter(version.createdAt)) return null
return version.referencedObject
}
export const getDateFromLimitsFactory =
({
getWorkspaceLimits,
environment: { personalProjectsLimitEnabled }
}: {
getWorkspaceLimits: GetWorkspaceLimits
environment: { personalProjectsLimitEnabled: boolean }
}) =>
async ({ workspaceId }: { workspaceId?: string | null }) => {
if (workspaceId) {
const limits = await getWorkspaceLimits({ workspaceId })
if (!limits?.versionsHistory) {
return null
}
return dayjs()
.subtract(limits.versionsHistory.value, limits.versionsHistory.unit)
.toDate()
}
if (!personalProjectsLimitEnabled) {
return null
}
return dayjs()
.subtract(
PersonalProjectsLimits.versionHistory.value,
PersonalProjectsLimits.versionHistory.unit
)
.toDate()
}
@@ -189,3 +189,42 @@ export const getProjectWithModelVersionsQuery = gql`
}
}
`
export const getNewWorkspaceExplainerDismissedQuery = gql`
query GetNewWorkspaceExplainerDismissed {
activeUser {
meta {
newWorkspaceExplainerDismissed
}
}
}
`
export const setNewWorkspaceExplainerDismissedMutation = gql`
mutation SetNewWorkspaceExplainerDismissed($input: Boolean!) {
activeUserMutations {
meta {
setNewWorkspaceExplainerDismissed(value: $input)
}
}
}
`
export const getLegacyProjectsExplainerCollapsedQuery = gql`
query GetLegacyProjectsExplainerCollapsed {
activeUser {
meta {
legacyProjectsExplainerCollapsed
}
}
}
`
export const setLegacyProjectsExplainerCollapsedMutation = gql`
mutation SetLegacyProjectsExplainerCollapsed($input: Boolean!) {
activeUserMutations {
meta {
setLegacyProjectsExplainerCollapsed(value: $input)
}
}
}
`
@@ -0,0 +1,63 @@
import { BasicTestUser, createTestUser } from '@/test/authHelper'
import {
GetLegacyProjectsExplainerCollapsedDocument,
GetNewWorkspaceExplainerDismissedDocument,
SetLegacyProjectsExplainerCollapsedDocument,
SetNewWorkspaceExplainerDismissedDocument
} from '@/test/graphql/generated/graphql'
import { testApolloServer, TestApolloServer } from '@/test/graphqlHelper'
import { beforeEachContext } from '@/test/hooks'
import { expect } from 'chai'
describe('UserMeta GraphQL', () => {
const me: BasicTestUser = {
id: '',
email: '',
name: 'Some User meta guy'
}
let apollo: TestApolloServer
before(async () => {
await beforeEachContext()
await createTestUser(me)
apollo = await testApolloServer({ authUserId: me.id })
})
it('newWorkspaceExplainerDismissed get/set works', async () => {
const getRes = await apollo.execute(GetNewWorkspaceExplainerDismissedDocument, {})
expect(getRes).to.not.haveGraphQLErrors()
expect(getRes.data?.activeUser?.meta.newWorkspaceExplainerDismissed).to.be.false
const setRes = await apollo.execute(SetNewWorkspaceExplainerDismissedDocument, {
input: true
})
expect(setRes).to.not.haveGraphQLErrors()
expect(setRes.data?.activeUserMutations?.meta.setNewWorkspaceExplainerDismissed).to
.be.true
const getRes2 = await apollo.execute(GetNewWorkspaceExplainerDismissedDocument, {})
expect(getRes2).to.not.haveGraphQLErrors()
expect(getRes2.data?.activeUser?.meta.newWorkspaceExplainerDismissed).to.be.true
})
it('setLegacyProjectsExplainerCollapsed get/set works', async () => {
const getRes = await apollo.execute(GetLegacyProjectsExplainerCollapsedDocument, {})
expect(getRes).to.not.haveGraphQLErrors()
expect(getRes.data?.activeUser?.meta.legacyProjectsExplainerCollapsed).to.be.false
const setRes = await apollo.execute(SetLegacyProjectsExplainerCollapsedDocument, {
input: true
})
expect(setRes).to.not.haveGraphQLErrors()
expect(setRes.data?.activeUserMutations?.meta.setLegacyProjectsExplainerCollapsed)
.to.be.true
const getRes2 = await apollo.execute(
GetLegacyProjectsExplainerCollapsedDocument,
{}
)
expect(getRes2).to.not.haveGraphQLErrors()
expect(getRes2.data?.activeUser?.meta.legacyProjectsExplainerCollapsed).to.be.true
})
})
@@ -83,7 +83,7 @@ const createUser = createUserFactory({
emitEvent: getEventBus().emit
})
const { FF_BILLING_INTEGRATION_ENABLED, FF_WORKSPACES_MODULE_ENABLED } =
const { FF_BILLING_INTEGRATION_ENABLED, FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED } =
getFeatureFlags()
describe('Versions graphql @core', () => {
@@ -136,11 +136,11 @@ describe('Versions graphql @core', () => {
}
)
})
;(FF_WORKSPACES_MODULE_ENABLED ? describe : describe.skip)(
;(FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED ? describe : describe.skip)(
'Version.referencedObject',
() => {
const tenDaysAgo = dayjs().subtract(10, 'day').toDate()
it('should return version referencedObject if version is the last model version', async () => {
const tenDaysAgo = dayjs().subtract(10, 'day').toDate()
const user = await createTestUser({
name: createRandomString(),
email: createRandomEmail()
@@ -235,25 +235,14 @@ describe('Versions graphql @core', () => {
})
})
it('should return version referencedObject if version is the last project version', async () => {
const tenDaysAgo = dayjs().subtract(10, 'day').toDate()
const user = await createTestUser({
name: createRandomString(),
email: createRandomEmail()
})
const workspace = {
id: createRandomString(),
name: createRandomString(),
slug: createRandomString(),
ownerId: user.id
}
await createTestWorkspace(workspace, user, {
addPlan: { name: 'free', status: 'valid' }
})
const project1 = {
id: '',
name: createRandomString(),
workspaceId: workspace.id
name: createRandomString()
}
await createTestStream(project1, user)
@@ -1,146 +0,0 @@
import { createRandomString } from '@/modules/core/helpers/testHelpers'
import {
getDateFromLimitsFactory,
getLimitedReferencedObjectFactory
} from '@/modules/core/services/versions/limits'
import { GetWorkspaceLimits } from '@speckle/shared/dist/commonjs/authz/domain/workspaces/operations'
import { expect } from 'chai'
import dayjs from 'dayjs'
describe('Module @core', () => {
describe('Services versions', () => {
describe('getDateFromLimits', () => {
it('should return null if workspace has no versionHistory limits', async () => {
const getWorkspaceLimits = async () => null
const workspaceId = createRandomString()
expect(
await getDateFromLimitsFactory({
getWorkspaceLimits,
environment: { personalProjectsLimitEnabled: false }
})({ workspaceId })
).to.eq(null)
})
it('should return date in workspace versionHistory limits', async () => {
const getWorkspaceLimits = async () => ({
projectCount: null,
modelCount: null,
versionsHistory: { value: 1, unit: 'month' as const }
})
const workspaceId = createRandomString()
expect(
(
await getDateFromLimitsFactory({
getWorkspaceLimits,
environment: { personalProjectsLimitEnabled: false }
})({ workspaceId })
)
?.toISOString()
.slice(0, -5)
).to.eq(dayjs().subtract(1, 'month').toDate().toISOString().slice(0, -5))
})
})
describe('getLimitedReferencedObjectFactory returns a function that, ', () => {
it('should return the version referencedObject if project workspace has no limits', async () => {
const getWorkspaceLimits = (() => null) as unknown as GetWorkspaceLimits
const workspaceId = createRandomString()
const version = {
referencedObject: createRandomString(),
createdAt: new Date()
}
expect(
await getLimitedReferencedObjectFactory({
environment: { personalProjectsLimitEnabled: false },
getWorkspaceLimits
})({ version, workspaceId })
).to.eq(version.referencedObject)
})
it('should return null if version is outside of workspace limit', async () => {
const getWorkspaceLimits = (() => ({
versionsHistory: { value: 7, unit: 'day' }
})) as unknown as GetWorkspaceLimits
const workspaceId = createRandomString()
const tenDaysAgo = new Date()
tenDaysAgo.setDate(new Date().getDate() - 10)
const version = {
referencedObject: createRandomString(),
createdAt: tenDaysAgo
}
expect(
await getLimitedReferencedObjectFactory({
environment: { personalProjectsLimitEnabled: false },
getWorkspaceLimits
})({ version, workspaceId })
).to.eq(null)
})
it('should return version referencedObject if version is inside of workspace limit', async () => {
const getWorkspaceLimits = (() => ({
versionsHistory: { value: 7, unit: 'day' }
})) as unknown as GetWorkspaceLimits
const workspaceId = createRandomString()
const twoDaysAgo = new Date()
twoDaysAgo.setDate(new Date().getDate() - 2)
const version = {
referencedObject: createRandomString(),
createdAt: twoDaysAgo
}
expect(
await getLimitedReferencedObjectFactory({
environment: { personalProjectsLimitEnabled: false },
getWorkspaceLimits
})({ version, workspaceId })
).to.eq(version.referencedObject)
})
it('should return version referencedObject if project is not in a workspace and personalProjectsLimits is not enabled', async () => {
const getWorkspaceLimits = (() =>
expect.fail()) as unknown as GetWorkspaceLimits
const workspaceId = null
const tenDaysAgo = new Date()
tenDaysAgo.setDate(new Date().getDate() - 10)
const version = {
referencedObject: createRandomString(),
createdAt: tenDaysAgo
}
expect(
await getLimitedReferencedObjectFactory({
environment: { personalProjectsLimitEnabled: false },
getWorkspaceLimits
})({ version, workspaceId })
).to.eq(version.referencedObject)
})
it('should return null if project is not in a workspace and personalProjectsLimits is enabled and version is outside of limits', async () => {
const getWorkspaceLimits = (() =>
expect.fail()) as unknown as GetWorkspaceLimits
const workspaceId = null
const tenDaysAgo = new Date()
tenDaysAgo.setDate(new Date().getDate() - 10)
const version = {
referencedObject: createRandomString(),
createdAt: tenDaysAgo
}
expect(
await getLimitedReferencedObjectFactory({
environment: { personalProjectsLimitEnabled: true },
getWorkspaceLimits
})({ version, workspaceId })
).to.eq(null)
})
it('should return version referencedObject if project is not in a workspace and personalProjectsLimits is enabled and version is inside limits', async () => {
const getWorkspaceLimits = (() =>
expect.fail()) as unknown as GetWorkspaceLimits
const workspaceId = null
const version = {
referencedObject: createRandomString(),
createdAt: new Date()
}
expect(
await getLimitedReferencedObjectFactory({
environment: { personalProjectsLimitEnabled: true },
getWorkspaceLimits
})({ version, workspaceId })
).to.eq(version.referencedObject)
})
})
})
})
@@ -25,6 +25,7 @@ export type ActiveUserMutations = {
emailMutations: UserEmailMutations;
/** Mark onboarding as complete */
finishOnboarding: Scalars['Boolean']['output'];
meta: UserMetaMutations;
setActiveWorkspace: Scalars['Boolean']['output'];
/** Edit a user's profile */
update: User;
@@ -614,7 +615,7 @@ export type Comment = {
parent?: Maybe<Comment>;
permissions: CommentPermissionChecks;
/** Plain-text version of the comment text, ideal for previews */
rawText: Scalars['String']['output'];
rawText?: Maybe<Scalars['String']['output']>;
/** @deprecated Not actually implemented */
reactions?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
/** Gets the replies to this comment. */
@@ -624,7 +625,7 @@ export type Comment = {
/** Resources that this comment targets. Can be a mixture of either one stream, or multiple commits and objects. */
resources: Array<ResourceIdentifier>;
screenshot?: Maybe<Scalars['String']['output']>;
text: SmartTextEditorValue;
text?: Maybe<SmartTextEditorValue>;
/** The time this comment was last updated. Corresponds also to the latest reply to this comment, if any. */
updatedAt: Scalars['DateTime']['output'];
/** The last time you viewed this comment. Present only if an auth'ed request. Relevant only if a top level commit. */
@@ -3875,6 +3876,7 @@ export type User = {
isOnboardingFinished?: Maybe<Scalars['Boolean']['output']>;
/** Returns `true` if last visited project was "legacy" "personal project" outside of a workspace */
isProjectsActive?: Maybe<Scalars['Boolean']['output']>;
meta: UserMeta;
name: Scalars['String']['output'];
notificationPreferences: Scalars['JSONObject']['output'];
permissions: RootPermissionChecks;
@@ -4095,6 +4097,28 @@ export type UserGendoAiCredits = {
used: Scalars['Int']['output'];
};
export type UserMeta = {
__typename?: 'UserMeta';
legacyProjectsExplainerCollapsed: Scalars['Boolean']['output'];
newWorkspaceExplainerDismissed: Scalars['Boolean']['output'];
};
export type UserMetaMutations = {
__typename?: 'UserMetaMutations';
setLegacyProjectsExplainerCollapsed: Scalars['Boolean']['output'];
setNewWorkspaceExplainerDismissed: Scalars['Boolean']['output'];
};
export type UserMetaMutationsSetLegacyProjectsExplainerCollapsedArgs = {
value: Scalars['Boolean']['input'];
};
export type UserMetaMutationsSetNewWorkspaceExplainerDismissedArgs = {
value: Scalars['Boolean']['input'];
};
export type UserProjectCollection = {
__typename?: 'UserProjectCollection';
cursor?: Maybe<Scalars['String']['output']>;
@@ -5114,9 +5138,9 @@ export type CrossSyncDownloadableCommitViewerThreadsQueryVariables = Exact<{
}>;
export type CrossSyncDownloadableCommitViewerThreadsQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, commentThreads: { __typename?: 'ProjectCommentCollection', totalCount: number, totalArchivedCount: number, items: Array<{ __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } }> }, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } }> } } };
export type CrossSyncDownloadableCommitViewerThreadsQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, commentThreads: { __typename?: 'ProjectCommentCollection', totalCount: number, totalArchivedCount: number, items: Array<{ __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } | null }> }, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } | null }> } } };
export type DownloadbleCommentMetadataFragment = { __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } };
export type DownloadbleCommentMetadataFragment = { __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } | null };
export type CrossSyncProjectMetadataQueryVariables = Exact<{
id: Scalars['String']['input'];
@@ -393,13 +393,13 @@ const saveNewThreadsFactory =
const threadInputs: { originalComment: ViewerThread; input: CreateCommentInput }[] =
threads
.filter((t) => !!t.text.doc)
.filter((t) => !!t.text?.doc)
.map((t) => ({
originalComment: t,
input: {
projectId: targetStream.id,
content: {
doc: t.text.doc,
doc: t.text?.doc,
blobIds: [] // TODO: Currently not supported
},
viewerState: t.viewerState
@@ -436,12 +436,12 @@ const saveNewThreadsFactory =
)
await Promise.all(
replies.items
.filter((i) => !!i.text.doc)
.filter((i) => !!i.text?.doc)
.map((r) =>
deps.createCommentReplyAndNotify(
{
content: {
doc: r.text.doc,
doc: r.text?.doc,
blobIds: []
},
threadId: newComment.id,
@@ -13,6 +13,7 @@ import {
import { renderEmail } from '@/modules/emails/services/emailRendering'
import { sendEmail } from '@/modules/emails/services/sending'
import { requestEmailVerificationFactory } from '@/modules/emails/services/verification/request'
import { withOperationLogging } from '@/observability/domain/businessLogging'
const getUser = getUserFactory({ db })
const requestEmailVerification = requestEmailVerificationFactory({
@@ -38,14 +39,25 @@ export = {
Mutation: {
async requestVerification(_parent, _args, ctx) {
const { userId } = ctx
await requestEmailVerification(userId || '')
await withOperationLogging(
async () => await requestEmailVerification(userId || ''),
{
logger: ctx.log,
operationName: 'requestEmailVerification',
operationDescription: 'Request email verification'
}
)
return true
},
async requestVerificationByEmail(_parent, args) {
async requestVerificationByEmail(_parent, args, ctx) {
const { email } = args
const user = await getUserByEmail(email)
if (!user?.email || user.verified) return false
await requestEmailVerification(user.id)
await withOperationLogging(async () => await requestEmailVerification(user.id), {
logger: ctx.log,
operationName: 'requestEmailVerificationFromEmail',
operationDescription: `Request verification by email`
})
return true
}
}
+11 -2
View File
@@ -9,9 +9,11 @@ import {
} from '@/modules/emails/repositories'
import { db } from '@/db/knex'
import { markUserAsVerifiedFactory } from '@/modules/core/repositories/users'
import { withOperationLogging } from '@/observability/domain/businessLogging'
export = (app: Express) => {
app.get('/auth/verifyemail', async (req, res) => {
const logger = req.log
try {
const finalizeEmailVerification = finalizeEmailVerificationFactory({
getPendingToken: getPendingTokenFactory({ db }),
@@ -19,7 +21,14 @@ export = (app: Express) => {
deleteVerifications: deleteVerificationsFactory({ db })
})
await finalizeEmailVerification(req.query.t as Optional<string>)
await withOperationLogging(
async () => await finalizeEmailVerification(req.query.t as Optional<string>),
{
logger,
operationName: 'finalizeEmailVerification',
operationDescription: 'Finalize email verification'
}
)
return res.redirect(
new URL('/?emailverifiedstatus=true', getFrontendOrigin()).toString()
)
@@ -28,7 +37,7 @@ export = (app: Express) => {
error instanceof EmailVerificationFinalizationError
? error.message
: 'Email verification unexpectedly failed'
req.log.info({ err: error }, 'Email verification failed.')
logger.info({ err: error }, 'Email verification failed.')
return res.redirect(
new URL(`/?emailverifiederror=${msg}`, getFrontendOrigin()).toString()
@@ -11,6 +11,7 @@ import { getRolesFactory } from '@/modules/shared/repositories/roles'
import { getStreamBranchByNameFactory } from '@/modules/core/repositories/branches'
import { getStreamFactory } from '@/modules/core/repositories/streams'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { withOperationLogging } from '@/observability/domain/businessLogging'
export const getRouter = () => {
const app = Router()
@@ -73,18 +74,26 @@ export const getRouter = () => {
`http://127.0.0.1:${getPort()}/api/stream/${req.params.streamId}/blob`,
async (err, response, body) => {
if (err) {
res.log.error(err, 'Error while uploading blob.')
req.log.error(err, 'Error while uploading blob.')
res.status(500).send(err.message)
return
}
if (response.statusCode === 201) {
const { uploadResults } = JSON.parse(body)
await saveFileUploads({
userId: req.context.userId!,
streamId: req.params.streamId,
branchName,
uploadResults
})
await withOperationLogging(
async () =>
await saveFileUploads({
userId: req.context.userId!,
streamId: req.params.streamId,
branchName,
uploadResults
}),
{
logger: req.log,
operationName: 'uploadFile',
operationDescription: 'Upload a file to a stream'
}
)
} else {
res.log.error(
{
+2 -3
View File
@@ -111,7 +111,6 @@ const scheduleWorkspaceSubscriptionDownscale = ({
)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const scheduleWorkspacePlanMigrations = (scheduleExecution: ScheduleExecution) => {
let isMigrationComplete = false
let isMigrationRunning = false
@@ -250,8 +249,8 @@ const gatekeeperModule: SpeckleModule = {
scheduledTasks = [
scheduleWorkspaceSubscriptionDownscale({ scheduleExecution }),
scheduleWorkspaceTrialEmails({ scheduleExecution }),
scheduleWorkspaceTrialExpiry({ scheduleExecution, emit: eventBus.emit })
// scheduleWorkspacePlanMigrations(scheduleExecution)
scheduleWorkspaceTrialExpiry({ scheduleExecution, emit: eventBus.emit }),
scheduleWorkspacePlanMigrations(scheduleExecution)
]
quitListeners = initializeEventListenersFactory({
@@ -45,6 +45,11 @@ export const migrateOldWorkspacePlans =
'unlimited'
])
if (oldPlanWorkspaces.length === 0) {
logger.info('No old workspace plans to migrate')
return
}
for (const oldPlan of oldPlanWorkspaces) {
try {
await migrateWorkspacePlan({ db, stripe, logger })({
@@ -63,7 +68,7 @@ export const migrateWorkspacePlan =
({ db, stripe, logger }: { db: Knex; stripe: Stripe; logger: Logger }) =>
async ({ workspaceId }: { workspaceId: string }) => {
let log = logger.child({ workspaceId })
log.info('Starting workspace plan migration')
log.info('Starting workspace plan migration for {workspaceId}')
const workspacePlan = await getWorkspacePlanFactory({ db })({ workspaceId })
if (!workspacePlan)
throw new Error(`Workspace ${workspaceId} has no workspace plan`)
@@ -90,7 +95,9 @@ export const migrateWorkspacePlan =
newTargetPlan = 'free'
break
case 'paymentFailed':
throw new Error('Cant migrate workspace, its currently failed in payment')
throw new Error(
`Cant migrate workspace ${workspaceId}, its currently an old 'starter' plan that has failed in payment`
)
case 'canceled':
// just switch the plan, no need to change stripe
newTargetPlan = 'teamUnlimited'
@@ -110,7 +117,9 @@ export const migrateWorkspacePlan =
case 'business':
switch (workspacePlan.status) {
case 'paymentFailed':
throw new Error('Cant migrate workspace, its currently failed in payment')
throw new Error(
`Cant migrate workspace ${workspaceId}, its currently an old 'business' plan that has failed in payment`
)
case 'canceled':
newTargetPlan = 'proUnlimited'
isStripeMigrationNeeded = false
@@ -148,13 +157,13 @@ export const migrateWorkspacePlan =
}
if (!newTargetPlan) {
log.info('No migration needed for this plan')
log.info('No migration needed for {workspaceId} from old plan {workspacePlan}')
return
}
log.info(
{ newTargetPlan, newPlanStatus, isStripeMigrationNeeded },
'Migrating to new plan'
'Migrating {workspaceId} from old plan {workspacePlan} to new plan {newTargetPlan}'
)
const trx = await db.transaction()
@@ -170,14 +179,20 @@ export const migrateWorkspacePlan =
createdAt: new Date(),
updatedAt: new Date()
}))
log.debug({ seats }, 'Inserting new seats for the workspace')
log.debug(
{ migratedSeats: seats, migratedSeatsCount: seats.length },
'Inserting {migratedSeatsCount} new seats for the workspace {workspaceId}'
)
await trx<WorkspaceSeat>('workspace_seats')
.insert(seats)
.onConflict(['workspaceId', 'userId'])
.merge()
log.debug('Workspace seats added')
log.debug(
{ migratedSeatsCount: seats.length },
'Workspace {workspaceId} has added {migratedSeatsCount} seats'
)
await upsertWorkspacePlanFactory({ db: trx })({
//@ts-expect-error the switch above makes sure things are ok
workspacePlan: {
@@ -187,9 +202,11 @@ export const migrateWorkspacePlan =
createdAt: workspacePlan.createdAt
}
})
log.debug('workspace plan changed to the new plan')
log.debug(
'workspace {workspaceId} has had plan {workspacePlan} changed to the new plan {newTargetPlan}'
)
if (isStripeMigrationNeeded) {
log.info('Migrating stripe subscription data')
log.info('Migrating stripe subscription data for workspace {workspaceId}')
switch (newTargetPlan) {
case 'academia':
case 'free':
@@ -198,14 +215,18 @@ export const migrateWorkspacePlan =
case 'unlimited':
// this is just double checking that everything is right
// the switch above sets things up properly
throw new Error('Cannot upgrade stripe for a non paid plan')
throw new Error(
`Cannot upgrade stripe for a non paid plan for workspace ${workspaceId}`
)
}
// if stripe paid plan, convert the stripe sub to use all editor seats
const workspaceSubscription = await getWorkspaceSubscriptionFactory({ db: trx })({
workspaceId
})
if (!workspaceSubscription)
throw new Error('Subscription data not found, cant do stripe migration')
throw new Error(
`Subscription data not found for workspace ${workspaceId}, cannot do stripe migration`
)
let memberAndGuestSeatCount = workspaceSubscription.subscriptionData.products
.map((p) => p.quantity)
@@ -214,9 +235,9 @@ export const migrateWorkspacePlan =
const workspaceTeamCount = workspaceMembers.length
if (memberAndGuestSeatCount < workspaceTeamCount) {
logger.warn(
{ workspaceId, memberAndGuestSeatCount, workspaceTeamCount },
'Workspace has less paid member and guest seats, than people in the workspace. Reconciling'
log.warn(
{ memberAndGuestSeatCount, workspaceTeamCount },
'Workspace {workspaceId} has less paid member and guest seats, than people in the workspace. Reconciling'
)
memberAndGuestSeatCount = workspaceTeamCount
}
@@ -244,7 +265,7 @@ export const migrateWorkspacePlan =
})
}
await trx.commit()
log.info('Workspace plan migration completed')
log.info('🥳 Workspace plan migration completed for workspace {workspaceId}')
// add and editor seat to all workspace members
// convert current plan to the new plan
@@ -1,20 +1,22 @@
import { WorkspacePlans } from '@speckle/shared'
import { z } from 'zod'
const WorkspacePlansUpgradeMapping = z.union([
z.object({
current: z.literal('free'),
upgrade: z.union([z.literal('team'), z.literal('pro')])
}),
z.object({
current: z.literal('team'),
upgrade: z.union([z.literal('team'), z.literal('pro')])
}),
z.object({
current: z.literal('pro'),
upgrade: z.literal('pro')
})
])
const WorkspacePlansUpgradeMapping: Record<WorkspacePlans, WorkspacePlans[]> = {
academia: [],
unlimited: [],
business: [],
businessInvoiced: [],
plus: [],
plusInvoiced: [],
starter: [],
starterInvoiced: [],
free: ['team', 'teamUnlimited', 'pro', 'proUnlimited'],
team: ['team', 'teamUnlimited', 'pro', 'proUnlimited'],
teamUnlimited: ['teamUnlimited', 'pro', 'proUnlimited'],
teamUnlimitedInvoiced: [],
pro: ['pro', 'proUnlimited'],
proUnlimited: ['proUnlimited'],
proUnlimitedInvoiced: []
}
export const isUpgradeWorkspacePlanValid = ({
current,
@@ -23,5 +25,5 @@ export const isUpgradeWorkspacePlanValid = ({
current: WorkspacePlans
upgrade: WorkspacePlans
}): boolean => {
return WorkspacePlansUpgradeMapping.safeParse({ current, upgrade }).success
return WorkspacePlansUpgradeMapping[current].includes(upgrade)
}
@@ -40,6 +40,7 @@ import {
} from '@/modules/shared/helpers/envHelper'
import { getProjectObjectStorage } from '@/modules/multiregion/utils/blobStorageSelector'
import { storeFileStreamFactory } from '@/modules/blobstorage/repositories/blobs'
import { withOperationLogging } from '@/observability/domain/businessLogging'
const upsertUserCredits = upsertUserCreditsFactory({ db })
const getUserGendoAiCredits = getUserGendoAiCreditsFactory({
@@ -79,6 +80,7 @@ export = FF_GENDOAI_MODULE_ENABLED
},
VersionMutations: {
async requestGendoAIRender(__parent, args, ctx) {
const projectId = args.input.projectId
const rateLimitResult = await getRateLimitResult(
'GENDO_AI_RENDER_REQUEST',
ctx.userId as string
@@ -89,14 +91,18 @@ export = FF_GENDOAI_MODULE_ENABLED
await authorizeResolver(
ctx.userId,
args.input.projectId,
projectId,
Roles.Stream.Reviewer,
ctx.resourceAccessRules
)
const logger = ctx.log.child({
projectId,
streamId: projectId //legacy
})
const userId = ctx.userId!
const projectId = args.input.projectId
const [projectDb, projectStorage] = await Promise.all([
getProjectDbClient({
projectId
@@ -128,10 +134,18 @@ export = FF_GENDOAI_MODULE_ENABLED
publish
})
await createRenderRequest({
...args.input,
userId
})
await withOperationLogging(
async () =>
await createRenderRequest({
...args.input,
userId
}),
{
logger,
operationName: 'createGendoRenderRequest',
operationDescription: 'Request GendoAI to generate a render'
}
)
return true
}
+21 -5
View File
@@ -16,6 +16,7 @@ import { createHmac, timingSafeEqual } from 'node:crypto'
import { getGendoAIKey } from '@/modules/shared/helpers/envHelper'
import { getProjectObjectStorage } from '@/modules/multiregion/utils/blobStorageSelector'
import { storeFileStreamFactory } from '@/modules/blobstorage/repositories/blobs'
import { withOperationLogging } from '@/observability/domain/businessLogging'
export default function (app: express.Express) {
// const responseToken = getGendoAIResponseKey()
@@ -47,6 +48,12 @@ export default function (app: express.Express) {
const gendoGenerationId = payload.generationId
const projectId = req.params.projectId
const logger = req.log.child({
projectId,
gendoGenerationId,
gendoResponseStatus: status
})
const [projectDb, projectStorage] = await Promise.all([
getProjectDbClient({ projectId }),
getProjectObjectStorage({ projectId })
@@ -64,11 +71,20 @@ export default function (app: express.Express) {
publish
})
await updateRenderRequest({
gendoGenerationId,
responseImage,
status
})
await withOperationLogging(
async () =>
await updateRenderRequest({
gendoGenerationId,
responseImage,
status
}),
{
logger,
operationName: 'updateGendoRenderRequest',
operationDescription:
'Handle response from GendoAI and update a render request'
}
)
res.status(200).send('Speckle says thank you 💖')
}
+3 -2
View File
@@ -409,7 +409,7 @@ export const moduleAuthLoaders = async (params: {
// since its the inmemory cache, we dont have to worry about true-myth results being
// serialized and deserialized as they would be with redis
cacheProvider: inMemoryCacheProviderFactory({ cache }),
ttlMs: 1000 * 60 * 60 // 1 hour (longer than any req will be)
ttlMs: 1000 * 60 * 60 // 1 hour (longer than any req will be),
})
acc[key] = newLoader
@@ -421,6 +421,7 @@ export const moduleAuthLoaders = async (params: {
clearCache: () => {
cache.clear()
dataLoaders.clearAll()
}
},
internalCache: cache
}
}
@@ -18,6 +18,7 @@ import {
updateAndValidateRegionFactory
} from '@/modules/multiregion/services/management'
import { initializeRegion as initializeBlobStorage } from '@/modules/multiregion/utils/blobStorageSelector'
import { withOperationLogging } from '@/observability/domain/businessLogging'
export default {
ServerMultiRegionConfiguration: {
@@ -36,7 +37,10 @@ export default {
}
},
ServerRegionMutations: {
create: async (_parent, args) => {
create: async (_parent, args, ctx) => {
const logger = ctx.log.child({
multiRegionKey: args.input.key
})
const createAndValidateNewRegion = createAndValidateNewRegionFactory({
getFreeRegionKeys: getFreeRegionKeysFactory({
getAvailableRegionKeys: getAvailableRegionKeysFactory({
@@ -51,15 +55,32 @@ export default {
initializeBlobStorage
})
})
return await createAndValidateNewRegion({ region: args.input })
return await withOperationLogging(
async () => await createAndValidateNewRegion({ region: args.input }),
{
logger,
operationName: 'createRegion',
operationDescription: 'Create a new region'
}
)
},
update: async (_parent, args) => {
update: async (_parent, args, ctx) => {
const logger = ctx.log.child({
multiRegionKey: args.input.key
})
const updateAndValidateRegion = updateAndValidateRegionFactory({
getRegion: getRegionFactory({ db }),
updateRegion: updateRegionFactory({ db })
})
return await updateAndValidateRegion({ input: args.input })
return await withOperationLogging(
async () => await updateAndValidateRegion({ input: args.input }),
{
logger,
operationName: 'updateRegion',
operationDescription: 'Update a region'
}
)
}
},
ServerRegionItem: {
@@ -33,19 +33,23 @@ const getMultiRegionConfig = async (): Promise<MultiRegionConfig> => {
regions: {}
})
if (isDevOrTestEnv() && !isMultiRegionEnabled()) {
return emptyReturn()
}
if (!multiRegionConfig) {
const relativePath = getMultiRegionConfigPath({ unsafe: isDevOrTestEnv() })
if (!relativePath) return emptyReturn()
const configPath = path.resolve(packageRoot, relativePath)
multiRegionConfig = await loadMultiRegionsConfig({
path: configPath
})
try {
multiRegionConfig = await loadMultiRegionsConfig({
path: configPath
})
} catch (e) {
if (isDevOrTestEnv() && !isMultiRegionEnabled()) {
return emptyReturn()
} else {
throw e
}
}
}
return multiRegionConfig
@@ -8,6 +8,7 @@ import {
getUserNotificationPreferencesFactory,
updateNotificationPreferencesFactory
} from '@/modules/notifications/services/notificationPreferences'
import { withOperationLogging } from '@/observability/domain/businessLogging'
const getUserNotificationPreferences = getUserNotificationPreferencesFactory({
getSavedUserNotificationPreferences: getSavedUserNotificationPreferencesFactory({
@@ -28,7 +29,15 @@ export = {
},
Mutation: {
async userNotificationPreferencesUpdate(_parent, args, context) {
await updateNotificationPreferences(context.userId!, args.preferences)
const logger = context.log
await await withOperationLogging(
async () => updateNotificationPreferences(context.userId!, args.preferences),
{
logger,
operationName: 'userNotificationPreferencesUpdate',
operationDescription: 'Update user notification preferences'
}
)
return true
}
}
+17 -3
View File
@@ -17,6 +17,7 @@ import {
import { finalizePasswordResetFactory } from '@/modules/pwdreset/services/finalize'
import { requestPasswordRecoveryFactory } from '@/modules/pwdreset/services/request'
import { BadRequestError } from '@/modules/shared/errors'
import { withOperationLogging } from '@/observability/domain/businessLogging'
import { ensureError } from '@speckle/shared'
import { Express } from 'express'
@@ -26,6 +27,8 @@ export default function (app: Express) {
// sends a password recovery email.
app.post('/auth/pwdreset/request', async (req, res) => {
try {
const email = req.body.email
const logger = req.log.child({ email })
const requestPasswordRecovery = requestPasswordRecoveryFactory({
getUserByEmail,
getPendingToken: getPendingTokenFactory({ db }),
@@ -35,8 +38,11 @@ export default function (app: Express) {
sendEmail
})
const email = req.body.email
await requestPasswordRecovery(email)
await withOperationLogging(async () => await requestPasswordRecovery(email), {
logger,
operationName: 'requestPasswordRecovery',
operationDescription: `Requesting password recovery`
})
return res.status(200).send('Password reset email sent.')
} catch (e: unknown) {
@@ -47,6 +53,7 @@ export default function (app: Express) {
// Finalizes password recovery.
app.post('/auth/pwdreset/finalize', async (req, res) => {
const logger = req.log
try {
const finalizePasswordReset = finalizePasswordResetFactory({
getUserByEmail,
@@ -61,7 +68,14 @@ export default function (app: Express) {
if (!req.body.tokenId || !req.body.password)
throw new BadRequestError('Invalid request.')
await finalizePasswordReset(req.body.tokenId, req.body.password)
await withOperationLogging(
async () => await finalizePasswordReset(req.body.tokenId, req.body.password),
{
logger,
operationName: 'finalizePasswordReset',
operationDescription: `Finalizing password reset`
}
)
return res.status(200).send('Password reset. Please log in.')
} catch (e: unknown) {
@@ -18,7 +18,10 @@ import {
} from '@/modules/serverinvites/services/retrieval'
import { authorizeResolver } from '@/modules/shared'
import { chunk } from 'lodash'
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import {
Resolvers,
TokenResourceIdentifierType
} from '@/modules/core/graph/generated/graphql'
import db from '@/db/knex'
import { ServerRoles } from '@speckle/shared'
import {
@@ -76,6 +79,8 @@ import {
} from '@/modules/core/services/streams/access'
import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/users'
import { getServerInfoFactory } from '@/modules/core/repositories/server'
import { throwIfResourceAccessNotAllowed } from '@/modules/core/helpers/token'
import { mapAuthToServerError } from '@/modules/shared/helpers/errorHelper'
const validateStreamAccess = validateStreamAccessFactory({ authorizeResolver })
@@ -390,6 +395,22 @@ export = {
},
ProjectInviteMutations: {
async create(_parent, args, ctx) {
const { projectId } = args
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
})
const canInvite = await ctx.authPolicies.project.canInvite({
userId: ctx.userId,
projectId
})
if (!canInvite.isOk) {
throw mapAuthToServerError(canInvite.error)
}
const createProjectInvite = createProjectInviteFactory({
createAndSendInvite: buildCreateAndSendServerOrProjectInvite(),
getStream
@@ -406,12 +427,7 @@ export = {
return ctx.loaders.streams.getStream.load(args.projectId)
},
async batchCreate(_parent, args, ctx) {
await authorizeResolver(
ctx.userId,
args.projectId,
Roles.Stream.Owner,
ctx.resourceAccessRules
)
const { projectId } = args
const inviteCount = args.input.length
if (inviteCount > 10 && ctx.role !== Roles.Server.Admin) {
@@ -420,6 +436,20 @@ export = {
)
}
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
})
const canInvite = await ctx.authPolicies.project.canInvite({
userId: ctx.userId,
projectId
})
if (!canInvite.isOk) {
throw mapAuthToServerError(canInvite.error)
}
const createProjectInvite = createProjectInviteFactory({
createAndSendInvite: buildCreateAndSendServerOrProjectInvite(),
getStream
@@ -477,12 +507,21 @@ export = {
return true
},
async cancel(_parent, args, ctx) {
await authorizeResolver(
ctx.userId,
args.projectId,
Roles.Stream.Owner,
ctx.resourceAccessRules
)
const { projectId } = args
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
})
const canInvite = await ctx.authPolicies.project.canInvite({
userId: ctx.userId,
projectId
})
if (!canInvite.isOk) {
throw mapAuthToServerError(canInvite.error)
}
const cancelInvite = cancelResourceInviteFactory({
findInvite: findInviteFactory({ db }),
@@ -392,7 +392,7 @@ describe('[Stream & Server Invites]', () => {
expect(result.data).to.not.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
'Invalid project ID'
projectInvite ? 'Project not found' : 'Invalid project ID specified'
)
})
@@ -418,7 +418,9 @@ describe('[Stream & Server Invites]', () => {
expect(result.data).to.not.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
'You are not authorized to access this resource'
projectInvite
? 'You do not have access to the project'
: "Inviter doesn't have owner access to"
)
})
@@ -1,5 +1,5 @@
import { MisconfiguredEnvironmentError } from '@/modules/shared/errors'
import { trimEnd } from 'lodash'
import { has, trimEnd } from 'lodash'
import * as Environment from '@speckle/shared/dist/commonjs/environment/index.js'
import { ensureError, Nullable } from '@speckle/shared'
@@ -25,7 +25,11 @@ export function getIntFromEnv(envVarKey: string, aDefault = '0'): number {
}
export function getBooleanFromEnv(envVarKey: string, aDefault = false): boolean {
return ['1', 'true', true].includes(process.env[envVarKey] || aDefault.toString())
if (!has(process.env, envVarKey)) {
return aDefault
}
return ['1', 'true', true].includes(process.env[envVarKey] || 'false')
}
function mustGetUrlFromEnv(name: string, trimTrailingSlash: boolean = false): URL {
@@ -451,7 +455,7 @@ export const knexAsyncStackTracesEnabled = () => {
}
export const asyncRequestContextEnabled = () => {
return getBooleanFromEnv('ASYNC_REQUEST_CONTEXT_ENABLED')
return getBooleanFromEnv('ASYNC_REQUEST_CONTEXT_ENABLED', isDevEnv())
}
export function enableImprovedKnexTelemetryStackTraces() {
@@ -54,19 +54,24 @@ export type SpeckleModule<T extends Record<string, unknown> = Record<string, unk
export type GraphQLContext = BaseContext &
AuthContext & {
authPolicies: Authz.AuthPolicies & { clearCache: () => void }
authPolicies: Authz.AuthPolicies & {
clearCache: () => void
}
/**
* Request-scoped GraphQL dataloaders
* @see https://github.com/graphql/dataloader
*/
loaders: RequestDataLoaders
log: Logger
/**
* @deprecated Should be cleaned up soon, just use dataloaders
*/
authLoaders: AuthCheckContextLoaders
/**
* Clear dataloader, auth policy loader etc. caches. Usually necessary after mutations
* are done in resolvers
*/
clearCache: () => Promise<void>
clearCache: () => void
}
export { Nullable, Optional, MaybeNullOrUndefined, MaybeAsync, MaybeFalsy }
@@ -47,6 +47,7 @@ import { getUserRoleFactory } from '@/modules/core/repositories/users'
import { UserInputError } from '@/modules/core/errors/userinput'
import compression from 'compression'
import { moduleAuthLoaders } from '@/modules'
import { getRequestLogger } from '@/observability/components/express/requestContext'
export const authMiddlewareCreator = (
steps: AuthPipelineFunction[]
@@ -198,6 +199,14 @@ export async function buildContext(params?: {
req?.context ||
(await createAuthContextFromToken(token ?? getTokenFromRequest(req), validateToken))
// Update req.log w/ userId
const reqLogger = getRequestLogger()
if (reqLogger) {
reqLogger.setBindings({
userId: ctx.userId || null
})
}
const log = Observability.extendLoggerComponent(
req?.log || subscriptionLogger,
'graphql'

Some files were not shown because too many files have changed in this diff Show More