Merge branch 'main' into iain/web-2732-observability-for-improved-reliability-core
This commit is contained in:
+1
-1
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+19
-9
@@ -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()
|
||||
|
||||
+19
-9
@@ -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
|
||||
|
||||
-65
@@ -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
|
||||
|
||||
+32
-9
@@ -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(() =>
|
||||
|
||||
+15
-34
@@ -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
|
||||
|
||||
|
||||
+7
-10
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 💖')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-1
@@ -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,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
Reference in New Issue
Block a user