From dda535e811a6c09d276527252864b6cfbca819c4 Mon Sep 17 00:00:00 2001 From: Alessandro Magionami Date: Thu, 18 Jul 2024 15:16:28 +0200 Subject: [PATCH 1/5] chore(core): eslint rule to avoid relative imports --- packages/server/eslint.config.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/server/eslint.config.mjs b/packages/server/eslint.config.mjs index bd8c6c135..368ad2b21 100644 --- a/packages/server/eslint.config.mjs +++ b/packages/server/eslint.config.mjs @@ -38,6 +38,12 @@ const configs = [ } }, rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: ['.*'] + } + ], '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-unsafe-return': 'error', '@typescript-eslint/no-base-to-string': 'off', From 447b48a6c240ea98b538ec339570bc937d4d059a Mon Sep 17 00:00:00 2001 From: Alessandro Magionami Date: Thu, 18 Jul 2024 16:07:16 +0200 Subject: [PATCH 2/5] chore(server): remove relative imports --- packages/server/app.ts | 1 + packages/server/logging/expressLogging.ts | 2 +- packages/server/modules/auth/tests/unit/logic.spec.ts | 2 +- packages/server/modules/core/graph/resolvers/objects.ts | 2 +- .../server/modules/core/services/streams/onboarding.ts | 4 ++-- .../modules/core/tests/integration/createUser.spec.ts | 4 ++-- packages/server/modules/core/tests/streams.spec.ts | 8 ++++---- .../server/modules/serverinvites/services/validation.ts | 6 +++++- 8 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/server/app.ts b/packages/server/app.ts index a1c484885..cba18dbc7 100644 --- a/packages/server/app.ts +++ b/packages/server/app.ts @@ -1,4 +1,5 @@ /* eslint-disable camelcase */ +/* eslint-disable no-restricted-imports */ /* istanbul ignore file */ import './bootstrap' import http from 'http' diff --git a/packages/server/logging/expressLogging.ts b/packages/server/logging/expressLogging.ts index 516580ecd..da67841cb 100644 --- a/packages/server/logging/expressLogging.ts +++ b/packages/server/logging/expressLogging.ts @@ -1,4 +1,4 @@ -import { logger } from './logging' +import { logger } from '@/logging/logging' import { randomUUID } from 'crypto' import HttpLogger from 'pino-http' import { IncomingMessage } from 'http' diff --git a/packages/server/modules/auth/tests/unit/logic.spec.ts b/packages/server/modules/auth/tests/unit/logic.spec.ts index 574324b09..0fb9eecc6 100644 --- a/packages/server/modules/auth/tests/unit/logic.spec.ts +++ b/packages/server/modules/auth/tests/unit/logic.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai' import { describe, it } from 'mocha' -import { getNameFromUserInfo } from '../../domain/logic' +import { getNameFromUserInfo } from '@/modules/auth/domain/logic' /* eslint-disable camelcase */ describe('getNameFromUserInfo', () => { diff --git a/packages/server/modules/core/graph/resolvers/objects.ts b/packages/server/modules/core/graph/resolvers/objects.ts index 4c692c359..644ea288a 100644 --- a/packages/server/modules/core/graph/resolvers/objects.ts +++ b/packages/server/modules/core/graph/resolvers/objects.ts @@ -4,7 +4,7 @@ import { createObjects, getObjectChildren, getObjectChildrenQuery -} from '../../services/objects' +} from '@/modules/core/services/objects' import { Roles } from '@speckle/shared' import { Resolvers } from '@/modules/core/graph/generated/graphql' diff --git a/packages/server/modules/core/services/streams/onboarding.ts b/packages/server/modules/core/services/streams/onboarding.ts index efa60b65e..d747c7163 100644 --- a/packages/server/modules/core/services/streams/onboarding.ts +++ b/packages/server/modules/core/services/streams/onboarding.ts @@ -8,8 +8,8 @@ import { StreamRecord } from '@/modules/core/helpers/types' import { logger } from '@/logging/logging' import { createStreamReturnRecord } from '@/modules/core/services/streams/management' import { getOnboardingBaseProject } from '@/modules/cross-server-sync/services/onboardingProject' -import { updateStream } from '../../repositories/streams' -import { getUser } from '../users' +import { updateStream } from '@/modules/core/repositories/streams' +import { getUser } from '@/modules/core/services/users' import { ContextResourceAccessRules, isNewResourceAllowed diff --git a/packages/server/modules/core/tests/integration/createUser.spec.ts b/packages/server/modules/core/tests/integration/createUser.spec.ts index a63fd6606..2f0f0449f 100644 --- a/packages/server/modules/core/tests/integration/createUser.spec.ts +++ b/packages/server/modules/core/tests/integration/createUser.spec.ts @@ -9,8 +9,8 @@ import { createRandomPassword } from '@/modules/core/helpers/testHelpers' import { expectToThrow } from '@/test/assertionHelper' -import { PasswordTooShortError } from '../../errors/userinput' -import { findPrimaryEmailForUserFactory } from '../../repositories/userEmails' +import { PasswordTooShortError } from '@/modules/core/errors/userinput' +import { findPrimaryEmailForUserFactory } from '@/modules/core/repositories/userEmails' describe('Users @core-users', () => { beforeEach(async () => { diff --git a/packages/server/modules/core/tests/streams.spec.ts b/packages/server/modules/core/tests/streams.spec.ts index 2e949f800..7681ebdd8 100644 --- a/packages/server/modules/core/tests/streams.spec.ts +++ b/packages/server/modules/core/tests/streams.spec.ts @@ -6,15 +6,15 @@ import { deleteStream, getStreamUsers, grantPermissionsStream -} from '../services/streams' +} from '@/modules/core/services/streams' import { createBranch, getBranchByNameAndStreamId, deleteBranchById -} from '../services/branches' -import { createObject } from '../services/objects' -import { createCommitByBranchName } from '../services/commits' +} from '@/modules/core/services/branches' +import { createObject } from '@/modules/core/services/objects' +import { createCommitByBranchName } from '@/modules/core/services/commits' import { beforeEachContext, truncateTables } from '@/test/hooks' import { diff --git a/packages/server/modules/serverinvites/services/validation.ts b/packages/server/modules/serverinvites/services/validation.ts index 4c3df1d8c..9aef30eab 100644 --- a/packages/server/modules/serverinvites/services/validation.ts +++ b/packages/server/modules/serverinvites/services/validation.ts @@ -1,7 +1,11 @@ import { UserRecord } from '@/modules/core/helpers/types' import { CreateInviteParams } from '@/modules/serverinvites/domain/operations' import { InviteCreateValidationError } from '@/modules/serverinvites/errors' -import { ResourceTargets, isServerInvite, resolveTarget } from '../helpers/inviteHelper' +import { + ResourceTargets, + isServerInvite, + resolveTarget +} from '@/modules/serverinvites/helpers/inviteHelper' import { UserWithOptionalRole } from '@/modules/core/repositories/users' import { authorizeResolver } from '@/modules/shared' import { Roles } from '@speckle/shared' From 8e3520fb672fe20310048ff9a076b8f38aa8ecb7 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Thu, 18 Jul 2024 17:23:10 +0100 Subject: [PATCH 3/5] chore(automate): track run duration in ms too (#2518) * chore(automate): track run duration in ms too * chore(automate): use `elapsed` --- packages/server/modules/automate/services/tracking.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/server/modules/automate/services/tracking.ts b/packages/server/modules/automate/services/tracking.ts index 0565f5dc9..d5940d937 100644 --- a/packages/server/modules/automate/services/tracking.ts +++ b/packages/server/modules/automate/services/tracking.ts @@ -17,7 +17,6 @@ import { getCommit } from '@/modules/core/repositories/commits' import { getUserById } from '@/modules/core/services/users' import { mixpanel } from '@/modules/shared/utils/mixpanel' import { throwUncoveredError } from '@speckle/shared' -import dayjs from 'dayjs' const isFinished = (runStatus: AutomationRunStatus) => { const finishedStatuses: AutomationRunStatus[] = [ @@ -80,10 +79,8 @@ const onAutomationRunStatusUpdated = runId: run.id, functionRunId: functionRun.id, status: functionRun.status, - durationInSeconds: dayjs(functionRun.updatedAt).diff( - functionRun.createdAt, - 'second' - ) + durationInSeconds: functionRun.elapsed / 1000, + durationInMilliseconds: functionRun.elapsed }) } From 27179ad4f1e05e981544b506987c07b652649556 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Fri, 19 Jul 2024 12:44:53 +0100 Subject: [PATCH 4/5] feat(workspaces): stencil gql resolvers (#2508) * feat(workspaces): stencil gql resolvers * fix(workspaces): lol lmao * feat(workspaces): stencil gql api and resolvers * fix(workspaces): roles and scopes * fix(workspaces): add scopes --- .../assets/workspaces/workspaces.graphql | 90 ----- .../typedefs/workspaces.graphql | 176 ++++++++++ .../modules/core/graph/generated/graphql.ts | 314 +++++++++++++++++- .../graph/generated/graphql.ts | 205 ++++++++++++ .../modules/workspaces/errors/workspace.ts | 5 + .../workspaces/graph/resolvers/workspaces.ts | 82 +++++ packages/server/modules/workspaces/scopes.ts | 5 + .../graph/resolvers/workspacesCore.ts | 75 +++++ .../server/test/graphql/generated/graphql.ts | 205 ++++++++++++ packages/shared/src/core/constants.ts | 3 +- 10 files changed, 1067 insertions(+), 93 deletions(-) delete mode 100644 packages/server/assets/workspaces/workspaces.graphql create mode 100644 packages/server/assets/workspacesCore/typedefs/workspaces.graphql create mode 100644 packages/server/modules/workspaces/graph/resolvers/workspaces.ts create mode 100644 packages/server/modules/workspacesCore/graph/resolvers/workspacesCore.ts diff --git a/packages/server/assets/workspaces/workspaces.graphql b/packages/server/assets/workspaces/workspaces.graphql deleted file mode 100644 index 7d34895e6..000000000 --- a/packages/server/assets/workspaces/workspaces.graphql +++ /dev/null @@ -1,90 +0,0 @@ -type Workspace { - id: ID! - name: String! - description: String - createdAt: DateTime! - updatedAt: DateTime! - """ - Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. - """ - role: String - team: [WorkspaceCollaborator!]! - invitedTeam: [PendingWorkspaceCollaborator!] - projects( - limit: Int! = 25 - cursor: String - filter: UserProjectsFilter - ): ProjectCollection! -} - -type WorkspaceCollaborator { - id: ID! - role: String! - user: LimitedUser! -} - -type PendingWorkspaceCollaborator { - id: ID! - inviteId: String! - workspaceId: String! - workspaceName: String! - """ - E-mail address or name of the invited user - """ - title: String! - role: String! - invitedBy: LimitedUser! - """ - Set only if user is registered - """ - user: LimitedUser - """ - Only available if the active user is the pending workspace collaborator - """ - token: String -} - -type WorkspaceCollection { - totalCount: Int! - cursor: String - items: [Workspace!]! -} - -extend type User { - """ - Get the workspaces for the user - """ - workspaces( - limit: Int! = 25 - cursor: String = null - filter: UserWorkspacesFilter - ): WorkspaceCollection! @isOwner -} - -extend type Project { - workspace: Workspace -} - -type ServerWorkspacesInfo { - """ - 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. - """ - workspacesEnabled: Boolean! -} - -extend type ServerInfo { - workspaces: ServerWorkspacesInfo! -} - -extend type AdminQueries { - workspaceList( - query: String - limit: Int! = 25 - cursor: String = null - ): WorkspaceCollection! -} - -input UserWorkspacesFilter { - search: String -} diff --git a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql new file mode 100644 index 000000000..1e874a97f --- /dev/null +++ b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql @@ -0,0 +1,176 @@ +extend type Query { + workspace(id: String!): Workspace! @hasScope(scope: "workspace:read") +} + +input WorkspaceCreateInput { + name: String! + description: String + logoUrl: String +} + +input WorkspaceUpdateInput { + id: String! + name: String + description: String + logoUrl: String +} + +input WorkspaceRoleUpdateInput { + userId: String! + workspaceId: String! + role: WorkspaceRole! +} + +input WorkspaceRoleDeleteInput { + userId: String! + workspaceId: String! +} + +extend type Mutation { + workspaceMutations: WorkspaceMutations! @hasServerRole(role: SERVER_USER) +} + +type WorkspaceMutations { + create(input: WorkspaceCreateInput!): Workspace! @hasScope(scope: "workspace:create") + delete(workspaceId: String!): Workspace! @hasScope(scope: "workspace:delete") + update(input: WorkspaceUpdateInput!): Workspace! @hasScope(scope: "workspace:update") + """ + TODO: `@hasWorkspaceRole(role: WORKSPACE_ADMIN)` for role changes + """ + updateRole(input: WorkspaceRoleUpdateInput!): Boolean! + @hasScope(scope: "workspace:update") + deleteRole(input: WorkspaceRoleDeleteInput!): Boolean! + @hasScope(scope: "workspace:update") + invites: WorkspaceInviteMutations! +} + +input WorkspaceInviteCreateInput { + """ + Either this or userId must be filled + """ + email: String + """ + Either this or email must be filled + """ + userId: String + """ + Defaults to the member role, if not specified + """ + role: WorkspaceRole +} + +input WorkspaceInviteUseInput { + workspaceId: String! + token: String! + accept: Boolean! +} + +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! + cancel(workspaceId: String!, inviteId: String!): Workspace! + @hasScope(scope: "users:invite") + @hasServerRole(role: SERVER_USER) +} + +type Workspace { + id: ID! + name: String! + description: String + createdAt: DateTime! + updatedAt: DateTime! + """ + Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. + """ + role: String + team: [WorkspaceCollaborator!]! + invitedTeam: [PendingWorkspaceCollaborator!] + projects( + limit: Int! = 25 + cursor: String + filter: UserProjectsFilter + ): ProjectCollection! +} + +type WorkspaceCollaborator { + id: ID! + role: String! + user: LimitedUser! +} + +type PendingWorkspaceCollaborator { + id: ID! + inviteId: String! + workspaceId: String! + workspaceName: String! + """ + E-mail address or name of the invited user + """ + title: String! + role: String! + invitedBy: LimitedUser! + """ + Set only if user is registered + """ + user: LimitedUser + """ + Only available if the active user is the pending workspace collaborator + """ + token: String +} + +type WorkspaceCollection { + totalCount: Int! + cursor: String + items: [Workspace!]! +} + +extend type User { + """ + Get the workspaces for the user + """ + workspaces( + limit: Int! = 25 + cursor: String = null + filter: UserWorkspacesFilter + ): WorkspaceCollection! @isOwner +} + +extend type Project { + workspace: Workspace +} + +type ServerWorkspacesInfo { + """ + 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. + """ + workspacesEnabled: Boolean! +} + +extend type ServerInfo { + workspaces: ServerWorkspacesInfo! +} + +extend type AdminQueries { + workspaceList( + query: String + limit: Int! = 25 + cursor: String = null + ): WorkspaceCollection! +} + +input UserWorkspacesFilter { + search: String +} + +enum WorkspaceRole { + ADMIN + MEMBER + GUEST +} diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 87e4e1b39..e81628202 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -76,6 +76,7 @@ export type AdminQueries = { projectList: ProjectCollection; serverStatistics: ServerStatistics; userList: AdminUserList; + workspaceList: WorkspaceCollection; }; @@ -102,6 +103,13 @@ export type AdminQueriesUserListArgs = { role?: InputMaybe; }; + +export type AdminQueriesWorkspaceListArgs = { + cursor?: InputMaybe; + limit?: Scalars['Int']['input']; + query?: InputMaybe; +}; + export type AdminUserList = { __typename?: 'AdminUserList'; cursor?: Maybe; @@ -1318,6 +1326,7 @@ export type Mutation = { webhookDelete: Scalars['String']['output']; /** Updates an existing webhook */ webhookUpdate: Scalars['String']['output']; + workspaceMutations: WorkspaceMutations; }; @@ -1699,6 +1708,22 @@ export type PendingStreamCollaborator = { user?: Maybe; }; +export type PendingWorkspaceCollaborator = { + __typename?: 'PendingWorkspaceCollaborator'; + id: Scalars['ID']['output']; + inviteId: Scalars['String']['output']; + invitedBy: LimitedUser; + role: Scalars['String']['output']; + /** E-mail address or name of the invited user */ + title: Scalars['String']['output']; + /** Only available if the active user is the pending workspace collaborator */ + token?: Maybe; + /** Set only if user is registered */ + user?: Maybe; + workspaceId: Scalars['String']['output']; + workspaceName: Scalars['String']['output']; +}; + export type Project = { __typename?: 'Project'; allowPublicComments: Scalars['Boolean']['output']; @@ -1750,6 +1775,7 @@ export type Project = { viewerResources: Array; visibility: ProjectVisibility; webhooks: WebhookCollection; + workspace?: Maybe; }; @@ -2396,6 +2422,7 @@ export type Query = { * The query looks for matches in name & email */ userSearch: UserSearchResultCollection; + workspace: Workspace; }; @@ -2521,6 +2548,11 @@ export type QueryUserSearchArgs = { query: Scalars['String']['input']; }; + +export type QueryWorkspaceArgs = { + id: Scalars['String']['input']; +}; + /** Deprecated: Used by old stream-based mutations */ export type ReplyCreateInput = { /** IDs of uploaded blobs that should be attached to this reply */ @@ -2623,6 +2655,7 @@ export type ServerInfo = { serverRoles: Array; termsOfService?: Maybe; version?: Maybe; + workspaces: ServerWorkspacesInfo; }; export type ServerInfoUpdateInput = { @@ -2691,6 +2724,15 @@ export type ServerStats = { userHistory?: Maybe>>; }; +export type ServerWorkspacesInfo = { + __typename?: 'ServerWorkspacesInfo'; + /** + * 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. + */ + workspacesEnabled: Scalars['Boolean']['output']; +}; + export type SmartTextEditorValue = { __typename?: 'SmartTextEditorValue'; /** File attachments, if any */ @@ -3311,6 +3353,8 @@ export type User = { * Note: Only count resolution is currently implemented */ versions: CountOnlyCollection; + /** Get the workspaces for the user */ + workspaces: WorkspaceCollection; }; @@ -3398,6 +3442,17 @@ export type UserVersionsArgs = { limit?: Scalars['Int']['input']; }; + +/** + * Full user type, should only be used in the context of admin operations or + * when a user is reading/writing info about himself + */ +export type UserWorkspacesArgs = { + cursor?: InputMaybe; + filter?: InputMaybe; + limit?: Scalars['Int']['input']; +}; + export type UserAutomateInfo = { __typename?: 'UserAutomateInfo'; availableGithubOrgs: Array; @@ -3448,6 +3503,10 @@ export type UserUpdateInput = { name?: InputMaybe; }; +export type UserWorkspacesFilter = { + search?: InputMaybe; +}; + export type Version = { __typename?: 'Version'; authorUser?: Maybe; @@ -3660,6 +3719,152 @@ export type WebhookUpdateInput = { url?: InputMaybe; }; +export type Workspace = { + __typename?: 'Workspace'; + createdAt: Scalars['DateTime']['output']; + description?: Maybe; + id: Scalars['ID']['output']; + invitedTeam?: Maybe>; + name: Scalars['String']['output']; + projects: ProjectCollection; + /** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */ + role?: Maybe; + team: Array; + updatedAt: Scalars['DateTime']['output']; +}; + + +export type WorkspaceProjectsArgs = { + cursor?: InputMaybe; + filter?: InputMaybe; + limit?: Scalars['Int']['input']; +}; + +export type WorkspaceCollaborator = { + __typename?: 'WorkspaceCollaborator'; + id: Scalars['ID']['output']; + role: Scalars['String']['output']; + user: LimitedUser; +}; + +export type WorkspaceCollection = { + __typename?: 'WorkspaceCollection'; + cursor?: Maybe; + items: Array; + totalCount: Scalars['Int']['output']; +}; + +export type WorkspaceCreateInput = { + description?: InputMaybe; + logoUrl?: InputMaybe; + name: Scalars['String']['input']; +}; + +export type WorkspaceInviteCreateInput = { + /** Either this or userId must be filled */ + email?: InputMaybe; + /** Defaults to the member role, if not specified */ + role?: InputMaybe; + /** Either this or email must be filled */ + userId?: InputMaybe; +}; + +export type WorkspaceInviteMutations = { + __typename?: 'WorkspaceInviteMutations'; + batchCreate: Workspace; + cancel: Workspace; + create: Workspace; + use: Scalars['Boolean']['output']; +}; + + +export type WorkspaceInviteMutationsBatchCreateArgs = { + input: Array; + workspaceId: Scalars['String']['input']; +}; + + +export type WorkspaceInviteMutationsCancelArgs = { + inviteId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + + +export type WorkspaceInviteMutationsCreateArgs = { + input: WorkspaceInviteCreateInput; + workspaceId: Scalars['String']['input']; +}; + + +export type WorkspaceInviteMutationsUseArgs = { + input: WorkspaceInviteUseInput; +}; + +export type WorkspaceInviteUseInput = { + accept: Scalars['Boolean']['input']; + token: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + +export type WorkspaceMutations = { + __typename?: 'WorkspaceMutations'; + create: Workspace; + delete: Workspace; + deleteRole: Scalars['Boolean']['output']; + invites: WorkspaceInviteMutations; + update: Workspace; + updateRole: Scalars['Boolean']['output']; +}; + + +export type WorkspaceMutationsCreateArgs = { + input: WorkspaceCreateInput; +}; + + +export type WorkspaceMutationsDeleteArgs = { + workspaceId: Scalars['String']['input']; +}; + + +export type WorkspaceMutationsDeleteRoleArgs = { + input: WorkspaceRoleDeleteInput; +}; + + +export type WorkspaceMutationsUpdateArgs = { + input: WorkspaceUpdateInput; +}; + + +export type WorkspaceMutationsUpdateRoleArgs = { + input: WorkspaceRoleUpdateInput; +}; + +export enum WorkspaceRole { + Admin = 'ADMIN', + Guest = 'GUEST', + Member = 'MEMBER' +} + +export type WorkspaceRoleDeleteInput = { + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + +export type WorkspaceRoleUpdateInput = { + role: WorkspaceRole; + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + +export type WorkspaceUpdateInput = { + description?: InputMaybe; + id: Scalars['String']['input']; + logoUrl?: InputMaybe; + name?: InputMaybe; +}; + export type ResolverTypeWrapper = Promise | T; @@ -3837,6 +4042,7 @@ export type ResolversTypes = { PasswordStrengthCheckFeedback: ResolverTypeWrapper; PasswordStrengthCheckResults: ResolverTypeWrapper; PendingStreamCollaborator: ResolverTypeWrapper; + PendingWorkspaceCollaborator: ResolverTypeWrapper & { invitedBy: ResolversTypes['LimitedUser'], user?: Maybe }>; Project: ResolverTypeWrapper; ProjectAccessRequest: ResolverTypeWrapper; ProjectAccessRequestMutations: ResolverTypeWrapper; @@ -3897,6 +4103,7 @@ export type ResolversTypes = { ServerRoleItem: ResolverTypeWrapper; ServerStatistics: ResolverTypeWrapper; ServerStats: ResolverTypeWrapper; + ServerWorkspacesInfo: ResolverTypeWrapper; SmartTextEditorValue: ResolverTypeWrapper; SortDirection: SortDirection; Stream: ResolverTypeWrapper; @@ -3921,7 +4128,7 @@ export type ResolversTypes = { UpdateAutomateFunctionInput: UpdateAutomateFunctionInput; UpdateModelInput: UpdateModelInput; UpdateVersionInput: UpdateVersionInput; - User: ResolverTypeWrapper & { automateInfo: ResolversTypes['UserAutomateInfo'], commits?: Maybe, favoriteStreams: ResolversTypes['StreamCollection'], projectAccessRequest?: Maybe, projectInvites: Array, projects: ResolversTypes['ProjectCollection'], streams: ResolversTypes['StreamCollection'] }>; + User: ResolverTypeWrapper & { automateInfo: ResolversTypes['UserAutomateInfo'], commits?: Maybe, favoriteStreams: ResolversTypes['StreamCollection'], projectAccessRequest?: Maybe, projectInvites: Array, projects: ResolversTypes['ProjectCollection'], streams: ResolversTypes['StreamCollection'], workspaces: ResolversTypes['WorkspaceCollection'] }>; UserAutomateInfo: ResolverTypeWrapper; UserDeleteInput: UserDeleteInput; UserProjectsFilter: UserProjectsFilter; @@ -3930,6 +4137,7 @@ export type ResolversTypes = { UserRoleInput: UserRoleInput; UserSearchResultCollection: ResolverTypeWrapper & { items: Array }>; UserUpdateInput: UserUpdateInput; + UserWorkspacesFilter: UserWorkspacesFilter; Version: ResolverTypeWrapper; VersionCollection: ResolverTypeWrapper & { items: Array }>; VersionCreatedTrigger: ResolverTypeWrapper; @@ -3948,6 +4156,18 @@ export type ResolversTypes = { WebhookEvent: ResolverTypeWrapper; WebhookEventCollection: ResolverTypeWrapper; WebhookUpdateInput: WebhookUpdateInput; + Workspace: ResolverTypeWrapper & { invitedTeam?: Maybe>, projects: ResolversTypes['ProjectCollection'], team: Array }>; + WorkspaceCollaborator: ResolverTypeWrapper & { user: ResolversTypes['LimitedUser'] }>; + WorkspaceCollection: ResolverTypeWrapper & { items: Array }>; + WorkspaceCreateInput: WorkspaceCreateInput; + WorkspaceInviteCreateInput: WorkspaceInviteCreateInput; + WorkspaceInviteMutations: ResolverTypeWrapper & { batchCreate: ResolversTypes['Workspace'], cancel: ResolversTypes['Workspace'], create: ResolversTypes['Workspace'] }>; + WorkspaceInviteUseInput: WorkspaceInviteUseInput; + WorkspaceMutations: ResolverTypeWrapper & { create: ResolversTypes['Workspace'], delete: ResolversTypes['Workspace'], invites: ResolversTypes['WorkspaceInviteMutations'], update: ResolversTypes['Workspace'] }>; + WorkspaceRole: WorkspaceRole; + WorkspaceRoleDeleteInput: WorkspaceRoleDeleteInput; + WorkspaceRoleUpdateInput: WorkspaceRoleUpdateInput; + WorkspaceUpdateInput: WorkspaceUpdateInput; }; /** Mapping between all available schema types and the resolvers parents */ @@ -4054,6 +4274,7 @@ export type ResolversParentTypes = { PasswordStrengthCheckFeedback: PasswordStrengthCheckFeedback; PasswordStrengthCheckResults: PasswordStrengthCheckResults; PendingStreamCollaborator: PendingStreamCollaboratorGraphQLReturn; + PendingWorkspaceCollaborator: Omit & { invitedBy: ResolversParentTypes['LimitedUser'], user?: Maybe }; Project: ProjectGraphQLReturn; ProjectAccessRequest: ProjectAccessRequestGraphQLReturn; ProjectAccessRequestMutations: MutationsObjectGraphQLReturn; @@ -4102,6 +4323,7 @@ export type ResolversParentTypes = { ServerRoleItem: ServerRoleItem; ServerStatistics: GraphQLEmptyReturn; ServerStats: ServerStats; + ServerWorkspacesInfo: ServerWorkspacesInfo; SmartTextEditorValue: SmartTextEditorValue; Stream: StreamGraphQLReturn; StreamAccessRequest: StreamAccessRequestGraphQLReturn; @@ -4123,7 +4345,7 @@ export type ResolversParentTypes = { UpdateAutomateFunctionInput: UpdateAutomateFunctionInput; UpdateModelInput: UpdateModelInput; UpdateVersionInput: UpdateVersionInput; - User: Omit & { automateInfo: ResolversParentTypes['UserAutomateInfo'], commits?: Maybe, favoriteStreams: ResolversParentTypes['StreamCollection'], projectAccessRequest?: Maybe, projectInvites: Array, projects: ResolversParentTypes['ProjectCollection'], streams: ResolversParentTypes['StreamCollection'] }; + User: Omit & { automateInfo: ResolversParentTypes['UserAutomateInfo'], commits?: Maybe, favoriteStreams: ResolversParentTypes['StreamCollection'], projectAccessRequest?: Maybe, projectInvites: Array, projects: ResolversParentTypes['ProjectCollection'], streams: ResolversParentTypes['StreamCollection'], workspaces: ResolversParentTypes['WorkspaceCollection'] }; UserAutomateInfo: UserAutomateInfoGraphQLReturn; UserDeleteInput: UserDeleteInput; UserProjectsFilter: UserProjectsFilter; @@ -4131,6 +4353,7 @@ export type ResolversParentTypes = { UserRoleInput: UserRoleInput; UserSearchResultCollection: Omit & { items: Array }; UserUpdateInput: UserUpdateInput; + UserWorkspacesFilter: UserWorkspacesFilter; Version: VersionGraphQLReturn; VersionCollection: Omit & { items: Array }; VersionCreatedTrigger: AutomationRunTriggerGraphQLReturn; @@ -4148,6 +4371,17 @@ export type ResolversParentTypes = { WebhookEvent: WebhookEvent; WebhookEventCollection: WebhookEventCollection; WebhookUpdateInput: WebhookUpdateInput; + Workspace: Omit & { invitedTeam?: Maybe>, projects: ResolversParentTypes['ProjectCollection'], team: Array }; + WorkspaceCollaborator: Omit & { user: ResolversParentTypes['LimitedUser'] }; + WorkspaceCollection: Omit & { items: Array }; + WorkspaceCreateInput: WorkspaceCreateInput; + WorkspaceInviteCreateInput: WorkspaceInviteCreateInput; + WorkspaceInviteMutations: Omit & { batchCreate: ResolversParentTypes['Workspace'], cancel: ResolversParentTypes['Workspace'], create: ResolversParentTypes['Workspace'] }; + WorkspaceInviteUseInput: WorkspaceInviteUseInput; + WorkspaceMutations: Omit & { create: ResolversParentTypes['Workspace'], delete: ResolversParentTypes['Workspace'], invites: ResolversParentTypes['WorkspaceInviteMutations'], update: ResolversParentTypes['Workspace'] }; + WorkspaceRoleDeleteInput: WorkspaceRoleDeleteInput; + WorkspaceRoleUpdateInput: WorkspaceRoleUpdateInput; + WorkspaceUpdateInput: WorkspaceUpdateInput; }; export type HasScopeDirectiveArgs = { @@ -4216,6 +4450,7 @@ export type AdminQueriesResolvers>; serverStatistics?: Resolver; userList?: Resolver>; + workspaceList?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -4756,6 +4991,7 @@ export type MutationResolvers>; webhookDelete?: Resolver>; webhookUpdate?: Resolver>; + workspaceMutations?: Resolver; }; export type ObjectResolvers = { @@ -4804,6 +5040,19 @@ export type PendingStreamCollaboratorResolvers; }; +export type PendingWorkspaceCollaboratorResolvers = { + id?: Resolver; + inviteId?: Resolver; + invitedBy?: Resolver; + role?: Resolver; + title?: Resolver; + token?: Resolver, ParentType, ContextType>; + user?: Resolver, ParentType, ContextType>; + workspaceId?: Resolver; + workspaceName?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type ProjectResolvers = { allowPublicComments?: Resolver; automation?: Resolver>; @@ -4834,6 +5083,7 @@ export type ProjectResolvers, ParentType, ContextType, RequireFields>; visibility?: Resolver; webhooks?: Resolver>; + workspace?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -5010,6 +5260,7 @@ export type QueryResolvers, ParentType, ContextType, Partial>; userPwdStrength?: Resolver>; userSearch?: Resolver>; + workspace?: Resolver>; }; export type ResourceIdentifierResolvers = { @@ -5083,6 +5334,7 @@ export type ServerInfoResolvers, ParentType, ContextType>; termsOfService?: Resolver, ParentType, ContextType>; version?: Resolver, ParentType, ContextType>; + workspaces?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -5124,6 +5376,11 @@ export type ServerStatsResolvers; }; +export type ServerWorkspacesInfoResolvers = { + workspacesEnabled?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type SmartTextEditorValueResolvers = { attachments?: Resolver>, ParentType, ContextType>; doc?: Resolver, ParentType, ContextType>; @@ -5282,6 +5539,7 @@ export type UserResolvers; verified?: Resolver, ParentType, ContextType>; versions?: Resolver>; + workspaces?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -5411,6 +5669,51 @@ export type WebhookEventCollectionResolvers; }; +export type WorkspaceResolvers = { + createdAt?: Resolver; + description?: Resolver, ParentType, ContextType>; + id?: Resolver; + invitedTeam?: Resolver>, ParentType, ContextType>; + name?: Resolver; + projects?: Resolver>; + role?: Resolver, ParentType, ContextType>; + team?: Resolver, ParentType, ContextType>; + updatedAt?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type WorkspaceCollaboratorResolvers = { + id?: Resolver; + role?: Resolver; + user?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type WorkspaceCollectionResolvers = { + cursor?: Resolver, ParentType, ContextType>; + items?: Resolver, ParentType, ContextType>; + totalCount?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type WorkspaceInviteMutationsResolvers = { + batchCreate?: Resolver>; + cancel?: Resolver>; + create?: Resolver>; + use?: Resolver>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type WorkspaceMutationsResolvers = { + create?: Resolver>; + delete?: Resolver>; + deleteRole?: Resolver>; + invites?: Resolver; + update?: Resolver>; + updateRole?: Resolver>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type Resolvers = { ActiveUserMutations?: ActiveUserMutationsResolvers; Activity?: ActivityResolvers; @@ -5474,6 +5777,7 @@ export type Resolvers = { PasswordStrengthCheckFeedback?: PasswordStrengthCheckFeedbackResolvers; PasswordStrengthCheckResults?: PasswordStrengthCheckResultsResolvers; PendingStreamCollaborator?: PendingStreamCollaboratorResolvers; + PendingWorkspaceCollaborator?: PendingWorkspaceCollaboratorResolvers; Project?: ProjectResolvers; ProjectAccessRequest?: ProjectAccessRequestResolvers; ProjectAccessRequestMutations?: ProjectAccessRequestMutationsResolvers; @@ -5506,6 +5810,7 @@ export type Resolvers = { ServerRoleItem?: ServerRoleItemResolvers; ServerStatistics?: ServerStatisticsResolvers; ServerStats?: ServerStatsResolvers; + ServerWorkspacesInfo?: ServerWorkspacesInfoResolvers; SmartTextEditorValue?: SmartTextEditorValueResolvers; Stream?: StreamResolvers; StreamAccessRequest?: StreamAccessRequestResolvers; @@ -5533,6 +5838,11 @@ export type Resolvers = { WebhookCollection?: WebhookCollectionResolvers; WebhookEvent?: WebhookEventResolvers; WebhookEventCollection?: WebhookEventCollectionResolvers; + Workspace?: WorkspaceResolvers; + WorkspaceCollaborator?: WorkspaceCollaboratorResolvers; + WorkspaceCollection?: WorkspaceCollectionResolvers; + WorkspaceInviteMutations?: WorkspaceInviteMutationsResolvers; + WorkspaceMutations?: WorkspaceMutationsResolvers; }; export type DirectiveResolvers = { diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index 5374ebb9e..bc72fba38 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -66,6 +66,7 @@ export type AdminQueries = { projectList: ProjectCollection; serverStatistics: ServerStatistics; userList: AdminUserList; + workspaceList: WorkspaceCollection; }; @@ -92,6 +93,13 @@ export type AdminQueriesUserListArgs = { role?: InputMaybe; }; + +export type AdminQueriesWorkspaceListArgs = { + cursor?: InputMaybe; + limit?: Scalars['Int']['input']; + query?: InputMaybe; +}; + export type AdminUserList = { __typename?: 'AdminUserList'; cursor?: Maybe; @@ -1308,6 +1316,7 @@ export type Mutation = { webhookDelete: Scalars['String']['output']; /** Updates an existing webhook */ webhookUpdate: Scalars['String']['output']; + workspaceMutations: WorkspaceMutations; }; @@ -1689,6 +1698,22 @@ export type PendingStreamCollaborator = { user?: Maybe; }; +export type PendingWorkspaceCollaborator = { + __typename?: 'PendingWorkspaceCollaborator'; + id: Scalars['ID']['output']; + inviteId: Scalars['String']['output']; + invitedBy: LimitedUser; + role: Scalars['String']['output']; + /** E-mail address or name of the invited user */ + title: Scalars['String']['output']; + /** Only available if the active user is the pending workspace collaborator */ + token?: Maybe; + /** Set only if user is registered */ + user?: Maybe; + workspaceId: Scalars['String']['output']; + workspaceName: Scalars['String']['output']; +}; + export type Project = { __typename?: 'Project'; allowPublicComments: Scalars['Boolean']['output']; @@ -1740,6 +1765,7 @@ export type Project = { viewerResources: Array; visibility: ProjectVisibility; webhooks: WebhookCollection; + workspace?: Maybe; }; @@ -2386,6 +2412,7 @@ export type Query = { * The query looks for matches in name & email */ userSearch: UserSearchResultCollection; + workspace: Workspace; }; @@ -2511,6 +2538,11 @@ export type QueryUserSearchArgs = { query: Scalars['String']['input']; }; + +export type QueryWorkspaceArgs = { + id: Scalars['String']['input']; +}; + /** Deprecated: Used by old stream-based mutations */ export type ReplyCreateInput = { /** IDs of uploaded blobs that should be attached to this reply */ @@ -2613,6 +2645,7 @@ export type ServerInfo = { serverRoles: Array; termsOfService?: Maybe; version?: Maybe; + workspaces: ServerWorkspacesInfo; }; export type ServerInfoUpdateInput = { @@ -2681,6 +2714,15 @@ export type ServerStats = { userHistory?: Maybe>>; }; +export type ServerWorkspacesInfo = { + __typename?: 'ServerWorkspacesInfo'; + /** + * 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. + */ + workspacesEnabled: Scalars['Boolean']['output']; +}; + export type SmartTextEditorValue = { __typename?: 'SmartTextEditorValue'; /** File attachments, if any */ @@ -3301,6 +3343,8 @@ export type User = { * Note: Only count resolution is currently implemented */ versions: CountOnlyCollection; + /** Get the workspaces for the user */ + workspaces: WorkspaceCollection; }; @@ -3388,6 +3432,17 @@ export type UserVersionsArgs = { limit?: Scalars['Int']['input']; }; + +/** + * Full user type, should only be used in the context of admin operations or + * when a user is reading/writing info about himself + */ +export type UserWorkspacesArgs = { + cursor?: InputMaybe; + filter?: InputMaybe; + limit?: Scalars['Int']['input']; +}; + export type UserAutomateInfo = { __typename?: 'UserAutomateInfo'; availableGithubOrgs: Array; @@ -3438,6 +3493,10 @@ export type UserUpdateInput = { name?: InputMaybe; }; +export type UserWorkspacesFilter = { + search?: InputMaybe; +}; + export type Version = { __typename?: 'Version'; authorUser?: Maybe; @@ -3650,6 +3709,152 @@ export type WebhookUpdateInput = { url?: InputMaybe; }; +export type Workspace = { + __typename?: 'Workspace'; + createdAt: Scalars['DateTime']['output']; + description?: Maybe; + id: Scalars['ID']['output']; + invitedTeam?: Maybe>; + name: Scalars['String']['output']; + projects: ProjectCollection; + /** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */ + role?: Maybe; + team: Array; + updatedAt: Scalars['DateTime']['output']; +}; + + +export type WorkspaceProjectsArgs = { + cursor?: InputMaybe; + filter?: InputMaybe; + limit?: Scalars['Int']['input']; +}; + +export type WorkspaceCollaborator = { + __typename?: 'WorkspaceCollaborator'; + id: Scalars['ID']['output']; + role: Scalars['String']['output']; + user: LimitedUser; +}; + +export type WorkspaceCollection = { + __typename?: 'WorkspaceCollection'; + cursor?: Maybe; + items: Array; + totalCount: Scalars['Int']['output']; +}; + +export type WorkspaceCreateInput = { + description?: InputMaybe; + logoUrl?: InputMaybe; + name: Scalars['String']['input']; +}; + +export type WorkspaceInviteCreateInput = { + /** Either this or userId must be filled */ + email?: InputMaybe; + /** Defaults to the member role, if not specified */ + role?: InputMaybe; + /** Either this or email must be filled */ + userId?: InputMaybe; +}; + +export type WorkspaceInviteMutations = { + __typename?: 'WorkspaceInviteMutations'; + batchCreate: Workspace; + cancel: Workspace; + create: Workspace; + use: Scalars['Boolean']['output']; +}; + + +export type WorkspaceInviteMutationsBatchCreateArgs = { + input: Array; + workspaceId: Scalars['String']['input']; +}; + + +export type WorkspaceInviteMutationsCancelArgs = { + inviteId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + + +export type WorkspaceInviteMutationsCreateArgs = { + input: WorkspaceInviteCreateInput; + workspaceId: Scalars['String']['input']; +}; + + +export type WorkspaceInviteMutationsUseArgs = { + input: WorkspaceInviteUseInput; +}; + +export type WorkspaceInviteUseInput = { + accept: Scalars['Boolean']['input']; + token: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + +export type WorkspaceMutations = { + __typename?: 'WorkspaceMutations'; + create: Workspace; + delete: Workspace; + deleteRole: Scalars['Boolean']['output']; + invites: WorkspaceInviteMutations; + update: Workspace; + updateRole: Scalars['Boolean']['output']; +}; + + +export type WorkspaceMutationsCreateArgs = { + input: WorkspaceCreateInput; +}; + + +export type WorkspaceMutationsDeleteArgs = { + workspaceId: Scalars['String']['input']; +}; + + +export type WorkspaceMutationsDeleteRoleArgs = { + input: WorkspaceRoleDeleteInput; +}; + + +export type WorkspaceMutationsUpdateArgs = { + input: WorkspaceUpdateInput; +}; + + +export type WorkspaceMutationsUpdateRoleArgs = { + input: WorkspaceRoleUpdateInput; +}; + +export enum WorkspaceRole { + Admin = 'ADMIN', + Guest = 'GUEST', + Member = 'MEMBER' +} + +export type WorkspaceRoleDeleteInput = { + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + +export type WorkspaceRoleUpdateInput = { + role: WorkspaceRole; + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + +export type WorkspaceUpdateInput = { + description?: InputMaybe; + id: Scalars['String']['input']; + logoUrl?: InputMaybe; + name?: InputMaybe; +}; + export type CrossSyncCommitBranchMetadataQueryVariables = Exact<{ streamId: Scalars['String']['input']; commitId: Scalars['String']['input']; diff --git a/packages/server/modules/workspaces/errors/workspace.ts b/packages/server/modules/workspaces/errors/workspace.ts index addb3583f..a1449567d 100644 --- a/packages/server/modules/workspaces/errors/workspace.ts +++ b/packages/server/modules/workspaces/errors/workspace.ts @@ -5,3 +5,8 @@ export class WorkspaceAdminRequiredError extends BaseError { static code = 'WORKSPACE_ADMIN_REQUIRED_ERROR' static statusCode = 400 } + +export class WorkspacesNotYetImplementedError extends BaseError { + static defaultMessage = 'Not yet implemented' + static code = 'WORKSPACES_NOT_YET_IMPLEMENTED_ERROR' +} diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts new file mode 100644 index 000000000..dbcac77cc --- /dev/null +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -0,0 +1,82 @@ +import { Resolvers } from '@/modules/core/graph/generated/graphql' +import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' +import { WorkspacesNotYetImplementedError } from '@/modules/workspaces/errors/workspace' + +const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() + +export = FF_WORKSPACES_MODULE_ENABLED + ? ({ + Query: { + workspace: async () => { + // Get workspace by id + throw new WorkspacesNotYetImplementedError() + } + }, + WorkspaceMutations: { + create: async () => { + throw new WorkspacesNotYetImplementedError() + }, + delete: async () => { + throw new WorkspacesNotYetImplementedError() + }, + update: async () => { + throw new WorkspacesNotYetImplementedError() + }, + updateRole: async () => { + throw new WorkspacesNotYetImplementedError() + }, + deleteRole: async () => { + throw new WorkspacesNotYetImplementedError() + } + }, + WorkspaceInviteMutations: { + create: async () => { + throw new WorkspacesNotYetImplementedError() + }, + batchCreate: async () => { + throw new WorkspacesNotYetImplementedError() + }, + use: async () => { + throw new WorkspacesNotYetImplementedError() + }, + cancel: async () => { + throw new WorkspacesNotYetImplementedError() + } + }, + Workspace: { + role: async () => { + // Get user id from parent, get role and return + throw new WorkspacesNotYetImplementedError() + }, + team: async () => { + // Get roles for workspace + throw new WorkspacesNotYetImplementedError() + }, + invitedTeam: async () => { + // Get invites + throw new WorkspacesNotYetImplementedError() + }, + projects: async () => { + // Get projects in workspace + throw new WorkspacesNotYetImplementedError() + } + }, + User: { + workspaces: async () => { + // Get roles for user, get workspaces + throw new WorkspacesNotYetImplementedError() + } + }, + Project: { + workspace: async () => { + // Get workspaceId from project, get and return workspace data + throw new WorkspacesNotYetImplementedError() + } + }, + AdminQueries: { + workspaceList: async () => { + throw new WorkspacesNotYetImplementedError() + } + } + } as Resolvers) + : {} diff --git a/packages/server/modules/workspaces/scopes.ts b/packages/server/modules/workspaces/scopes.ts index 9daa25404..41cf0db51 100644 --- a/packages/server/modules/workspaces/scopes.ts +++ b/packages/server/modules/workspaces/scopes.ts @@ -12,6 +12,11 @@ export const workspaceScopes: TokenScopeData[] = [ description: 'Required for editing workspace information', public: true }, + { + name: Scopes.Workspaces.Read, + description: 'Required for reading workspace data', + public: true + }, { name: Scopes.Workspaces.Delete, description: 'Required for deleting workspaces', diff --git a/packages/server/modules/workspacesCore/graph/resolvers/workspacesCore.ts b/packages/server/modules/workspacesCore/graph/resolvers/workspacesCore.ts new file mode 100644 index 000000000..464167186 --- /dev/null +++ b/packages/server/modules/workspacesCore/graph/resolvers/workspacesCore.ts @@ -0,0 +1,75 @@ +import { WorkspacesModuleDisabledError } from '@/modules/core/errors/workspaces' +import { Resolvers } from '@/modules/core/graph/generated/graphql' +import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' + +const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() + +export = !FF_WORKSPACES_MODULE_ENABLED + ? ({ + Query: { + workspace: async () => { + throw new WorkspacesModuleDisabledError() + } + }, + WorkspaceMutations: { + create: async () => { + throw new WorkspacesModuleDisabledError() + }, + delete: async () => { + throw new WorkspacesModuleDisabledError() + }, + update: async () => { + throw new WorkspacesModuleDisabledError() + }, + updateRole: async () => { + throw new WorkspacesModuleDisabledError() + }, + deleteRole: async () => { + throw new WorkspacesModuleDisabledError() + } + }, + WorkspaceInviteMutations: { + create: async () => { + throw new WorkspacesModuleDisabledError() + }, + batchCreate: async () => { + throw new WorkspacesModuleDisabledError() + }, + use: async () => { + throw new WorkspacesModuleDisabledError() + }, + cancel: async () => { + throw new WorkspacesModuleDisabledError() + } + }, + Workspace: { + role: async () => { + throw new WorkspacesModuleDisabledError() + }, + team: async () => { + throw new WorkspacesModuleDisabledError() + }, + invitedTeam: async () => { + throw new WorkspacesModuleDisabledError() + }, + projects: async () => { + throw new WorkspacesModuleDisabledError() + } + }, + User: { + workspaces: async () => { + throw new WorkspacesModuleDisabledError() + } + }, + Project: { + workspace: async () => { + throw new WorkspacesModuleDisabledError() + } + }, + AdminQueries: { + workspaceList: async () => { + throw new WorkspacesModuleDisabledError() + } + } + } as Resolvers) + : {} diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 78fff5bbe..01fd97ed4 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -67,6 +67,7 @@ export type AdminQueries = { projectList: ProjectCollection; serverStatistics: ServerStatistics; userList: AdminUserList; + workspaceList: WorkspaceCollection; }; @@ -93,6 +94,13 @@ export type AdminQueriesUserListArgs = { role?: InputMaybe; }; + +export type AdminQueriesWorkspaceListArgs = { + cursor?: InputMaybe; + limit?: Scalars['Int']['input']; + query?: InputMaybe; +}; + export type AdminUserList = { __typename?: 'AdminUserList'; cursor?: Maybe; @@ -1309,6 +1317,7 @@ export type Mutation = { webhookDelete: Scalars['String']['output']; /** Updates an existing webhook */ webhookUpdate: Scalars['String']['output']; + workspaceMutations: WorkspaceMutations; }; @@ -1690,6 +1699,22 @@ export type PendingStreamCollaborator = { user?: Maybe; }; +export type PendingWorkspaceCollaborator = { + __typename?: 'PendingWorkspaceCollaborator'; + id: Scalars['ID']['output']; + inviteId: Scalars['String']['output']; + invitedBy: LimitedUser; + role: Scalars['String']['output']; + /** E-mail address or name of the invited user */ + title: Scalars['String']['output']; + /** Only available if the active user is the pending workspace collaborator */ + token?: Maybe; + /** Set only if user is registered */ + user?: Maybe; + workspaceId: Scalars['String']['output']; + workspaceName: Scalars['String']['output']; +}; + export type Project = { __typename?: 'Project'; allowPublicComments: Scalars['Boolean']['output']; @@ -1741,6 +1766,7 @@ export type Project = { viewerResources: Array; visibility: ProjectVisibility; webhooks: WebhookCollection; + workspace?: Maybe; }; @@ -2387,6 +2413,7 @@ export type Query = { * The query looks for matches in name & email */ userSearch: UserSearchResultCollection; + workspace: Workspace; }; @@ -2512,6 +2539,11 @@ export type QueryUserSearchArgs = { query: Scalars['String']['input']; }; + +export type QueryWorkspaceArgs = { + id: Scalars['String']['input']; +}; + /** Deprecated: Used by old stream-based mutations */ export type ReplyCreateInput = { /** IDs of uploaded blobs that should be attached to this reply */ @@ -2614,6 +2646,7 @@ export type ServerInfo = { serverRoles: Array; termsOfService?: Maybe; version?: Maybe; + workspaces: ServerWorkspacesInfo; }; export type ServerInfoUpdateInput = { @@ -2682,6 +2715,15 @@ export type ServerStats = { userHistory?: Maybe>>; }; +export type ServerWorkspacesInfo = { + __typename?: 'ServerWorkspacesInfo'; + /** + * 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. + */ + workspacesEnabled: Scalars['Boolean']['output']; +}; + export type SmartTextEditorValue = { __typename?: 'SmartTextEditorValue'; /** File attachments, if any */ @@ -3302,6 +3344,8 @@ export type User = { * Note: Only count resolution is currently implemented */ versions: CountOnlyCollection; + /** Get the workspaces for the user */ + workspaces: WorkspaceCollection; }; @@ -3389,6 +3433,17 @@ export type UserVersionsArgs = { limit?: Scalars['Int']['input']; }; + +/** + * Full user type, should only be used in the context of admin operations or + * when a user is reading/writing info about himself + */ +export type UserWorkspacesArgs = { + cursor?: InputMaybe; + filter?: InputMaybe; + limit?: Scalars['Int']['input']; +}; + export type UserAutomateInfo = { __typename?: 'UserAutomateInfo'; availableGithubOrgs: Array; @@ -3439,6 +3494,10 @@ export type UserUpdateInput = { name?: InputMaybe; }; +export type UserWorkspacesFilter = { + search?: InputMaybe; +}; + export type Version = { __typename?: 'Version'; authorUser?: Maybe; @@ -3651,6 +3710,152 @@ export type WebhookUpdateInput = { url?: InputMaybe; }; +export type Workspace = { + __typename?: 'Workspace'; + createdAt: Scalars['DateTime']['output']; + description?: Maybe; + id: Scalars['ID']['output']; + invitedTeam?: Maybe>; + name: Scalars['String']['output']; + projects: ProjectCollection; + /** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */ + role?: Maybe; + team: Array; + updatedAt: Scalars['DateTime']['output']; +}; + + +export type WorkspaceProjectsArgs = { + cursor?: InputMaybe; + filter?: InputMaybe; + limit?: Scalars['Int']['input']; +}; + +export type WorkspaceCollaborator = { + __typename?: 'WorkspaceCollaborator'; + id: Scalars['ID']['output']; + role: Scalars['String']['output']; + user: LimitedUser; +}; + +export type WorkspaceCollection = { + __typename?: 'WorkspaceCollection'; + cursor?: Maybe; + items: Array; + totalCount: Scalars['Int']['output']; +}; + +export type WorkspaceCreateInput = { + description?: InputMaybe; + logoUrl?: InputMaybe; + name: Scalars['String']['input']; +}; + +export type WorkspaceInviteCreateInput = { + /** Either this or userId must be filled */ + email?: InputMaybe; + /** Defaults to the member role, if not specified */ + role?: InputMaybe; + /** Either this or email must be filled */ + userId?: InputMaybe; +}; + +export type WorkspaceInviteMutations = { + __typename?: 'WorkspaceInviteMutations'; + batchCreate: Workspace; + cancel: Workspace; + create: Workspace; + use: Scalars['Boolean']['output']; +}; + + +export type WorkspaceInviteMutationsBatchCreateArgs = { + input: Array; + workspaceId: Scalars['String']['input']; +}; + + +export type WorkspaceInviteMutationsCancelArgs = { + inviteId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + + +export type WorkspaceInviteMutationsCreateArgs = { + input: WorkspaceInviteCreateInput; + workspaceId: Scalars['String']['input']; +}; + + +export type WorkspaceInviteMutationsUseArgs = { + input: WorkspaceInviteUseInput; +}; + +export type WorkspaceInviteUseInput = { + accept: Scalars['Boolean']['input']; + token: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + +export type WorkspaceMutations = { + __typename?: 'WorkspaceMutations'; + create: Workspace; + delete: Workspace; + deleteRole: Scalars['Boolean']['output']; + invites: WorkspaceInviteMutations; + update: Workspace; + updateRole: Scalars['Boolean']['output']; +}; + + +export type WorkspaceMutationsCreateArgs = { + input: WorkspaceCreateInput; +}; + + +export type WorkspaceMutationsDeleteArgs = { + workspaceId: Scalars['String']['input']; +}; + + +export type WorkspaceMutationsDeleteRoleArgs = { + input: WorkspaceRoleDeleteInput; +}; + + +export type WorkspaceMutationsUpdateArgs = { + input: WorkspaceUpdateInput; +}; + + +export type WorkspaceMutationsUpdateRoleArgs = { + input: WorkspaceRoleUpdateInput; +}; + +export enum WorkspaceRole { + Admin = 'ADMIN', + Guest = 'GUEST', + Member = 'MEMBER' +} + +export type WorkspaceRoleDeleteInput = { + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + +export type WorkspaceRoleUpdateInput = { + role: WorkspaceRole; + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + +export type WorkspaceUpdateInput = { + description?: InputMaybe; + id: Scalars['String']['input']; + logoUrl?: InputMaybe; + name?: InputMaybe; +}; + export type BasicStreamAccessRequestFieldsFragment = { __typename?: 'StreamAccessRequest', id: string, requesterId: string, streamId: string, createdAt: string, requester: { __typename?: 'LimitedUser', id: string, name: string } }; export type CreateStreamAccessRequestMutationVariables = Exact<{ diff --git a/packages/shared/src/core/constants.ts b/packages/shared/src/core/constants.ts index 2931c0abb..8d674fc07 100644 --- a/packages/shared/src/core/constants.ts +++ b/packages/shared/src/core/constants.ts @@ -113,6 +113,7 @@ export const Scopes = Object.freeze({ }, Workspaces: { Create: 'workspace:create', + Read: 'workspace:read', Update: 'workspace:update', Delete: 'workspace:delete' } @@ -140,7 +141,7 @@ export type AvailableScopes = | AppScopes | AutomateScopes | AutomateFunctionScopes - | WorkspaceRoles + | WorkspaceScopes /** * All scopes From 66eb539aa0248c78c966a3eb510997c5ffaedfc9 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Sat, 20 Jul 2024 00:03:27 +0100 Subject: [PATCH 5/5] feat(workspaces): assign project roles for workspace projects (#2499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(workspaces): drop createdByUserId from the dataschema * feat(workspaces): repositories WIP * merge * protect against removing last admin in workspace * quick impl and stub tests * add tests * services * unit tests for role services * feat(workspaces): authorize project creation if workspace specified * feat(workspaces): emit project created event * feat(workspaces): assign roles on project create in workspace * feat(workspaces): update project roles when user added to workspace * fix(workspaces): perform automatic project role update in service function * fix(workspaces): also delete roles * fix(workspaces): broke tests again oops * fix(workspaces): update `onProjectCreated` listener to use new repo method * fix(workspaces): use service function in event listener * fix(workspaces): get workspace projects via existing stream repo functions * fix(workspaces): roles mapping in domain, use enum * fix(workspaces): repair type reference in tests * fix(workspaces): consolidate files, use different existing stream-getter * fix(workspaces): more specific error * fix(workspaces): yield per page * fix(workspaces): some test dry * fix(workspaces): superdry * fix(workspaces): classic --------- Co-authored-by: Gergő Jedlicska --- packages/server/modules/core/helpers/types.ts | 1 + .../server/modules/core/services/admin.ts | 3 +- .../server/modules/core/services/streams.js | 8 +- .../tests/activityDigest.spec.ts | 3 +- .../modules/workspaces/domain/operations.ts | 25 +- .../server/modules/workspaces/domain/roles.ts | 16 + .../modules/workspaces/errors/workspace.ts | 10 + .../workspaces/events/eventListener.ts | 52 +++ packages/server/modules/workspaces/index.ts | 6 +- .../workspaces/repositories/workspaces.ts | 5 +- .../modules/workspaces/services/management.ts | 190 +++++++++ .../modules/workspaces/services/projects.ts | 35 ++ .../workspaces/services/workspaceCreation.ts | 57 --- .../services/workspaceRoleCreation.ts | 89 ---- .../tests/unit/events/eventListener.spec.ts | 54 +++ .../tests/unit/services/management.spec.ts | 384 ++++++++++++++++++ .../tests/unit/services/projects.spec.ts | 77 ++++ .../unit/services/workspaceCreation.spec.ts | 96 ----- .../services/workspaceRoleCreation.spec.ts | 172 -------- ...stWorkspaceAdmin.spec.ts => roles.spec.ts} | 2 +- .../{isUserLastWorkspaceAdmin.ts => roles.ts} | 3 +- 21 files changed, 866 insertions(+), 422 deletions(-) create mode 100644 packages/server/modules/workspaces/domain/roles.ts create mode 100644 packages/server/modules/workspaces/events/eventListener.ts create mode 100644 packages/server/modules/workspaces/services/management.ts create mode 100644 packages/server/modules/workspaces/services/projects.ts delete mode 100644 packages/server/modules/workspaces/services/workspaceCreation.ts delete mode 100644 packages/server/modules/workspaces/services/workspaceRoleCreation.ts create mode 100644 packages/server/modules/workspaces/tests/unit/events/eventListener.spec.ts create mode 100644 packages/server/modules/workspaces/tests/unit/services/management.spec.ts create mode 100644 packages/server/modules/workspaces/tests/unit/services/projects.spec.ts delete mode 100644 packages/server/modules/workspaces/tests/unit/services/workspaceCreation.spec.ts delete mode 100644 packages/server/modules/workspaces/tests/unit/services/workspaceRoleCreation.spec.ts rename packages/server/modules/workspaces/tests/unit/utils/{isUserLastWorkspaceAdmin.spec.ts => roles.spec.ts} (98%) rename packages/server/modules/workspaces/utils/{isUserLastWorkspaceAdmin.ts => roles.ts} (80%) diff --git a/packages/server/modules/core/helpers/types.ts b/packages/server/modules/core/helpers/types.ts index 7fbf3342c..3027ee7d4 100644 --- a/packages/server/modules/core/helpers/types.ts +++ b/packages/server/modules/core/helpers/types.ts @@ -49,6 +49,7 @@ export type StreamRecord = { updatedAt: Date allowPublicComments: boolean isDiscoverable: boolean + workspaceId: Nullable } export type StreamAclRecord = { diff --git a/packages/server/modules/core/services/admin.ts b/packages/server/modules/core/services/admin.ts index 043971a23..cc9a2609b 100644 --- a/packages/server/modules/core/services/admin.ts +++ b/packages/server/modules/core/services/admin.ts @@ -106,7 +106,8 @@ export const adminProjectList = async ( ...args, searchQuery: args.query, cursor: parsedCursor, - streamIdWhitelist: args.streamIdWhitelist + streamIdWhitelist: args.streamIdWhitelist, + workspaceIdWhitelist: null }) const cursor = cursorDate ? convertDateToCursor(cursorDate) : null return { diff --git a/packages/server/modules/core/services/streams.js b/packages/server/modules/core/services/streams.js index deb1da66d..eab643deb 100644 --- a/packages/server/modules/core/services/streams.js +++ b/packages/server/modules/core/services/streams.js @@ -83,7 +83,8 @@ module.exports = { orderBy, visibility, searchQuery, - streamIdWhitelist + streamIdWhitelist, + workspaceIdWhitelist }) { const query = knex.select().from('streams') @@ -116,6 +117,11 @@ module.exports = { countQuery.whereIn('id', streamIdWhitelist) } + if (workspaceIdWhitelist?.length) { + query.whereIn('workspaceId', workspaceIdWhitelist) + countQuery.whereIn('workspaceId', workspaceIdWhitelist) + } + const [res] = await countQuery.count() const count = parseInt(res.count) diff --git a/packages/server/modules/notifications/tests/activityDigest.spec.ts b/packages/server/modules/notifications/tests/activityDigest.spec.ts index 4c7cf2798..a4a89259f 100644 --- a/packages/server/modules/notifications/tests/activityDigest.spec.ts +++ b/packages/server/modules/notifications/tests/activityDigest.spec.ts @@ -111,7 +111,8 @@ describe('Activity digest notifications @notifications', () => { createdAt: new Date(), updatedAt: new Date(), allowPublicComments: true, - isDiscoverable: true + isDiscoverable: true, + workspaceId: null }, activity: activities ?? [createActivity()] }) diff --git a/packages/server/modules/workspaces/domain/operations.ts b/packages/server/modules/workspaces/domain/operations.ts index 2ffed3229..bf39b8057 100644 --- a/packages/server/modules/workspaces/domain/operations.ts +++ b/packages/server/modules/workspaces/domain/operations.ts @@ -2,6 +2,7 @@ import { WorkspaceEvents, WorkspaceEventsPayloads } from '@/modules/workspacesCore/domain/events' +import { StreamRecord } from '@/modules/core/helpers/types' import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types' /** Workspace */ @@ -18,7 +19,7 @@ type GetWorkspaceArgs = { export type GetWorkspace = (args: GetWorkspaceArgs) => Promise -/** WorkspaceRole */ +/** Workspace Roles */ type DeleteWorkspaceRoleArgs = { workspaceId: string @@ -63,6 +64,28 @@ export type GetWorkspaceRolesForUser = ( export type UpsertWorkspaceRole = (args: WorkspaceAcl) => Promise +/** Workspace Projects */ + +type GetAllWorkspaceProjectsForUserArgs = { + userId: string + workspaceId: string +} + +export type GetAllWorkspaceProjectsForUser = ( + args: GetAllWorkspaceProjectsForUserArgs +) => Promise + +/** Workspace Project Roles */ + +type GrantWorkspaceProjectRolesArgs = { + projectId: string + workspaceId: string +} + +export type GrantWorkspaceProjectRoles = ( + args: GrantWorkspaceProjectRolesArgs +) => Promise + /** Blob */ export type StoreBlob = (args: string) => Promise diff --git a/packages/server/modules/workspaces/domain/roles.ts b/packages/server/modules/workspaces/domain/roles.ts new file mode 100644 index 000000000..e90a9fc63 --- /dev/null +++ b/packages/server/modules/workspaces/domain/roles.ts @@ -0,0 +1,16 @@ +import { Roles, StreamRoles, WorkspaceRoles } from '@speckle/shared' + +/** + * Given a user's workspace role, return the role they should have for workspace projects. + */ +export const mapWorkspaceRoleToProjectRole = ( + workspaceRole: WorkspaceRoles +): StreamRoles => { + switch (workspaceRole) { + case Roles.Workspace.Guest: + case Roles.Workspace.Member: + return Roles.Stream.Reviewer + case Roles.Workspace.Admin: + return Roles.Stream.Owner + } +} diff --git a/packages/server/modules/workspaces/errors/workspace.ts b/packages/server/modules/workspaces/errors/workspace.ts index a1449567d..d6aa65f95 100644 --- a/packages/server/modules/workspaces/errors/workspace.ts +++ b/packages/server/modules/workspaces/errors/workspace.ts @@ -6,6 +6,16 @@ export class WorkspaceAdminRequiredError extends BaseError { static statusCode = 400 } +export class WorkspaceInvalidRoleError extends BaseError { + static defaultMessage = 'Invalid workspace role provided' + static code = 'WORKSPACE_INVALID_ROLE_ERROR' +} + +export class WorkspaceQueryError extends BaseError { + static defaultMessage = 'Unexpected error during query operation' + static code = 'WORKSPACE_QUERY_ERROR' +} + export class WorkspacesNotYetImplementedError extends BaseError { static defaultMessage = 'Not yet implemented' static code = 'WORKSPACES_NOT_YET_IMPLEMENTED_ERROR' diff --git a/packages/server/modules/workspaces/events/eventListener.ts b/packages/server/modules/workspaces/events/eventListener.ts new file mode 100644 index 000000000..119a178ac --- /dev/null +++ b/packages/server/modules/workspaces/events/eventListener.ts @@ -0,0 +1,52 @@ +import { + ProjectsEmitter, + ProjectEvents, + ProjectEventsPayloads +} from '@/modules/core/events/projectsEmitter' +import { getWorkspaceRolesFactory } from '@/modules/workspaces/repositories/workspaces' +import { grantStreamPermissions as repoGrantStreamPermissions } from '@/modules/core/repositories/streams' +import { Knex } from 'knex' +import { GetWorkspaceRoles } from '@/modules/workspaces/domain/operations' +import { mapWorkspaceRoleToProjectRole } from '@/modules/workspaces/domain/roles' + +export const onProjectCreatedFactory = + ({ + getWorkspaceRoles, + grantStreamPermissions + }: { + getWorkspaceRoles: GetWorkspaceRoles + grantStreamPermissions: typeof repoGrantStreamPermissions + }) => + async (payload: ProjectEventsPayloads[typeof ProjectEvents.Created]) => { + const { id: projectId, workspaceId } = payload.project + + if (!workspaceId) { + return + } + + const workspaceMembers = await getWorkspaceRoles({ workspaceId }) + + await Promise.all( + workspaceMembers.map(({ userId, role: workspaceRole }) => + grantStreamPermissions({ + streamId: projectId, + userId, + role: mapWorkspaceRoleToProjectRole(workspaceRole) + }) + ) + ) + } + +export const initializeEventListenersFactory = + ({ db }: { db: Knex }) => + () => { + const onProjectCreated = onProjectCreatedFactory({ + getWorkspaceRoles: getWorkspaceRolesFactory({ db }), + // TODO: Instantiate via factory function + grantStreamPermissions: repoGrantStreamPermissions + }) + + const quitCbs = [ProjectsEmitter.listen(ProjectEvents.Created, onProjectCreated)] + + return () => quitCbs.forEach((quit) => quit()) + } diff --git a/packages/server/modules/workspaces/index.ts b/packages/server/modules/workspaces/index.ts index 9d2054f0d..003a34386 100644 --- a/packages/server/modules/workspaces/index.ts +++ b/packages/server/modules/workspaces/index.ts @@ -6,6 +6,7 @@ import { SpeckleModule } from '@/modules/shared/helpers/typeHelper' import { workspaceRoles } from '@/modules/workspaces/roles' import { workspaceScopes } from '@/modules/workspaces/scopes' import { registerOrUpdateRole } from '@/modules/shared/repositories/roles' +import { initializeEventListenersFactory } from '@/modules/workspaces/events/eventListener' const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() @@ -20,9 +21,12 @@ const initRoles = () => { } const workspacesModule: SpeckleModule = { - async init() { + async init(_, isInitial) { if (!FF_WORKSPACES_MODULE_ENABLED) return moduleLogger.info('⚒️ Init workspaces module') + if (isInitial) { + initializeEventListenersFactory({ db })() + } await Promise.all([initScopes(), initRoles()]) } } diff --git a/packages/server/modules/workspaces/repositories/workspaces.ts b/packages/server/modules/workspaces/repositories/workspaces.ts index 2ab15a06c..636b3cf13 100644 --- a/packages/server/modules/workspaces/repositories/workspaces.ts +++ b/packages/server/modules/workspaces/repositories/workspaces.ts @@ -10,8 +10,11 @@ import { } from '@/modules/workspaces/domain/operations' import { Knex } from 'knex' import { Roles } from '@speckle/shared' +import { StreamRecord } from '@/modules/core/helpers/types' +import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace' const tables = { + streams: (db: Knex) => db('streams'), workspaces: (db: Knex) => db('workspaces'), workspacesAcl: (db: Knex) => db('workspace_acl') } @@ -93,7 +96,7 @@ export const upsertWorkspaceRoleFactory = // Verify requested role is valid workspace role const validRoles = Object.values(Roles.Workspace) if (!validRoles.includes(role)) { - throw new Error(`Unexpected workspace role provided: ${role}`) + throw new WorkspaceInvalidRoleError() } await tables diff --git a/packages/server/modules/workspaces/services/management.ts b/packages/server/modules/workspaces/services/management.ts new file mode 100644 index 000000000..c26e19295 --- /dev/null +++ b/packages/server/modules/workspaces/services/management.ts @@ -0,0 +1,190 @@ +import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' +import { + EmitWorkspaceEvent, + StoreBlob, + UpsertWorkspace, + UpsertWorkspaceRole +} from '@/modules/workspaces/domain/operations' +import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types' +import { Roles } from '@speckle/shared' +import cryptoRandomString from 'crypto-random-string' +import { + grantStreamPermissions as repoGrantStreamPermissions, + revokeStreamPermissions as repoRevokeStreamPermissions +} from '@/modules/core/repositories/streams' +import { getStreams as repoGetStreams } from '@/modules/core/services/streams' +import { + DeleteWorkspaceRole, + GetWorkspaceRoleForUser, + GetWorkspaceRoles +} from '@/modules/workspaces/domain/operations' +import { WorkspaceAdminRequiredError } from '@/modules/workspaces/errors/workspace' +import { isUserLastWorkspaceAdmin } from '@/modules/workspaces/utils/roles' +import { mapWorkspaceRoleToProjectRole } from '@/modules/workspaces/domain/roles' +import { queryAllWorkspaceProjectsFactory } from '@/modules/workspaces/services/projects' + +type WorkspaceCreateArgs = { + workspaceInput: { name: string; description: string | null; logo: string | null } + userId: string +} + +export const createWorkspaceFactory = + ({ + upsertWorkspace, + upsertWorkspaceRole, + emitWorkspaceEvent, + storeBlob + }: { + upsertWorkspace: UpsertWorkspace + upsertWorkspaceRole: UpsertWorkspaceRole + storeBlob: StoreBlob + emitWorkspaceEvent: EmitWorkspaceEvent + }) => + async ({ userId, workspaceInput }: WorkspaceCreateArgs): Promise => { + let logoUrl: string | null = null + if (workspaceInput.logo) { + logoUrl = await storeBlob(workspaceInput.logo) + } + + const workspace = { + ...workspaceInput, + id: cryptoRandomString({ length: 10 }), + createdAt: new Date(), + updatedAt: new Date(), + logoUrl + } + await upsertWorkspace({ workspace }) + // assign the creator as workspace administrator + await upsertWorkspaceRole({ + userId, + role: Roles.Workspace.Admin, + workspaceId: workspace.id + }) + + // emit a workspace created event + await emitWorkspaceEvent({ + eventName: WorkspaceEvents.Created, + payload: { ...workspace, createdByUserId: userId } + }) + + return workspace + } + +type WorkspaceRoleDeleteArgs = { + userId: string + workspaceId: string +} + +export const deleteWorkspaceRoleFactory = + ({ + getWorkspaceRoles, + deleteWorkspaceRole, + emitWorkspaceEvent, + getStreams, + revokeStreamPermissions + }: { + getWorkspaceRoles: GetWorkspaceRoles + deleteWorkspaceRole: DeleteWorkspaceRole + emitWorkspaceEvent: EmitWorkspaceEvent + getStreams: typeof repoGetStreams + revokeStreamPermissions: typeof repoRevokeStreamPermissions + }) => + async ({ + userId, + workspaceId + }: WorkspaceRoleDeleteArgs): Promise => { + // Protect against removing last admin + const workspaceRoles = await getWorkspaceRoles({ workspaceId }) + if (isUserLastWorkspaceAdmin(workspaceRoles, userId)) { + throw new WorkspaceAdminRequiredError() + } + + // Perform delete + const deletedRole = await deleteWorkspaceRole({ userId, workspaceId }) + if (!deletedRole) { + return null + } + + // Delete workspace project roles + const queryAllWorkspaceProjectsGenerator = queryAllWorkspaceProjectsFactory({ + getStreams + }) + for await (const projectsPage of queryAllWorkspaceProjectsGenerator(workspaceId)) { + await Promise.all( + projectsPage.map(({ id: streamId }) => + revokeStreamPermissions({ streamId, userId }) + ) + ) + } + + // Emit deleted role + await emitWorkspaceEvent({ + eventName: WorkspaceEvents.RoleDeleted, + payload: deletedRole + }) + + return deletedRole + } + +type WorkspaceRoleGetArgs = { + userId: string + workspaceId: string +} + +export const getWorkspaceRoleFactory = + ({ getWorkspaceRoleForUser }: { getWorkspaceRoleForUser: GetWorkspaceRoleForUser }) => + async ({ + userId, + workspaceId + }: WorkspaceRoleGetArgs): Promise => { + return await getWorkspaceRoleForUser({ userId, workspaceId }) + } + +export const setWorkspaceRoleFactory = + ({ + getWorkspaceRoles, + upsertWorkspaceRole, + emitWorkspaceEvent, + getStreams, + grantStreamPermissions + }: { + getWorkspaceRoles: GetWorkspaceRoles + upsertWorkspaceRole: UpsertWorkspaceRole + emitWorkspaceEvent: EmitWorkspaceEvent + // TODO: Create `core` domain and import type from there + getStreams: typeof repoGetStreams + grantStreamPermissions: typeof repoGrantStreamPermissions + }) => + async ({ userId, workspaceId, role }: WorkspaceAcl): Promise => { + // Protect against removing last admin + const workspaceRoles = await getWorkspaceRoles({ workspaceId }) + if ( + isUserLastWorkspaceAdmin(workspaceRoles, userId) && + role !== 'workspace:admin' + ) { + throw new WorkspaceAdminRequiredError() + } + + // Perform upsert + await upsertWorkspaceRole({ userId, workspaceId, role }) + + // Update user role in all workspace projects + // TODO: Should these be in a transaction with the workspace role change? + const queryAllWorkspaceProjectsGenerator = queryAllWorkspaceProjectsFactory({ + getStreams + }) + const projectRole = mapWorkspaceRoleToProjectRole(role) + for await (const projectsPage of queryAllWorkspaceProjectsGenerator(workspaceId)) { + await Promise.all( + projectsPage.map(({ id: streamId }) => + grantStreamPermissions({ streamId, userId, role: projectRole }) + ) + ) + } + + // Emit new role + await emitWorkspaceEvent({ + eventName: WorkspaceEvents.RoleUpdated, + payload: { userId, workspaceId, role } + }) + } diff --git a/packages/server/modules/workspaces/services/projects.ts b/packages/server/modules/workspaces/services/projects.ts new file mode 100644 index 000000000..5bb3ae488 --- /dev/null +++ b/packages/server/modules/workspaces/services/projects.ts @@ -0,0 +1,35 @@ +import { StreamRecord } from '@/modules/core/helpers/types' +import { getStreams as repoGetStreams } from '@/modules/core/services/streams' +import { WorkspaceQueryError } from '@/modules/workspaces/errors/workspace' + +export const queryAllWorkspaceProjectsFactory = ({ + getStreams +}: { + // TODO: Core service factory functions + getStreams: typeof repoGetStreams +}) => + async function* queryAllWorkspaceProjects( + workspaceId: string + ): AsyncGenerator { + let cursor: Date | null = null + let iterationCount = 0 + + do { + if (iterationCount > 500) throw new WorkspaceQueryError() + + const { streams, cursorDate } = await getStreams({ + cursor, + orderBy: null, + limit: 1000, + visibility: null, + searchQuery: null, + streamIdWhitelist: null, + workspaceIdWhitelist: [workspaceId] + }) + + yield streams + + cursor = cursorDate + iterationCount++ + } while (!!cursor) + } diff --git a/packages/server/modules/workspaces/services/workspaceCreation.ts b/packages/server/modules/workspaces/services/workspaceCreation.ts deleted file mode 100644 index cc2839d46..000000000 --- a/packages/server/modules/workspaces/services/workspaceCreation.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' -import { - EmitWorkspaceEvent, - StoreBlob, - UpsertWorkspace, - UpsertWorkspaceRole -} from '@/modules/workspaces/domain/operations' -import { Workspace } from '@/modules/workspacesCore/domain/types' -import { Roles } from '@speckle/shared' -import cryptoRandomString from 'crypto-random-string' - -type WorkspaceCreateArgs = { - workspaceInput: { name: string; description: string | null; logo: string | null } - userId: string -} - -export const createWorkspaceFactory = - ({ - upsertWorkspace, - upsertWorkspaceRole, - emitWorkspaceEvent, - storeBlob - }: { - upsertWorkspace: UpsertWorkspace - upsertWorkspaceRole: UpsertWorkspaceRole - storeBlob: StoreBlob - emitWorkspaceEvent: EmitWorkspaceEvent - }) => - async ({ userId, workspaceInput }: WorkspaceCreateArgs): Promise => { - let logoUrl: string | null = null - if (workspaceInput.logo) { - logoUrl = await storeBlob(workspaceInput.logo) - } - - const workspace = { - ...workspaceInput, - id: cryptoRandomString({ length: 10 }), - createdAt: new Date(), - updatedAt: new Date(), - logoUrl - } - await upsertWorkspace({ workspace }) - // assign the creator as workspace administrator - await upsertWorkspaceRole({ - userId, - role: Roles.Workspace.Admin, - workspaceId: workspace.id - }) - - await emitWorkspaceEvent({ - eventName: WorkspaceEvents.Created, - payload: { ...workspace, createdByUserId: userId } - }) - // emit a workspace created event - - return workspace - } diff --git a/packages/server/modules/workspaces/services/workspaceRoleCreation.ts b/packages/server/modules/workspaces/services/workspaceRoleCreation.ts deleted file mode 100644 index 64b2859e7..000000000 --- a/packages/server/modules/workspaces/services/workspaceRoleCreation.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - DeleteWorkspaceRole, - EmitWorkspaceEvent, - GetWorkspaceRoleForUser, - GetWorkspaceRoles, - UpsertWorkspaceRole -} from '@/modules/workspaces/domain/operations' -import { WorkspaceAcl } from '@/modules/workspacesCore/domain/types' -import { WorkspaceAdminRequiredError } from '@/modules/workspaces/errors/workspace' -import { isUserLastWorkspaceAdmin } from '@/modules/workspaces/utils/isUserLastWorkspaceAdmin' -import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' - -type WorkspaceRoleDeleteArgs = { - userId: string - workspaceId: string -} - -export const deleteWorkspaceRoleFactory = - ({ - getWorkspaceRoles, - deleteWorkspaceRole, - emitWorkspaceEvent - }: { - getWorkspaceRoles: GetWorkspaceRoles - deleteWorkspaceRole: DeleteWorkspaceRole - emitWorkspaceEvent: EmitWorkspaceEvent - }) => - async ({ - userId, - workspaceId - }: WorkspaceRoleDeleteArgs): Promise => { - const workspaceRoles = await getWorkspaceRoles({ workspaceId }) - - if (isUserLastWorkspaceAdmin(workspaceRoles, userId)) { - throw new WorkspaceAdminRequiredError() - } - - const deletedRole = await deleteWorkspaceRole({ userId, workspaceId }) - - if (!deletedRole) { - return null - } - - emitWorkspaceEvent({ eventName: WorkspaceEvents.RoleDeleted, payload: deletedRole }) - - return deletedRole - } - -type WorkspaceRoleGetArgs = { - userId: string - workspaceId: string -} - -export const getWorkspaceRoleFactory = - ({ getWorkspaceRoleForUser }: { getWorkspaceRoleForUser: GetWorkspaceRoleForUser }) => - async ({ - userId, - workspaceId - }: WorkspaceRoleGetArgs): Promise => { - return await getWorkspaceRoleForUser({ userId, workspaceId }) - } - -export const setWorkspaceRoleFactory = - ({ - getWorkspaceRoles, - upsertWorkspaceRole, - emitWorkspaceEvent - }: { - getWorkspaceRoles: GetWorkspaceRoles - upsertWorkspaceRole: UpsertWorkspaceRole - emitWorkspaceEvent: EmitWorkspaceEvent - }) => - async ({ userId, workspaceId, role }: WorkspaceAcl): Promise => { - const workspaceRoles = await getWorkspaceRoles({ workspaceId }) - - if ( - isUserLastWorkspaceAdmin(workspaceRoles, userId) && - role !== 'workspace:admin' - ) { - throw new WorkspaceAdminRequiredError() - } - - await upsertWorkspaceRole({ userId, workspaceId, role }) - - await emitWorkspaceEvent({ - eventName: WorkspaceEvents.RoleUpdated, - payload: { userId, workspaceId, role } - }) - } diff --git a/packages/server/modules/workspaces/tests/unit/events/eventListener.spec.ts b/packages/server/modules/workspaces/tests/unit/events/eventListener.spec.ts new file mode 100644 index 000000000..c23c65d79 --- /dev/null +++ b/packages/server/modules/workspaces/tests/unit/events/eventListener.spec.ts @@ -0,0 +1,54 @@ +import cryptoRandomString from 'crypto-random-string' +import { WorkspaceAcl } from '@/modules/workspacesCore/domain/types' +import { Roles } from '@speckle/shared' +import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types' +import { onProjectCreatedFactory } from '@/modules/workspaces/events/eventListener' +import { expect } from 'chai' + +describe('Event handlers', () => { + describe('onProjectCreatedFactory creates a function, that', () => { + it('grants project roles for all workspace members', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const projectId = cryptoRandomString({ length: 10 }) + + const workspaceRoles: WorkspaceAcl[] = [ + { + workspaceId, + userId: cryptoRandomString({ length: 10 }), + role: Roles.Workspace.Admin + }, + { + workspaceId, + userId: cryptoRandomString({ length: 10 }), + role: Roles.Workspace.Member + }, + { + workspaceId, + userId: cryptoRandomString({ length: 10 }), + role: Roles.Workspace.Guest + } + ] + + const projectRoles: StreamAclRecord[] = [] + + const onProjectCreated = onProjectCreatedFactory({ + getWorkspaceRoles: async () => workspaceRoles, + grantStreamPermissions: async ({ streamId, userId, role }) => { + projectRoles.push({ + resourceId: streamId, + userId, + role + }) + + return {} as StreamRecord + } + }) + + await onProjectCreated({ + project: { workspaceId, id: projectId } as StreamRecord + }) + + expect(projectRoles.length).to.equal(3) + }) + }) +}) diff --git a/packages/server/modules/workspaces/tests/unit/services/management.spec.ts b/packages/server/modules/workspaces/tests/unit/services/management.spec.ts new file mode 100644 index 000000000..04d4bd7db --- /dev/null +++ b/packages/server/modules/workspaces/tests/unit/services/management.spec.ts @@ -0,0 +1,384 @@ +import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types' +import { + createWorkspaceFactory, + deleteWorkspaceRoleFactory, + setWorkspaceRoleFactory +} from '@/modules/workspaces/services/management' +import { Roles } from '@speckle/shared' +import { expect } from 'chai' +import cryptoRandomString from 'crypto-random-string' +import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' +import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types' +import { expectToThrow } from '@/test/assertionHelper' + +type WorkspaceTestContext = { + storedWorkspaces: Workspace[] + storedRoles: WorkspaceAcl[] + eventData: { + isCalled: boolean + eventName: string + payload: unknown + } +} + +const buildCreateWorkspaceWithTestContext = ( + dependecyOverrides: Partial[0]> = {} +) => { + const context: WorkspaceTestContext = { + storedWorkspaces: [], + storedRoles: [], + eventData: { + isCalled: false, + eventName: '', + payload: {} + } + } + + const deps: Parameters[0] = { + upsertWorkspace: async ({ workspace }: { workspace: Workspace }) => { + context.storedWorkspaces.push(workspace) + }, + upsertWorkspaceRole: async (workspaceAcl: WorkspaceAcl) => { + context.storedRoles.push(workspaceAcl) + }, + emitWorkspaceEvent: async ({ eventName, payload }) => { + context.eventData.isCalled = true + context.eventData.eventName = eventName + context.eventData.payload = payload + return [] + }, + storeBlob: async () => cryptoRandomString({ length: 10 }), + ...dependecyOverrides + } + + const createWorkspace = createWorkspaceFactory(deps) + + return { context, createWorkspace } +} + +const getCreateWorkspaceInput = () => { + return { + userId: cryptoRandomString({ length: 10 }), + workspaceInput: { + description: 'foobar', + logo: null, + name: cryptoRandomString({ length: 6 }) + } + } +} + +describe('Workspace services', () => { + describe('createWorkspaceFactory creates a function, that', () => { + it('stores the workspace', async () => { + const { context, createWorkspace } = buildCreateWorkspaceWithTestContext() + + const { userId, workspaceInput } = getCreateWorkspaceInput() + const workspace = await createWorkspace({ userId, workspaceInput }) + + expect(context.storedWorkspaces.length).to.equal(1) + expect(context.storedWorkspaces[0]).to.deep.equal(workspace) + }) + it('makes the workspace creator becomes a workspace:admin', async () => { + const { context, createWorkspace } = buildCreateWorkspaceWithTestContext() + + const { userId, workspaceInput } = getCreateWorkspaceInput() + const workspace = await createWorkspace({ userId, workspaceInput }) + + expect(context.storedRoles.length).to.equal(1) + expect(context.storedRoles[0]).to.deep.equal({ + userId, + workspaceId: workspace.id, + role: Roles.Workspace.Admin + }) + }) + it('emits a workspace created event', async () => { + const { context, createWorkspace } = buildCreateWorkspaceWithTestContext() + + const { userId, workspaceInput } = getCreateWorkspaceInput() + const workspace = await createWorkspace({ userId, workspaceInput }) + + expect(context.eventData.isCalled).to.equal(true) + expect(context.eventData.eventName).to.equal(WorkspaceEvents.Created) + expect(context.eventData.payload).to.deep.equal({ + ...workspace, + createdByUserId: userId + }) + }) + }) +}) + +type WorkspaceRoleTestContext = { + workspaceId: string + workspaceRoles: WorkspaceAcl[] + workspaceProjects: StreamRecord[] + workspaceProjectRoles: StreamAclRecord[] + eventData: { + isCalled: boolean + eventName: string + payload: unknown + } +} + +const getDefaultWorkspaceRoleTestContext = (): WorkspaceRoleTestContext => { + return { + workspaceId: cryptoRandomString({ length: 10 }), + workspaceRoles: [], + workspaceProjects: [], + workspaceProjectRoles: [], + eventData: { + isCalled: false, + eventName: '', + payload: {} + } + } +} + +const buildDeleteWorkspaceRoleAndTestContext = ( + contextOverrides: Partial = {}, + dependencyOverrides: Partial[0]> = {} +) => { + const context: WorkspaceRoleTestContext = { + ...getDefaultWorkspaceRoleTestContext(), + ...contextOverrides + } + + const deps: Parameters[0] = { + getWorkspaceRoles: async () => context.workspaceRoles, + deleteWorkspaceRole: async (role) => { + const isMatch = (acl: WorkspaceAcl): boolean => { + return acl.workspaceId === role.workspaceId && acl.userId === role.userId + } + + const deletedRoleIndex = context.workspaceRoles.findIndex(isMatch) + + if (deletedRoleIndex < 0) { + return null + } + + const deletedRole = structuredClone(context.workspaceRoles[deletedRoleIndex]) + + context.workspaceRoles = context.workspaceRoles.filter((acl) => !isMatch(acl)) + + return deletedRole + }, + emitWorkspaceEvent: async ({ eventName, payload }) => { + context.eventData.isCalled = true + context.eventData.eventName = eventName + context.eventData.payload = payload + + return [] + }, + getStreams: async () => ({ + streams: context.workspaceProjects, + totalCount: context.workspaceProjects.length, + cursorDate: null + }), + revokeStreamPermissions: async ({ streamId, userId }) => { + context.workspaceProjectRoles = context.workspaceProjectRoles.filter( + (role) => role.resourceId !== streamId && role.userId !== userId + ) + return {} as StreamRecord + }, + ...dependencyOverrides + } + + const deleteWorkspaceRole = deleteWorkspaceRoleFactory(deps) + + return { deleteWorkspaceRole, context } +} + +const buildSetWorkspaceRoleAndTestContext = ( + contextOverrides: Partial = {}, + dependencyOverrides: Partial[0]> = {} +) => { + const context = { + ...getDefaultWorkspaceRoleTestContext(), + ...contextOverrides + } + + const deps: Parameters[0] = { + getWorkspaceRoles: async () => context.workspaceRoles, + upsertWorkspaceRole: async (role) => { + const currentRoleIndex = context.workspaceRoles.findIndex( + (acl) => acl.userId === role.userId && acl.workspaceId === role.workspaceId + ) + + if (currentRoleIndex >= 0) { + context.workspaceRoles[currentRoleIndex] = role + } else { + context.workspaceRoles.push(role) + } + }, + emitWorkspaceEvent: async ({ eventName, payload }) => { + context.eventData.isCalled = true + context.eventData.eventName = eventName + context.eventData.payload = payload + + return [] + }, + getStreams: async () => ({ + streams: context.workspaceProjects, + totalCount: context.workspaceProjects.length, + cursorDate: null + }), + grantStreamPermissions: async (role) => { + const currentRoleIndex = context.workspaceProjectRoles.findIndex( + (acl) => acl.userId === role.userId && acl.resourceId === role.streamId + ) + + const streamAcl: StreamAclRecord = { + userId: role.userId, + role: role.role, + resourceId: role.streamId + } + + if (currentRoleIndex > 0) { + context.workspaceProjectRoles[currentRoleIndex] = streamAcl + } else { + context.workspaceProjectRoles.push(streamAcl) + } + + return {} as StreamRecord + }, + ...dependencyOverrides + } + + const setWorkspaceRole = setWorkspaceRoleFactory(deps) + + return { setWorkspaceRole, context } +} + +describe('Workspace role services', () => { + describe('deleteWorkspaceRoleFactory creates a function, that', () => { + it('deletes the workspace role', async () => { + const userId = cryptoRandomString({ length: 10 }) + const workspaceId = cryptoRandomString({ length: 10 }) + const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member } + + const { deleteWorkspaceRole, context } = buildDeleteWorkspaceRoleAndTestContext({ + workspaceId, + workspaceRoles: [role] + }) + + const deletedRole = await deleteWorkspaceRole({ userId, workspaceId }) + + expect(context.workspaceRoles.length).to.equal(0) + expect(deletedRole).to.deep.equal(role) + }) + it('emits a role-deleted event', async () => { + const userId = cryptoRandomString({ length: 10 }) + const workspaceId = cryptoRandomString({ length: 10 }) + const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member } + + const { deleteWorkspaceRole, context } = buildDeleteWorkspaceRoleAndTestContext({ + workspaceId, + workspaceRoles: [role] + }) + + await deleteWorkspaceRole({ userId, workspaceId }) + + expect(context.eventData.isCalled).to.be.true + expect(context.eventData.eventName).to.equal(WorkspaceEvents.RoleDeleted) + expect(context.eventData.payload).to.deep.equal(role) + }) + it('throws if attempting to delete the last admin from a workspace', async () => { + const userId = cryptoRandomString({ length: 10 }) + const workspaceId = cryptoRandomString({ length: 10 }) + const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Admin } + + const { deleteWorkspaceRole } = buildDeleteWorkspaceRoleAndTestContext({ + workspaceId, + workspaceRoles: [role] + }) + + await expectToThrow(() => deleteWorkspaceRole({ userId, workspaceId })) + }) + it('deletes workspace project roles', async () => { + const userId = cryptoRandomString({ length: 10 }) + const workspaceId = cryptoRandomString({ length: 10 }) + const projectId = cryptoRandomString({ length: 10 }) + + const { deleteWorkspaceRole, context } = buildDeleteWorkspaceRoleAndTestContext({ + workspaceId, + workspaceRoles: [{ userId, workspaceId, role: Roles.Workspace.Member }], + workspaceProjects: [{ id: projectId } as StreamRecord], + workspaceProjectRoles: [ + { userId, role: Roles.Stream.Contributor, resourceId: projectId } + ] + }) + + await deleteWorkspaceRole({ userId, workspaceId }) + + expect(context.workspaceProjectRoles.length).to.equal(0) + }) + }) + + describe('setWorkspaceRoleFactory creates a function, that', () => { + it('sets the workspace role', async () => { + const userId = cryptoRandomString({ length: 10 }) + const workspaceId = cryptoRandomString({ length: 10 }) + const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member } + + const { setWorkspaceRole, context } = buildSetWorkspaceRoleAndTestContext({ + workspaceId + }) + + await setWorkspaceRole(role) + + expect(context.workspaceRoles.length).to.equal(1) + expect(context.workspaceRoles[0]).to.deep.equal(role) + }) + it('emits a role-updated event', async () => { + const userId = cryptoRandomString({ length: 10 }) + const workspaceId = cryptoRandomString({ length: 10 }) + const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member } + + const { setWorkspaceRole, context } = buildSetWorkspaceRoleAndTestContext({ + workspaceId + }) + + await setWorkspaceRole(role) + + expect(context.eventData.isCalled).to.be.true + expect(context.eventData.eventName).to.equal(WorkspaceEvents.RoleUpdated) + expect(context.eventData.payload).to.deep.equal(role) + }) + it('throws if attempting to remove the last admin in a workspace', async () => { + const userId = cryptoRandomString({ length: 10 }) + const workspaceId = cryptoRandomString({ length: 10 }) + const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Admin } + + const { setWorkspaceRole } = buildSetWorkspaceRoleAndTestContext({ + workspaceId, + workspaceRoles: [role] + }) + + await expectToThrow(() => + setWorkspaceRole({ ...role, role: Roles.Workspace.Member }) + ) + }) + it('sets roles on workspace projects', async () => { + const userId = cryptoRandomString({ length: 10 }) + const workspaceId = cryptoRandomString({ length: 10 }) + const projectId = cryptoRandomString({ length: 10 }) + + const workspaceRole: WorkspaceAcl = { + userId, + workspaceId, + role: Roles.Workspace.Admin + } + + const { setWorkspaceRole, context } = buildSetWorkspaceRoleAndTestContext({ + workspaceId, + workspaceProjects: [{ id: projectId } as StreamRecord] + }) + + await setWorkspaceRole(workspaceRole) + + expect(context.workspaceProjectRoles.length).to.equal(1) + expect(context.workspaceProjectRoles[0].userId).to.equal(userId) + expect(context.workspaceProjectRoles[0].resourceId).to.equal(projectId) + expect(context.workspaceProjectRoles[0].role).to.equal(Roles.Stream.Owner) + }) + }) +}) diff --git a/packages/server/modules/workspaces/tests/unit/services/projects.spec.ts b/packages/server/modules/workspaces/tests/unit/services/projects.spec.ts new file mode 100644 index 000000000..e3dfd74ba --- /dev/null +++ b/packages/server/modules/workspaces/tests/unit/services/projects.spec.ts @@ -0,0 +1,77 @@ +import { StreamRecord } from '@/modules/core/helpers/types' +import { queryAllWorkspaceProjectsFactory } from '@/modules/workspaces/services/projects' +import { expect } from 'chai' +import cryptoRandomString from 'crypto-random-string' + +describe('Project retrieval services', () => { + describe('queryAllWorkspaceProjectFactory returns a generator, that', () => { + it('returns all streams for a workspace', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + + const foundProjects: StreamRecord[] = [] + const storedProjects: StreamRecord[] = [{ workspaceId } as StreamRecord] + + const queryAllWorkspaceProjectsGenerator = queryAllWorkspaceProjectsFactory({ + getStreams: async () => { + return { + streams: storedProjects, + totalCount: storedProjects.length, + cursorDate: null + } + } + }) + + for await (const projectsPage of queryAllWorkspaceProjectsGenerator( + workspaceId + )) { + foundProjects.push(...projectsPage) + } + + expect(foundProjects.length).to.equal(1) + }) + it('returns all streams for a workspace if the query requires multiple pages of results', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + + const foundProjects: StreamRecord[] = [] + const storedProjects: StreamRecord[] = [ + { workspaceId } as StreamRecord, + { workspaceId } as StreamRecord + ] + + const queryAllWorkspaceProjectsGenerator = queryAllWorkspaceProjectsFactory({ + getStreams: async ({ cursor }) => { + return cursor + ? { streams: [storedProjects[1]], totalCount: 1, cursorDate: null } + : { streams: [storedProjects[0]], totalCount: 1, cursorDate: new Date() } + } + }) + + for await (const projectsPage of queryAllWorkspaceProjectsGenerator( + workspaceId + )) { + foundProjects.push(...projectsPage) + } + + expect(foundProjects.length).to.equal(2) + }) + it('exits if no results are found', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + + const foundProjects: StreamRecord[] = [] + + const queryAllWorkspaceProjectsGenerator = queryAllWorkspaceProjectsFactory({ + getStreams: async () => { + return { streams: [], totalCount: 0, cursorDate: null } + } + }) + + for await (const projectsPage of queryAllWorkspaceProjectsGenerator( + workspaceId + )) { + foundProjects.push(...projectsPage) + } + + expect(foundProjects.length).to.equal(0) + }) + }) +}) diff --git a/packages/server/modules/workspaces/tests/unit/services/workspaceCreation.spec.ts b/packages/server/modules/workspaces/tests/unit/services/workspaceCreation.spec.ts deleted file mode 100644 index f202933f1..000000000 --- a/packages/server/modules/workspaces/tests/unit/services/workspaceCreation.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types' -import { createWorkspaceFactory } from '@/modules/workspaces/services/workspaceCreation' -import { Roles } from '@speckle/shared' -import { expect } from 'chai' -import cryptoRandomString from 'crypto-random-string' -import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' - -describe('Workspace services', () => { - describe('createWorkspaceFactory creates a function, that', () => { - it('stores the workspace', async () => { - const storedWorkspaces: Workspace[] = [] - const createWorkspace = createWorkspaceFactory({ - upsertWorkspace: async ({ workspace }: { workspace: Workspace }) => { - storedWorkspaces.push(workspace) - }, - upsertWorkspaceRole: async () => {}, - emitWorkspaceEvent: async () => [], - storeBlob: async () => cryptoRandomString({ length: 10 }) - }) - - const workspaceInput = { - description: 'foobar', - logo: null, - name: cryptoRandomString({ length: 6 }) - } - const workspace = await createWorkspace({ - userId: cryptoRandomString({ length: 10 }), - workspaceInput - }) - expect(storedWorkspaces.length).to.equal(1) - expect(storedWorkspaces[0]).to.deep.equal(workspace) - }) - it('makes the workspace creator becomes a workspace:admin', async () => { - const storedRole: WorkspaceAcl[] = [] - const createWorkspace = createWorkspaceFactory({ - upsertWorkspace: async () => {}, - upsertWorkspaceRole: async (workspaceAcl: WorkspaceAcl) => { - storedRole.push(workspaceAcl) - }, - emitWorkspaceEvent: async () => [], - storeBlob: async () => cryptoRandomString({ length: 10 }) - }) - - const workspaceInput = { - description: 'foobar', - logo: null, - name: cryptoRandomString({ length: 6 }) - } - const userId = cryptoRandomString({ length: 10 }) - const workspace = await createWorkspace({ - userId, - workspaceInput - }) - expect(storedRole.length).to.equal(1) - expect(storedRole[0]).to.deep.equal({ - userId, - workspaceId: workspace.id, - role: Roles.Workspace.Admin - }) - }) - it('emits a workspace created event', async () => { - const eventData = { - isCalled: false, - eventName: '', - payload: {} - } - const createWorkspace = createWorkspaceFactory({ - upsertWorkspace: async () => {}, - upsertWorkspaceRole: async () => {}, - emitWorkspaceEvent: async ({ eventName, payload }) => { - eventData.isCalled = true - eventData.eventName = eventName - eventData.payload = payload - return [] - }, - storeBlob: async () => cryptoRandomString({ length: 10 }) - }) - - const workspaceInput = { - description: 'foobar', - logo: null, - name: cryptoRandomString({ length: 6 }) - } - const userId = cryptoRandomString({ length: 10 }) - - const workspace = await createWorkspace({ - userId, - workspaceInput - }) - - expect(eventData.isCalled).to.equal(true) - expect(eventData.eventName).to.equal(WorkspaceEvents.Created) - expect(eventData.payload).to.deep.equal({ ...workspace, createdByUserId: userId }) - }) - }) -}) diff --git a/packages/server/modules/workspaces/tests/unit/services/workspaceRoleCreation.spec.ts b/packages/server/modules/workspaces/tests/unit/services/workspaceRoleCreation.spec.ts deleted file mode 100644 index e147cc3cb..000000000 --- a/packages/server/modules/workspaces/tests/unit/services/workspaceRoleCreation.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { WorkspaceAcl } from '@/modules/workspacesCore/domain/types' -import { - deleteWorkspaceRoleFactory, - setWorkspaceRoleFactory -} from '@/modules/workspaces/services/workspaceRoleCreation' -import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' -import { expectToThrow } from '@/test/assertionHelper' -import { Roles } from '@speckle/shared' -import { expect } from 'chai' -import cryptoRandomString from 'crypto-random-string' - -describe('Workspace role services', () => { - describe('deleteWorkspaceRoleFactory creates a function, that', () => { - it('deletes the workspace role', async () => { - const userId = cryptoRandomString({ length: 10 }) - const workspaceId = cryptoRandomString({ length: 10 }) - - const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member } - - let storedRoles: WorkspaceAcl[] = [role] - - const deleteWorkspaceRole = deleteWorkspaceRoleFactory({ - getWorkspaceRoles: async () => storedRoles, - deleteWorkspaceRole: async ({ userId, workspaceId }) => { - const role = storedRoles.find( - (r) => r.userId === userId && r.workspaceId === workspaceId - ) - - storedRoles = storedRoles.filter((r) => r.userId !== userId) - - return role ?? null - }, - emitWorkspaceEvent: async () => [] - }) - - const deletedRole = await deleteWorkspaceRole({ userId, workspaceId }) - - expect(storedRoles.length).to.equal(0) - expect(deletedRole).to.deep.equal(role) - }) - it('emits a role-deleted event', async () => { - const eventData = { - isCalled: false, - eventName: '', - payload: {} - } - - const userId = cryptoRandomString({ length: 10 }) - const workspaceId = cryptoRandomString({ length: 10 }) - - const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member } - - const storedRoles: WorkspaceAcl[] = [role] - - const deleteWorkspaceRole = deleteWorkspaceRoleFactory({ - getWorkspaceRoles: async () => storedRoles, - deleteWorkspaceRole: async () => { - return storedRoles[0] - }, - emitWorkspaceEvent: async ({ eventName, payload }) => { - eventData.isCalled = true - eventData.eventName = eventName - eventData.payload = payload - - return [] - } - }) - - await deleteWorkspaceRole({ userId, workspaceId }) - - expect(eventData.isCalled).to.be.true - expect(eventData.eventName).to.equal(WorkspaceEvents.RoleDeleted) - expect(eventData.payload).to.deep.equal(role) - }) - it('throws if attempting to delete the last admin from a workspace', async () => { - const userId = cryptoRandomString({ length: 10 }) - const workspaceId = cryptoRandomString({ length: 10 }) - - const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Admin } - - let storedRoles: WorkspaceAcl[] = [role] - - const deleteWorkspaceRole = deleteWorkspaceRoleFactory({ - getWorkspaceRoles: async () => storedRoles, - deleteWorkspaceRole: async ({ userId, workspaceId }) => { - const role = storedRoles.find( - (r) => r.userId === userId && r.workspaceId === workspaceId - ) - - storedRoles = storedRoles.filter((r) => r.userId !== userId) - - return role ?? null - }, - emitWorkspaceEvent: async () => [] - }) - - await expectToThrow(() => deleteWorkspaceRole({ userId, workspaceId })) - }) - }) - - describe('setWorkspaceRoleFactory creates a function, that', () => { - it('sets the workspace role', async () => { - const userId = cryptoRandomString({ length: 10 }) - const workspaceId = cryptoRandomString({ length: 10 }) - - const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member } - - const storedRoles: WorkspaceAcl[] = [] - - const setWorkspaceRole = setWorkspaceRoleFactory({ - getWorkspaceRoles: async () => storedRoles, - upsertWorkspaceRole: async (role) => { - storedRoles.push(role) - }, - emitWorkspaceEvent: async () => [] - }) - - await setWorkspaceRole(role) - - expect(storedRoles.length).to.equal(1) - expect(storedRoles[0]).to.deep.equal(role) - }) - it('emits a role-updated event', async () => { - const eventData = { - isCalled: false, - eventName: '', - payload: {} - } - - const userId = cryptoRandomString({ length: 10 }) - const workspaceId = cryptoRandomString({ length: 10 }) - - const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member } - - const setWorkspaceRole = setWorkspaceRoleFactory({ - getWorkspaceRoles: async () => [], - upsertWorkspaceRole: async () => {}, - emitWorkspaceEvent: async ({ eventName, payload }) => { - eventData.isCalled = true - eventData.eventName = eventName - eventData.payload = payload - - return [] - } - }) - - await setWorkspaceRole(role) - - expect(eventData.isCalled).to.be.true - expect(eventData.eventName).to.equal(WorkspaceEvents.RoleUpdated) - expect(eventData.payload).to.deep.equal(role) - }) - it('throws if attempting to remove the last admin in a workspace', async () => { - const userId = cryptoRandomString({ length: 10 }) - const workspaceId = cryptoRandomString({ length: 10 }) - - const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Admin } - - const storedRoles: WorkspaceAcl[] = [role] - - const setWorkspaceRole = setWorkspaceRoleFactory({ - getWorkspaceRoles: async () => storedRoles, - upsertWorkspaceRole: async () => {}, - emitWorkspaceEvent: async () => [] - }) - - await expectToThrow(() => - setWorkspaceRole({ ...role, role: Roles.Workspace.Member }) - ) - }) - }) -}) diff --git a/packages/server/modules/workspaces/tests/unit/utils/isUserLastWorkspaceAdmin.spec.ts b/packages/server/modules/workspaces/tests/unit/utils/roles.spec.ts similarity index 98% rename from packages/server/modules/workspaces/tests/unit/utils/isUserLastWorkspaceAdmin.spec.ts rename to packages/server/modules/workspaces/tests/unit/utils/roles.spec.ts index 539dc7b6e..d0488126b 100644 --- a/packages/server/modules/workspaces/tests/unit/utils/isUserLastWorkspaceAdmin.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/utils/roles.spec.ts @@ -1,4 +1,4 @@ -import { isUserLastWorkspaceAdmin } from '@/modules/workspaces/utils/isUserLastWorkspaceAdmin' +import { isUserLastWorkspaceAdmin } from '@/modules/workspaces/utils/roles' import { WorkspaceAcl } from '@/modules/workspacesCore/domain/types' import { expect } from 'chai' import { Roles } from '@speckle/shared' diff --git a/packages/server/modules/workspaces/utils/isUserLastWorkspaceAdmin.ts b/packages/server/modules/workspaces/utils/roles.ts similarity index 80% rename from packages/server/modules/workspaces/utils/isUserLastWorkspaceAdmin.ts rename to packages/server/modules/workspaces/utils/roles.ts index e4a0f86af..9e11039d4 100644 --- a/packages/server/modules/workspaces/utils/isUserLastWorkspaceAdmin.ts +++ b/packages/server/modules/workspaces/utils/roles.ts @@ -1,3 +1,4 @@ +import { Roles } from '@speckle/shared' import { WorkspaceAcl } from '@/modules/workspacesCore/domain/types' export const isUserLastWorkspaceAdmin = ( @@ -5,7 +6,7 @@ export const isUserLastWorkspaceAdmin = ( userId: string ): boolean => { const workspaceAdmins = workspaceRoles.filter( - ({ role }) => role === 'workspace:admin' + ({ role }) => role === Roles.Workspace.Admin ) const isUserAdmin = workspaceAdmins.some((role) => role.userId === userId)