Merge branch 'main' into andrew/web-3448-enable-auto-joining-a-workspace-as-a-setting

This commit is contained in:
andrewwallacespeckle
2025-06-05 11:28:40 +02:00
27 changed files with 344 additions and 222 deletions
+13 -12
View File
@@ -20,7 +20,7 @@ aliases:
- &yarn
run:
name: Install Dependencies
command: PUPPETEER_SKIP_DOWNLOAD=true PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 yarn
command: YARN_ENABLE_HARDENED_MODE=0 PUPPETEER_SKIP_DOWNLOAD=true PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 yarn
workflows:
test-build:
@@ -433,7 +433,9 @@ jobs:
working_directory: *work-dir
steps:
- checkout
- *yarn
- run:
name: Install Hardened Dependencies
command: YARN_ENABLE_HARDENED_MODE=1 PUPPETEER_SKIP_DOWNLOAD=true PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 yarn
- run:
name: Build public packages
command: yarn build:public
@@ -550,15 +552,14 @@ jobs:
name: Introspect GQL schema for subsequent checks
command: 'IGNORE_MISSING_MIRATIONS=true yarn cli graphql introspect'
working_directory: 'packages/server'
# Temporarily disabled while Apollo Studio is down - https://status.apollographql.com/
# - run:
# name: Checking for GQL schema breakages against app.speckle.systems
# command: 'yarn rover graph check Speckle-Server@app-speckle-systems --schema ./introspected-schema.graphql'
# working_directory: 'packages/server'
# - run:
# name: Checking for GQL schema breakages against latest.speckle.systems
# command: 'yarn rover graph check Speckle-Server@latest-speckle-systems --schema ./introspected-schema.graphql'
# working_directory: 'packages/server'
- run:
name: Checking for GQL schema breakages against app.speckle.systems
command: 'yarn rover graph check Speckle-Server@app-speckle-systems --schema ./introspected-schema.graphql'
working_directory: 'packages/server'
- run:
name: Checking for GQL schema breakages against latest.speckle.systems
command: 'yarn rover graph check Speckle-Server@latest-speckle-systems --schema ./introspected-schema.graphql'
working_directory: 'packages/server'
- store_test_results:
path: packages/server/reports
@@ -774,7 +775,7 @@ jobs:
- checkout
- run:
name: Install Dependencies
command: PUPPETEER_SKIP_DOWNLOAD=true yarn
command: YARN_ENABLE_HARDENED_MODE=0 PUPPETEER_SKIP_DOWNLOAD=true yarn
- run:
name: Build public packages
command: yarn build:public
@@ -56,12 +56,15 @@ import { useInviteUserToWorkspace } from '~/lib/workspaces/composables/managemen
import { getRoleLabel } from '~~/lib/settings/helpers/utils'
import { matchesDomainPolicy } from '~/lib/invites/helpers/validation'
import { useInviteUserToProject } from '~~/lib/projects/composables/projectManagement'
import { useWorkspacePlan } from '~/lib/workspaces/composables/plan'
graphql(`
fragment InviteDialogWorkspace_Workspace on Workspace {
id
name
slug
domainBasedMembershipProtectionEnabled
defaultSeatType
domains {
domain
id
@@ -78,6 +81,9 @@ const mixpanel = useMixpanel()
const inviteToWorkspace = useInviteUserToWorkspace()
const inviteToProject = useInviteUserToProject()
const workspaceSlug = computed(() => props.workspace?.slug || '')
const { isSelfServePlan } = useWorkspacePlan(workspaceSlug.value)
const isSelectingRole = ref(true)
const selectedRole = ref<WorkspaceRoles>(Roles.Workspace.Member)
const project = ref<FormSelectProjects_ProjectFragment>()
@@ -119,9 +125,21 @@ const allowedDomains = computed(() =>
)
const infoText = computed(() => {
if (selectedRole.value === Roles.Workspace.Member) {
return 'Inviting is free. Members join your workspace on a free Viewer seat. You can give them an Editor seat later if they need to contribute to projects beyond viewing and commenting.'
if (props.workspace?.defaultSeatType === 'editor') {
if (isSelfServePlan.value) {
return `Members join your workspace on an Editor seat by default. Editor seats may incur charges based on your workspace plan. You can change their seat type to Viewer after they join if they only need to view and comment.`
}
return `Members join your workspace on an Editor seat by default. Editor seats have additional permissions compared to Viewer seats. You can change their seat type to Viewer after they join if they only need to view and comment.`
}
return `Inviting is free. Members join your workspace on a free Viewer seat. You can give them an Editor seat later if they need to contribute to projects beyond viewing and commenting.`
}
if (props.workspace?.defaultSeatType === 'editor') {
if (isSelfServePlan.value) {
return `Guests join your workspace on an Editor seat by default. Editor seats may incur charges based on your workspace plan. You can change their seat type to Viewer after they join if they only need to view and comment.`
}
return `Guests join your workspace on an Editor seat by default. Editor seats have additional permissions compared to Viewer seats. You can change their seat type to Viewer after they join if they only need to view and comment.`
}
return `Inviting is free. Guests join your workspace on a free Viewer seat. You can give them an Editor seat later if they need to contribute to a project beyond viewing and commenting.`
})
@@ -280,9 +280,10 @@ onMounted(() => {
// Watch for plan limit conditions and show dialog if needed
watch(
[hasMissingReferencedObject],
([missingObject]) => {
if (missingObject) {
[hasMissingReferencedObject, state.resources.response.resourcesLoading],
([missingObject, resourcesLoading]: [boolean, boolean]) => {
// Only show dialog if resources are not loading to prevent flashing during version switches
if (missingObject && !resourcesLoading) {
if (isFederated.value) {
limitsDialogType.value = 'federated'
} else {
@@ -26,7 +26,8 @@ export const formatName = (plan?: MaybeNullOrUndefined<WorkspacePlans>) => {
[WorkspacePlans.TeamUnlimitedInvoiced]: 'Starter (Invoiced)',
[WorkspacePlans.Pro]: 'Business',
[WorkspacePlans.ProUnlimited]: 'Business',
[WorkspacePlans.ProUnlimitedInvoiced]: 'Business (Invoiced)'
[WorkspacePlans.ProUnlimitedInvoiced]: 'Business (Invoiced)',
[WorkspacePlans.Enterprise]: 'Enterprise'
}
return formattedPlanNames[plan]
}
@@ -50,7 +50,7 @@ type Documents = {
"\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n": typeof types.HeaderNavShare_ProjectFragmentDoc,
"\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n workspaceSlug\n user {\n id\n }\n }\n": typeof types.HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragmentDoc,
"\n fragment HeaderNavNotificationsWorkspaceInvite_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.HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n name\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n }\n": typeof types.InviteDialogWorkspace_WorkspaceFragmentDoc,
"\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n name\n slug\n domainBasedMembershipProtectionEnabled\n defaultSeatType\n domains {\n domain\n id\n }\n }\n": typeof types.InviteDialogWorkspace_WorkspaceFragmentDoc,
"\n fragment InviteDialogProject_Project on Project {\n id\n name\n workspaceId\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 query InviteDialogProjectRowProjectCollaborators(\n $projectId: String!\n $filter: InvitableCollaboratorsFilter\n ) {\n project(id: $projectId) {\n invitableCollaborators(filter: $filter) {\n items {\n user {\n id\n name\n }\n }\n }\n }\n }\n": typeof types.InviteDialogProjectRowProjectCollaboratorsDocument,
"\n fragment ProjectCardImportFileArea_Project on Project {\n id\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n ...UseFileImport_Project\n }\n": typeof types.ProjectCardImportFileArea_ProjectFragmentDoc,
@@ -495,7 +495,7 @@ const documents: Documents = {
"\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n": types.HeaderNavShare_ProjectFragmentDoc,
"\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n workspaceSlug\n user {\n id\n }\n }\n": types.HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragmentDoc,
"\n fragment HeaderNavNotificationsWorkspaceInvite_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.HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n name\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n }\n": types.InviteDialogWorkspace_WorkspaceFragmentDoc,
"\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n name\n slug\n domainBasedMembershipProtectionEnabled\n defaultSeatType\n domains {\n domain\n id\n }\n }\n": types.InviteDialogWorkspace_WorkspaceFragmentDoc,
"\n fragment InviteDialogProject_Project on Project {\n id\n name\n workspaceId\n workspace {\n id\n name\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n }\n }\n": types.InviteDialogProject_ProjectFragmentDoc,
"\n query InviteDialogProjectRowProjectCollaborators(\n $projectId: String!\n $filter: InvitableCollaboratorsFilter\n ) {\n project(id: $projectId) {\n invitableCollaborators(filter: $filter) {\n items {\n user {\n id\n name\n }\n }\n }\n }\n }\n": types.InviteDialogProjectRowProjectCollaboratorsDocument,
"\n fragment ProjectCardImportFileArea_Project on Project {\n id\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n ...UseFileImport_Project\n }\n": types.ProjectCardImportFileArea_ProjectFragmentDoc,
@@ -1065,7 +1065,7 @@ export function graphql(source: "\n fragment HeaderNavNotificationsWorkspaceInv
/**
* 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 InviteDialogWorkspace_Workspace on Workspace {\n id\n name\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n }\n"): (typeof documents)["\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n name\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n }\n"];
export function graphql(source: "\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n name\n slug\n domainBasedMembershipProtectionEnabled\n defaultSeatType\n domains {\n domain\n id\n }\n }\n"): (typeof documents)["\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n name\n slug\n domainBasedMembershipProtectionEnabled\n defaultSeatType\n domains {\n domain\n id\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
@@ -2,51 +2,51 @@ import type { TutorialItem } from '~/lib/dashboard/helpers/types'
export const tutorialItems: TutorialItem[] = [
{
title: 'Get Civil 3D Pipe Networks Into Revit as Families',
title: 'Embed 3D models in Miro',
image:
'https://cdn.prod.website-files.com/66c31b5a50432200dc753cc4/67b8980920e42b89aff75a3f_C3d%20to%20Revit.jpg',
url: 'https://www.speckle.systems/tutorials/pipe-networks-civil3d-revit'
'https://cdn.prod.website-files.com/66c31b5a50432200dc753cc4/6840300850bf20bbc4598692_Embed%20in%20Miro.jpg',
url: 'https://www.speckle.systems/tutorials/embed-3d-models-in-miro'
},
{
title: 'How To Get Data From Grasshopper Into Power BI',
title: 'Embed 3D models in Notion',
image:
'https://cdn.prod.website-files.com/66c31b5a50432200dc753cc4/677d34c3f1e3b99aa578bfea_GH%20to%20PBI%20tutorial-p-800.jpg',
url: 'https://www.speckle.systems/tutorials/best-way-to-get-data-from-grasshopper-into-power-bi'
'https://cdn.prod.website-files.com/66c31b5a50432200dc753cc4/68402d8d2220374a63dc4164_Embed%20in%20Notion.jpg',
url: 'https://www.speckle.systems/tutorials/how-to-embed-3d-models-in-notion'
},
{
title: 'Automating CFD Analysis with Speckle',
title: 'Visualize Archicad models in 3D with Power BI',
image:
'https://cdn.prod.website-files.com/66c31b5a50432200dc753cc4/66f9c508e14c68996f7aa1f5_66d648086cd656dae5168a85_Tutorial-Graphics-automate-centeres-cfd-1.png',
url: 'https://www.speckle.systems/tutorials/automating-cfd-analysis-with-speckle'
'https://cdn.prod.website-files.com/66c31b5a50432200dc753cc4/68345b16d7223d552bf58f84_Archicad%20to%20%20Power%20BI.jpg',
url: 'https://www.speckle.systems/tutorials/visualize-archicad-models-in-3d-with-power-bi'
},
{
title: 'PowerQuery(QL) for AEC Data Analysis',
title: 'Visualize Rhino models in 3D with Power BI',
image:
'https://cdn.prod.website-files.com/66c31b5a50432200dc753cc4/66f9c49549279bcda21c3115_66e047f5c915694aefb78105_powerquery-specklecon%25400.5x.png',
url: 'https://www.speckle.systems/tutorials/powerquery-ql-for-aec-data-analysis'
'https://cdn.prod.website-files.com/66c31b5a50432200dc753cc4/68345a0a3271258ec8a1dacc_Rhino%20to%20%20Power%20BI.jpg',
url: 'https://www.speckle.systems/tutorials/visualize-rhino-models-in-3d-with-power-bi'
},
{
title: 'From Grasshopper Placeholders to Unreal Assets',
title: 'Visualize SketchUp models in 3D with Power BI',
image:
'https://cdn.prod.website-files.com/66c31b5a50432200dc753cc4/66f9c50919d477e7b4454747_66e047f5b7f992018acdb66b_grasshopper-unreal%25400.5x.png',
url: 'https://www.speckle.systems/tutorials/from-grasshopper-placeholders-to-unreal-assets'
'https://cdn.prod.website-files.com/66c31b5a50432200dc753cc4/68345976fda085cd403855c0_SketchUp%20to%20%20Power%20BI.jpg',
url: 'https://www.speckle.systems/tutorials/visualize-sketchup-models-in-3d-with-power-bi'
},
{
title: 'Rhino Block to Revit Family',
title: 'Visualize Tekla models in 3D with Power BI',
image:
'https://cdn.prod.website-files.com/66c31b5a50432200dc753cc4/66f9c50a49279bcda21c9483_66e047f58da87f021da53e06_rhino-2-rvt-families%25400.5x.png',
url: 'https://www.speckle.systems/tutorials/rhino-block-to-revit-family'
'https://cdn.prod.website-files.com/66c31b5a50432200dc753cc4/683458947403486c2b2235ac_Tekla%20to%20%20Power%20BI.jpg',
url: 'https://www.speckle.systems/tutorials/visualize-tekla-models-in-3d-with-power-bi'
},
{
title: 'SketchUp Components to Revit Families',
title: 'Visualize Navisworks models in 3D with Power BI',
image:
'https://cdn.prod.website-files.com/66c31b5a50432200dc753cc4/66f9c50ac6269f3166039982_66e047f51d1cb84078e7320a_skp-2-rvt-families%25400.5x.png',
url: 'https://www.speckle.systems/tutorials/sketchup-components-to-revit-families'
'https://cdn.prod.website-files.com/66c31b5a50432200dc753cc4/6834573d14d3d379e7c0ffa9_Navisworks%20to%20%20Power%20BI.jpg',
url: 'https://www.speckle.systems/tutorials/visualize-navisworks-models-in-3d-with-power-bi'
},
{
title: 'Block to Family Conversion with Speckle',
title: 'Visualize ETABS models in 3D with Power BI',
image:
'https://cdn.prod.website-files.com/66c31b5a50432200dc753cc4/66f9c50ac50a28b58fe0e10b_66e047f505114bd4a6854b6a_216-blocks-to-families%25400.5x.png',
url: 'https://www.speckle.systems/tutorials/new-in-2-16-block-to-family-conversion'
'https://cdn.prod.website-files.com/66c31b5a50432200dc753cc4/683454bb49e49e15ec574877_ETABS%20to%20%20Power%20BI.jpg',
url: 'https://www.speckle.systems/tutorials/visualize-etabs-models-in-3d-with-power-bi'
}
]
@@ -217,6 +217,7 @@ export type InjectableViewerState = Readonly<{
* Fetch the next page of versions for a loaded model
*/
loadMoreVersions: (modelId: string) => Promise<void>
resourcesLoading: ComputedRef<boolean>
}
}
/**
@@ -713,6 +714,7 @@ function setupResponseResourceData(
// sorting variables so that we don't refetech just because the order changed
const {
result: viewerLoadedResourcesResult,
loading: viewerLoadedResourcesLoading,
variables: viewerLoadedResourcesVariables,
onError: onViewerLoadedResourcesError,
onResult: onViewerLoadedResourcesResult,
@@ -880,7 +882,8 @@ function setupResponseResourceData(
resourceQueryVariables: computed(() => viewerLoadedResourcesVariables.value),
threadsQueryVariables: computed(() => threadsQueryVariables.value),
loadMoreVersions,
resourcesLoaded: computed(() => initLoadDone.value)
resourcesLoaded: computed(() => initLoadDone.value),
resourcesLoading: computed(() => viewerLoadedResourcesLoading.value)
}
}
@@ -0,0 +1,53 @@
import { describe, test, expect, beforeEach } from 'vitest'
import { DefermentManager } from './defermentManager.js'
import { DefermentManagerOptions } from '../operations/options.js'
import { Base, Item } from '../types/types.js'
const makeItem = (id: string, size = 1): Item => ({
baseId: id,
base: { foo: 'bar' } as unknown as Base,
size
})
describe('DefermentManager totalDefermentRequests', () => {
let manager: DefermentManager
let options: DefermentManagerOptions
beforeEach(() => {
options = { maxSizeInMb: 1, ttlms: 1000, logger: (): void => {} }
manager = new DefermentManager(options)
})
test('tracks deferment requests for each id', () => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
manager.defer({ id: 'a' })
// eslint-disable-next-line @typescript-eslint/no-floating-promises
manager.defer({ id: 'a' })
// eslint-disable-next-line @typescript-eslint/no-floating-promises
manager.defer({ id: 'b' })
// @ts-expect-error: access private for test
expect(manager.totalDefermentRequests.get('a')).toBe(2)
// @ts-expect-error: access private for test
expect(manager.totalDefermentRequests.get('b')).toBe(1)
})
test('increments and does not reset on undefer', () => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
manager.defer({ id: 'x' })
// eslint-disable-next-line @typescript-eslint/no-floating-promises
manager.defer({ id: 'x' })
manager.undefer(makeItem('x'))
// @ts-expect-error: access private for test
expect(manager.totalDefermentRequests.get('x')).toBe(2)
// @ts-expect-error: access private for test
const deferredBase = manager.deferments.get('x')
expect(deferredBase).toBeDefined()
expect(deferredBase?.getId()).toBe('x')
})
test('does not increment for undefer only', () => {
manager.undefer(makeItem('y'))
// @ts-expect-error: access private for test
expect(manager.totalDefermentRequests.get('y')).toBeUndefined()
})
})
@@ -4,13 +4,14 @@ import { DefermentManagerOptions } from '../operations/options.js'
export class DefermentManager {
private deferments: Map<string, DeferredBase> = new Map()
private timer?: ReturnType<typeof setTimeout>
private logger: CustomLogger
private currentSize = 0
private disposed = false
//tracks total deferment requests for each id
//this is used to prevent cleaning up deferments that are still being requested
private totalDefermentRequests: Map<string, number> = new Map()
constructor(private options: DefermentManagerOptions) {
this.resetGlobalTimer()
this.logger = options.logger || ((): void => {})
}
@@ -29,6 +30,7 @@ export class DefermentManager {
async defer(params: { id: string }): Promise<Base> {
if (this.disposed) throw new Error('DefermentManager is disposed')
this.trackDefermentRequest(params.id)
const now = this.now()
const deferredBase = this.deferments.get(params.id)
if (deferredBase) {
@@ -44,10 +46,27 @@ export class DefermentManager {
return notYetFound.getPromise()
}
private trackDefermentRequest(id: string): void {
const request = this.totalDefermentRequests.get(id)
if (request) {
this.totalDefermentRequests.set(id, request + 1)
} else {
this.totalDefermentRequests.set(id, 1)
}
}
undefer(item: Item): void {
if (this.disposed) throw new Error('DefermentManager is disposed')
const now = this.now()
this.currentSize += item.size || 0
if (this.currentSize > this.options.maxSizeInMb * 1024 * 1024) {
this.logger(
'deferments size exceeded, cleaning up',
this.currentSize,
this.options.maxSizeInMb
)
this.cleanDeferments()
}
//order matters here with found before undefer
const deferredBase = this.deferments.get(item.baseId)
if (deferredBase) {
@@ -60,21 +79,9 @@ export class DefermentManager {
}
}
private resetGlobalTimer(): void {
const run = (): void => {
this.cleanDeferments()
this.timer = setTimeout(run, this.options.ttlms)
}
this.timer = setTimeout(run, this.options.ttlms)
}
dispose(): void {
if (this.disposed) return
this.disposed = true
if (this.timer) {
clearTimeout(this.timer)
this.timer = undefined
}
this.clearDeferments()
}
@@ -92,15 +99,6 @@ export class DefermentManager {
}
private cleanDeferments(): void {
const maxSizeBytes = this.options.maxSizeInMb * 1024 * 1024
if (this.currentSize < maxSizeBytes) {
this.logger(
'deferments size is ok, no need to clean',
this.currentSize,
maxSizeBytes
)
return
}
const now = this.now()
let cleaned = 0
const start = performance.now()
@@ -108,16 +106,19 @@ export class DefermentManager {
.filter((x) => x.isExpired(now))
.sort((a, b) => this.compareMaybeBasesBySize(a.getItem(), b.getItem()))) {
if (deferredBase.done(now)) {
//if the deferment is done but has been requested multiple times,
//we do not clean it up to allow the requests to resolve
const requestCount = this.totalDefermentRequests.get(deferredBase.getId())
if (requestCount && requestCount > 1) {
return
}
this.currentSize -= deferredBase.getItem()?.size || 0
this.deferments.delete(deferredBase.getId())
cleaned++
if (this.currentSize < maxSizeBytes) {
break
}
}
}
this.logger(
'cleaned deferments, cleaned, left',
'cleaned deferments: cleaned, left, time',
cleaned,
this.deferments.size,
performance.now() - start
@@ -41,7 +41,7 @@ export class ObjectLoader2 {
this.#database = options.database
this.#deferments = new DefermentManager({
maxSizeInMb: 2_000, // 2 GBs
ttlms: 5_000, // 5 seconds
ttlms: 15_000, // 15 seconds
logger: this.#logger
})
this.#cache = new CacheReader(this.#database, this.#deferments, cacheOptions)
@@ -94,7 +94,11 @@ export class ObjectLoader2 {
yield rootItem.base
if (!rootItem.base.__closure) return
const children = Object.keys(rootItem.base.__closure)
//sort the closures by their values descending
const sortedClosures = Object.entries(rootItem.base.__closure).sort(
(a, b) => b[1] - a[1]
)
const children = sortedClosures.map((x) => x[0])
const total = children.length
this.#downloader.initializePool({
results: new AggregateQueue(this.#gathered, this.#pump),
@@ -64,6 +64,7 @@ enum WorkspacePlans {
pro
proUnlimited
proUnlimitedInvoiced
enterprise
}
enum WorkspacePlanStatuses {
@@ -10,7 +10,7 @@ import {
upsertWorkspacePlanFactory
} from '@/modules/gatekeeper/repositories/billing'
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import { PaidWorkspacePlans, PaidWorkspacePlanStatuses } from '@speckle/shared'
import { WorkspacePlans, WorkspacePlanStatuses } from '@speckle/shared'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { updateWorkspacePlanFactory } from '@/modules/gatekeeper/services/workspacePlans'
@@ -18,8 +18,9 @@ const command: CommandModule<
unknown,
{
workspaceSlugOrId: string
status: PaidWorkspacePlanStatuses
plan: PaidWorkspacePlans
// you need to know what you are doing, status and plan pairing validity is not ensured here
status: WorkspacePlanStatuses
plan: WorkspacePlans
}
> = {
command: 'set-plan <workspaceSlugOrId> [plan] [status]',
@@ -32,8 +33,8 @@ const command: CommandModule<
plan: {
describe: 'Plan to set the status for',
type: 'string',
default: PaidWorkspacePlans.Team,
choices: [PaidWorkspacePlans.Team, PaidWorkspacePlans.Pro]
default: WorkspacePlans.Team,
choices: Object.values(WorkspacePlans)
},
status: {
describe: 'Status to set for the workspace plan',
@@ -4954,6 +4954,7 @@ export type WorkspacePlanUsage = {
export const WorkspacePlans = {
Academia: 'academia',
Enterprise: 'enterprise',
Free: 'free',
Pro: 'pro',
ProUnlimited: 'proUnlimited',
@@ -4934,6 +4934,7 @@ export type WorkspacePlanUsage = {
export const WorkspacePlans = {
Academia: 'academia',
Enterprise: 'enterprise',
Free: 'free',
Pro: 'pro',
ProUnlimited: 'proUnlimited',
@@ -1,7 +1,12 @@
import { getFeatureFlags, getFrontendOrigin } from '@/modules/shared/helpers/envHelper'
import type { Resolvers } from '@/modules/core/graph/generated/graphql'
import { authorizeResolver } from '@/modules/shared'
import { Roles, throwUncoveredError, WorkspacePlanFeatures } from '@speckle/shared'
import {
Roles,
throwUncoveredError,
WorkspacePlanFeatures,
WorkspacePlans
} from '@speckle/shared'
import {
getWorkspaceFactory,
getWorkspaceRoleForUserFactory,
@@ -71,19 +76,20 @@ export = FF_GATEKEEPER_MODULE_ENABLED
if (!workspacePlan) return null
let paymentMethod: WorkspacePaymentMethod
switch (workspacePlan.name) {
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
paymentMethod = WorkspacePaymentMethod.Billing
break
case 'unlimited':
case 'academia':
case 'free':
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.Free:
paymentMethod = WorkspacePaymentMethod.Unpaid
break
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.Enterprise:
paymentMethod = WorkspacePaymentMethod.Invoice
break
default:
@@ -248,17 +254,18 @@ export = FF_GATEKEEPER_MODULE_ENABLED
if (subscription) {
let purchased = 0
switch (workspacePlan.name) {
case 'unlimited':
case 'academia':
case 'free':
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.Free:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.Enterprise:
// not stripe paid plans and old plans do not have seats available
break
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
purchased = getTotalSeatsCountByPlanFactory({
getWorkspacePlanProductId
})({
@@ -19,7 +19,8 @@ import {
import {
PaidWorkspacePlans,
PaidWorkspacePlanStatuses,
throwUncoveredError
throwUncoveredError,
WorkspacePlans
} from '@speckle/shared'
import { cloneDeep } from 'lodash'
import { CountSeatsByTypeInWorkspace } from '@/modules/gatekeeper/domain/operations'
@@ -78,16 +79,17 @@ export const handleSubscriptionUpdateFactory =
if (status) {
switch (workspacePlan.name) {
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
break
case 'unlimited':
case 'academia':
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
case 'free':
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.Free:
case WorkspacePlans.Enterprise:
throw new WorkspacePlanMismatchError()
default:
throwUncoveredError(workspacePlan)
@@ -146,21 +148,22 @@ export const addWorkspaceSubscriptionSeatIfNeededFactory =
// if (!workspaceSubscription) throw new WorkspaceSubscriptionNotFoundError()
switch (workspacePlan.name) {
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
// If viewer seat type, we don't need to do anything
if (seatType === WorkspaceSeatType.Viewer) {
return
} else {
break
}
case 'unlimited':
case 'academia':
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
case 'free':
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.Free:
case WorkspacePlans.Enterprise:
throw new WorkspacePlanMismatchError()
default:
throwUncoveredError(workspacePlan)
@@ -15,7 +15,7 @@ import {
} from '@/modules/gatekeeper/errors/billing'
import { mutateSubscriptionDataWithNewValidSeatNumbers } from '@/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers'
import { Logger } from '@/observability/logging'
import { throwUncoveredError } from '@speckle/shared'
import { throwUncoveredError, WorkspacePlans } from '@speckle/shared'
import { cloneDeep, isEqual } from 'lodash'
type DownscaleWorkspaceSubscription = (args: {
@@ -41,16 +41,17 @@ export const downscaleWorkspaceSubscriptionFactory =
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
switch (workspacePlan.name) {
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
break
case 'unlimited':
case 'academia':
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
case 'free':
case WorkspacePlans.Free:
case WorkspacePlans.Academia:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.Enterprise:
case WorkspacePlans.Unlimited:
throw new WorkspacePlanMismatchError()
default:
throwUncoveredError(workspacePlan)
@@ -27,7 +27,8 @@ import { EventBusEmit } from '@/modules/shared/services/eventBus'
import {
PaidWorkspacePlans,
throwUncoveredError,
WorkspacePlanBillingIntervals
WorkspacePlanBillingIntervals,
WorkspacePlans
} from '@speckle/shared'
import { cloneDeep } from 'lodash'
@@ -68,16 +69,17 @@ export const upgradeWorkspaceSubscriptionFactory =
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
switch (workspacePlan.name) {
case 'unlimited':
case 'academia':
case 'teamUnlimitedInvoiced':
case 'proUnlimitedInvoiced':
case 'free': // Upgrade from free is handled through startCheckout since it is from free to paid
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.Enterprise:
case WorkspacePlans.Free: // Upgrade from free is handled through startCheckout since it is from free to paid
throw new WorkspaceNotPaidPlanError()
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
break
default:
throwUncoveredError(workspacePlan)
@@ -9,7 +9,8 @@ const WorkspacePlansUpgradeMapping: Record<WorkspacePlans, WorkspacePlans[]> = {
teamUnlimitedInvoiced: [],
pro: ['pro', 'proUnlimited'],
proUnlimited: ['proUnlimited'],
proUnlimitedInvoiced: []
proUnlimitedInvoiced: [],
enterprise: []
}
export const isUpgradeWorkspacePlanValid = ({
@@ -6,7 +6,7 @@ import { InvalidWorkspacePlanStatus } from '@/modules/gatekeeper/errors/billing'
import { EventBusEmit } from '@/modules/shared/services/eventBus'
import { GetWorkspace } from '@/modules/workspaces/domain/operations'
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import { throwUncoveredError, WorkspacePlan } from '@speckle/shared'
import { throwUncoveredError, WorkspacePlan, WorkspacePlans } from '@speckle/shared'
export const updateWorkspacePlanFactory =
({
@@ -35,10 +35,10 @@ export const updateWorkspacePlanFactory =
const createdAt = new Date()
const updatedAt = new Date()
switch (name) {
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
switch (status) {
case 'valid':
case 'cancelationScheduled':
@@ -53,11 +53,12 @@ export const updateWorkspacePlanFactory =
}
break
case 'free':
case 'academia':
case 'unlimited':
case 'teamUnlimitedInvoiced':
case 'proUnlimitedInvoiced':
case WorkspacePlans.Free:
case WorkspacePlans.Academia:
case WorkspacePlans.Unlimited:
case WorkspacePlans.Enterprise:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.ProUnlimitedInvoiced:
switch (status) {
case 'valid':
await upsertWorkspacePlan({
@@ -116,6 +116,7 @@ import {
} from '@/modules/workspaces/services/retrieval'
import {
Roles,
WorkspacePlans,
WorkspaceRoles,
removeNullOrUndefinedKeys,
throwUncoveredError
@@ -707,10 +708,10 @@ export = FF_WORKSPACES_MODULE_ENABLED
const workspacePlan = await getWorkspacePlanFactory({ db })({ workspaceId })
if (workspacePlan) {
switch (workspacePlan.name) {
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
switch (workspacePlan.status) {
case 'cancelationScheduled':
case 'valid':
@@ -721,11 +722,12 @@ export = FF_WORKSPACES_MODULE_ENABLED
default:
throwUncoveredError(workspacePlan)
}
case 'free':
case 'unlimited':
case 'academia':
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
case WorkspacePlans.Free:
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.Enterprise:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.TeamUnlimitedInvoiced:
break
default:
throwUncoveredError(workspacePlan)
@@ -27,9 +27,9 @@ export const withOperationLogging = async <T>(
try {
logger.info(
OperationStatus.start,
{ ...OperationStatus.start, operationDescription },
`${OperationLogLinePrefix}${
operationDescription ? ` ${operationDescription}` : ''
operationDescription ? ' {operationDescription}' : ''
}`
)
const results = await operation()
@@ -4935,6 +4935,7 @@ export type WorkspacePlanUsage = {
export const WorkspacePlans = {
Academia: 'academia',
Enterprise: 'enterprise',
Free: 'free',
Pro: 'pro',
ProUnlimited: 'proUnlimited',
@@ -148,6 +148,17 @@ export const WorkspacePaidPlanConfigs: {
export const WorkspaceUnpaidPlanConfigs: {
[plan in UnpaidWorkspacePlans]: WorkspacePlanConfig<plan>
} = {
[UnpaidWorkspacePlans.Enterprise]: {
plan: UnpaidWorkspacePlans.Enterprise,
features: [
...baseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding
],
limits: unlimited
},
[UnpaidWorkspacePlans.Unlimited]: {
plan: UnpaidWorkspacePlans.Unlimited,
features: [
@@ -18,7 +18,8 @@ describe('plan helpers', () => {
proUnlimitedInvoiced: false,
team: false,
teamUnlimited: true,
teamUnlimitedInvoiced: false
teamUnlimitedInvoiced: false,
enterprise: false
}
it.each(Object.entries(planCases))(
'plan %s include the paid unlimited projects addon -> %s',
@@ -41,7 +42,8 @@ describe('plan helpers', () => {
proUnlimitedInvoiced: false,
team: true,
teamUnlimited: true,
teamUnlimitedInvoiced: false
teamUnlimitedInvoiced: false,
enterprise: false
}
it.each(Object.entries(planCases))(
'is plan %s available self served -> %s',
+31 -27
View File
@@ -13,6 +13,7 @@ export type PaidWorkspacePlans =
export const UnpaidWorkspacePlans = <const>{
TeamUnlimitedInvoiced: 'teamUnlimitedInvoiced',
ProUnlimitedInvoiced: 'proUnlimitedInvoiced',
Enterprise: 'enterprise',
Unlimited: 'unlimited',
Academia: 'academia',
Free: 'free'
@@ -32,16 +33,17 @@ export const doesPlanIncludeUnlimitedProjectsAddon = (
plan: WorkspacePlans
): boolean => {
switch (plan) {
case 'teamUnlimited':
case 'proUnlimited':
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.ProUnlimited:
return true
case 'free':
case 'team':
case 'pro':
case 'teamUnlimitedInvoiced':
case 'proUnlimitedInvoiced':
case 'unlimited':
case 'academia':
case WorkspacePlans.Free:
case WorkspacePlans.Team:
case WorkspacePlans.Pro:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.Enterprise:
return false
default:
@@ -51,16 +53,17 @@ export const doesPlanIncludeUnlimitedProjectsAddon = (
export const isSelfServeAvailablePlan = (plan: WorkspacePlans): boolean => {
switch (plan) {
case 'free':
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Free:
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
return true
case 'teamUnlimitedInvoiced':
case 'proUnlimitedInvoiced':
case 'unlimited':
case 'academia':
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.Enterprise:
return false
default:
@@ -70,16 +73,17 @@ export const isSelfServeAvailablePlan = (plan: WorkspacePlans): boolean => {
export const isPaidPlan = (plan: WorkspacePlans): boolean => {
switch (plan) {
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
return true
case 'free':
case 'teamUnlimitedInvoiced':
case 'proUnlimitedInvoiced':
case 'unlimited':
case 'academia':
case WorkspacePlans.Free:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.Enterprise:
return false
default: