From cc376dd5966d30d6cc31ded5e2ab500c32a6a16f Mon Sep 17 00:00:00 2001 From: andrewwallacespeckle Date: Wed, 27 Aug 2025 13:33:53 +0100 Subject: [PATCH 01/25] fix(fe): selection info alignment for primitive arrays --- packages/frontend-2/components/viewer/selection/Object.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend-2/components/viewer/selection/Object.vue b/packages/frontend-2/components/viewer/selection/Object.vue index 8bfe72ff3..d2b3b232b 100644 --- a/packages/frontend-2/components/viewer/selection/Object.vue +++ b/packages/frontend-2/components/viewer/selection/Object.vue @@ -81,7 +81,7 @@ class="col-span-2 flex w-full min-w-0 truncate text-xs text-foreground" :title="(kvp.value as string)" > -
{{ kvp.arrayPreview }}
+
{{ kvp.arrayPreview }}
({{ kvp.arrayLength }})
From a2a5b6bab8222cc4dcd0163b9d620e56fdbdbfd7 Mon Sep 17 00:00:00 2001 From: andrewwallacespeckle Date: Wed, 27 Aug 2025 13:39:58 +0100 Subject: [PATCH 02/25] Adjust text styles --- .../frontend-2/components/viewer/selection/Object.vue | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/frontend-2/components/viewer/selection/Object.vue b/packages/frontend-2/components/viewer/selection/Object.vue index d2b3b232b..5029e5ec6 100644 --- a/packages/frontend-2/components/viewer/selection/Object.vue +++ b/packages/frontend-2/components/viewer/selection/Object.vue @@ -52,17 +52,17 @@
{{ kvp.key }}
{{ kvp.innerType }} array
({{ kvp.arrayLength }})
@@ -72,13 +72,13 @@
{{ kvp.key }}
{{ kvp.arrayPreview }}
From 2e30c34cd9c6511cc161dc38c37c357a46a69d75 Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Thu, 28 Aug 2025 09:04:20 +0300 Subject: [PATCH 03/25] fix(fe2): nuxt 4 related memory leak (#5328) --- .../frontend-2/components/preview/Image.vue | 5 ++-- .../lib/projects/composables/previewImage.ts | 24 ++++++++----------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/frontend-2/components/preview/Image.vue b/packages/frontend-2/components/preview/Image.vue index e55706674..e7f5e68e9 100644 --- a/packages/frontend-2/components/preview/Image.vue +++ b/packages/frontend-2/components/preview/Image.vue @@ -112,8 +112,7 @@ const { shouldLoadPanorama, isLoadingPanorama, hasDoneFirstLoad, - isPanoramaPlaceholder, - init + isPanoramaPlaceholder } = usePreviewImageBlob(basePreviewUrl, { enabled: computed(() => props.eagerLoad || isInViewport.value), eagerLoad: props.eagerLoad @@ -197,5 +196,5 @@ if (import.meta.client) { }) } -await init() +// await init() diff --git a/packages/frontend-2/lib/projects/composables/previewImage.ts b/packages/frontend-2/lib/projects/composables/previewImage.ts index 7e7e995b7..fbdef8c81 100644 --- a/packages/frontend-2/lib/projects/composables/previewImage.ts +++ b/packages/frontend-2/lib/projects/composables/previewImage.ts @@ -37,6 +37,8 @@ const usePreviewsState = () => /** * Get authenticated preview image URL and subscribes to preview image generation events so that the preview image URL * is updated whenever generation finishes + * + * TODO: Refactor, the internals have gotten very messy and overly complicated */ export function usePreviewImageBlob( previewUrl: MaybeRef, @@ -281,22 +283,16 @@ export function usePreviewImageBlob( }) } - return { - ...ret, - /** - * Run this at the bottom of the component to fully initialize it - */ - init: async () => { - if (!eagerLoad && import.meta.server) { - return // don't do anything - show spinner - } - - const promise = regeneratePreviews() - if (eagerLoad) { - await promise - } + const init = () => { + if (!eagerLoad && import.meta.server) { + return // don't do anything - show spinner } + + void regeneratePreviews() } + init() + + return ret } export function useCommentScreenshotImage( From 8a9b4829d93bf4e5299fa281bd8557117f4189c2 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Thu, 28 Aug 2025 09:02:53 +0100 Subject: [PATCH 04/25] feat(multiregion): replace user replication (#5253) --- .../tests/integration/activity.graph.spec.ts | 168 +++----- packages/server/modules/auth/index.ts | 98 +++-- .../server/modules/auth/strategies/azureAd.ts | 8 +- .../server/modules/auth/strategies/github.ts | 8 +- .../server/modules/auth/strategies/google.ts | 7 +- .../server/modules/auth/strategies/oidc.ts | 8 +- .../modules/auth/tests/apps.graphql.spec.ts | 67 +-- .../server/modules/auth/tests/apps.spec.ts | 76 +--- .../server/modules/auth/tests/auth.spec.ts | 57 +-- .../tests/e2e/blobstorage.graph.spec.ts | 54 +-- .../tests/e2e/blobstorage.rest.spec.ts | 62 +-- .../comments/tests/comments.graph.spec.ts | 63 +-- .../modules/comments/tests/comments.spec.ts | 68 +-- .../modules/core/graph/resolvers/users.ts | 138 ++++-- .../20250820101112_drop_user_defaults.ts | 23 + .../server/modules/core/repositories/users.ts | 15 +- .../modules/core/services/users/management.ts | 13 +- .../modules/core/tests/branches.spec.ts | 54 +-- .../server/modules/core/tests/commits.spec.ts | 53 +-- .../core/tests/favoriteStreams.spec.ts | 70 +--- .../server/modules/core/tests/generic.spec.ts | 66 +-- .../server/modules/core/tests/graph.spec.ts | 392 ++++++++---------- .../tests/integration/commits.graph.spec.ts | 61 +-- .../integration/emailVerification.spec.ts | 53 +-- .../core/tests/integration/findUsers.spec.ts | 122 ++---- .../tests/integration/objects.graph.spec.ts | 60 +-- .../tests/integration/objects.rest.spec.ts | 60 +-- .../integration/objectsStream.rest.spec.ts | 61 +-- .../core/tests/integration/updateUser.spec.ts | 82 +--- .../integration/userEmails.graph.spec.ts | 26 +- .../core/tests/integration/userEmails.spec.ts | 51 +-- .../tests/integration/versions.graph.spec.ts | 64 +-- .../server/modules/core/tests/objects.spec.ts | 53 +-- .../server/modules/core/tests/rest.spec.ts | 115 ++--- .../server/modules/core/tests/users.spec.ts | 191 ++++++--- .../modules/core/tests/usersAdmin.spec.ts | 17 +- .../modules/core/tests/usersAdminList.spec.ts | 57 +-- .../modules/core/tests/usersGraphql.spec.ts | 24 +- packages/server/modules/emails/rest/index.ts | 35 +- .../emails/services/verification/finalize.ts | 8 +- .../modules/fileuploads/tests/helpers/init.ts | 29 +- .../tests/integration/fileuploads.spec.ts | 8 +- .../tests/integration/results.graphql.spec.ts | 8 +- .../tests/unit/fileuploads.spec.ts | 7 +- .../tests/e2e/serverAdmin.graph.spec.ts | 2 + .../modules/multiregion/tests/helpers.ts | 12 + .../repositories/transactions.spec.ts | 17 +- .../modules/multiregion/utils/dbSelector.ts | 166 ++++---- .../server/modules/pwdreset/rest/index.ts | 41 +- .../modules/pwdreset/tests/pwdrest.spec.ts | 65 +-- packages/server/modules/shared/command.ts | 149 ++++++- .../server/modules/shared/domain/constants.ts | 5 + .../server/modules/shared/errors/index.ts | 34 +- .../server/modules/shared/helpers/dbHelper.ts | 122 +----- .../modules/shared/test/dbHelper.spec.ts | 88 +++- .../server/modules/stats/tests/stats.spec.ts | 81 +--- .../modules/webhooks/tests/cleanup.spec.ts | 39 +- .../modules/webhooks/tests/webhooks.spec.ts | 67 +-- .../server/modules/workspaces/rest/sso.ts | 97 +++-- .../tests/integration/workspaceSeat.spec.ts | 17 +- packages/server/scripts/seedUsers.ts | 85 ++-- packages/server/test/authHelper.ts | 103 +++-- 62 files changed, 1506 insertions(+), 2444 deletions(-) create mode 100644 packages/server/modules/core/migrations/20250820101112_drop_user_defaults.ts create mode 100644 packages/server/modules/multiregion/tests/helpers.ts diff --git a/packages/server/modules/activitystream/tests/integration/activity.graph.spec.ts b/packages/server/modules/activitystream/tests/integration/activity.graph.spec.ts index c2236702a..8aa735ad7 100644 --- a/packages/server/modules/activitystream/tests/integration/activity.graph.spec.ts +++ b/packages/server/modules/activitystream/tests/integration/activity.graph.spec.ts @@ -16,28 +16,7 @@ import { getStreamRolesFactory, grantStreamPermissionsFactory } from '@/modules/core/repositories/streams' -import { - getUserFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory -} from '@/modules/core/repositories/users' -import { - findEmailFactory, - createUserEmailFactory, - ensureNoPrimaryEmailForUserFactory -} from '@/modules/core/repositories/userEmails' -import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' -import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' -import { renderEmail } from '@/modules/emails/services/emailRendering' -import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' -import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' -import { - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} from '@/modules/serverinvites/repositories/serverInvites' +import { getUserFactory } from '@/modules/core/repositories/users' import { createPersonalAccessTokenFactory } from '@/modules/core/services/tokens' import { storePersonalApiTokenFactory, @@ -45,7 +24,6 @@ import { storeTokenScopesFactory, storeTokenResourceAccessDefinitionsFactory } from '@/modules/core/repositories/tokens' -import { getServerInfoFactory } from '@/modules/core/repositories/server' import { createObjectFactory } from '@/modules/core/services/objects/management' import { storeSingleObjectIfNotFoundFactory } from '@/modules/core/repositories/objects' import { getEventBus } from '@/modules/shared/services/eventBus' @@ -55,6 +33,8 @@ import { createTestStream } from '@/test/speckle-helpers/streamHelper' import type { BasicTestBranch } from '@/test/speckle-helpers/branchHelper' import { createTestBranch } from '@/test/speckle-helpers/branchHelper' import { getActivitiesFactory } from '@/modules/activitystream/repositories/index' +import type { BasicTestUser } from '@/test/authHelper' +import { createTestUser } from '@/test/authHelper' const getUser = getUserFactory({ db }) const getUserActivity = getUserActivityFactory({ db }) @@ -67,34 +47,6 @@ const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({ emitEvent: getEventBus().emit }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo: getServerInfoFactory({ db }), - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo: getServerInfoFactory({ db }), - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) - const createPersonalAccessToken = createPersonalAccessTokenFactory({ storeApiToken: storeApiTokenFactory({ db }), storeTokenScopes: storeTokenScopesFactory({ db }), @@ -113,29 +65,13 @@ let server: http.Server let sendRequest: Awaited>['sendRequest'] describe('Activity @activity', () => { - const userIz = { - name: 'Izzy Lyseggen', - email: 'izzybizzi@speckle.systems', - password: 'sp0ckle sucks 9001', - id: '', - token: '' - } + let userIz: BasicTestUser + let userCr: BasicTestUser + let userX: BasicTestUser - const userCr = { - name: 'Cristi Balas', - email: 'cristib@speckle.systems', - password: 'hack3r man 666', - id: '', - token: '' - } - - const userX = { - name: 'Mystery User', - email: 'mysteriousDude@speckle.systems', - password: 'super $ecret pw0rd', - id: '', - token: '' - } + let userIzToken: string + let userCrToken: string + let userXToken: string const streamPublic: BasicTestStream = { name: 'a fun stream for sharing', @@ -197,30 +133,42 @@ describe('Activity @activity', () => { ] // create users - await Promise.all([ - createUser(userIz).then((id) => (userIz.id = id)), - createUser(userCr).then((id) => (userCr.id = id)), - createUser(userX).then((id) => (userX.id = id)) - ]) + userIz = await createTestUser({ + name: 'Izzy Lyseggen', + email: 'izzybizzi@speckle.systems', + password: 'sp0ckle sucks 9001' + }) + userCr = await createTestUser({ + name: 'Cristi Balas', + email: 'cristib@speckle.systems', + password: 'hack3r man 666' + }) + userX = await createTestUser({ + name: 'Mystery User', + email: 'mysteriousDude@speckle.systems', + password: 'super $ecret pw0rd' + }) - // create tokens and streams - await Promise.all([ - // tokens - createPersonalAccessToken(userIz.id, 'izz test token', normalScopesList).then( - (token) => (userIz.token = `Bearer ${token}`) - ), - createPersonalAccessToken(userCr.id, 'cristi test token', normalScopesList).then( - (token) => (userCr.token = `Bearer ${token}`) - ), - createPersonalAccessToken(userX.id, 'no users:read test token', [ - Scopes.Streams.Read, - Scopes.Streams.Write - ]).then((token) => (userX.token = `Bearer ${token}`)) - // streams - // createStream({ ...collaboratorTestStream, ownerId: userIz.id }).then( - // (id) => (collaboratorTestStream.id = id) - // ) - ]) + userIzToken = `Bearer ${await createPersonalAccessToken( + userIz.id, + 'izz test token', + normalScopesList + )}` + userCrToken = `Bearer ${await createPersonalAccessToken( + userCr.id, + 'cristi test token', + normalScopesList + )}` + userXToken = `Bearer ${createPersonalAccessToken( + userX.id, + 'no users:read test token', + [Scopes.Streams.Read, Scopes.Streams.Write] + )}` + + // streams + // createStream({ ...collaboratorTestStream, ownerId: userIz.id }).then( + // (id) => (collaboratorTestStream.id = id) + // ) // It's definitely not great that there's a full on test case in the before() hook, but that's because // these tests were originally written incorrectly - they depend on each other. So this is a temporary fix that @@ -232,7 +180,7 @@ describe('Activity @activity', () => { // create commit (cr2) testObj2.id = await createObject({ streamId: streamSecret.id, object: testObj2 }) - const resCommit1 = await sendRequest(userCr.token, { + const resCommit1 = await sendRequest(userCrToken, { query: `mutation { commitCreate(commit: {streamId: "${streamSecret.id}", branchName: "main", objectId: "${testObj2.id}", message: "first commit"})}` }) expect(noErrors(resCommit1)) @@ -249,7 +197,7 @@ describe('Activity @activity', () => { // create commit #2 (iz3) testObj.id = await createObject({ streamId: streamPublic.id, object: testObj }) - const resCommit2 = await sendRequest(userIz.token, { + const resCommit2 = await sendRequest(userIzToken, { query: `mutation { commitCreate(commit: { streamId: "${streamPublic.id}", branchName: "${branchPublic.name}", objectId: "${testObj.id}", message: "first commit" })}` }) expect(noErrors(resCommit2)) @@ -263,7 +211,7 @@ describe('Activity @activity', () => { ) // update collaborator (iz4) - const resCollab = await sendRequest(userIz.token, { + const resCollab = await sendRequest(userIzToken, { query: `mutation { streamUpdatePermission( permissionParams: { streamId: "${streamPublic.id}", userId: "${userCr.id}", role: "stream:contributor" } ) }` }) expect(noErrors(resCollab)) @@ -312,7 +260,7 @@ describe('Activity @activity', () => { }) it("Should get a user's own activity", async () => { - const res = await sendRequest(userIz.token, { + const res = await sendRequest(userIzToken, { query: `query {activeUser { name activity { totalCount items {id streamId resourceType resourceId actionType userId message time}}} }` }) expect(noErrors(res)) @@ -325,7 +273,7 @@ describe('Activity @activity', () => { }) it("Should get another user's activity", async () => { - const res = await sendRequest(userIz.token, { + const res = await sendRequest(userIzToken, { query: `query {otherUser(id:"${userCr.id}") { name activity { totalCount items {id streamId resourceType resourceId actionType userId message time}}} }` }) expect(noErrors(res)) @@ -334,7 +282,7 @@ describe('Activity @activity', () => { }) it("Should get a user's timeline", async () => { - const res = await sendRequest(userIz.token, { + const res = await sendRequest(userIzToken, { query: `query {otherUser(id:"${userCr.id}") { name timeline { totalCount items {id streamId resourceType resourceId actionType userId message time}}} }` }) expect(noErrors(res)) @@ -343,7 +291,7 @@ describe('Activity @activity', () => { }) it("Should get a stream's activity", async () => { - const res = await sendRequest(userCr.token, { + const res = await sendRequest(userCrToken, { query: `query { stream(id: "${streamPublic.id}") { activity { totalCount items {id streamId resourceId actionType message} } } }` }) expect(noErrors(res)) @@ -354,7 +302,7 @@ describe('Activity @activity', () => { }) it("Should get a branch's activity", async () => { - const res = await sendRequest(userCr.token, { + const res = await sendRequest(userCrToken, { query: `query { stream(id: "${streamPublic.id}") { branch(name: "${branchPublic.name}") { activity { totalCount items {id streamId resourceId actionType message} } } } }` }) expect(noErrors(res)) @@ -365,7 +313,7 @@ describe('Activity @activity', () => { }) it("Should *not* get a stream's activity if you don't have access to it", async () => { - const res = await sendRequest(userIz.token, { + const res = await sendRequest(userIzToken, { query: `query {stream(id:"${streamSecret.id}") {name activity {items {id streamId resourceType resourceId actionType userId message time}}} }` }) expect(res.body.errors?.length).to.equal(1) @@ -379,16 +327,18 @@ describe('Activity @activity', () => { }) it("Should *not* get a user's activity without the `users:read` scope", async () => { - const res = await sendRequest(userX.token, { + const res = await sendRequest(userXToken, { query: `query {otherUser(id:"${userCr.id}") { name activity {items {id streamId resourceType resourceId actionType userId message time}}} }` }) - expect(res.body.errors?.length).to.equal(1) + + expect(res.body.error).to.exist }) it("Should *not* get a user's timeline without the `users:read` scope", async () => { - const res = await sendRequest(userX.token, { + const res = await sendRequest(userXToken, { query: `query {otherUser(id:"${userCr.id}") { name timeline {items {id streamId resourceType resourceId actionType userId message time}}} }` }) - expect(res.body.errors?.length).to.equal(1) + + expect(res.body.error).to.exist }) }) diff --git a/packages/server/modules/auth/index.ts b/packages/server/modules/auth/index.ts index 4cc96a2cf..bf36102e2 100644 --- a/packages/server/modules/auth/index.ts +++ b/packages/server/modules/auth/index.ts @@ -1,7 +1,7 @@ import type { SpeckleModule } from '@/modules/shared/helpers/typeHelper' import { registerOrUpdateScopeFactory } from '@/modules/shared/repositories/scopes' -import { moduleLogger } from '@/observability/logging' +import { logger, moduleLogger } from '@/observability/logging' import db from '@/db/knex' import { initializeDefaultAppsFactory } from '@/modules/auth/services/serverApps' import { @@ -60,42 +60,9 @@ import { sendEmail } from '@/modules/emails/services/sending' import { getServerInfoFactory } from '@/modules/core/repositories/server' import { getEventBus } from '@/modules/shared/services/eventBus' import { isRateLimiterEnabled } from '@/modules/shared/helpers/envHelper' - -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo: getServerInfoFactory({ db }), - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ - db - }), - renderEmail, - sendEmail -}) - -const createUser = createUserFactory({ - getServerInfo: getServerInfoFactory({ db }), - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) - -const findOrCreateUser = findOrCreateUserFactory({ - createUser, - findPrimaryEmailForUser: findPrimaryEmailForUserFactory({ db }) -}) +import { getAllRegisteredDbs } from '@/modules/multiregion/utils/dbSelector' +import type { CreateValidatedUser } from '@/modules/core/domain/users/operations' +import { asMultiregionalOperation } from '@/modules/shared/command' const initializeDefaultApps = initializeDefaultAppsFactory({ getAllScopes: getAllScopesFactory({ db }), @@ -113,10 +80,65 @@ const finalizeInvitedServerRegistration = finalizeInvitedServerRegistrationFacto }) const resolveAuthRedirectPath = resolveAuthRedirectPathFactory() +const createUser: CreateValidatedUser = async (...input) => + asMultiregionalOperation( + async ({ mainDb, allDbs, emit }) => { + const createUser = createUserFactory({ + getServerInfo: getServerInfoFactory({ db: mainDb }), + findEmail: findEmailFactory({ db: mainDb }), + storeUser: async (...params) => { + const [user] = await Promise.all( + allDbs.map((db) => storeUserFactory({ db })(...params)) + ) + + return user + }, + countAdminUsers: countAdminUsersFactory({ db: mainDb }), + storeUserAcl: storeUserAclFactory({ db: mainDb }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db: mainDb }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ + db: mainDb + }), + findEmail: findEmailFactory({ db: mainDb }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db: mainDb }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db: mainDb }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + getServerInfo: getServerInfoFactory({ db }), + findEmail: findEmailFactory({ db: mainDb }), + getUser: getUserFactory({ db: mainDb }), + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory( + { + db: mainDb + } + ), + renderEmail, + sendEmail + }) + }), + emitEvent: emit + }) + + return createUser(...input) + }, + { + dbs: await getAllRegisteredDbs(), + name: 'create user', + logger + } + ) + const commonBuilderDeps = { getServerInfo: getServerInfoFactory({ db }), getUserByEmail: legacyGetUserByEmailFactory({ db }), - findOrCreateUser, + buildFindOrCreateUser: async () => { + return findOrCreateUserFactory({ + createUser, + findPrimaryEmailForUser: findPrimaryEmailForUserFactory({ db }) + }) + }, validateServerInvite, finalizeInvitedServerRegistration, resolveAuthRedirectPath, diff --git a/packages/server/modules/auth/strategies/azureAd.ts b/packages/server/modules/auth/strategies/azureAd.ts index 7bf3121ad..5d51c21a2 100644 --- a/packages/server/modules/auth/strategies/azureAd.ts +++ b/packages/server/modules/auth/strategies/azureAd.ts @@ -40,7 +40,7 @@ const azureAdStrategyBuilderFactory = (deps: { getServerInfo: GetServerInfo getUserByEmail: LegacyGetUserByEmail - findOrCreateUser: FindOrCreateValidatedUser + buildFindOrCreateUser: () => Promise validateServerInvite: ValidateServerInvite finalizeInvitedServerRegistration: FinalizeInvitedServerRegistration resolveAuthRedirectPath: ResolveAuthRedirectPath @@ -102,6 +102,8 @@ const azureAdStrategyBuilderFactory = serverVersion: serverInfo.version }) + const findOrCreateUser = await deps.buildFindOrCreateUser() + try { // This is the only strategy that does its own type for req.user - easier to force type cast for now // than to refactor everything @@ -130,7 +132,7 @@ const azureAdStrategyBuilderFactory = // if there is an existing user, go ahead and log them in (regardless of // whether the server is invite only or not). if (existingUser) { - const myUser = await deps.findOrCreateUser({ + const myUser = await findOrCreateUser({ user }) // ID is used later for verifying access token @@ -156,7 +158,7 @@ const azureAdStrategyBuilderFactory = } // create the user - const myUser = await deps.findOrCreateUser({ + const myUser = await findOrCreateUser({ user: { ...user, role: invite diff --git a/packages/server/modules/auth/strategies/github.ts b/packages/server/modules/auth/strategies/github.ts index f736ac356..afb613b9d 100644 --- a/packages/server/modules/auth/strategies/github.ts +++ b/packages/server/modules/auth/strategies/github.ts @@ -44,7 +44,7 @@ const githubStrategyBuilderFactory = (deps: { getServerInfo: GetServerInfo getUserByEmail: LegacyGetUserByEmail - findOrCreateUser: FindOrCreateValidatedUser + buildFindOrCreateUser: () => Promise validateServerInvite: ValidateServerInvite finalizeInvitedServerRegistration: FinalizeInvitedServerRegistration resolveAuthRedirectPath: ResolveAuthRedirectPath @@ -91,6 +91,8 @@ const githubStrategyBuilderFactory = serverVersion: serverInfo.version }) + const findOrCreateUser = await deps.buildFindOrCreateUser() + try { const email = profile.emails?.[0].value if (!email) { @@ -115,7 +117,7 @@ const githubStrategyBuilderFactory = // if there is an existing user, go ahead and log them in (regardless of // whether the server is invite only or not). if (existingUser) { - const myUser = await deps.findOrCreateUser({ user }) + const myUser = await findOrCreateUser({ user }) return done(null, myUser) } @@ -133,7 +135,7 @@ const githubStrategyBuilderFactory = } // create the user - const myUser = await deps.findOrCreateUser({ + const myUser = await findOrCreateUser({ user: { ...user, role: invite diff --git a/packages/server/modules/auth/strategies/google.ts b/packages/server/modules/auth/strategies/google.ts index 747224bce..27cde564d 100644 --- a/packages/server/modules/auth/strategies/google.ts +++ b/packages/server/modules/auth/strategies/google.ts @@ -39,7 +39,7 @@ const googleStrategyBuilderFactory = (deps: { getServerInfo: GetServerInfo getUserByEmail: LegacyGetUserByEmail - findOrCreateUser: FindOrCreateValidatedUser + buildFindOrCreateUser: () => Promise validateServerInvite: ValidateServerInvite finalizeInvitedServerRegistration: FinalizeInvitedServerRegistration resolveAuthRedirectPath: ResolveAuthRedirectPath @@ -75,6 +75,7 @@ const googleStrategyBuilderFactory = profileId: profile.id, serverVersion: serverInfo.version }) + const findOrCreateUser = await deps.buildFindOrCreateUser() try { // seems very weird that the Google strategy is not parsing 'error' query params @@ -117,7 +118,7 @@ const googleStrategyBuilderFactory = // if there is an existing user, go ahead and log them in (regardless of // whether the server is invite only or not). if (existingUser) { - const myUser = await deps.findOrCreateUser({ user }) + const myUser = await findOrCreateUser({ user }) return done(null, myUser) } @@ -135,7 +136,7 @@ const googleStrategyBuilderFactory = } // create the user - const myUser = await deps.findOrCreateUser({ + const myUser = await findOrCreateUser({ user: { ...user, role: invite diff --git a/packages/server/modules/auth/strategies/oidc.ts b/packages/server/modules/auth/strategies/oidc.ts index e79410e62..e19de4829 100644 --- a/packages/server/modules/auth/strategies/oidc.ts +++ b/packages/server/modules/auth/strategies/oidc.ts @@ -38,7 +38,7 @@ const oidcStrategyBuilderFactory = (deps: { getServerInfo: GetServerInfo getUserByEmail: LegacyGetUserByEmail - findOrCreateUser: FindOrCreateValidatedUser + buildFindOrCreateUser: () => Promise validateServerInvite: ValidateServerInvite finalizeInvitedServerRegistration: FinalizeInvitedServerRegistration resolveAuthRedirectPath: ResolveAuthRedirectPath @@ -78,6 +78,8 @@ const oidcStrategyBuilderFactory = serverVersion: serverInfo.version }) + const findOrCreateUser = await deps.buildFindOrCreateUser() + // TODO: req.session.inviteId doesn't appear to exist, but i'm not removing it to not break things const token: Optional = get(req.session, 'inviteId') || req.session.token @@ -107,7 +109,7 @@ const oidcStrategyBuilderFactory = // if there is an existing user, go ahead and log them in (regardless of // whether the server is invite only or not). if (existingUser) { - const myUser = await deps.findOrCreateUser({ + const myUser = await findOrCreateUser({ user }) @@ -128,7 +130,7 @@ const oidcStrategyBuilderFactory = } // create the user - const myUser = await deps.findOrCreateUser({ + const myUser = await findOrCreateUser({ user: { ...user, role: invite diff --git a/packages/server/modules/auth/tests/apps.graphql.spec.ts b/packages/server/modules/auth/tests/apps.graphql.spec.ts index 8fb60c6ad..5e40785ce 100644 --- a/packages/server/modules/auth/tests/apps.graphql.spec.ts +++ b/packages/server/modules/auth/tests/apps.graphql.spec.ts @@ -20,28 +20,6 @@ import { } from '@/modules/auth/repositories/apps' import { db } from '@/db/knex' import { createAppTokenFromAccessCodeFactory } from '@/modules/auth/services/serverApps' -import { - findEmailFactory, - createUserEmailFactory, - ensureNoPrimaryEmailForUserFactory -} from '@/modules/core/repositories/userEmails' -import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' -import { - getUserFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory -} from '@/modules/core/repositories/users' -import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' -import { renderEmail } from '@/modules/emails/services/emailRendering' -import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' -import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' -import { - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} from '@/modules/serverinvites/repositories/serverInvites' import { storeApiTokenFactory, storeTokenScopesFactory, @@ -49,9 +27,7 @@ import { storeUserServerAppTokenFactory, storePersonalApiTokenFactory } from '@/modules/core/repositories/tokens' -import { getServerInfoFactory } from '@/modules/core/repositories/server' -import { getEventBus } from '@/modules/shared/services/eventBus' -import type { BasicTestUser } from '@/test/authHelper' +import { createTestUser, type BasicTestUser } from '@/test/authHelper' let sendRequest: Awaited>['sendRequest'] @@ -73,34 +49,6 @@ const createAppTokenFromAccessCode = createAppTokenFromAccessCodeFactory({ createBareToken }) -const getServerInfo = getServerInfoFactory({ db }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) const createPersonalAccessToken = createPersonalAccessTokenFactory({ storeApiToken: storeApiTokenFactory({ db }), storeTokenScopes: storeTokenScopesFactory({ db }), @@ -119,28 +67,25 @@ describe('GraphQL @apps-api', () => { before(async () => { const ctx = await beforeEachContext() ;({ sendRequest } = await initializeTestServer(ctx)) - testUser = { + + testUser = await createTestUser({ name: 'Dimitrie Stefanescu', email: 'didimitrie@example.org', password: 'wtfwtfwtf', id: '' - } - - testUser.id = await createUser(testUser) + }) testToken = `Bearer ${await createPersonalAccessToken(testUser.id, 'test token', [ Scopes.Profile.Read, Scopes.Apps.Read, Scopes.Apps.Write ])}` - testUser2 = { + testUser2 = await createTestUser({ name: 'Mr. Mac', email: 'steve@jobs.com', password: 'wtfwtfwtf', id: '' - } - - testUser2.id = await createUser(testUser2) + }) testToken2 = `Bearer ${await createPersonalAccessToken(testUser2.id, 'test token', [ Scopes.Profile.Read, Scopes.Apps.Read, diff --git a/packages/server/modules/auth/tests/apps.spec.ts b/packages/server/modules/auth/tests/apps.spec.ts index c7e18a70d..c53934f12 100644 --- a/packages/server/modules/auth/tests/apps.spec.ts +++ b/packages/server/modules/auth/tests/apps.spec.ts @@ -31,29 +31,7 @@ import { createAppTokenFromAccessCodeFactory, refreshAppTokenFactory } from '@/modules/auth/services/serverApps' -import { - findEmailFactory, - createUserEmailFactory, - ensureNoPrimaryEmailForUserFactory -} from '@/modules/core/repositories/userEmails' -import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' -import { - getUserFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory, - getUserRoleFactory -} from '@/modules/core/repositories/users' -import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' -import { renderEmail } from '@/modules/emails/services/emailRendering' -import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' -import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' -import { - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} from '@/modules/serverinvites/repositories/serverInvites' +import { getUserRoleFactory } from '@/modules/core/repositories/users' import { storeApiTokenFactory, storeTokenScopesFactory, @@ -65,9 +43,7 @@ import { getTokenResourceAccessDefinitionsByIdFactory, updateApiTokenFactory } from '@/modules/core/repositories/tokens' -import { getServerInfoFactory } from '@/modules/core/repositories/server' -import { getEventBus } from '@/modules/shared/services/eventBus' -import type { BasicTestUser } from '@/test/authHelper' +import { createTestUser, type BasicTestUser } from '@/test/authHelper' import type { AppScopes } from '@speckle/shared' import { ensureError } from '@speckle/shared' import type { ValidTokenResult } from '@/modules/core/helpers/types' @@ -115,34 +91,6 @@ const refreshAppToken = refreshAppTokenFactory({ createBareToken }) -const getServerInfo = getServerInfoFactory({ db }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) const validateToken = validateTokenFactory({ revokeUserTokenById: revokeUserTokenByIdFactory({ db }), getApiTokenById: getApiTokenByIdFactory({ db }), @@ -156,16 +104,16 @@ const validateToken = validateTokenFactory({ }) describe('Services @apps-services', () => { - const actor: BasicTestUser = { - name: 'Dimitrie Stefanescu', - email: 'didimitrie@example.org', - password: 'wtfwtfwtf', - id: '' - } + let actor: BasicTestUser before(async () => { await beforeEachContext() - actor.id = await createUser(actor) + actor = await createTestUser({ + name: 'Dimitrie Stefanescu', + email: 'didimitrie@example.org', + password: 'wtfwtfwtf', + id: '' + }) }) it('Should register an app', async () => { @@ -507,14 +455,12 @@ describe('Services @apps-services', () => { redirectUrl: 'http://127.0.0.1:1335', authorId: actor.id }) - const secondUser: BasicTestUser = { + const secondUser = await createTestUser({ name: 'Dimitrie Stefanescu', email: 'didimitrie.wow@example.org', password: 'wtfwtfwtf', id: '' - } - - secondUser.id = await createUser(secondUser) + }) const accessCode = await createAuthorizationCode({ appId: myTestApp.id, userId: secondUser.id, diff --git a/packages/server/modules/auth/tests/auth.spec.ts b/packages/server/modules/auth/tests/auth.spec.ts index 9161bd476..d47f51765 100644 --- a/packages/server/modules/auth/tests/auth.spec.ts +++ b/packages/server/modules/auth/tests/auth.spec.ts @@ -31,9 +31,6 @@ import { createBranchFactory } from '@/modules/core/repositories/branches' import { getUsersFactory, getUserFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory, legacyGetUserByEmailFactory } from '@/modules/core/repositories/users' import { @@ -45,7 +42,6 @@ import { requestNewEmailVerificationFactory } from '@/modules/emails/services/ve import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' import { finalizeInvitedServerRegistrationFactory, @@ -76,6 +72,8 @@ import { UserInputError } from '@/modules/core/errors/userinput' import { createRandomEmail } from '@/modules/core/helpers/testHelpers' import cryptoRandomString from 'crypto-random-string' import { getFrontendOrigin } from '@/modules/shared/helpers/envHelper' +import type { BasicTestUser } from '@/test/authHelper' +import { createTestUser } from '@/test/authHelper' import { storeProjectRoleFactory } from '@/modules/core/repositories/projects' const getServerInfo = getServerInfoFactory({ db }) @@ -161,33 +159,6 @@ const createStream = legacyCreateStreamFactory({ }) }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) const getUserByEmail = legacyGetUserByEmailFactory({ db }) const updateServerInfo = updateServerInfoFactory({ db }) const logger = extendLoggerComponent(baseLogger, 'auth-tests') @@ -201,20 +172,7 @@ describe('Auth @auth', () => { describe('Local authN & authZ (token endpoints)', () => { const registeredUserEmail = 'registered@speckle.systems' - const me: { - name: string - company: string - email: string - password: string - id?: string - } = { - name: 'dimitrie stefanescu', - company: 'speckle', - email: registeredUserEmail, - password: 'roll saving throws', - id: undefined - } - + let me: BasicTestUser const myPrivateStream: { name: string isPublic: boolean @@ -231,8 +189,13 @@ describe('Auth @auth', () => { ;({ sendRequest } = await initializeTestServer(ctx)) // Register a user for testing login flows - const meId = await createUser(me) - me.id = meId + me = await createTestUser({ + name: 'dimitrie stefanescu', + company: 'speckle', + email: registeredUserEmail, + password: 'roll saving throws', + id: undefined + }) // Create a test stream for testing stream invites const myPrivateStreamId = await createStream({ diff --git a/packages/server/modules/blobstorage/tests/e2e/blobstorage.graph.spec.ts b/packages/server/modules/blobstorage/tests/e2e/blobstorage.graph.spec.ts index 49c3cc521..25f12223b 100644 --- a/packages/server/modules/blobstorage/tests/e2e/blobstorage.graph.spec.ts +++ b/packages/server/modules/blobstorage/tests/e2e/blobstorage.graph.spec.ts @@ -31,13 +31,7 @@ import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/se import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' import { getEventBus } from '@/modules/shared/services/eventBus' import { createBranchFactory } from '@/modules/core/repositories/branches' -import { - getUsersFactory, - getUserFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory -} from '@/modules/core/repositories/users' +import { getUsersFactory, getUserFactory } from '@/modules/core/repositories/users' import { findEmailFactory, createUserEmailFactory, @@ -47,7 +41,6 @@ import { requestNewEmailVerificationFactory } from '@/modules/emails/services/ve import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' import { finalizeInvitedServerRegistrationFactory, @@ -63,6 +56,8 @@ import { validateStreamAccessFactory } from '@/modules/core/services/streams/access' import { authorizeResolver } from '@/modules/shared' +import type { BasicTestUser } from '@/test/authHelper' +import { createTestUser } from '@/test/authHelper' import { storeProjectRoleFactory } from '@/modules/core/repositories/projects' const getServerInfo = getServerInfoFactory({ db }) @@ -146,47 +141,18 @@ const createStream = legacyCreateStreamFactory({ }) }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) - describe('Blobs graphql @blobstorage', () => { let graphqlServer: ServerAndContext - - const user = { - name: 'Baron Von Blubba', - email: 'zebarron@bubble.bobble', - password: 'bubblesAreMyBlobs', - id: '' - } + let user: BasicTestUser before(async () => { await truncateTables(['blob_storage', Users.name, Streams.name]) - user.id = await createUser(user) + user = await createTestUser({ + name: 'Baron Von Blubba', + email: 'zebarron@bubble.bobble', + password: 'bubblesAreMyBlobs', + id: '' + }) graphqlServer = { apollo: await buildApolloServer(), context: await createAuthedTestContext(user.id) diff --git a/packages/server/modules/blobstorage/tests/e2e/blobstorage.rest.spec.ts b/packages/server/modules/blobstorage/tests/e2e/blobstorage.rest.spec.ts index 64cdb4171..55564062c 100644 --- a/packages/server/modules/blobstorage/tests/e2e/blobstorage.rest.spec.ts +++ b/packages/server/modules/blobstorage/tests/e2e/blobstorage.rest.spec.ts @@ -4,87 +4,29 @@ import { expect } from 'chai' import { beforeEachContext, getMainTestRegionKeyIfMultiRegion } from '@/test/hooks' import { Scopes } from '@/modules/core/helpers/mainConstants' import { db } from '@/db/knex' -import { - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} from '@/modules/serverinvites/repositories/serverInvites' - -import { - getUserFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory -} from '@/modules/core/repositories/users' -import { - findEmailFactory, - createUserEmailFactory, - ensureNoPrimaryEmailForUserFactory -} from '@/modules/core/repositories/userEmails' -import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' -import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' -import { renderEmail } from '@/modules/emails/services/emailRendering' -import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' -import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' import { createTokenFactory } from '@/modules/core/services/tokens' import { storeApiTokenFactory, storeTokenScopesFactory, storeTokenResourceAccessDefinitionsFactory } from '@/modules/core/repositories/tokens' -import { getServerInfoFactory } from '@/modules/core/repositories/server' import type { BasicTestStream } from '@/test/speckle-helpers/streamHelper' import { createTestStream } from '@/test/speckle-helpers/streamHelper' import { waitForRegionUser } from '@/test/speckle-helpers/regions' import { createTestWorkspace } from '@/modules/workspaces/tests/helpers/creation' import { faker } from '@faker-js/faker' -import type { BasicTestUser } from '@/test/authHelper' +import { createTestUser, type BasicTestUser } from '@/test/authHelper' import cryptoRandomString from 'crypto-random-string' import type { BlobStorageItem } from '@/modules/blobstorage/domain/types' -import { getEventBus } from '@/modules/shared/services/eventBus' import { fileURLToPath } from 'url' -const getServerInfo = getServerInfoFactory({ db }) - -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) - const createRandomUser = async (): Promise => { const userDetails = { name: cryptoRandomString({ length: 10 }), email: `${cryptoRandomString({ length: 10, type: 'url-safe' })}@example.org`, password: cryptoRandomString({ length: 12 }) } - return { - ...userDetails, - id: await createUser(userDetails) - } + return createTestUser(userDetails) } const createToken = createTokenFactory({ storeApiToken: storeApiTokenFactory({ db }), diff --git a/packages/server/modules/comments/tests/comments.graph.spec.ts b/packages/server/modules/comments/tests/comments.graph.spec.ts index 255654087..1395e46b8 100644 --- a/packages/server/modules/comments/tests/comments.graph.spec.ts +++ b/packages/server/modules/comments/tests/comments.graph.spec.ts @@ -53,30 +53,6 @@ import { getStreamObjectsFactory } from '@/modules/core/repositories/objects' import { legacyUpdateStreamFactory } from '@/modules/core/services/streams/management' -import { - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} from '@/modules/serverinvites/repositories/serverInvites' -import { getEventBus } from '@/modules/shared/services/eventBus' -import { - getUserFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory -} from '@/modules/core/repositories/users' -import { - findEmailFactory, - ensureNoPrimaryEmailForUserFactory, - createUserEmailFactory -} from '@/modules/core/repositories/userEmails' -import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' -import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' -import { renderEmail } from '@/modules/emails/services/emailRendering' -import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' -import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' -import { getServerInfoFactory } from '@/modules/core/repositories/server' import { createObjectFactory } from '@/modules/core/services/objects/management' import { getViewerResourcesFromLegacyIdentifiersFactory, @@ -84,8 +60,10 @@ import { } from '@/modules/core/services/commit/viewerResources' import type { SetNonNullable } from 'type-fest' import { createProject } from '@/test/projectHelper' +import type { BasicTestUser } from '@/test/authHelper' +import { createTestUser } from '@/test/authHelper' +import { getEventBus } from '@/modules/shared/services/eventBus' -const getServerInfo = getServerInfoFactory({ db }) const markCommitStreamUpdated = markCommitStreamUpdatedFactory({ db }) const streamResourceCheck = streamResourceCheckFactory({ checkStreamResourceAccess: checkStreamResourceAccessFactory({ db }) @@ -139,33 +117,6 @@ const updateStream = legacyUpdateStreamFactory({ }) const grantPermissionsStream = grantStreamPermissionsFactory({ db }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) const createObject = createObjectFactory({ storeSingleObjectIfNotFoundFactory: storeSingleObjectIfNotFoundFactory({ db }) }) @@ -718,7 +669,7 @@ describe('Graphql @comments', () => { // this user will be admin by default // it will be used to create all resources, that the other actors can // be tested against - const myTestActor = { + let myTestActor: BasicTestUser = { name: 'Gergo Jedlicska', email: 'gergo@jedlicska.com', password: 'sn3aky-1337-b1m', @@ -1011,11 +962,11 @@ describe('Graphql @comments', () => { before(async () => { await beforeEachContext() - myTestActor.id = await createUser(myTestActor) + myTestActor = await createTestUser(myTestActor) await Promise.all( [chadTheEngineer, archived].map((user) => - createUser({ name: user.name, email: user.email, password: user.password }) - .then((id) => (user.id = id)) + createTestUser({ name: user.name, email: user.email, password: user.password }) + .then(({ id }) => (user.id = id)) .catch((err) => { throw err }) diff --git a/packages/server/modules/comments/tests/comments.spec.ts b/packages/server/modules/comments/tests/comments.spec.ts index d41d94432..c7b947420 100644 --- a/packages/server/modules/comments/tests/comments.spec.ts +++ b/packages/server/modules/comments/tests/comments.spec.ts @@ -21,7 +21,8 @@ import { import { get, range } from 'lodash-es' import { buildApolloServer } from '@/app' import { AllScopes } from '@/modules/core/helpers/mainConstants' -import { createAuthTokenForUser } from '@/test/authHelper' +import type { BasicTestUser } from '@/test/authHelper' +import { createAuthTokenForUser, createTestUser } from '@/test/authHelper' import type { UploadedBlob } from '@/test/blobHelper' import { uploadBlob } from '@/test/blobHelper' import { Comments } from '@/modules/core/dbSchema' @@ -95,13 +96,7 @@ import { import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' import { getEventBus } from '@/modules/shared/services/eventBus' -import { - getUsersFactory, - getUserFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory -} from '@/modules/core/repositories/users' +import { getUsersFactory, getUserFactory } from '@/modules/core/repositories/users' import { findEmailFactory, createUserEmailFactory, @@ -111,7 +106,6 @@ import { requestNewEmailVerificationFactory } from '@/modules/emails/services/ve import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' import { finalizeInvitedServerRegistrationFactory, @@ -313,33 +307,6 @@ const createStream = legacyCreateStreamFactory({ createStreamReturnRecord }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) const createObject = createObjectFactory({ storeSingleObjectIfNotFoundFactory: storeSingleObjectIfNotFoundFactory({ db }) }) @@ -360,19 +327,8 @@ describe('Comments @comments', () => { let notificationsState: NotificationsStateManager - const user = { - name: 'The comment wizard', - email: 'comment@wizard.ry', - password: 'i did not like Rivendel wine :(', - id: '' - } - - const otherUser = { - name: 'Fondalf The Brey', - email: 'totalnotfakegandalf87@mordor.com', - password: 'what gandalf puts in his pipe stays in his pipe', - id: '' - } + let user: BasicTestUser + let otherUser: BasicTestUser const stream = { name: 'Commented stream', @@ -400,8 +356,18 @@ describe('Comments @comments', () => { const { app: express } = await beforeEachContext() app = express - user.id = await createUser(user) - otherUser.id = await createUser(otherUser) + user = await createTestUser({ + name: 'The comment wizard', + email: 'comment@wizard.ry', + password: 'i did not like Rivendel wine :(', + id: '' + }) + otherUser = await createTestUser({ + name: 'Fondalf The Brey', + email: 'totalnotfakegandalf87@mordor.com', + password: 'what gandalf puts in his pipe stays in his pipe', + id: '' + }) stream.id = await createStream({ ...stream, ownerId: user.id }) diff --git a/packages/server/modules/core/graph/resolvers/users.ts b/packages/server/modules/core/graph/resolvers/users.ts index 73963db92..b302e0794 100644 --- a/packages/server/modules/core/graph/resolvers/users.ts +++ b/packages/server/modules/core/graph/resolvers/users.ts @@ -40,7 +40,6 @@ import { dbLogger } from '@/observability/logging' import { getAdminUsersListCollectionFactory } from '@/modules/core/services/users/legacyAdminUsersList' import type { Resolvers } from '@/modules/core/graph/generated/graphql' import { getServerInfoFactory } from '@/modules/core/repositories/server' -import { getEventBus } from '@/modules/shared/services/eventBus' import { getMailchimpStatus, getMailchimpOnboardingIds @@ -48,36 +47,19 @@ import { import { updateMailchimpMemberTags } from '@/modules/auth/services/mailchimp' import { withOperationLogging } from '@/observability/domain/businessLogging' import { metaHelpers } from '@/modules/core/helpers/meta' -import { asOperation } from '@/modules/shared/command' +import { asMultiregionalOperation, asOperation } from '@/modules/shared/command' import { setUserOnboardingChoicesFactory } from '@/modules/core/services/users/tracking' import { getMixpanelClient } from '@/modules/shared/utils/mixpanel' import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper' import { getUserWorkspaceSeatsFactory } from '@/modules/workspacesCore/repositories/workspaces' import { queryAllProjectsFactory } from '@/modules/core/services/projects' +import { getAllRegisteredDbs } from '@/modules/multiregion/utils/dbSelector' const getUser = legacyGetUserFactory({ db }) const getUserByEmail = legacyGetUserByEmailFactory({ db }) -const updateUserAndNotify = updateUserAndNotifyFactory({ - getUser: getUserFactory({ db }), - updateUser: updateUserFactory({ db }), - emitEvent: getEventBus().emit -}) - const getServerInfo = getServerInfoFactory({ db }) -const deleteUser = deleteUserFactory({ - deleteStream: deleteStreamFactory({ db }), - logger: dbLogger, - isLastAdminUser: isLastAdminUserFactory({ db }), - getUserDeletableStreams: getUserDeletableStreamsFactory({ db }), - queryAllProjects: queryAllProjectsFactory({ - getExplicitProjects: getExplicitProjects({ db }) - }), - getUserWorkspaceSeats: getUserWorkspaceSeatsFactory({ db }), - deleteAllUserInvites: deleteAllUserInvitesFactory({ db }), - deleteUserRecord: deleteUserRecordFactory({ db }), - emitEvent: getEventBus().emit -}) + const getUserRole = getUserRoleFactory({ db }) const changeUserRole = changeUserRoleFactory({ getServerInfo, @@ -261,14 +243,31 @@ export default { const logger = context.log.child({ userIdToOperateOn: context.userId }) - await withOperationLogging( - async () => await updateUserAndNotify(context.userId!, args.user), + + await asMultiregionalOperation( + async ({ mainDb, allDbs, emit }) => { + const updateUserAndNotify = updateUserAndNotifyFactory({ + getUser: getUserFactory({ db: mainDb }), + updateUser: async (...params) => { + const [res] = await Promise.all( + allDbs.map((db) => updateUserFactory({ db })(...params)) + ) + + return res + }, + emitEvent: emit + }) + + return await updateUserAndNotify(context.userId!, args.user) + }, { + dbs: await getAllRegisteredDbs(), logger, - operationName: 'updateUser', - operationDescription: `Update user` + name: 'updateUser', + description: `Update user` } ) + return true }, @@ -299,14 +298,39 @@ export default { const logger = context.log.child({ userIdToOperateOn: user.id }) - await withOperationLogging( - async () => await deleteUser(user.id, context.userId), + + await asMultiregionalOperation( + ({ mainDb, allDbs, emit }) => { + const deleteUser = deleteUserFactory({ + deleteStream: deleteStreamFactory({ db: mainDb }), + logger: dbLogger, + isLastAdminUser: isLastAdminUserFactory({ db: mainDb }), + getUserDeletableStreams: getUserDeletableStreamsFactory({ db: mainDb }), + queryAllProjects: queryAllProjectsFactory({ + getExplicitProjects: getExplicitProjects({ db: mainDb }) + }), + getUserWorkspaceSeats: getUserWorkspaceSeatsFactory({ db: mainDb }), + deleteAllUserInvites: deleteAllUserInvitesFactory({ db: mainDb }), + deleteUserRecord: async (params) => { + const [res] = await Promise.all( + allDbs.map((db) => deleteUserRecordFactory({ db })(params)) + ) + + return res + }, + emitEvent: emit + }) + + return deleteUser(user.id, context.userId) + }, { logger, - operationName: 'adminDeleteUser', - operationDescription: `Admin deletion of an user` + name: 'adminDeleteUser', + description: 'Admin deletion of an user', + dbs: await getAllRegisteredDbs() } ) + return true }, @@ -325,19 +349,40 @@ export default { // Since I am paranoid, I'll leave them here too. await throwForNotHavingServerRole(context, Roles.Server.Guest) await validateScopes(context.scopes, Scopes.Profile.Delete) + await asMultiregionalOperation( + ({ mainDb, allDbs, emit }) => { + const deleteUser = deleteUserFactory({ + deleteStream: deleteStreamFactory({ db: mainDb }), + logger: dbLogger, + isLastAdminUser: isLastAdminUserFactory({ db: mainDb }), + getUserDeletableStreams: getUserDeletableStreamsFactory({ db: mainDb }), + queryAllProjects: queryAllProjectsFactory({ + getExplicitProjects: getExplicitProjects({ db: mainDb }) + }), + getUserWorkspaceSeats: getUserWorkspaceSeatsFactory({ db: mainDb }), + deleteAllUserInvites: deleteAllUserInvitesFactory({ db: mainDb }), + deleteUserRecord: async (params) => { + const [res] = await Promise.all( + allDbs.map((db) => deleteUserRecordFactory({ db })(params)) + ) - await withOperationLogging( - async () => await deleteUser(context.userId!, context.userId!), + return res + }, + emitEvent: emit + }) + + return deleteUser(user.id, context.userId) + }, { logger, - operationName: 'deleteUser', - operationDescription: `Delete user` + name: 'deleteUser', + description: 'Delete user', + dbs: await getAllRegisteredDbs() } ) return true }, - activeUserMutations: () => ({}) }, ActiveUserMutations: { @@ -394,14 +439,31 @@ export default { }, async update(_parent, args, context) { const logger = context.log - const newUser = await withOperationLogging( - async () => await updateUserAndNotify(context.userId!, args.user), + + const newUser = await asMultiregionalOperation( + async ({ mainDb, allDbs, emit }) => { + const updateUserAndNotify = updateUserAndNotifyFactory({ + getUser: getUserFactory({ db: mainDb }), + updateUser: async (...params) => { + const [res] = await Promise.all( + allDbs.map((db) => updateUserFactory({ db })(...params)) + ) + + return res + }, + emitEvent: emit + }) + + return await updateUserAndNotify(context.userId!, args.user) + }, { + dbs: await getAllRegisteredDbs(), logger, - operationName: 'updateUser', - operationDescription: 'Update user' + name: 'updateUser', + description: `Update user` } ) + return newUser }, meta: () => ({}) diff --git a/packages/server/modules/core/migrations/20250820101112_drop_user_defaults.ts b/packages/server/modules/core/migrations/20250820101112_drop_user_defaults.ts new file mode 100644 index 000000000..5bc352a7b --- /dev/null +++ b/packages/server/modules/core/migrations/20250820101112_drop_user_defaults.ts @@ -0,0 +1,23 @@ +import type { Knex } from 'knex' + +const tableName = 'users' +const colUuid = 'suuid' +const colCreatedAt = 'createdAt' +const colVerified = 'verified' + +export async function up(knex: Knex): Promise { + await knex.schema.raw(` + ALTER TABLE "${tableName}" + ALTER COLUMN "${colUuid}" DROP DEFAULT, + ALTER COLUMN "${colCreatedAt}" DROP DEFAULT, + ALTER COLUMN "${colVerified}" DROP DEFAULT; + `) +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(tableName, (table) => { + table.string(colUuid).defaultTo(knex.raw('gen_random_uuid()')).alter() + table.timestamp(colCreatedAt).defaultTo(knex.fn.now()).alter() + table.boolean(colVerified).defaultTo(false).alter() + }) +} diff --git a/packages/server/modules/core/repositories/users.ts b/packages/server/modules/core/repositories/users.ts index ae0533917..5ea5db604 100644 --- a/packages/server/modules/core/repositories/users.ts +++ b/packages/server/modules/core/repositories/users.ts @@ -21,8 +21,6 @@ import { UserValidationError } from '@/modules/core/errors/user' import type { Knex } from 'knex' import type { ServerRoles } from '@speckle/shared' import { Roles } from '@speckle/shared' -import { updateUserEmailFactory } from '@/modules/core/repositories/userEmails' -import { markUserEmailAsVerifiedFactory } from '@/modules/core/services/users/emailVerification' import type { UserWithOptionalRole } from '@/modules/core/domain/users/types' import type { BulkLookupUsers, @@ -228,11 +226,7 @@ export const markUserAsVerifiedFactory = [UserCols.verified]: true }) - const userEmailsUpdate = await markUserEmailAsVerifiedFactory({ - updateUserEmail: updateUserEmailFactory({ db: deps.db }) - })({ email: email.toLowerCase().trim() }) - - return !!(usersUpdate || userEmailsUpdate) + return !!usersUpdate } export const markOnboardingCompleteFactory = @@ -285,13 +279,6 @@ export const updateUserFactory = .where(Users.col.id, userId) .update(update, '*') - if (update.email) { - await updateUserEmailFactory(deps)({ - query: { userId, primary: true }, - update: { email: update.email } - }) - } - return newUser as Nullable } diff --git a/packages/server/modules/core/services/users/management.ts b/packages/server/modules/core/services/users/management.ts index 40687f4e5..558d52f75 100644 --- a/packages/server/modules/core/services/users/management.ts +++ b/packages/server/modules/core/services/users/management.ts @@ -54,6 +54,7 @@ import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' import { ProjectEvents } from '@/modules/core/domain/projects/events' import type { QueryAllProjects } from '@/modules/core/domain/projects/operations' import type { StreamWithOptionalRole } from '@/modules/core/repositories/streams' +import { v4 } from 'uuid' const { FF_NO_PERSONAL_EMAILS_ENABLED } = getFeatureFlags() @@ -169,11 +170,12 @@ export const createUserFactory = const signUpCtx = user.signUpContext - let finalUser: typeof user & - Omit, 'suuid' | 'createdAt'> = { + let finalUser: typeof user & NullableKeysToOptional = { ...user, id: crs({ length: 10 }), - verified: user.verified || false + verified: user.verified || false, + createdAt: new Date(), + suuid: v4() } delete finalUser.signUpContext @@ -207,7 +209,10 @@ export const createUserFactory = 'name', 'company', 'verified', - 'avatar' + 'avatar', + 'verified', + 'createdAt', + 'suuid' ]) as typeof finalUser) finalUser.email = finalUser.email.toLowerCase() diff --git a/packages/server/modules/core/tests/branches.spec.ts b/packages/server/modules/core/tests/branches.spec.ts index a583e6990..f0c095be3 100644 --- a/packages/server/modules/core/tests/branches.spec.ts +++ b/packages/server/modules/core/tests/branches.spec.ts @@ -61,13 +61,7 @@ import { import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' import { getEventBus } from '@/modules/shared/services/eventBus' -import { - getUsersFactory, - getUserFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory -} from '@/modules/core/repositories/users' +import { getUsersFactory, getUserFactory } from '@/modules/core/repositories/users' import { findEmailFactory, createUserEmailFactory, @@ -77,7 +71,6 @@ import { requestNewEmailVerificationFactory } from '@/modules/emails/services/ve import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' import { finalizeInvitedServerRegistrationFactory, @@ -97,6 +90,8 @@ import { validateStreamAccessFactory } from '@/modules/core/services/streams/access' import { authorizeResolver } from '@/modules/shared' +import type { BasicTestUser } from '@/test/authHelper' +import { createTestUser } from '@/test/authHelper' import { storeProjectRoleFactory } from '@/modules/core/repositories/projects' const db = knex @@ -217,34 +212,6 @@ const createStream = legacyCreateStreamFactory({ emitEvent: getEventBus().emit }) }) - -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) const getBranchesByStreamId = getPaginatedStreamBranchesFactory({ getPaginatedStreamBranchesPage: getPaginatedStreamBranchesPageFactory({ db }), getStreamBranchCount: getStreamBranchCountFactory({ db }) @@ -254,13 +221,7 @@ const createObject = createObjectFactory({ }) describe('Branches @core-branches', () => { - const user = { - name: 'Dimitrie Stefanescu', - email: 'didimitrie4342@example.org', - password: 'sn3aky-1337-b1m', - id: '' - } - + let user: BasicTestUser const stream = { name: 'Test Stream References', description: 'Whatever goes in here usually...', @@ -278,7 +239,12 @@ describe('Branches @core-branches', () => { before(async () => { await beforeEachContext() - user.id = await createUser(user) + user = await createTestUser({ + name: 'Dimitrie Stefanescu', + email: 'didimitrie4342@example.org', + password: 'sn3aky-1337-b1m', + id: '' + }) stream.id = await createStream({ ...stream, ownerId: user.id }) testObject.id = await createObject({ streamId: stream.id, object: testObject }) }) diff --git a/packages/server/modules/core/tests/commits.spec.ts b/packages/server/modules/core/tests/commits.spec.ts index 05b5adb6a..01a68bc04 100644 --- a/packages/server/modules/core/tests/commits.spec.ts +++ b/packages/server/modules/core/tests/commits.spec.ts @@ -62,13 +62,7 @@ import { import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' import { getEventBus } from '@/modules/shared/services/eventBus' -import { - getUsersFactory, - getUserFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory -} from '@/modules/core/repositories/users' +import { getUsersFactory, getUserFactory } from '@/modules/core/repositories/users' import { findEmailFactory, createUserEmailFactory, @@ -78,7 +72,6 @@ import { requestNewEmailVerificationFactory } from '@/modules/emails/services/ve import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' import { finalizeInvitedServerRegistrationFactory, @@ -101,6 +94,8 @@ import { validateStreamAccessFactory } from '@/modules/core/services/streams/access' import { authorizeResolver } from '@/modules/shared' +import type { BasicTestUser } from '@/test/authHelper' +import { createTestUser } from '@/test/authHelper' import { storeProjectRoleFactory } from '@/modules/core/repositories/projects' const getServerInfo = getServerInfoFactory({ db }) @@ -232,33 +227,6 @@ const createStream = legacyCreateStreamFactory({ }) }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) const getCommitsByUserId = legacyGetPaginatedUserCommitsPage({ db }) const getCommitsByStreamId = legacyGetPaginatedStreamCommitsPageFactory({ db }) const getCommitsTotalCountByBranchName = getBranchCommitsTotalCountByNameFactory({ @@ -274,13 +242,7 @@ const createObject = createObjectFactory({ }) describe('Commits @core-commits', () => { - const user = { - name: 'Dimitrie Stefanescu', - email: 'didimitrie4342@example.org', - password: 'sn3aky-1337-b1m', - id: '' - } - + let user: BasicTestUser const stream = { name: 'Test Stream References', description: 'Whatever goes in here usually...', @@ -314,7 +276,12 @@ describe('Commits @core-commits', () => { before(async () => { await beforeEachContext() - user.id = await createUser(user) + user = await createTestUser({ + name: 'Dimitrie Stefanescu', + email: 'didimitrie4342@example.org', + password: 'sn3aky-1337-b1m', + id: '' + }) stream.id = await createStream({ ...stream, ownerId: user.id }) const testObjectId = await createObject({ streamId: stream.id, object: testObject }) diff --git a/packages/server/modules/core/tests/favoriteStreams.spec.ts b/packages/server/modules/core/tests/favoriteStreams.spec.ts index 9a9a3cc38..a2089ca40 100644 --- a/packages/server/modules/core/tests/favoriteStreams.spec.ts +++ b/packages/server/modules/core/tests/favoriteStreams.spec.ts @@ -37,13 +37,7 @@ import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/se import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' import { getEventBus } from '@/modules/shared/services/eventBus' import { createBranchFactory } from '@/modules/core/repositories/branches' -import { - getUsersFactory, - getUserFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory -} from '@/modules/core/repositories/users' +import { getUsersFactory, getUserFactory } from '@/modules/core/repositories/users' import { findEmailFactory, createUserEmailFactory, @@ -53,7 +47,6 @@ import { requestNewEmailVerificationFactory } from '@/modules/emails/services/ve import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' import { finalizeInvitedServerRegistrationFactory, @@ -69,6 +62,7 @@ import { validateStreamAccessFactory } from '@/modules/core/services/streams/access' import { authorizeResolver } from '@/modules/shared' +import { createTestUser, type BasicTestUser } from '@/test/authHelper' import { storeProjectRoleFactory } from '@/modules/core/repositories/projects' const getServerInfo = getServerInfoFactory({ db }) @@ -152,34 +146,6 @@ const createStream = legacyCreateStreamFactory({ }) }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) - /** * Cleaning up relevant tables */ @@ -274,27 +240,25 @@ describe('Favorite streams', () => { isPublic: true, id: '' } - const me = { - name: 'Itsa Me', - email: 'me@example.org', - password: 'sn3aky-1337-b1m', - id: '' - } - const otherGuy = { - name: 'Some Other DUde', - email: 'otherguy@example.org', - password: 'sn3aky-1337-b1m', - id: '' - } + let me: BasicTestUser + let otherGuy: BasicTestUser before(async function () { await cleanup() - // Seeding - await Promise.all([ - createUser(me).then((id) => (me.id = id)), - createUser(otherGuy).then((id) => (otherGuy.id = id)) - ]) + me = await createTestUser({ + name: 'Itsa Me', + email: 'me@example.org', + password: 'sn3aky-1337-b1m', + id: '' + }) + + otherGuy = await createTestUser({ + name: 'Some Other DUde', + email: 'otherguy@example.org', + password: 'sn3aky-1337-b1m', + id: '' + }) await Promise.all([ createStream({ ...myPubStream, ownerId: me.id }).then( diff --git a/packages/server/modules/core/tests/generic.spec.ts b/packages/server/modules/core/tests/generic.spec.ts index 9fd764615..06d33d03c 100644 --- a/packages/server/modules/core/tests/generic.spec.ts +++ b/packages/server/modules/core/tests/generic.spec.ts @@ -34,13 +34,7 @@ import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/se import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' import { getEventBus } from '@/modules/shared/services/eventBus' import { createBranchFactory } from '@/modules/core/repositories/branches' -import { - getUsersFactory, - getUserFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory -} from '@/modules/core/repositories/users' +import { getUsersFactory, getUserFactory } from '@/modules/core/repositories/users' import { findEmailFactory, createUserEmailFactory, @@ -50,7 +44,6 @@ import { requestNewEmailVerificationFactory } from '@/modules/emails/services/ve import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' import { finalizeInvitedServerRegistrationFactory, @@ -67,6 +60,8 @@ import { validateStreamAccessFactory } from '@/modules/core/services/streams/access' import type { Request } from 'express' +import type { BasicTestUser } from '@/test/authHelper' +import { createTestUser } from '@/test/authHelper' import { storeProjectRoleFactory } from '@/modules/core/repositories/projects' const buildFinalizeProjectInvite = () => @@ -149,33 +144,6 @@ const createStream = legacyCreateStreamFactory({ }) }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) const adminOverrideMock = mockAdminOverride() describe('Generic AuthN & AuthZ controller tests', () => { @@ -295,23 +263,23 @@ describe('Generic AuthN & AuthZ controller tests', () => { isPublic: false, id: '' } - const serverOwner = { - name: 'Itsa Me', - email: 'me@example.org', - password: 'sn3aky-1337-b1m', - id: '' - } - const otherGuy = { - name: 'Some Other DUde', - email: 'otherguy@example.org', - password: 'sn3aky-1337-b1m', - id: '' - } + let serverOwner: BasicTestUser + let otherGuy: BasicTestUser before(async function () { // Seeding - serverOwner.id = await createUser(serverOwner) - otherGuy.id = await createUser(otherGuy) + serverOwner = await createTestUser({ + name: 'Itsa Me', + email: 'me@example.org', + password: 'sn3aky-1337-b1m', + id: '' + }) + otherGuy = await createTestUser({ + name: 'Some Other DUde', + email: 'otherguy@example.org', + password: 'sn3aky-1337-b1m', + id: '' + }) await Promise.all([ createStream({ ...myStream, ownerId: serverOwner.id }).then( diff --git a/packages/server/modules/core/tests/graph.spec.ts b/packages/server/modules/core/tests/graph.spec.ts index 4b0d9d816..49152255f 100644 --- a/packages/server/modules/core/tests/graph.spec.ts +++ b/packages/server/modules/core/tests/graph.spec.ts @@ -24,31 +24,10 @@ import { import { getUserFactory, legacyGetPaginatedUsersFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory, isLastAdminUserFactory, updateUserServerRoleFactory } from '@/modules/core/repositories/users' -import { - findEmailFactory, - createUserEmailFactory, - ensureNoPrimaryEmailForUserFactory -} from '@/modules/core/repositories/userEmails' -import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' -import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' -import { renderEmail } from '@/modules/emails/services/emailRendering' -import { sendEmail } from '@/modules/emails/services/sending' -import { - createUserFactory, - changeUserRoleFactory -} from '@/modules/core/services/users/management' -import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' -import { - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} from '@/modules/serverinvites/repositories/serverInvites' +import { changeUserRoleFactory } from '@/modules/core/services/users/management' import { createPersonalAccessTokenFactory } from '@/modules/core/services/tokens' import { storeApiTokenFactory, @@ -72,6 +51,8 @@ import { type TestApolloServer } from '@/test/graphqlHelper' import { AllScopes } from '@/modules/core/helpers/mainConstants' +import type { BasicTestUser } from '@/test/authHelper' +import { createTestUser } from '@/test/authHelper' const { FF_PERSONAL_PROJECTS_LIMITS_ENABLED, FF_USERS_INVITE_SCOPE_IS_PUBLIC } = getFeatureFlags() @@ -100,33 +81,6 @@ const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({ const getUsers = legacyGetPaginatedUsersFactory({ db }) const getServerInfo = getServerInfoFactory({ db }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) const createPersonalAccessToken = createPersonalAccessTokenFactory({ storeApiToken: storeApiTokenFactory({ db }), @@ -151,27 +105,13 @@ const changeUserRole = changeUserRoleFactory({ ;(FF_PERSONAL_PROJECTS_LIMITS_ENABLED ? describe.skip : describe)( 'GraphQL API Core @core-api (Legacy)', () => { - const userA = { - id: '', - token: '', - name: 'd1', - email: 'd.1@speckle.systems', - password: 'wowwowwowwowwow' - } - const userB = { - id: '', - token: '', - name: 'd2', - email: 'd.2@speckle.systems', - password: 'wowwowwowwowwow' - } - const userC = { - id: '', - token: '', - name: 'd3', - email: 'd.3@speckle.systems', - password: 'wowwowwowwowwow' - } + let userA: BasicTestUser + let userB: BasicTestUser + let userC: BasicTestUser + + let tokenUserA: string + let tokenUserB: string + let tokenUserC: string // set up app & two basic users to ping pong permissions around before(async () => { @@ -180,8 +120,14 @@ const changeUserRole = changeUserRoleFactory({ app = ctx.app ;({ sendRequest } = await initializeTestServer(ctx)) - userA.id = await createUser(userA) - userA.token = `Bearer ${await createPersonalAccessToken( + userA = await createTestUser({ + id: '', + name: 'd1', + email: 'd.1@speckle.systems', + password: 'wowwowwowwowwow' + }) + + tokenUserA = `Bearer ${await createPersonalAccessToken( userA.id, 'test token user A', [ @@ -196,8 +142,15 @@ const changeUserRole = changeUserRoleFactory({ Scopes.Profile.Email ] )}` - userB.id = await createUser(userB) - userB.token = `Bearer ${await createPersonalAccessToken( + + userB = await createTestUser({ + id: '', + name: 'd2', + email: 'd.2@speckle.systems', + password: 'wowwowwowwowwow' + }) + + tokenUserB = `Bearer ${await createPersonalAccessToken( userB.id, 'test token user B', [ @@ -211,8 +164,13 @@ const changeUserRole = changeUserRoleFactory({ Scopes.Profile.Email ] )}` - userC.id = await createUser(userC) - userC.token = `Bearer ${await createPersonalAccessToken( + userC = await createTestUser({ + id: '', + name: 'd3', + email: 'd.3@speckle.systems', + password: 'wowwowwowwowwow' + }) + tokenUserC = `Bearer ${await createPersonalAccessToken( userC.id, 'test token user B', [ @@ -238,19 +196,19 @@ const changeUserRole = changeUserRoleFactory({ }) // Prepare API tokens for use in tests - const res1 = await sendRequest(userA.token, { + const res1 = await sendRequest(tokenUserA, { query: 'mutation { apiTokenCreate(token: {name:"Token 1", scopes: ["streams:read", "users:read", "tokens:read"]}) }' }) token1 = `Bearer ${res1.body.data.apiTokenCreate}` - const res2 = await sendRequest(userA.token, { + const res2 = await sendRequest(tokenUserA, { query: 'mutation { apiTokenCreate(token: {name:"Token 1", scopes: ["streams:write", "streams:read", "users:email"]}) }' }) token2 = `Bearer ${res2.body.data.apiTokenCreate}` - const res3 = await sendRequest(userB.token, { + const res3 = await sendRequest(tokenUserB, { query: 'mutation { apiTokenCreate(token: {name:"Token 1", scopes: ["streams:write", "streams:read", "users:email"]}) }' }) @@ -376,7 +334,7 @@ const changeUserRole = changeUserRoleFactory({ describe('Mutations', () => { describe('Users & Api tokens', () => { it('Should create some api tokens', async () => { - const res1 = await sendRequest(userA.token, { + const res1 = await sendRequest(tokenUserA, { query: 'mutation { apiTokenCreate(token: {name:"Token 1", scopes: ["streams:read", "users:read", "tokens:read"]}) }' }) @@ -385,13 +343,13 @@ const changeUserRole = changeUserRoleFactory({ expect(res1.body.data.apiTokenCreate).to.be.a('string') token1 = `Bearer ${res1.body.data.apiTokenCreate}` - const res2 = await sendRequest(userA.token, { + const res2 = await sendRequest(tokenUserA, { query: 'mutation { apiTokenCreate(token: {name:"Token 1", scopes: ["streams:write", "streams:read", "users:email"]}) }' }) token2 = `Bearer ${res2.body.data.apiTokenCreate}` - const res3 = await sendRequest(userB.token, { + const res3 = await sendRequest(tokenUserB, { query: 'mutation { apiTokenCreate(token: {name:"Token 1", scopes: ["streams:write", "streams:read", "users:email"]}) }' }) @@ -399,7 +357,7 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should revoke an api token that the user owns', async () => { - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: `mutation{ apiTokenRevoke(token:"${token2}")}` }) expect(res).to.be.json @@ -408,7 +366,7 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should fail to revoke an api token that I do not own', async () => { - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: `mutation{ apiTokenRevoke(token:"${token3}")}` }) expect(res).to.be.json @@ -427,7 +385,7 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should edit my profile', async () => { - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: 'mutation($user:UserUpdateInput!) { userUpdate( user: $user) } ', variables: { user: { @@ -442,16 +400,14 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should delete my account', async () => { - const userDelete = { + const userDelete = await createTestUser({ id: '', - token: '', name: 'delete', email: `${cryptoRandomString({ length: 10 })}@example.org`, password: 'wowwowwowwowwow' - } - userDelete.id = await createUser(userDelete) + }) - userDelete.token = `Bearer ${await createPersonalAccessToken( + let userDeleteToken = `Bearer ${await createPersonalAccessToken( userDelete.id, 'fail token user del', [ @@ -466,7 +422,7 @@ const changeUserRole = changeUserRoleFactory({ ] )}` - const badTokenScopesBadEmail = await sendRequest(userDelete.token, { + const badTokenScopesBadEmail = await sendRequest(userDeleteToken, { query: 'mutation($user:UserDeleteInput!) { userDelete( userConfirmation: $user) } ', variables: { user: { email: 'wrongEmail@email.com' } } @@ -475,7 +431,7 @@ const changeUserRole = changeUserRoleFactory({ expect(badTokenScopesBadEmail.body.errors[0].extensions?.code).to.equal( 'FORBIDDEN' ) - const badTokenScopesGoodEmail = await sendRequest(userDelete.token, { + const badTokenScopesGoodEmail = await sendRequest(userDeleteToken, { query: 'mutation($user:UserDeleteInput!) { userDelete( userConfirmation: $user) } ', variables: { user: { email: userDelete.email } } @@ -485,7 +441,7 @@ const changeUserRole = changeUserRoleFactory({ 'FORBIDDEN' ) - userDelete.token = `Bearer ${await createPersonalAccessToken( + userDeleteToken = `Bearer ${await createPersonalAccessToken( userDelete.id, 'test token user del', [ @@ -501,7 +457,7 @@ const changeUserRole = changeUserRoleFactory({ ] )}` - const goodTokenScopesBadEmail = await sendRequest(userDelete.token, { + const goodTokenScopesBadEmail = await sendRequest(userDeleteToken, { query: 'mutation($user:UserDeleteInput!) { userDelete( userConfirmation: $user) } ', variables: { user: { email: 'wrongEmail@email.com' } } @@ -510,7 +466,7 @@ const changeUserRole = changeUserRoleFactory({ expect(goodTokenScopesBadEmail.body.errors[0].extensions?.code).to.equal( 'BAD_REQUEST_ERROR' ) - const goodTokenScopesGoodEmail = await sendRequest(userDelete.token, { + const goodTokenScopesGoodEmail = await sendRequest(userDeleteToken, { query: 'mutation($user:UserDeleteInput!) { userDelete( userConfirmation: $user) } ', variables: { user: { email: userDelete.email } } @@ -521,20 +477,20 @@ const changeUserRole = changeUserRoleFactory({ describe('User role change', () => { it('User role is changed', async () => { - let queriedUserB = await sendRequest(userA.token, { + let queriedUserB = await sendRequest(tokenUserA, { query: ` { otherUser(id:"${userB.id}") { id name role } }` }) expect(queriedUserB.body.data.otherUser.role).to.equal(Roles.Server.User) let query = `mutation { userRoleChange(userRoleInput: {id: "${userB.id}", role: "${Roles.Server.Admin}"})}` - await sendRequest(userA.token, { query }) - queriedUserB = await sendRequest(userA.token, { + await sendRequest(tokenUserA, { query }) + queriedUserB = await sendRequest(tokenUserA, { query: ` { otherUser(id:"${userB.id}") { id name role } }` }) expect(queriedUserB.body.data.otherUser.role).to.equal(Roles.Server.Admin) expect(queriedUserB.body.data) query = `mutation { userRoleChange(userRoleInput: {id: "${userB.id}", role: "${Roles.Server.User}"})}` - await sendRequest(userA.token, { query }) - queriedUserB = await sendRequest(userA.token, { + await sendRequest(tokenUserA, { query }) + queriedUserB = await sendRequest(tokenUserA, { query: ` { otherUser(id:"${userB.id}") { id name role } }` }) expect(queriedUserB.body.data.otherUser.role).to.equal(Roles.Server.User) @@ -542,8 +498,8 @@ const changeUserRole = changeUserRoleFactory({ it('Only admins can change user role', async () => { const query = `mutation { userRoleChange(userRoleInput: {id: "${userB.id}", role: "${Roles.Server.Admin}"})}` - const res = await sendRequest(userB.token, { query }) - const queriedUserB = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserB, { query }) + const queriedUserB = await sendRequest(tokenUserA, { query: ` { otherUser(id:"${userB.id}") { id name role } }` }) expect(res.body.errors).to.exist @@ -554,35 +510,33 @@ const changeUserRole = changeUserRoleFactory({ describe('User deletion', () => { it('Only admins can delete user', async () => { - const userDelete = { + const userDelete = await createTestUser({ id: '', name: 'delete', email: `${cryptoRandomString({ length: 10 })}@example.org`, password: 'wowwowwowwowwow' - } - userDelete.id = await createUser(userDelete) + }) const users = await getUsers() expect(users.map((u) => u.id)).to.contain(userDelete.id) const query = `mutation { adminDeleteUser( userConfirmation: { email: "${userDelete.email}" } ) } ` - const res = await sendRequest(userB.token, { query }) + const res = await sendRequest(tokenUserB, { query }) expect(res.body.errors).to.exist expect(res.body.errors[0].extensions.code).to.equal('FORBIDDEN') }) it('Admin can delete user', async () => { - const userDelete = { + const userDelete = await createTestUser({ id: '', name: 'delete', email: 'd3l3t3@speckle.systems', password: 'wowwowwowwowwow' - } - userDelete.id = await createUser(userDelete) + }) let users = await getUsers() expect(users.map((u) => u.id)).to.contain(userDelete.id) const query = `mutation { adminDeleteUser( userConfirmation: { email: "${userDelete.email}" } ) } ` - const deleteResult = await sendRequest(userA.token, { query }) + const deleteResult = await sendRequest(tokenUserA, { query }) expect(deleteResult.body.data.adminDeleteUser).to.equal(true) users = await getUsers() expect(users.map((u) => u.id)).to.not.contain(userDelete.id) @@ -590,7 +544,7 @@ const changeUserRole = changeUserRoleFactory({ it('Cannot delete the last admin', async () => { const query = `mutation { adminDeleteUser( userConfirmation: { email: "${userA.email}" } ) } ` - const res = await sendRequest(userA.token, { query }) + const res = await sendRequest(tokenUserA, { query }) expect(res.body.errors).to.exist expect(res.body.errors[0].extensions?.code).to.equal('USER_INPUT_ERROR') expect(res.body.errors[0].message).to.equal( @@ -665,7 +619,7 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should create some streams', async () => { - const resS1 = await sendRequest(userA.token, { + const resS1 = await sendRequest(tokenUserA, { query: 'mutation { streamCreate(stream: { name: "TS1 (u A) Private", description: "Hello World", isPublic:false } ) }' }) @@ -676,7 +630,7 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should update a stream', async () => { - const resS1 = await sendRequest(userA.token, { + const resS1 = await sendRequest(tokenUserA, { query: `mutation { streamUpdate(stream: {id:"${ts1}" name: "TS1 (u A) Private UPDATED", description: "Hello World, Again!", isPublic:false } ) }` }) @@ -693,7 +647,7 @@ const changeUserRole = changeUserRoleFactory({ publicPrivateDataset.forEach(({ display, isPublic }) => { it(`Should not allow updating permissions if target user isnt a collaborator on a ${display} stream`, async () => { const streamId = isPublic ? ts2 : ts1 - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: `mutation{ streamUpdatePermission( permissionParams: {streamId: "${streamId}", userId: "${userB.id}" role: "stream:owner"}) }` }) @@ -713,7 +667,7 @@ const changeUserRole = changeUserRoleFactory({ Roles.Stream.Reviewer, userA.id ) - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: `mutation{ streamUpdatePermission( permissionParams: {streamId: "${ts1}", userId: "${userB.id}" role: "stream:owner"}) }` }) @@ -727,7 +681,7 @@ const changeUserRole = changeUserRoleFactory({ Roles.Stream.Reviewer, userB.id ) - const res2 = await sendRequest(userB.token, { + const res2 = await sendRequest(tokenUserB, { query: `mutation{ streamUpdatePermission( permissionParams: {streamId: "${ts5}", userId: "${userA.id}" role: "stream:owner"}) }` }) expect(res2).to.be.json @@ -739,7 +693,7 @@ const changeUserRole = changeUserRoleFactory({ Roles.Stream.Reviewer, userB.id ) - const res3 = await sendRequest(userB.token, { + const res3 = await sendRequest(tokenUserB, { query: `mutation{ streamUpdatePermission( permissionParams: {streamId: "${ts3}", userId: "${userC.id}" role: "stream:owner"}) }` }) expect(res3).to.be.json @@ -747,7 +701,7 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should fail to grant permissions if not owner', async () => { - const res = await sendRequest(userB.token, { + const res = await sendRequest(tokenUserB, { query: `mutation{ streamUpdatePermission( permissionParams: {streamId: "${ts1}", userId: "${userB.id}" role: "stream:owner"}) }` }) expect(res).to.be.json @@ -758,7 +712,7 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should fail to grant myself permissions', async () => { - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: `mutation{ streamUpdatePermission( permissionParams: {streamId: "${ts1}", userId: "${userA.id}" role: "stream:owner"}) }` }) expect(res).to.be.json @@ -777,21 +731,21 @@ const changeUserRole = changeUserRoleFactory({ ) // first test if we can get it - const res = await sendRequest(userC.token, { + const res = await sendRequest(tokenUserC, { query: `query { stream(id:"${ts3}") { id name } }` }) expect(res).to.be.json expect(res.body.errors).to.not.exist expect(res.body.data.stream.name).to.equal('TS3 (u B) Private') - const revokeRes = await sendRequest(userB.token, { + const revokeRes = await sendRequest(tokenUserB, { query: `mutation { streamRevokePermission( permissionParams: {streamId: "${ts3}", userId:"${userC.id}"} ) }` }) expect(revokeRes).to.be.json expect(revokeRes.body.errors).to.not.exist expect(revokeRes.body.data.streamRevokePermission).to.equal(true) - const resNotAuth = await sendRequest(userC.token, { + const resNotAuth = await sendRequest(tokenUserC, { query: `query { stream(id:"${ts3}") { id name role } }` }) expect(resNotAuth).to.be.json @@ -801,7 +755,7 @@ const changeUserRole = changeUserRoleFactory({ it('Should fail to edit/write on a public stream if no access is provided', async () => { // ts4 is a public stream from uesrB - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: `mutation { streamUpdate(stream: {id:"${ts4}" name: "HACK", description: "Hello World, Again!", isPublic:false } ) }` }) expect(res.body.errors).to.exist @@ -809,7 +763,7 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should fail editing a private stream if no access has been granted', async () => { - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: `mutation { streamUpdate(stream: {id:"${ts3}" name: "HACK", description: "Hello World, Again!", isPublic:false } ) }` }) @@ -821,7 +775,7 @@ const changeUserRole = changeUserRoleFactory({ // Make sure user is no longer a stream collaborator await removeStreamCollaborator(ts1, userB.id, userB.id) - const res = await sendRequest(userB.token, { + const res = await sendRequest(tokenUserB, { query: `mutation { streamDelete( id:"${ts1}")}` }) expect(res).to.be.json @@ -831,7 +785,7 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should fail to delete streams if not admin', async () => { - const res = await sendRequest(userB.token, { + const res = await sendRequest(tokenUserB, { query: `mutation { streamsDelete( ids:"[${ts4}]")}` }) expect(res).to.be.json @@ -841,7 +795,7 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should delete a stream', async () => { - const res = await sendRequest(userB.token, { + const res = await sendRequest(tokenUserB, { query: `mutation { streamDelete( id:"${ts4}")}` }) @@ -852,7 +806,7 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should be forbidden to query admin streams if not admin', async () => { - const res = await sendRequest(userC.token, { + const res = await sendRequest(tokenUserC, { query: '{ adminStreams { totalCount items { id name } } }' }) expect(res).to.be.json @@ -862,13 +816,13 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should query admin streams', async () => { - let streamResults = await sendRequest(userA.token, { + let streamResults = await sendRequest(tokenUserA, { query: '{ adminStreams { totalCount items { id name } } }' }) expect(streamResults.body.data.adminStreams.totalCount).to.equal(10) - streamResults = await sendRequest(userA.token, { + streamResults = await sendRequest(tokenUserA, { query: '{ adminStreams(limit: 200) { totalCount items { id name } } }' }) expect(streamResults.body.errors).to.exist @@ -876,18 +830,18 @@ const changeUserRole = changeUserRoleFactory({ 'BAD_REQUEST_ERROR' ) - streamResults = await sendRequest(userA.token, { + streamResults = await sendRequest(tokenUserA, { query: '{ adminStreams(limit: 2) { totalCount items { id name } } }' }) expect(streamResults.body.data.adminStreams.totalCount).to.equal(10) expect(streamResults.body.data.adminStreams.items.length).to.equal(2) - streamResults = await sendRequest(userA.token, { + streamResults = await sendRequest(tokenUserA, { query: '{ adminStreams( query: "Admin" ) { totalCount items { id name } } }' }) expect(streamResults.body.data.adminStreams.totalCount).to.equal(5) - streamResults = await sendRequest(userA.token, { + streamResults = await sendRequest(tokenUserA, { query: '{ adminStreams( visibility: "private" ) { totalCount items { id name isPublic } } }' }) @@ -896,7 +850,7 @@ const changeUserRole = changeUserRoleFactory({ streams.every((stream) => !stream.isPublic) ) - streamResults = await sendRequest(userA.token, { + streamResults = await sendRequest(tokenUserA, { query: '{ adminStreams( visibility: "public" ) { totalCount items { id name isPublic } } }' }) @@ -907,14 +861,14 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should delete streams', async () => { - const streamResults = await sendRequest(userA.token, { + const streamResults = await sendRequest(tokenUserA, { query: '{ adminStreams( query: "Admin" ) { totalCount items { id name } } }' }) expect(streamResults.body.data.adminStreams.totalCount).to.equal(5) const streamIds = streamResults.body.data.adminStreams.items.map( (stream: { id: string }) => stream.id ) - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: 'mutation ( $ids: [String!] ){ streamsDelete( ids: $ids )}', variables: { ids: streamIds } }) @@ -963,7 +917,7 @@ const changeUserRole = changeUserRoleFactory({ }) } - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: `mutation( $objs: [JSONObject]! ) { objectCreate( objectInput: {streamId:"${ts1}", objects: $objs} ) }`, variables: { objs } }) @@ -981,7 +935,7 @@ const changeUserRole = changeUserRoleFactory({ c1.objectId = objIds[0] c1.branchName = 'main' - let res = await sendRequest(userA.token, { + let res = await sendRequest(tokenUserA, { query: 'mutation( $myCommit: CommitCreateInput! ) { commitCreate( commit: $myCommit ) }', variables: { myCommit: omit(c1, 'id') } @@ -999,7 +953,7 @@ const changeUserRole = changeUserRoleFactory({ c2.branchName = 'main' c2.previousCommitIds = [c1.id] - res = await sendRequest(userA.token, { + res = await sendRequest(tokenUserA, { query: 'mutation( $myCommit: CommitCreateInput! ) { commitCreate( commit: $myCommit ) }', variables: { myCommit: omit(c2, 'id') } @@ -1018,7 +972,7 @@ const changeUserRole = changeUserRoleFactory({ id: c1.id, message: 'first commit' } - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: 'mutation( $myCommit: CommitUpdateInput! ) { commitUpdate( commit: $myCommit ) }', variables: { myCommit: updatePayload } @@ -1027,7 +981,7 @@ const changeUserRole = changeUserRoleFactory({ expect(res.body.errors).to.not.exist expect(res.body.data).to.have.property('commitUpdate') - const res2 = await sendRequest(userB.token, { + const res2 = await sendRequest(tokenUserB, { query: 'mutation( $myCommit: CommitUpdateInput! ) { commitUpdate( commit: $myCommit ) }', variables: { myCommit: updatePayload } @@ -1038,7 +992,7 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should create a read receipt', async () => { - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: 'mutation($input: CommitReceivedInput!) { commitReceive(input: $input) }', variables: { @@ -1076,7 +1030,7 @@ const changeUserRole = changeUserRoleFactory({ it('Should delete a commit', async () => { const payload = { streamId: ts1, id: c2.id } - const res = await sendRequest(userB.token, { + const res = await sendRequest(tokenUserB, { query: 'mutation( $myCommit: CommitDeleteInput! ) { commitDelete( commit: $myCommit ) }', variables: { myCommit: payload } @@ -1085,7 +1039,7 @@ const changeUserRole = changeUserRoleFactory({ expect(res.body.errors).to.exist expect(res.body.errors[0].extensions?.code).to.equal('FORBIDDEN') - const res2 = await sendRequest(userA.token, { + const res2 = await sendRequest(tokenUserA, { query: 'mutation( $myCommit: CommitDeleteInput! ) { commitDelete( commit: $myCommit ) }', variables: { myCommit: payload } @@ -1103,7 +1057,7 @@ const changeUserRole = changeUserRoleFactory({ description: 'dimitries development branch' } - const res1 = await sendRequest(userA.token, { + const res1 = await sendRequest(tokenUserA, { query: 'mutation( $branch:BranchCreateInput! ) { branchCreate( branch:$branch ) }', variables: { branch: omit(b1, 'id') } @@ -1127,7 +1081,7 @@ const changeUserRole = changeUserRoleFactory({ Roles.Stream.Contributor, userA.id ) - const res2 = await sendRequest(userB.token, { + const res2 = await sendRequest(tokenUserB, { query: 'mutation( $branch:BranchCreateInput! ) { branchCreate( branch:$branch ) }', variables: { branch: omit(b2, 'id') } @@ -1141,7 +1095,7 @@ const changeUserRole = changeUserRoleFactory({ name: 'userB/dev/api', description: 'more branches branch' } - const res3 = await sendRequest(userB.token, { + const res3 = await sendRequest(tokenUserB, { query: 'mutation( $branch:BranchCreateInput! ) { branchCreate( branch:$branch ) }', variables: { branch: omit(b3, 'id') } @@ -1157,7 +1111,7 @@ const changeUserRole = changeUserRoleFactory({ name: 'randomupdateablebranch', description: 'dimitries development branch' } - const b1Res = await sendRequest(userA.token, { + const b1Res = await sendRequest(tokenUserA, { query: 'mutation( $branch:BranchCreateInput! ) { branchCreate( branch:$branch ) }', variables: { branch: omit(b1, 'id') } @@ -1172,7 +1126,7 @@ const changeUserRole = changeUserRoleFactory({ name: 'userb/whatever/whatever' } - const res1 = await sendRequest(userA.token, { + const res1 = await sendRequest(tokenUserA, { query: 'mutation( $branch:BranchUpdateInput! ) { branchUpdate( branch:$branch ) }', variables: { branch: payload } @@ -1190,7 +1144,7 @@ const changeUserRole = changeUserRoleFactory({ name: 'randomudeletablebranch', description: 'dimitries development branch' } - const b1Res = await sendRequest(userA.token, { + const b1Res = await sendRequest(tokenUserA, { query: 'mutation( $branch:BranchCreateInput! ) { branchCreate( branch:$branch ) }', variables: { branch: omit(b1, 'id') } @@ -1217,7 +1171,7 @@ const changeUserRole = changeUserRoleFactory({ id: 'APRIL FOOOLS!' } - const res = await sendRequest(userC.token, { + const res = await sendRequest(tokenUserC, { query: 'mutation( $branch:BranchDeleteInput! ) { branchDelete( branch: $branch ) }', variables: { branch: badPayload } @@ -1226,7 +1180,7 @@ const changeUserRole = changeUserRoleFactory({ expect(res.body.errors).to.exist expect(res.body.errors[0].extensions.code).to.equal('NOT_FOUND_ERROR') - const res1 = await sendRequest(userC.token, { + const res1 = await sendRequest(tokenUserC, { query: 'mutation( $branch:BranchDeleteInput! ) { branchDelete( branch: $branch ) }', variables: { branch: payload } @@ -1235,7 +1189,7 @@ const changeUserRole = changeUserRoleFactory({ expect(res1.body.errors).to.exist expect(res1.body.errors[0].extensions.code).to.equal('FORBIDDEN') - const res2 = await sendRequest(userA.token, { + const res2 = await sendRequest(tokenUserA, { query: 'mutation( $branch:BranchDeleteInput! ) { branchDelete( branch: $branch ) }', variables: { branch: payload } @@ -1244,7 +1198,7 @@ const changeUserRole = changeUserRoleFactory({ expect(res2.body.errors).to.not.exist // revoke perms for c back (dont' wanna mess up our integration-unit tests below) - await sendRequest(userA.token, { + await sendRequest(tokenUserA, { query: `mutation{ streamRevokePermission( permissionParams: {streamId: "${ts1}", userId: "${userC.id}"} ) }` }) }) @@ -1257,7 +1211,7 @@ const changeUserRole = changeUserRoleFactory({ branchName: 'userB/dev/api' } - const res = await sendRequest(userB.token, { + const res = await sendRequest(tokenUserB, { query: 'mutation( $myCommit: CommitCreateInput! ) { commitCreate( commit: $myCommit ) }', variables: { myCommit: cc } @@ -1270,7 +1224,7 @@ const changeUserRole = changeUserRoleFactory({ it('Should *not* update a branch if given the wrong stream id', async () => { // create stream for user C - const res = await sendRequest(userC.token, { + const res = await sendRequest(tokenUserC, { query: 'mutation { streamCreate(stream: { name: "TS (u C) private", description: "sup my dudes", isPublic:false } ) }' }) @@ -1285,7 +1239,7 @@ const changeUserRole = changeUserRoleFactory({ name: 'izz/secret', description: 'a private branch on a private stream' } - const res1 = await sendRequest(userB.token, { + const res1 = await sendRequest(tokenUserB, { query: 'mutation( $branch:BranchCreateInput! ) { branchCreate( branch:$branch ) }', variables: { branch: omit(b4, 'id') } @@ -1302,7 +1256,7 @@ const changeUserRole = changeUserRoleFactory({ name: 'izz/not-so-secret' } - const res2 = await sendRequest(userC.token, { + const res2 = await sendRequest(tokenUserC, { query: 'mutation( $branch:BranchUpdateInput! ) { branchUpdate( branch:$branch ) }', variables: { branch: badPayload } @@ -1321,7 +1275,7 @@ const changeUserRole = changeUserRoleFactory({ id: b4.id // branch user C doesn't have access to } - const res = await sendRequest(userC.token, { + const res = await sendRequest(tokenUserC, { query: 'mutation( $branch:BranchDeleteInput! ) { branchDelete( branch: $branch ) }', variables: { branch: badPayload } @@ -1339,7 +1293,7 @@ const changeUserRole = changeUserRoleFactory({ describe('Queries', () => { describe('My Profile', () => { it('Should retrieve my profile', async () => { - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: '{ user { id name email role apiTokens { id name } } }' }) expect(res).to.be.json @@ -1352,25 +1306,25 @@ const changeUserRole = changeUserRoleFactory({ it('Should retrieve my streams', async () => { // add more streams - await sendRequest(userA.token, { + await sendRequest(tokenUserA, { query: 'mutation( $myStream: StreamCreateInput! ) { streamCreate( stream: $myStream ) }', variables: { myStream: { name: 'o hai' } } }) - await sendRequest(userA.token, { + await sendRequest(tokenUserA, { query: 'mutation( $myStream: StreamCreateInput! ) { streamCreate( stream: $myStream ) }', variables: { myStream: { name: 'bai now' } } }) - await sendRequest(userA.token, { + await sendRequest(tokenUserA, { query: 'mutation( $myStream: StreamCreateInput! ) { streamCreate( stream: $myStream ) }', variables: { myStream: { name: 'one more for the road' } } }) - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: '{ user { streams( limit: 3 ) { totalCount cursor items { id name } } } }' }) @@ -1378,7 +1332,7 @@ const changeUserRole = changeUserRoleFactory({ expect(res.body.errors).to.not.exist expect(res.body.data.user.streams.items.length).to.equal(3) - const res2 = await sendRequest(userA.token, { + const res2 = await sendRequest(tokenUserA, { query: `{ user { streams( limit: 3, cursor: "${res.body.data.user.streams.cursor}" ) { totalCount cursor items { id name } } } }` }) expect(res2).to.be.json @@ -1400,14 +1354,14 @@ const changeUserRole = changeUserRoleFactory({ objectId: objIds[i], branchName: 'main' } - await sendRequest(userA.token, { + await sendRequest(tokenUserA, { query: 'mutation( $myCommit: CommitCreateInput! ) { commitCreate( commit: $myCommit ) }', variables: { myCommit: c1 } }) } - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: '{ user { commits( limit: 3 ) { totalCount cursor items { id message referencedObject } } } }' }) @@ -1418,7 +1372,7 @@ const changeUserRole = changeUserRoleFactory({ expect(res.body.data.user.commits.cursor).to.exist expect(res.body.data.user.commits.items.length).to.equal(3) - const res2 = await sendRequest(userA.token, { + const res2 = await sendRequest(tokenUserA, { query: `{ user { commits( limit: 3, cursor: "${res.body.data.user.commits.cursor}") { totalCount cursor items { id message referencedObject } } } }` }) expect(res2).to.be.json @@ -1434,7 +1388,7 @@ const changeUserRole = changeUserRoleFactory({ */ it('Should retrieve a different profile profile', async () => { - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: ` { user(id:"${userB.id}") { id name email } }` }) @@ -1476,7 +1430,7 @@ const changeUserRole = changeUserRoleFactory({ it('Should search for some users', async () => { for (let i = 0; i < 10; i++) { // create 10 users: 3 bakers and 7 millers - await createUser({ + await createTestUser({ name: `Master ${i <= 2 ? 'Baker' : 'Miller'} Matteo The ${i}${ i === 1 ? 'st' : i === 2 ? 'nd' : i === 3 ? 'rd' : 'th' } of His Name`, @@ -1499,7 +1453,7 @@ const changeUserRole = changeUserRoleFactory({ } ` - let res = await sendRequest(userB.token, { query }) + let res = await sendRequest(tokenUserB, { query }) expect(res).to.be.json expect(res.body.errors).to.not.exist expect(res.body.data.userSearch.items.length).to.equal(7) @@ -1516,7 +1470,7 @@ const changeUserRole = changeUserRoleFactory({ } ` - res = await sendRequest(userB.token, { query }) + res = await sendRequest(tokenUserB, { query }) expect(res).to.be.json expect(res.body.errors).to.not.exist expect(res.body.data.userSearch.items.length).to.equal(3) @@ -1524,7 +1478,7 @@ const changeUserRole = changeUserRoleFactory({ // by email query = 'query { userSearch( query: "matteo_2@tomato.com" ) { cursor items { id name } } } ' - res = await sendRequest(userB.token, { query }) + res = await sendRequest(tokenUserB, { query }) expect(res).to.be.json expect(res.body.errors).to.not.exist expect(res.body.data.userSearch.items.length).to.equal(1) @@ -1533,14 +1487,14 @@ const changeUserRole = changeUserRoleFactory({ it('Should not search for some users if bad request', async () => { const queryLim = 'query { userSearch( query: "mi" ) { cursor items { id name } } } ' - let res = await sendRequest(userB.token, { query: queryLim }) + let res = await sendRequest(tokenUserB, { query: queryLim }) expect(res).to.be.json expect(res.body.errors).to.exist expect(res.body.errors[0].extensions.code).to.equal('BAD_REQUEST_ERROR') const queryPagination = 'query { userSearch( query: "matteo", limit: 200 ) { cursor items { id name } } } ' - res = await sendRequest(userB.token, { query: queryPagination }) + res = await sendRequest(tokenUserB, { query: queryPagination }) expect(res).to.be.json expect(res.body.errors).to.exist expect(res.body.errors[0].extensions.code).to.equal('BAD_REQUEST_ERROR') @@ -1549,7 +1503,7 @@ const changeUserRole = changeUserRoleFactory({ describe('Streams', () => { it('Should retrieve a stream', async () => { - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: ` query { stream(id:"${ts1}") { @@ -1613,7 +1567,7 @@ const changeUserRole = changeUserRoleFactory({ } ` - const res = await sendRequest(userA.token, { query }) + const res = await sendRequest(tokenUserA, { query }) expect(res).to.be.json expect(res.body.errors).to.not.exist expect(res.body.data.stream.branches.items).to.be.ok @@ -1621,7 +1575,7 @@ const changeUserRole = changeUserRoleFactory({ expect(res.body.data.stream.branches.cursor).to.exist const firstBranchName = res.body.data.stream.branches.items[0].name - const res1 = await sendRequest(userA.token, { + const res1 = await sendRequest(tokenUserA, { query: `query { stream(id:"${ts1}") { branch( name: "${firstBranchName}" ) { name description } } } ` }) @@ -1631,7 +1585,7 @@ const changeUserRole = changeUserRoleFactory({ }) it("it should retrieve a stream's default 'main' branch if no branch name is specified", async () => { - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: `query { stream(id:"${ts1}") { branch { name description } } } ` }) expect(res).to.be.json @@ -1661,7 +1615,7 @@ const changeUserRole = changeUserRoleFactory({ } } ` - const res = await sendRequest(userA.token, { query }) + const res = await sendRequest(tokenUserA, { query }) expect(res.body.data.stream.branch.commits.items.length).to.equal(5) expect(res.body.data.stream.branch.commits.items[0]).to.have.property('id') expect(res.body.data.stream.branch.commits.items[0]).to.have.property( @@ -1693,7 +1647,7 @@ const changeUserRole = changeUserRoleFactory({ } }` - const res2 = await sendRequest(userA.token, { query: query2 }) + const res2 = await sendRequest(tokenUserA, { query: query2 }) // console.log( res2.body.errors ) // console.log( res2.body.data.stream.branch.commits ) @@ -1726,7 +1680,7 @@ const changeUserRole = changeUserRoleFactory({ } } ` - const res = await sendRequest(userA.token, { query }) + const res = await sendRequest(tokenUserA, { query }) expect(res).to.be.json expect(res.body.errors).to.not.exist @@ -1752,7 +1706,7 @@ const changeUserRole = changeUserRoleFactory({ } ` - const res2 = await sendRequest(userA.token, { query: query2 }) + const res2 = await sendRequest(tokenUserA, { query: query2 }) expect(res2).to.be.json expect(res2.body.errors).to.not.exist @@ -1760,7 +1714,7 @@ const changeUserRole = changeUserRoleFactory({ }) it('should retrieve a stream commit', async () => { - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: `query { stream( id:"${ts1}" ) { commit( id: "${commitList[0].id}" ) { id message referencedObject } } }` }) @@ -1772,7 +1726,7 @@ const changeUserRole = changeUserRoleFactory({ }) it('should retrieve the latest stream commit if no id is specified', async () => { - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: `query { stream( id:"${ts1}" ) { commit { id message referencedObject } } }` }) expect(res).to.be.json @@ -1795,7 +1749,7 @@ const changeUserRole = changeUserRoleFactory({ it('should save many objects', async () => { const everything = [myCommit, ...myObjs] - const res = await sendRequest(userA.token, { + const res = await sendRequest(tokenUserA, { query: `mutation($objs:[JSONObject]!) { objectCreate(objectInput: {streamId:"${ts1}", objects: $objs}) }`, variables: { objs: everything } }) @@ -1808,7 +1762,7 @@ const changeUserRole = changeUserRoleFactory({ }) it("should get an object's sub-objects' objects", async () => { - const first = await sendRequest(userA.token, { + const first = await sendRequest(tokenUserA, { query: ` query { stream( id:"${ts1}" ) { @@ -1835,7 +1789,7 @@ const changeUserRole = changeUserRoleFactory({ expect(first.body.data.stream.object).to.be.an('object') expect(first.body.data.stream.object.children.objects.length).to.equal(2) - const second = await sendRequest(userA.token, { + const second = await sendRequest(tokenUserA, { query: ` query { stream(id:"${ts1}") { @@ -1870,7 +1824,7 @@ const changeUserRole = changeUserRoleFactory({ }) it("should query an object's subojects", async () => { - const first = await sendRequest(userA.token, { + const first = await sendRequest(tokenUserA, { query: ` query( $query: [JSONObject!], $orderBy: JSONObject ) { stream(id:"${ts1}") { @@ -1999,7 +1953,7 @@ const changeUserRole = changeUserRoleFactory({ info: { name: 'Super Duper Test Server Yo!', company: 'Super Systems' } } - const res = await sendRequest(userA.token, { query, variables }) + const res = await sendRequest(tokenUserA, { query, variables }) expect(res).to.be.json expect(res.body.errors).to.not.exist }) @@ -2011,7 +1965,7 @@ const changeUserRole = changeUserRoleFactory({ info: { name: 'Super Duper Test Server Yo!', company: 'Super Systems' } } - const res = await sendRequest(userB.token, { query, variables }) + const res = await sendRequest(tokenUserB, { query, variables }) expect(res).to.be.json expect(res.body.errors).to.exist expect(res.body.errors[0].extensions.code).to.equal('FORBIDDEN') @@ -2019,17 +1973,17 @@ const changeUserRole = changeUserRoleFactory({ }) describe('Archived role access validation', () => { - const archivedUser = { - id: '', - token: '', - name: 'Mark von Archival', - email: 'archi@speckle.systems', - password: 'i"ll be back, just wait' - } + let archivedUser: BasicTestUser + let archivedUserToken: string let streamId: string before(async () => { - archivedUser.id = await createUser(archivedUser) - archivedUser.token = `Bearer ${await createPersonalAccessToken( + archivedUser = await createTestUser({ + id: '', + name: 'Mark von Archival', + email: 'archi@speckle.systems', + password: 'i"ll be back, just wait' + }) + archivedUserToken = `Bearer ${await createPersonalAccessToken( archivedUser.id, 'this will be archived', [ @@ -2053,7 +2007,7 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should be able to read public streams', async () => { - const streamRes = await sendRequest(userA.token, { + const streamRes = await sendRequest(tokenUserA, { query: 'mutation { streamCreate( stream: { name: "Share this with poor Mark", description: "💩", isPublic:true } ) }' }) @@ -2064,7 +2018,7 @@ const changeUserRole = changeUserRoleFactory({ userA.id ) - const res = await sendRequest(archivedUser.token, { + const res = await sendRequest(archivedUserToken, { query: `query { stream(id:"${streamRes.body.data.streamCreate}") { id name } }` }) expect(res.body.errors).to.not.exist @@ -2074,7 +2028,7 @@ const changeUserRole = changeUserRoleFactory({ it('Should be forbidden to create token', async () => { const query = 'mutation( $tokenInput:ApiTokenCreateInput! ) { apiTokenCreate ( token: $tokenInput ) }' - const res = await sendRequest(archivedUser.token, { + const res = await sendRequest(archivedUserToken, { query, variables: { tokenInput: { @@ -2094,7 +2048,7 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should be forbidden to interact (read, write, delete) private streams it had access to', async () => { - const streamRes = await sendRequest(userA.token, { + const streamRes = await sendRequest(tokenUserA, { query: 'mutation { streamCreate( stream: { name: "Share this with poor Mark", description: "💩", isPublic:false } ) }' }) @@ -2107,7 +2061,7 @@ const changeUserRole = changeUserRoleFactory({ userA.id ) - let res = await sendRequest(archivedUser.token, { + let res = await sendRequest(archivedUserToken, { query: `query { stream(id:"${streamId}") { id name } }` }) expect(res.body.errors).to.exist @@ -2116,7 +2070,7 @@ const changeUserRole = changeUserRoleFactory({ 'You do not have the required server role' ) - res = await sendRequest(archivedUser.token, { + res = await sendRequest(archivedUserToken, { query: '{ user { streams( limit: 30 ) { totalCount cursor items { id name } } } }' }) @@ -2126,7 +2080,7 @@ const changeUserRole = changeUserRoleFactory({ 'You do not have the required server role' ) - res = await sendRequest(archivedUser.token, { + res = await sendRequest(archivedUserToken, { query: `mutation { streamDelete( id:"${streamId}")}` }) expect(res.body.errors).to.exist @@ -2135,7 +2089,7 @@ const changeUserRole = changeUserRoleFactory({ 'You do not have the required server role' ) - res = await sendRequest(archivedUser.token, { + res = await sendRequest(archivedUserToken, { query: `mutation { streamUpdate(stream: {id:"${streamId}" name: "HACK", description: "Hello World, Again!", isPublic:false } ) }` }) expect(res.body.errors).to.exist @@ -2149,7 +2103,7 @@ const changeUserRole = changeUserRoleFactory({ const query = 'mutation ( $streamInput: StreamCreateInput!) { streamCreate(stream: $streamInput ) }' - let res = await sendRequest(archivedUser.token, { + let res = await sendRequest(archivedUserToken, { query, variables: { streamInput: { @@ -2165,7 +2119,7 @@ const changeUserRole = changeUserRoleFactory({ 'You do not have the required server role' ) - res = await sendRequest(archivedUser.token, { + res = await sendRequest(archivedUserToken, { query, variables: { streamInput: { @@ -2195,7 +2149,7 @@ const changeUserRole = changeUserRoleFactory({ } } - const res = await sendRequest(archivedUser.token, { query, variables }) + const res = await sendRequest(archivedUserToken, { query, variables }) expect(res.body.errors).to.exist expect(res.body.errors[0].extensions.code).to.equal('FORBIDDEN') @@ -2205,7 +2159,7 @@ const changeUserRole = changeUserRoleFactory({ }) it('Should be forbidden to send email invites', async () => { - const res = await sendRequest(archivedUser.token, { + const res = await sendRequest(archivedUserToken, { query: 'mutation inviteToServer($input: ServerInviteCreateInput!) { serverInviteCreate( input: $input ) }', variables: { input: { email: 'cabbages@speckle.systems', message: 'wow!' } } @@ -2220,7 +2174,7 @@ const changeUserRole = changeUserRoleFactory({ it('Should be forbidden to create object', async () => { const objects = generateManyObjects(10) - const res = await sendRequest(archivedUser.token, { + const res = await sendRequest(archivedUserToken, { query: `mutation( $objs: [JSONObject]! ) { objectCreate( objectInput: {streamId:"${ts1}", objects: $objs} ) }`, variables: { objs: objects.objs } }) @@ -2239,7 +2193,7 @@ const changeUserRole = changeUserRoleFactory({ objectId: 'justARandomHash', branchName: 'main' } - const res = await sendRequest(archivedUser.token, { + const res = await sendRequest(archivedUserToken, { query: 'mutation( $myCommit: CommitCreateInput! ) { commitCreate( commit: $myCommit ) }', variables: { myCommit: commit } @@ -2255,7 +2209,7 @@ const changeUserRole = changeUserRoleFactory({ const objects = generateManyObjects(2) const res = await request(app) .post(`/objects/${streamId}`) - .set('Authorization', archivedUser.token) + .set('Authorization', archivedUserToken) .set('Content-type', 'multipart/form-data') .attach('batch1', Buffer.from(JSON.stringify(objects.objs), 'utf8')) expect(res).to.have.status(401) @@ -2265,12 +2219,12 @@ const changeUserRole = changeUserRoleFactory({ // even if the object doesn't exist, so im not creating it... const res = await request(app) .get('/objects/thisIs/bogus') - .set('Authorization', archivedUser.token) + .set('Authorization', archivedUserToken) expect(res).to.have.status(401) }) it('Should be able to download from public stream via rest API', async () => { - const streamRes = await sendRequest(userA.token, { + const streamRes = await sendRequest(tokenUserA, { query: 'mutation { streamCreate( stream: { name: "Mark will read this", description: "🥔", isPublic:true } ) }' }) @@ -2285,14 +2239,14 @@ const changeUserRole = changeUserRoleFactory({ const objects = generateManyObjects(2) let res = await request(app) .post(`/objects/${streamRes.body.data.streamCreate}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) .set('Content-type', 'multipart/form-data') .attach('batch1', Buffer.from(JSON.stringify(objects.objs), 'utf8')) expect(res).to.have.status(201) res = await request(app) .get(`/objects/${streamRes.body.data.streamCreate}/${objects.objs[0].id}`) - .set('Authorization', archivedUser.token) + .set('Authorization', archivedUserToken) expect(res).to.have.status(200) expect(res.body[0].id).to.equal(objects.objs[0].id) }) diff --git a/packages/server/modules/core/tests/integration/commits.graph.spec.ts b/packages/server/modules/core/tests/integration/commits.graph.spec.ts index a0f153f25..9b9b7633e 100644 --- a/packages/server/modules/core/tests/integration/commits.graph.spec.ts +++ b/packages/server/modules/core/tests/integration/commits.graph.spec.ts @@ -4,72 +4,17 @@ import { createRandomEmail, createRandomPassword } from '@/modules/core/helpers/testHelpers' -import { - createUserEmailFactory, - ensureNoPrimaryEmailForUserFactory, - findEmailFactory -} from '@/modules/core/repositories/userEmails' + import { db } from '@/db/knex' import { testApolloServer } from '@/test/graphqlHelper' import { CreateWorkspaceDocument, CreateWorkspaceProjectDocument } from '@/modules/core/graph/generated/graphql' -import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' -import { - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} from '@/modules/serverinvites/repositories/serverInvites' -import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' -import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' -import { renderEmail } from '@/modules/emails/services/emailRendering' -import { sendEmail } from '@/modules/emails/services/sending' -import { - countAdminUsersFactory, - legacyGetUserFactory, - storeUserAclFactory, - storeUserFactory -} from '@/modules/core/repositories/users' -import { createUserFactory } from '@/modules/core/services/users/management' -import { getServerInfoFactory } from '@/modules/core/repositories/server' import { WorkspaceReadOnlyError } from '@/modules/gatekeeper/errors/billing' import gql from 'graphql-tag' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' -import { getEventBus } from '@/modules/shared/services/eventBus' - -const getServerInfo = getServerInfoFactory({ db }) -const getUser = legacyGetUserFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail: findEmailFactory({ db }), - getUser, - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) - -const createUserEmail = validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail: findEmailFactory({ db }), - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification -}) - -const findEmail = findEmailFactory({ db }) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: createUserEmail, - emitEvent: getEventBus().emit -}) +import { createTestUser } from '@/test/authHelper' const { FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() @@ -87,7 +32,7 @@ describe('Commits graphql @core', () => { ;(FF_BILLING_INTEGRATION_ENABLED ? it : it.skip)( 'should return error if project is read-only', async () => { - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'emails user', email: createRandomEmail(), password: createRandomPassword() diff --git a/packages/server/modules/core/tests/integration/emailVerification.spec.ts b/packages/server/modules/core/tests/integration/emailVerification.spec.ts index 959995288..a338fc59e 100644 --- a/packages/server/modules/core/tests/integration/emailVerification.spec.ts +++ b/packages/server/modules/core/tests/integration/emailVerification.spec.ts @@ -1,6 +1,4 @@ import { - createUserEmailFactory, - ensureNoPrimaryEmailForUserFactory, findEmailFactory, updateUserEmailFactory } from '@/modules/core/repositories/userEmails' @@ -11,60 +9,13 @@ import { } from '@/modules/core/helpers/testHelpers' import { markUserEmailAsVerifiedFactory } from '@/modules/core/services/users/emailVerification' import { expect } from 'chai' -import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' -import { - countAdminUsersFactory, - getUserFactory, - storeUserAclFactory, - storeUserFactory -} from '@/modules/core/repositories/users' -import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' -import { renderEmail } from '@/modules/emails/services/emailRendering' -import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' -import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' -import { - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} from '@/modules/serverinvites/repositories/serverInvites' -import { getServerInfoFactory } from '@/modules/core/repositories/server' -import { getEventBus } from '@/modules/shared/services/eventBus' - -const getServerInfo = getServerInfoFactory({ db }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) +import { createTestUser } from '@/test/authHelper' describe('Verification @user-emails', () => { it('should mark user email as verified', async () => { const email = createRandomEmail() - await createUser({ + await createTestUser({ name: 'John', email, password: createRandomPassword() diff --git a/packages/server/modules/core/tests/integration/findUsers.spec.ts b/packages/server/modules/core/tests/integration/findUsers.spec.ts index 3ad2a2305..87e92c820 100644 --- a/packages/server/modules/core/tests/integration/findUsers.spec.ts +++ b/packages/server/modules/core/tests/integration/findUsers.spec.ts @@ -2,72 +2,22 @@ import { createRandomEmail, createRandomPassword } from '@/modules/core/helpers/testHelpers' -import { - createUserEmailFactory, - ensureNoPrimaryEmailForUserFactory, - findEmailFactory, - updateUserEmailFactory -} from '@/modules/core/repositories/userEmails' +import { updateUserEmailFactory } from '@/modules/core/repositories/userEmails' import { db } from '@/db/knex' import { expect } from 'chai' import { bulkLookupUsersFactory, - countAdminUsersFactory, getUserByEmailFactory, - getUserFactory, getUsersFactory, listUsersFactory, - lookupUsersFactory, - storeUserAclFactory, - storeUserFactory + lookupUsersFactory } from '@/modules/core/repositories/users' -import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' -import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' -import { renderEmail } from '@/modules/emails/services/emailRendering' -import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' -import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' -import { - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} from '@/modules/serverinvites/repositories/serverInvites' -import { getServerInfoFactory } from '@/modules/core/repositories/server' import type { BasicTestStream } from '@/test/speckle-helpers/streamHelper' import { createTestStream } from '@/test/speckle-helpers/streamHelper' import type { BasicTestUser } from '@/test/authHelper' -import { createTestUser } from '@/test/authHelper' -import { getEventBus } from '@/modules/shared/services/eventBus' +import { buildBasicTestUser, createTestUser } from '@/test/authHelper' -const getServerInfo = getServerInfoFactory({ db }) const getUsers = getUsersFactory({ db }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) const getUserByEmail = getUserByEmailFactory({ db }) const listUsers = listUsersFactory({ db }) const lookupUsers = lookupUsersFactory({ db }) @@ -79,7 +29,7 @@ describe('Find users @core', () => { const email = createRandomEmail() const password = createRandomPassword() - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'John Doe', password, email @@ -103,7 +53,7 @@ describe('Find users @core', () => { const email = createRandomEmail() const password = createRandomPassword() - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'John Doe', password, email @@ -129,7 +79,7 @@ describe('Find users @core', () => { const email = createRandomEmail() const password = createRandomPassword() - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'John Doe', password, email @@ -155,11 +105,13 @@ describe('Find users @core', () => { describe('getUserByEmail', () => { it('should ignore email casing', async () => { const email = 'TeST@ExamPLE.oRg' - await createUser({ - name: 'John Doe', - password: createRandomPassword(), - email - }) + await createTestUser( + buildBasicTestUser({ + name: 'John Doe', + password: createRandomPassword(), + email + }) + ) const user = await getUserByEmail(email) expect(user!.email).to.equal(email.toLowerCase()) }) @@ -168,41 +120,49 @@ describe('Find users @core', () => { describe('lookupUsers', () => { it('should find matches by name', async () => { const email = createRandomEmail() - const userId = await createUser({ - email, - name: 'John Spackle', - password: createRandomPassword() - }) + const { id: userId } = await createTestUser( + buildBasicTestUser({ + email, + name: 'John Spackle', + password: createRandomPassword() + }) + ) const { users } = await lookupUsers({ query: 'Spack' }) expect(users.some((user) => user.id === userId)).to.equal(true) }) it('should not find matches by name if filtered to emails only', async () => { const email = createRandomEmail() - const userId = await createUser({ - email, - name: 'John Spackle', - password: createRandomPassword() - }) + const { id: userId } = await createTestUser( + buildBasicTestUser({ + email, + name: 'John Spackle', + password: createRandomPassword() + }) + ) const { users } = await lookupUsers({ query: 'Spack', emailOnly: true }) expect(users.some((user) => user.id === userId)).to.equal(false) }) it('should find matches by email', async () => { const email = createRandomEmail() - const userId = await createUser({ - email, - name: 'John Spackle', - password: createRandomPassword() - }) + const { id: userId } = await createTestUser( + buildBasicTestUser({ + email, + name: 'John Spackle', + password: createRandomPassword() + }) + ) const { users } = await lookupUsers({ query: email }) expect(users.some((user) => user.id === userId)).to.equal(true) }) it('should find matches by email, case insensitive', async () => { const email = 'fooBAR@example.org' - const userId = await createUser({ - email, - name: 'John Spackle', - password: createRandomPassword() - }) + const { id: userId } = await createTestUser( + buildBasicTestUser({ + email, + name: 'John Spackle', + password: createRandomPassword() + }) + ) const { users } = await lookupUsers({ query: 'FoObAr@example.org' }) expect(users.some((user) => user.id === userId)).to.equal(true) }) diff --git a/packages/server/modules/core/tests/integration/objects.graph.spec.ts b/packages/server/modules/core/tests/integration/objects.graph.spec.ts index f0f4bdba8..8e6c3672b 100644 --- a/packages/server/modules/core/tests/integration/objects.graph.spec.ts +++ b/packages/server/modules/core/tests/integration/objects.graph.spec.ts @@ -4,11 +4,6 @@ import { createRandomEmail, createRandomPassword } from '@/modules/core/helpers/testHelpers' -import { - createUserEmailFactory, - ensureNoPrimaryEmailForUserFactory, - findEmailFactory -} from '@/modules/core/repositories/userEmails' import { db } from '@/db/knex' import { testApolloServer } from '@/test/graphqlHelper' import { @@ -16,61 +11,10 @@ import { CreateWorkspaceDocument, CreateWorkspaceProjectDocument } from '@/modules/core/graph/generated/graphql' -import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' -import { - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} from '@/modules/serverinvites/repositories/serverInvites' -import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' -import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' -import { renderEmail } from '@/modules/emails/services/emailRendering' -import { sendEmail } from '@/modules/emails/services/sending' -import { - countAdminUsersFactory, - legacyGetUserFactory, - storeUserAclFactory, - storeUserFactory -} from '@/modules/core/repositories/users' -import { createUserFactory } from '@/modules/core/services/users/management' -import { getServerInfoFactory } from '@/modules/core/repositories/server' import { WorkspaceReadOnlyError } from '@/modules/gatekeeper/errors/billing' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' -import { getEventBus } from '@/modules/shared/services/eventBus' import { PaidWorkspacePlanStatuses } from '@speckle/shared' - -const getServerInfo = getServerInfoFactory({ db }) -const getUser = legacyGetUserFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail: findEmailFactory({ db }), - getUser, - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) - -const createUserEmail = validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail: findEmailFactory({ db }), - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification -}) - -const findEmail = findEmailFactory({ db }) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: createUserEmail, - emitEvent: getEventBus().emit -}) +import { createTestUser } from '@/test/authHelper' const { FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() @@ -83,7 +27,7 @@ describe('Objects graphql @core', () => { ;(FF_BILLING_INTEGRATION_ENABLED ? it : it.skip)( 'should return error if project is read-only', async () => { - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'emails user', email: createRandomEmail(), password: createRandomPassword() diff --git a/packages/server/modules/core/tests/integration/objects.rest.spec.ts b/packages/server/modules/core/tests/integration/objects.rest.spec.ts index f8378a64a..e3b4f3d99 100644 --- a/packages/server/modules/core/tests/integration/objects.rest.spec.ts +++ b/packages/server/modules/core/tests/integration/objects.rest.spec.ts @@ -3,29 +3,7 @@ import { createRandomEmail, createRandomPassword } from '@/modules/core/helpers/testHelpers' -import { getServerInfoFactory } from '@/modules/core/repositories/server' -import { - createUserEmailFactory, - ensureNoPrimaryEmailForUserFactory, - findEmailFactory -} from '@/modules/core/repositories/userEmails' -import { - countAdminUsersFactory, - legacyGetUserFactory, - storeUserAclFactory, - storeUserFactory -} from '@/modules/core/repositories/users' -import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { createUserFactory } from '@/modules/core/services/users/management' -import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' -import { renderEmail } from '@/modules/emails/services/emailRendering' -import { sendEmail } from '@/modules/emails/services/sending' -import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' -import { - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} from '@/modules/serverinvites/repositories/serverInvites' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' +import { legacyGetUserFactory } from '@/modules/core/repositories/users' import { createTestWorkspace } from '@/modules/workspaces/tests/helpers/creation' import { beforeEachContext } from '@/test/hooks' import type { BasicTestStream } from '@/test/speckle-helpers/streamHelper' @@ -42,41 +20,9 @@ import { import { PaidWorkspacePlans, Scopes } from '@speckle/shared' import { expect } from 'chai' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' -import { getEventBus } from '@/modules/shared/services/eventBus' +import { createTestUser } from '@/test/authHelper' -const getServerInfo = getServerInfoFactory({ db }) const getUser = legacyGetUserFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail: findEmailFactory({ db }), - getUser, - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) - -const createUserEmail = validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail: findEmailFactory({ db }), - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification -}) - -const findEmail = findEmailFactory({ db }) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: createUserEmail, - emitEvent: getEventBus().emit -}) - const createPersonalAccessToken = createPersonalAccessTokenFactory({ storeApiToken: storeApiTokenFactory({ db }), storeTokenScopes: storeTokenScopesFactory({ db }), @@ -96,7 +42,7 @@ describe('Objects REST @core', () => { ;(FF_BILLING_INTEGRATION_ENABLED ? it : it.skip)( 'should return an error if the project is read-only', async () => { - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'emails user', email: createRandomEmail(), password: createRandomPassword() diff --git a/packages/server/modules/core/tests/integration/objectsStream.rest.spec.ts b/packages/server/modules/core/tests/integration/objectsStream.rest.spec.ts index 01df0fed6..560a1827e 100644 --- a/packages/server/modules/core/tests/integration/objectsStream.rest.spec.ts +++ b/packages/server/modules/core/tests/integration/objectsStream.rest.spec.ts @@ -6,29 +6,7 @@ import { createRandomEmail, createRandomPassword } from '@/modules/core/helpers/testHelpers' -import { getServerInfoFactory } from '@/modules/core/repositories/server' -import { - createUserEmailFactory, - ensureNoPrimaryEmailForUserFactory, - findEmailFactory -} from '@/modules/core/repositories/userEmails' -import { - countAdminUsersFactory, - legacyGetUserFactory, - storeUserAclFactory, - storeUserFactory -} from '@/modules/core/repositories/users' -import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { createUserFactory } from '@/modules/core/services/users/management' -import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' -import { renderEmail } from '@/modules/emails/services/emailRendering' -import { sendEmail } from '@/modules/emails/services/sending' -import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' -import { - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} from '@/modules/serverinvites/repositories/serverInvites' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' +import { legacyGetUserFactory } from '@/modules/core/repositories/users' import { beforeEachContext, initializeTestServer } from '@/test/hooks' import type { BasicTestStream } from '@/test/speckle-helpers/streamHelper' import { createTestStream } from '@/test/speckle-helpers/streamHelper' @@ -40,7 +18,6 @@ import { storeTokenScopesFactory } from '@/modules/core/repositories/tokens' import { Scopes } from '@speckle/shared' -import { getEventBus } from '@/modules/shared/services/eventBus' import { generateManyObjects } from '@/test/helpers' import type { RawSpeckleObject } from '@/modules/core/domain/objects/types' import { storeObjectsIfNotFoundFactory } from '@/modules/core/repositories/objects' @@ -49,41 +26,11 @@ import type { Parser } from 'csv-parse' import { parse } from 'csv-parse' import { createReadStream } from 'fs' import { createObjectsBatchedAndNoClosuresFactory } from '@/modules/core/services/objects/management' +import { createTestUser } from '@/test/authHelper' const IS_NODE_22_OR_ABOVE = process.versions.node.split('.').map(Number)[0] >= 22 -const getServerInfo = getServerInfoFactory({ db }) const getUser = legacyGetUserFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail: findEmailFactory({ db }), - getUser, - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) - -const createUserEmail = validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail: findEmailFactory({ db }), - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification -}) - -const findEmail = findEmailFactory({ db }) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: createUserEmail, - emitEvent: getEventBus().emit -}) const createPersonalAccessToken = createPersonalAccessTokenFactory({ storeApiToken: storeApiTokenFactory({ db }), @@ -106,7 +53,7 @@ describe('Objects streaming REST @core', () => { ;(IS_NODE_22_OR_ABOVE ? it : it.skip)( 'should close database connections if client connection is prematurely closed', async () => { - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'emails user', email: createRandomEmail(), password: createRandomPassword() @@ -160,7 +107,7 @@ describe('Objects streaming REST @core', () => { ;(IS_NODE_22_OR_ABOVE ? it : it.skip)( 'should stream model with some failing feature', async () => { - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'emails user', email: createRandomEmail(), password: createRandomPassword() diff --git a/packages/server/modules/core/tests/integration/updateUser.spec.ts b/packages/server/modules/core/tests/integration/updateUser.spec.ts index 1654bae30..5b5dd4da2 100644 --- a/packages/server/modules/core/tests/integration/updateUser.spec.ts +++ b/packages/server/modules/core/tests/integration/updateUser.spec.ts @@ -5,66 +5,11 @@ import { createRandomEmail, createRandomPassword } from '@/modules/core/helpers/testHelpers' -import { UserEmails } from '@/modules/core/dbSchema' -import { - countAdminUsersFactory, - getUserFactory, - legacyGetUserFactory, - storeUserAclFactory, - storeUserFactory, - updateUserFactory -} from '@/modules/core/repositories/users' +import { updateUserFactory } from '@/modules/core/repositories/users' import { expectToThrow } from '@/test/assertionHelper' -import { - createUserEmailFactory, - ensureNoPrimaryEmailForUserFactory, - findEmailFactory -} from '@/modules/core/repositories/userEmails' -import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' -import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' -import { renderEmail } from '@/modules/emails/services/emailRendering' -import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' -import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' -import { - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} from '@/modules/serverinvites/repositories/serverInvites' -import { getServerInfoFactory } from '@/modules/core/repositories/server' -import { getEventBus } from '@/modules/shared/services/eventBus' -const userEmailsDB = db(UserEmails.name) +import { buildBasicTestUser, createTestUser } from '@/test/authHelper' -const getServerInfo = getServerInfoFactory({ db }) -const getUser = legacyGetUserFactory({ db }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) const updateUser = updateUserFactory({ db }) describe('Users @core-users', () => { @@ -85,23 +30,20 @@ describe('Users @core-users', () => { expect(err.message).eq('User update payload empty') }) - it('Should update user email if skipClean is true', async () => { - const email = createRandomEmail() - const newUser = { - name: 'John Doe', - email, - password: createRandomPassword() - } - - const userId = await createUser(newUser) + // this will never actually happen + it('updates the user email', async () => { + const { id: userId } = await createTestUser( + buildBasicTestUser({ + name: 'John Doe', + email: createRandomEmail(), + password: createRandomPassword() + }) + ) const newEmail = createRandomEmail() await updateUser(userId, { email: newEmail }, { skipClean: true }) - const updated = await getUser(userId) - const updatedUserEmail = await userEmailsDB.where({ userId, primary: true }).first() - + const updated = await db('users').where({ id: userId }).first() expect(updated.email.toLowerCase()).eq(newEmail.toLowerCase()) - expect(updatedUserEmail.email.toLowerCase()).eq(newEmail.toLowerCase()) }) }) diff --git a/packages/server/modules/core/tests/integration/userEmails.graph.spec.ts b/packages/server/modules/core/tests/integration/userEmails.graph.spec.ts index 170909239..9c7ea39da 100644 --- a/packages/server/modules/core/tests/integration/userEmails.graph.spec.ts +++ b/packages/server/modules/core/tests/integration/userEmails.graph.spec.ts @@ -28,15 +28,8 @@ import { requestNewEmailVerificationFactory } from '@/modules/emails/services/ve import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' -import { - countAdminUsersFactory, - legacyGetUserFactory, - storeUserAclFactory, - storeUserFactory -} from '@/modules/core/repositories/users' -import { createUserFactory } from '@/modules/core/services/users/management' +import { legacyGetUserFactory } from '@/modules/core/repositories/users' import { getServerInfoFactory } from '@/modules/core/repositories/server' -import { getEventBus } from '@/modules/shared/services/eventBus' import { createTestUser, login } from '@/test/authHelper' import { EmailVerificationFinalizationError } from '@/modules/emails/errors' import { Roles } from '@speckle/shared' @@ -63,17 +56,6 @@ const createUserEmail = validateAndCreateUserEmailFactory({ requestNewEmailVerification }) -const findEmail = findEmailFactory({ db }) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: createUserEmail, - emitEvent: getEventBus().emit -}) - describe('User emails graphql @core', () => { before(async () => { await beforeEachContext() @@ -85,7 +67,7 @@ describe('User emails graphql @core', () => { describe('createUserEmail mutation', () => { it('should create new email for user', async () => { const firstEmail = createRandomEmail() - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'emails user', email: firstEmail, password: createRandomPassword() @@ -117,7 +99,7 @@ describe('User emails graphql @core', () => { describe('deleteUserEmail mutation', () => { it('should delete email for user', async () => { const firstEmail = createRandomEmail() - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'emails user', email: firstEmail, password: createRandomPassword() @@ -147,7 +129,7 @@ describe('User emails graphql @core', () => { describe('setPrimaryUserEmail mutation', () => { it('should set primary email for user', async () => { - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'emails user', email: createRandomEmail(), password: createRandomPassword() diff --git a/packages/server/modules/core/tests/integration/userEmails.spec.ts b/packages/server/modules/core/tests/integration/userEmails.spec.ts index b57662bb3..229906063 100644 --- a/packages/server/modules/core/tests/integration/userEmails.spec.ts +++ b/packages/server/modules/core/tests/integration/userEmails.spec.ts @@ -1,16 +1,12 @@ import { beforeEachContext } from '@/test/hooks' import { expect } from 'chai' import { - countAdminUsersFactory, getUserByEmailFactory, getUserFactory, legacyGetPaginatedUsersCountFactory, legacyGetPaginatedUsersFactory, legacyGetUserByEmailFactory, - listUsersFactory, - markUserAsVerifiedFactory, - storeUserAclFactory, - storeUserFactory + listUsersFactory } from '@/modules/core/repositories/users' import { db } from '@/db/knex' import { @@ -30,7 +26,7 @@ import { import { expectToThrow } from '@/test/assertionHelper' import type { MaybeNullOrUndefined } from '@speckle/shared' import type { BasicTestUser } from '@/test/authHelper' -import { createTestUsers } from '@/test/authHelper' +import { createTestUser, createTestUsers } from '@/test/authHelper' import { UserEmails, Users } from '@/modules/core/dbSchema' import { UserEmailPrimaryUnverifiedError } from '@/modules/core/errors/userEmails' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' @@ -43,9 +39,8 @@ import { requestNewEmailVerificationFactory } from '@/modules/emails/services/ve import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' import { getServerInfoFactory } from '@/modules/core/repositories/server' -import { getEventBus } from '@/modules/shared/services/eventBus' +import { markUserEmailAsVerifiedFactory } from '@/modules/core/services/users/emailVerification' const getServerInfo = getServerInfoFactory({ db }) const getUsers = legacyGetPaginatedUsersFactory({ db }) @@ -72,20 +67,12 @@ const createUserEmail = validateAndCreateUserEmailFactory({ requestNewEmailVerification }) -const findEmail = findEmailFactory({ db }) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: createUserEmail, - emitEvent: getEventBus().emit -}) const getUserByEmail = getUserByEmailFactory({ db }) const legacyGetUserByEmail = legacyGetUserByEmailFactory({ db }) const listUsers = listUsersFactory({ db }) -const markUserAsVerified = markUserAsVerifiedFactory({ db }) +const markUserEmailAsVerified = markUserEmailAsVerifiedFactory({ + updateUserEmail: updateUserEmailFactory({ db }) +}) describe('Core @user-emails', () => { before(async () => { @@ -100,13 +87,13 @@ describe('Core @user-emails', () => { describe('markUserEmailAsVerified', () => { it('should mark user email as verified', async () => { const email = createRandomEmail() - await createUser({ + await createTestUser({ name: 'John Doe', email, password: createRandomPassword() }) - await markUserAsVerified(email) + await markUserEmailAsVerified({ email }) const userEmail = await findEmailFactory({ db })({ email }) expect(userEmail?.verified).to.be.true @@ -116,7 +103,7 @@ describe('Core @user-emails', () => { describe('deleteUserEmail', () => { it('should throw and error when trying to delete last email', async () => { const email = createRandomEmail() - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'John Doe', email, password: createRandomPassword() @@ -133,7 +120,7 @@ describe('Core @user-emails', () => { it('should throw and error when trying to delete primary email', async () => { const email = createRandomEmail() - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'John Doe', email, password: createRandomPassword() @@ -157,7 +144,7 @@ describe('Core @user-emails', () => { it('should delete email', async () => { const email = createRandomEmail() - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'John Doe', email: createRandomEmail(), password: createRandomPassword() @@ -198,7 +185,7 @@ describe('Core @user-emails', () => { it('should throw an error if trying to set non verified email as primary', async () => { const email1 = createRandomEmail() const email2 = createRandomEmail() - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'John Doe', email: email1, password: createRandomPassword() @@ -239,7 +226,7 @@ describe('Core @user-emails', () => { }) it('should set primary email', async () => { const email = createRandomEmail() - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'John Doe', email: createRandomEmail(), password: createRandomPassword() @@ -284,7 +271,7 @@ describe('Core @user-emails', () => { describe('validateAndCreateUserEmailFactory', () => { it('should throw an error when trying to create a primary email for a user and there is already one for that user', async () => { const email = createRandomEmail() - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'John Doe', email: createRandomEmail(), password: createRandomPassword() @@ -304,12 +291,12 @@ describe('Core @user-emails', () => { }) it('should throw an error when trying to create an email for a user and the same email is already on the server', async () => { const email = createRandomEmail() - const userId1 = await createUser({ + const { id: userId1 } = await createTestUser({ name: 'John Doe 2', email: createRandomEmail(), password: createRandomPassword() }) - const userId2 = await createUser({ + const { id: userId2 } = await createTestUser({ name: 'John Doe', email: createRandomEmail(), password: createRandomPassword() @@ -341,7 +328,7 @@ describe('Core @user-emails', () => { describe('updateUserEmail', () => { it('should throw an error when trying to mark an email as primary and there is already one for the user', async () => { const email = createRandomEmail() - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'John Doe', email: createRandomEmail(), password: createRandomPassword() @@ -486,7 +473,9 @@ describe('Core @user-emails', () => { }) it('with markUserAsVerified()', async () => { - const res = await markUserAsVerified(randomizeCase(randomCaseGuy.email)) + const res = await markUserEmailAsVerified({ + email: randomizeCase(randomCaseGuy.email) + }) expect(res).to.be.ok const user = await getUserByEmail(randomCaseGuy.email) diff --git a/packages/server/modules/core/tests/integration/versions.graph.spec.ts b/packages/server/modules/core/tests/integration/versions.graph.spec.ts index 26292c6bb..d5b609eff 100644 --- a/packages/server/modules/core/tests/integration/versions.graph.spec.ts +++ b/packages/server/modules/core/tests/integration/versions.graph.spec.ts @@ -5,11 +5,6 @@ import { createRandomPassword, createRandomString } from '@/modules/core/helpers/testHelpers' -import { - createUserEmailFactory, - ensureNoPrimaryEmailForUserFactory, - findEmailFactory -} from '@/modules/core/repositories/userEmails' import { db } from '@/db/knex' import { testApolloServer } from '@/test/graphqlHelper' import { @@ -20,28 +15,10 @@ import { GetProjectWithModelVersionsDocument, GetProjectWithVersionsDocument } from '@/modules/core/graph/generated/graphql' -import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' -import { - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} from '@/modules/serverinvites/repositories/serverInvites' -import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' -import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' -import { renderEmail } from '@/modules/emails/services/emailRendering' -import { sendEmail } from '@/modules/emails/services/sending' -import { - countAdminUsersFactory, - legacyGetUserFactory, - storeUserAclFactory, - storeUserFactory -} from '@/modules/core/repositories/users' -import { createUserFactory } from '@/modules/core/services/users/management' -import { getServerInfoFactory } from '@/modules/core/repositories/server' import { WorkspaceReadOnlyError } from '@/modules/gatekeeper/errors/billing' import type { CreateVersionInput } from '@/modules/core/graph/generated/graphql' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' -import { getEventBus } from '@/modules/shared/services/eventBus' +import type { BasicTestUser } from '@/test/authHelper' import { buildBasicTestUser, createTestUser, login } from '@/test/authHelper' import type { BasicTestStream } from '@/test/speckle-helpers/streamHelper' import { createTestStream } from '@/test/speckle-helpers/streamHelper' @@ -62,39 +39,6 @@ import { } from '@/modules/core/tests/helpers/creation' import type { Optional } from '@speckle/shared' -const getServerInfo = getServerInfoFactory({ db }) -const getUser = legacyGetUserFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail: findEmailFactory({ db }), - getUser, - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) - -const createUserEmail = validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail: findEmailFactory({ db }), - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification -}) - -const findEmail = findEmailFactory({ db }) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: createUserEmail, - emitEvent: getEventBus().emit -}) - const { FF_BILLING_INTEGRATION_ENABLED, FF_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags() @@ -107,7 +51,7 @@ describe('Versions graphql @core', () => { ;(FF_BILLING_INTEGRATION_ENABLED ? it : it.skip)( 'should return error if project is read-only', async () => { - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'emails user', email: createRandomEmail(), password: createRandomPassword() @@ -156,7 +100,7 @@ describe('Versions graphql @core', () => { createdAt: Date // Make the project read-only ) => await db('commits').update({ createdAt }).where({ id }) - const user = buildBasicTestUser() + let user: BasicTestUser const workspace = buildBasicTestWorkspace() const model1 = buildBasicTestModel() const model2 = buildBasicTestModel() @@ -169,7 +113,7 @@ describe('Versions graphql @core', () => { let objectId3: Optional = undefined before(async () => { - user.id = await createUser(user) + user = await createTestUser(buildBasicTestUser()) await createTestWorkspace(workspace, user, { addPlan: { name: 'free', status: 'valid' } }) diff --git a/packages/server/modules/core/tests/objects.spec.ts b/packages/server/modules/core/tests/objects.spec.ts index d59645690..8fd82e288 100644 --- a/packages/server/modules/core/tests/objects.spec.ts +++ b/packages/server/modules/core/tests/objects.spec.ts @@ -32,13 +32,7 @@ import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/se import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' import { getEventBus } from '@/modules/shared/services/eventBus' import { createBranchFactory } from '@/modules/core/repositories/branches' -import { - getUsersFactory, - getUserFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory -} from '@/modules/core/repositories/users' +import { getUsersFactory, getUserFactory } from '@/modules/core/repositories/users' import { findEmailFactory, createUserEmailFactory, @@ -48,7 +42,6 @@ import { requestNewEmailVerificationFactory } from '@/modules/emails/services/ve import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' import { finalizeInvitedServerRegistrationFactory, @@ -79,6 +72,7 @@ import { } from '@/modules/core/services/streams/access' import { authorizeResolver } from '@/modules/shared' import type { ObjectRecord } from '@/modules/core/helpers/types' +import { createTestUser, type BasicTestUser } from '@/test/authHelper' import { storeProjectRoleFactory } from '@/modules/core/repositories/projects' const sampleCommit = JSON.parse(`{ @@ -184,34 +178,6 @@ const createStream = legacyCreateStreamFactory({ emitEvent: getEventBus().emit }) }) - -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) const createObject = createObjectFactory({ storeSingleObjectIfNotFoundFactory: storeSingleObjectIfNotFoundFactory({ db }) }) @@ -228,13 +194,7 @@ const getObjectChildrenQuery = getObjectChildrenQueryFactory({ db }) const getObjects = getStreamObjectsFactory({ db }) describe('Objects @core-objects', () => { - const userOne = { - name: 'Dimitrie Stefanescu', - email: 'didimitrie43@example.org', - password: 'sn3aky-1337-b1m', - id: '' - } - + let userOne: BasicTestUser const stream = { name: 'Test Streams', description: 'Whatever goes in here usually...', @@ -244,7 +204,12 @@ describe('Objects @core-objects', () => { before(async () => { await beforeEachContext() - userOne.id = await createUser(userOne) + userOne = await createTestUser({ + name: 'Dimitrie Stefanescu', + email: 'didimitrie43@example.org', + password: 'sn3aky-1337-b1m', + id: '' + }) stream.id = await createStream({ ...stream, isPublic: false, ownerId: userOne.id }) }) diff --git a/packages/server/modules/core/tests/rest.spec.ts b/packages/server/modules/core/tests/rest.spec.ts index fbb1880b6..b862a625a 100644 --- a/packages/server/modules/core/tests/rest.spec.ts +++ b/packages/server/modules/core/tests/rest.spec.ts @@ -34,13 +34,7 @@ import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/se import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' import { getEventBus } from '@/modules/shared/services/eventBus' import { createBranchFactory } from '@/modules/core/repositories/branches' -import { - getUsersFactory, - getUserFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory -} from '@/modules/core/repositories/users' +import { getUsersFactory, getUserFactory } from '@/modules/core/repositories/users' import { findEmailFactory, createUserEmailFactory, @@ -50,7 +44,6 @@ import { requestNewEmailVerificationFactory } from '@/modules/emails/services/ve import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' import { finalizeInvitedServerRegistrationFactory, @@ -75,6 +68,7 @@ import { } from '@/modules/core/services/streams/access' import { authorizeResolver } from '@/modules/shared' import type Express from 'express' +import { createTestUser, type BasicTestUser } from '@/test/authHelper' import { storeProjectRoleFactory } from '@/modules/core/repositories/projects' const getServerInfo = getServerInfoFactory({ db }) @@ -158,33 +152,6 @@ const createStream = legacyCreateStreamFactory({ }) }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) const createPersonalAccessToken = createPersonalAccessTokenFactory({ storeApiToken: storeApiTokenFactory({ db }), storeTokenScopes: storeTokenScopesFactory({ db }), @@ -195,20 +162,10 @@ const createPersonalAccessToken = createPersonalAccessTokenFactory({ }) describe('Upload/Download Routes @api-rest', () => { - const userA = { - name: 'd1', - email: 'd.1@speckle.systems', - password: 'wowwow8charsplease', - id: '', - token: '' - } - const userB = { - name: 'd2', - email: 'd.2@speckle.systems', - password: 'wowwow8charsplease', - id: '', - token: '' - } + let userA: BasicTestUser + let tokenUserA: string + let userB: BasicTestUser + let tokenUserB: string const testStream = { name: 'Test Stream 01', @@ -228,8 +185,13 @@ describe('Upload/Download Routes @api-rest', () => { before(async () => { ;({ app } = await beforeEachContext()) - userA.id = await createUser(userA) - userA.token = `Bearer ${await createPersonalAccessToken( + userA = await createTestUser({ + name: 'd1', + email: 'd.1@speckle.systems', + password: 'wowwow8charsplease', + id: '' + }) + tokenUserA = `Bearer ${await createPersonalAccessToken( userA.id, 'test token user A', [ @@ -244,8 +206,13 @@ describe('Upload/Download Routes @api-rest', () => { ] )}` - userB.id = await createUser(userB) - userB.token = `Bearer ${await createPersonalAccessToken( + userB = await createTestUser({ + name: 'd2', + email: 'd.2@speckle.systems', + password: 'wowwow8charsplease', + id: '' + }) + tokenUserB = `Bearer ${await createPersonalAccessToken( userB.id, 'test token user B', [ @@ -289,7 +256,7 @@ describe('Upload/Download Routes @api-rest', () => { // invalid streamId res = await request(app) .get(`/objects/${'thisDoesNotExist'}/null`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) expect(res).to.have.status(404) // create some objects @@ -297,7 +264,7 @@ describe('Upload/Download Routes @api-rest', () => { await request(app) .post(`/objects/${testStream.id}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) .set('Content-type', 'multipart/form-data') .attach('batch1', Buffer.from(JSON.stringify(objBatches[0]), 'utf8')) .attach('batch2', Buffer.from(JSON.stringify(objBatches[1]), 'utf8')) @@ -318,14 +285,14 @@ describe('Upload/Download Routes @api-rest', () => { // should not allow user b to access user a's private stream res = await request(app) .get(`/objects/${privateTestStream.id}/${objBatches[0][0].id}`) - .set('Authorization', userB.token) + .set('Authorization', tokenUserB) expect(res).to.have.status(401) }) it('should not allow a non-multipart/form-data request without a boundary', async () => { const res = await request(app) .post(`/objects/${testStream.id}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) .set('Content-type', 'multipart/form-data') .send(Buffer.from(JSON.stringify(objBatches[0]), 'utf8')) //sent, not attached, so no boundary will be added to Content-type header. expect(res).to.have.status(400) @@ -337,7 +304,7 @@ describe('Upload/Download Routes @api-rest', () => { it('should not allow a non-multipart/form-data request, even if it has a valid header', async () => { const res = await request(app) .post(`/objects/${testStream.id}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) .set('Content-type', 'application/json') .attach( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -354,7 +321,7 @@ describe('Upload/Download Routes @api-rest', () => { it('should not allow non-buffered requests', async () => { const res = await request(app) .post(`/objects/${testStream.id}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) .set('Content-type', 'multipart/form-data') // eslint-disable-next-line @typescript-eslint/no-explicit-any .attach(JSON.stringify(objBatches[0]) as any, undefined as any) @@ -369,20 +336,20 @@ describe('Upload/Download Routes @api-rest', () => { await request(app) .post(`/objects/${privateTestStream.id}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) .set('Content-type', 'multipart/form-data') .attach('batch1', Buffer.from(JSON.stringify(objBatch), 'utf8')) // should allow userA to access privateTestStream object let res = await request(app) .get(`/objects/${privateTestStream.id}/${objBatch[0].id}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) expect(res).to.have.status(200) // should not allow userB to access privateTestStream object by pretending it's in public stream res = await request(app) .get(`/objects/${testStream.id}/${objBatch[0].id}`) - .set('Authorization', userB.token) + .set('Authorization', tokenUserB) expect(res).to.have.status(404) }) @@ -402,7 +369,7 @@ describe('Upload/Download Routes @api-rest', () => { // invalid streamId res = await request(app) .post(`/objects/${'thisDoesNotExist'}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) expect(res).to.have.status(401) }) @@ -420,7 +387,7 @@ describe('Upload/Download Routes @api-rest', () => { const res = await request(app) .post(`/objects/${testStream.id}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) .set('Content-type', 'multipart/form-data') .attach('batch1', Buffer.from(JSON.stringify(objectToPost), 'utf8')) @@ -430,7 +397,7 @@ describe('Upload/Download Routes @api-rest', () => { it('Should not allow upload with invalid body (invalid json)', async () => { const res = await request(app) .post(`/objects/${testStream.id}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) .set('Content-type', 'multipart/form-data') .attach('batch1', Buffer.from(JSON.stringify('this is not json'), 'utf8')) @@ -445,7 +412,7 @@ describe('Upload/Download Routes @api-rest', () => { // const res = await request(app) // .post(`/objects/${testStream.id}`) - // .set('Authorization', userA.token) + // .set('Authorization', tokenUserA) // .set('Content-type', 'multipart/form-data') // .attach('batch1', Buffer.from(JSON.stringify([objectToPost]), 'utf8')) @@ -466,7 +433,7 @@ describe('Upload/Download Routes @api-rest', () => { const res = await request(app) .post(`/objects/${testStream.id}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) .set('Content-type', 'multipart/form-data') .attach('batch1', Buffer.from(JSON.stringify(objBatches[0]), 'utf8')) .attach('batch2', Buffer.from(JSON.stringify(objBatches[1]), 'utf8')) @@ -486,7 +453,7 @@ describe('Upload/Download Routes @api-rest', () => { await new Promise((resolve, reject) => { void request(app) .get(`/objects/${testStream.id}/${parentId}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) .buffer() .parse((res, cb) => { const resTyped = res as typeof res & { data: string } @@ -518,7 +485,7 @@ describe('Upload/Download Routes @api-rest', () => { await new Promise((resolve, reject) => { void request(app) .get(`/objects/${testStream.id}/${parentId}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) .set('Accept', 'text/plain') .buffer() .parse((res, cb) => { @@ -557,7 +524,7 @@ describe('Upload/Download Routes @api-rest', () => { await new Promise((resolve, reject) => { void request(app) .post(`/api/getobjects/${testStream.id}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) .set('Accept', 'text/plain') .send({ objects: JSON.stringify(objectIds) }) .buffer() @@ -594,7 +561,7 @@ describe('Upload/Download Routes @api-rest', () => { const res = await request(app) .post(`/api/getobjects/${testStream.id}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) .set('Accept', 'text/plain') .send({ objects: JSON.stringify(objectIds) }) .buffer() @@ -605,7 +572,7 @@ describe('Upload/Download Routes @api-rest', () => { it('Should return status code 400 when getting the list of objects and if it is not parseable', async () => { const response = await request(app) .post(`/api/getobjects/${testStream.id}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) .send({ objects: ['lolz', 'thisIsBroken', 'shouldHaveBeenJSONStringified'] }) expect(response).to.have.status(400) @@ -629,7 +596,7 @@ describe('Upload/Download Routes @api-rest', () => { await new Promise((resolve, reject) => { void request(app) .post(`/api/diff/${testStream.id}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) .send({ objects: JSON.stringify(objectIds) }) .buffer() .parse((res, cb) => { @@ -674,7 +641,7 @@ describe('Upload/Download Routes @api-rest', () => { it('Should return status code 400 if the list of objects is not parseable', async () => { const response = await request(app) .post(`/api/diff/${testStream.id}`) - .set('Authorization', userA.token) + .set('Authorization', tokenUserA) .send({ objects: ['lolz', 'thisIsBroken', 'shouldHaveBeenJSONStringified'] }) expect(response).to.have.status(400) diff --git a/packages/server/modules/core/tests/users.spec.ts b/packages/server/modules/core/tests/users.spec.ts index 286ed20c2..7227e0fc4 100644 --- a/packages/server/modules/core/tests/users.spec.ts +++ b/packages/server/modules/core/tests/users.spec.ts @@ -132,6 +132,14 @@ import { import { authorizeResolver } from '@/modules/shared' import { getUserWorkspaceSeatsFactory } from '@/modules/workspacesCore/repositories/workspaces' import { queryAllProjectsFactory } from '@/modules/core/services/projects' +import { getTestRegionClients } from '@/modules/multiregion/tests/helpers' +import { asMultiregionalOperation } from '@/modules/shared/command' +import type { + ChangeUserPassword, + CreateValidatedUser, + DeleteUser, + UpdateUserAndNotify +} from '@/modules/core/domain/users/operations' import { storeProjectRoleFactory } from '@/modules/core/repositories/projects' const getServerInfo = getServerInfoFactory({ db }) @@ -237,63 +245,144 @@ const createStream = legacyCreateStreamFactory({ }) const grantPermissionsStream = grantStreamPermissionsFactory({ db }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) +const createUser: CreateValidatedUser = async (...input) => + asMultiregionalOperation( + async ({ mainDb, allDbs, emit }) => { + const createUser = createUserFactory({ + getServerInfo, + findEmail: findEmailFactory({ db: mainDb }), + storeUser: async (...params) => { + const [user] = await Promise.all( + allDbs.map((db) => storeUserFactory({ db })(...params)) + ) + + return user + }, + countAdminUsers: countAdminUsersFactory({ db: mainDb }), + storeUserAcl: storeUserAclFactory({ db: mainDb }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db: mainDb }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ + db: mainDb + }), + findEmail: findEmailFactory({ db: mainDb }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db: mainDb }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db: mainDb }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + getServerInfo, + findEmail: findEmailFactory({ db: mainDb }), + getUser: getUserFactory({ db: mainDb }), + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory( + { + db: mainDb + } + ), + renderEmail, + sendEmail + }) + }), + emitEvent: emit + }) + + return createUser(...input) + }, + { + dbs: await getTestRegionClients(), + name: 'create user spec', + logger: dbLogger + } + ) + const findOrCreateUser = findOrCreateUserFactory({ createUser, findPrimaryEmailForUser: findPrimaryEmailForUserFactory({ db }) }) const getUserByEmail = legacyGetUserByEmailFactory({ db }) -const updateUser = updateUserAndNotifyFactory({ - getUser: getUserFactory({ db }), - updateUser: updateUserFactory({ db }), - emitEvent: getEventBus().emit -}) -const updateUserPassword = changePasswordFactory({ - getUser: getUserFactory({ db }), - updateUser: updateUserFactory({ db }) -}) +const updateUser: UpdateUserAndNotify = async (...input) => + asMultiregionalOperation( + ({ mainDb, allDbs, emit }) => { + const updateUserAndNotify = updateUserAndNotifyFactory({ + getUser: getUserFactory({ db: mainDb }), + updateUser: async (...params) => { + const [res] = await Promise.all( + allDbs.map((db) => updateUserFactory({ db })(...params)) + ) + + return res + }, + emitEvent: emit + }) + + return updateUserAndNotify(...input) + }, + { + logger: dbLogger, + name: 'update user and notify spec', + dbs: await getTestRegionClients() + } + ) + +const updateUserPassword: ChangeUserPassword = async (...input) => + asMultiregionalOperation( + ({ mainDb, allDbs }) => { + const updateUserPassword = changePasswordFactory({ + getUser: getUserFactory({ db: mainDb }), + updateUser: async (...params) => { + const [res] = await Promise.all( + allDbs.map((db) => updateUserFactory({ db })(...params)) + ) + + return res + } + }) + + return updateUserPassword(...input) + }, + { + logger: dbLogger, + name: 'update user password spec', + dbs: await getTestRegionClients() + } + ) + const validateUserPassword = validateUserPasswordFactory({ getUserByEmail: getUserByEmailFactory({ db }) }) -const deleteUser = deleteUserFactory({ - deleteStream: deleteStreamFactory({ db }), - logger: dbLogger, - isLastAdminUser: isLastAdminUserFactory({ db }), - getUserDeletableStreams: getUserDeletableStreamsFactory({ db }), - queryAllProjects: queryAllProjectsFactory({ - getExplicitProjects: getExplicitProjects({ db }) - }), - getUserWorkspaceSeats: getUserWorkspaceSeatsFactory({ db }), - deleteAllUserInvites: deleteAllUserInvitesFactory({ db }), - deleteUserRecord: deleteUserRecordFactory({ db }), - emitEvent: getEventBus().emit -}) + +const deleteUser: DeleteUser = async (...input) => + asMultiregionalOperation( + ({ mainDb, allDbs, emit }) => { + const deleteUser = deleteUserFactory({ + deleteStream: deleteStreamFactory({ db: mainDb }), + logger: dbLogger, + isLastAdminUser: isLastAdminUserFactory({ db: mainDb }), + getUserDeletableStreams: getUserDeletableStreamsFactory({ db: mainDb }), + queryAllProjects: queryAllProjectsFactory({ + getExplicitProjects: getExplicitProjects({ db: mainDb }) + }), + getUserWorkspaceSeats: getUserWorkspaceSeatsFactory({ db: mainDb }), + deleteAllUserInvites: deleteAllUserInvitesFactory({ db: mainDb }), + deleteUserRecord: async (params) => { + const [res] = await Promise.all( + allDbs.map((db) => deleteUserRecordFactory({ db })(params)) + ) + + return res + }, + emitEvent: emit + }) + + return deleteUser(...input) + }, + { + logger: dbLogger, + name: 'delete user spec', + dbs: await getTestRegionClients() + } + ) + const changeUserRole = changeUserRoleFactory({ getServerInfo, isLastAdminUser: isLastAdminUserFactory({ db }), @@ -334,7 +423,7 @@ const createObject = createObjectFactory({ storeSingleObjectIfNotFoundFactory: storeSingleObjectIfNotFoundFactory({ db }) }) -describe('Actors & Tokens @user-services', () => { +describe('Actors & Tokens @user-services @multiregion', () => { const myTestActor = { name: 'Dimitrie Stefanescu', email: 'didimitrie@example.org', @@ -394,7 +483,7 @@ describe('Actors & Tokens @user-services', () => { }) // Note: deletion is more complicated. - it('Should delete a user', async () => { + it('Should delete a user @multiregion', async () => { const soloOwnerStream = { name: 'Test Stream 01', description: 'wonderful test stream', diff --git a/packages/server/modules/core/tests/usersAdmin.spec.ts b/packages/server/modules/core/tests/usersAdmin.spec.ts index 58d773dd9..dd9696d31 100644 --- a/packages/server/modules/core/tests/usersAdmin.spec.ts +++ b/packages/server/modules/core/tests/usersAdmin.spec.ts @@ -47,6 +47,8 @@ import { getEventBus } from '@/modules/shared/services/eventBus' import { expect } from 'chai' import { getUserWorkspaceSeatsFactory } from '@/modules/workspacesCore/repositories/workspaces' import { queryAllProjectsFactory } from '@/modules/core/services/projects' +import type { BasicTestUser } from '@/test/authHelper' +import { createTestUser } from '@/test/authHelper' const getUsers = legacyGetPaginatedUsersFactory({ db }) const countUsers = legacyGetPaginatedUsersCountFactory({ db }) @@ -61,6 +63,7 @@ const requestNewEmailVerification = requestNewEmailVerificationFactory({ renderEmail, sendEmail }) +// this does not uses createTestUser as 250 parallel transactions for user creation can timeout some of them const createUser = createUserFactory({ getServerInfo, findEmail, @@ -112,8 +115,8 @@ describe('User admin @user-services', () => { before(async () => { await beforeEachContext() - const actorId = await createUser(myTestActor) - myTestActor.id = actorId + const actor = await createTestUser(myTestActor) + myTestActor.id = actor.id }) it('First created user should be admin', async () => { @@ -133,16 +136,16 @@ describe('User admin @user-services', () => { newUser.email = 'bill@gates.com' newUser.password = 'testthebest' - const actorId = await createUser(newUser) + const actor = await createTestUser(newUser) expect(await countUsers()).to.equal(2) - await deleteUser(actorId) + await deleteUser(actor.id) expect(await countUsers()).to.equal(1) }) it('Get users query limit is sanitized to upper limit', async () => { - const userInputs = Array(250) + const userInputs: BasicTestUser[] = Array(250) .fill(undefined) .map((v, i) => createNewDroid(i)) @@ -191,9 +194,10 @@ describe('User admin @user-services', () => { } }) it('modifies role', async () => { - const userId = await createUser( + const user = await createTestUser( createNewDroid(cryptoRandomString({ length: 13 })) ) + const userId = user.id const oldRole = await getUserRole(userId) expect(oldRole).to.equal(Roles.Server.User) @@ -228,6 +232,7 @@ describe('User admin @user-services', () => { const createNewDroid = (number: string | number) => { return { + id: `${number}`, name: `${number}`, email: `${number}@droidarmy.com`, password: 'sn3aky-1337-b1m' diff --git a/packages/server/modules/core/tests/usersAdminList.spec.ts b/packages/server/modules/core/tests/usersAdminList.spec.ts index ab55fe9c0..b87daf60b 100644 --- a/packages/server/modules/core/tests/usersAdminList.spec.ts +++ b/packages/server/modules/core/tests/usersAdminList.spec.ts @@ -35,13 +35,7 @@ import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/se import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' import { getEventBus } from '@/modules/shared/services/eventBus' import { createBranchFactory } from '@/modules/core/repositories/branches' -import { - countAdminUsersFactory, - getUserFactory, - getUsersFactory, - storeUserAclFactory, - storeUserFactory -} from '@/modules/core/repositories/users' +import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/users' import { createUserEmailFactory, ensureNoPrimaryEmailForUserFactory, @@ -51,7 +45,6 @@ import { requestNewEmailVerificationFactory } from '@/modules/emails/services/ve import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' -import { createUserFactory } from '@/modules/core/services/users/management' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' import { finalizeInvitedServerRegistrationFactory, @@ -67,6 +60,8 @@ import { validateStreamAccessFactory } from '@/modules/core/services/streams/access' import { authorizeResolver } from '@/modules/shared' +import type { BasicTestUser } from '@/test/authHelper' +import { createTestUser } from '@/test/authHelper' import { storeProjectRoleFactory } from '@/modules/core/repositories/projects' // To ensure that the invites are created in the correct order, we need to wait a bit between each creation @@ -154,34 +149,6 @@ const createStream = legacyCreateStreamFactory({ }) const createInviteDirectly = createStreamInviteDirectly -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) - function randomEl(array: T[]): T { return array[Math.floor(Math.random() * array.length)] } @@ -204,13 +171,7 @@ async function getOrderedUserIds() { } describe('[Admin users list]', () => { - const me = { - name: 'Mr Server Admin Dude', - email: 'adminuserguy@example.org', - password: 'sn3aky-1337-b1m', - id: undefined as Optional, - verified: false - } + let me: BasicTestUser const USER_COUNT = 15 const SERVER_INVITE_COUNT = 5 @@ -251,7 +212,13 @@ describe('[Admin users list]', () => { await cleanup() - await createUser(me).then((id) => (me.id = id)) + me = await createTestUser({ + name: 'Mr Server Admin Dude', + email: 'adminuserguy@example.org', + password: 'sn3aky-1337-b1m', + id: undefined as Optional, + verified: false + }) const userIds: string[] = [] let remainingSearchQueryUserCount = SEARCH_QUERY_RESULT_COUNT @@ -260,7 +227,7 @@ describe('[Admin users list]', () => { // Create Users // count - 1, cause `me` also exists for (let i = 0; i < USER_COUNT - 1; i++) { - const id = await createUser({ + const { id } = await createTestUser({ name: `User #${i} - ${ remainingSearchQueryUserCount-- >= 1 ? SEARCH_QUERY : '' }`, diff --git a/packages/server/modules/core/tests/usersGraphql.spec.ts b/packages/server/modules/core/tests/usersGraphql.spec.ts index bb67ca623..62b3fae79 100644 --- a/packages/server/modules/core/tests/usersGraphql.spec.ts +++ b/packages/server/modules/core/tests/usersGraphql.spec.ts @@ -1,6 +1,6 @@ import { Users } from '@/modules/core/dbSchema' import type { BasicTestUser } from '@/test/authHelper' -import { createTestUsers } from '@/test/authHelper' +import { createTestUser, createTestUsers } from '@/test/authHelper' import { getActiveUser, getOtherUser } from '@/test/graphql/users' import { beforeEachContext, truncateTables } from '@/test/hooks' import { expect } from 'chai' @@ -32,15 +32,8 @@ import { requestNewEmailVerificationFactory } from '@/modules/emails/services/ve import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' -import { - countAdminUsersFactory, - getUserFactory, - storeUserAclFactory, - storeUserFactory -} from '@/modules/core/repositories/users' -import { createUserFactory } from '@/modules/core/services/users/management' +import { getUserFactory } from '@/modules/core/repositories/users' import { getServerInfoFactory } from '@/modules/core/repositories/server' -import { getEventBus } from '@/modules/shared/services/eventBus' const getServerInfo = getServerInfoFactory({ db }) const getUser = getUserFactory({ db }) @@ -64,17 +57,6 @@ const createUserEmail = validateAndCreateUserEmailFactory({ requestNewEmailVerification }) -const findEmail = findEmailFactory({ db }) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: createUserEmail, - emitEvent: getEventBus().emit -}) - describe('Users (GraphQL)', () => { const me: BasicTestUser = { id: '', @@ -162,7 +144,7 @@ describe('Users (GraphQL)', () => { }) it('should return emails for user', async () => { - const userId = await createUser({ + const { id: userId } = await createTestUser({ name: 'emails user', email: createRandomEmail(), password: createRandomPassword(), diff --git a/packages/server/modules/emails/rest/index.ts b/packages/server/modules/emails/rest/index.ts index ba09fe427..b9a38799b 100644 --- a/packages/server/modules/emails/rest/index.ts +++ b/packages/server/modules/emails/rest/index.ts @@ -7,26 +7,39 @@ import { deleteVerificationsFactory, getPendingTokenFactory } from '@/modules/emails/repositories' -import { db } from '@/db/knex' import { markUserAsVerifiedFactory } from '@/modules/core/repositories/users' -import { withOperationLogging } from '@/observability/domain/businessLogging' +import { markUserEmailAsVerifiedFactory } from '@/modules/core/services/users/emailVerification' +import { updateUserEmailFactory } from '@/modules/core/repositories/userEmails' +import { getAllRegisteredDbs } from '@/modules/multiregion/utils/dbSelector' +import { asMultiregionalOperation } from '@/modules/shared/command' export default (app: Express) => { app.get('/auth/verifyemail', async (req, res) => { const logger = req.log try { - const finalizeEmailVerification = finalizeEmailVerificationFactory({ - getPendingToken: getPendingTokenFactory({ db }), - markUserAsVerified: markUserAsVerifiedFactory({ db }), - deleteVerifications: deleteVerificationsFactory({ db }) - }) + await asMultiregionalOperation( + async ({ mainDb, allDbs }) => { + const finalizeEmailVerification = finalizeEmailVerificationFactory({ + getPendingToken: getPendingTokenFactory({ db: mainDb }), + markUserAsVerified: async (params) => { + const [res] = await Promise.all( + allDbs.map((db) => markUserAsVerifiedFactory({ db })(params)) + ) + return res + }, + deleteVerifications: deleteVerificationsFactory({ db: mainDb }), + markUserEmailAsVerified: markUserEmailAsVerifiedFactory({ + updateUserEmail: updateUserEmailFactory({ db: mainDb }) + }) + }) - await withOperationLogging( - async () => await finalizeEmailVerification(req.query.t as Optional), + return await finalizeEmailVerification(req.query.t as Optional) + }, { logger, - operationName: 'finalizeEmailVerification', - operationDescription: 'Finalize email verification' + dbs: await getAllRegisteredDbs(), + name: 'finalizeEmailVerification', + description: 'Finalize email verification' } ) return res.redirect( diff --git a/packages/server/modules/emails/services/verification/finalize.ts b/packages/server/modules/emails/services/verification/finalize.ts index b8b612938..b05bcf022 100644 --- a/packages/server/modules/emails/services/verification/finalize.ts +++ b/packages/server/modules/emails/services/verification/finalize.ts @@ -5,6 +5,7 @@ import type { GetPendingToken } from '@/modules/emails/domain/operations' import type { MarkUserAsVerified } from '@/modules/core/domain/users/operations' +import type { MarkUserEmailAsVerified } from '@/modules/core/domain/userEmails/operations' type InitializeStateDeps = { getPendingToken: GetPendingToken @@ -28,6 +29,7 @@ type FinalizationState = Awaited>> type FinalizeVerificationDeps = { markUserAsVerified: MarkUserAsVerified + markUserEmailAsVerified: MarkUserEmailAsVerified deleteVerifications: DeleteVerifications } @@ -36,7 +38,11 @@ const finalizeVerification = const { token } = state const { email } = token - await Promise.all([deps.markUserAsVerified(email), deps.deleteVerifications(email)]) + await Promise.all([ + deps.markUserEmailAsVerified({ email: email.toLowerCase().trim() }), + deps.markUserAsVerified(email), + deps.deleteVerifications(email) + ]) } /** diff --git a/packages/server/modules/fileuploads/tests/helpers/init.ts b/packages/server/modules/fileuploads/tests/helpers/init.ts index 816b0d04a..fddca7756 100644 --- a/packages/server/modules/fileuploads/tests/helpers/init.ts +++ b/packages/server/modules/fileuploads/tests/helpers/init.ts @@ -18,13 +18,7 @@ import { ensureNoPrimaryEmailForUserFactory, findEmailFactory } from '@/modules/core/repositories/userEmails' -import { - countAdminUsersFactory, - getUserFactory, - getUsersFactory, - storeUserAclFactory, - storeUserFactory -} from '@/modules/core/repositories/users' +import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/users' import { addOrUpdateStreamCollaboratorFactory, validateStreamAccessFactory @@ -35,7 +29,6 @@ import { } from '@/modules/core/services/streams/management' import { createTokenFactory } from '@/modules/core/services/tokens' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { createUserFactory } from '@/modules/core/services/users/management' import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' @@ -81,25 +74,6 @@ export const initUploadTestEnvironment = () => { sendEmail }) - const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit - }) - const createToken = createTokenFactory({ storeApiToken: storeApiTokenFactory({ db }), storeTokenScopes: storeTokenScopesFactory({ db }), @@ -197,7 +171,6 @@ export const initUploadTestEnvironment = () => { return { findEmail, requestNewEmailVerification, - createUser, createToken, createStream, getUser, diff --git a/packages/server/modules/fileuploads/tests/integration/fileuploads.spec.ts b/packages/server/modules/fileuploads/tests/integration/fileuploads.spec.ts index 73c56be9f..37aef3b8e 100644 --- a/packages/server/modules/fileuploads/tests/integration/fileuploads.spec.ts +++ b/packages/server/modules/fileuploads/tests/integration/fileuploads.spec.ts @@ -9,8 +9,9 @@ import { noErrors } from '@/test/helpers' import { TIME_MS } from '@speckle/shared' import { initUploadTestEnvironment } from '@/modules/fileuploads/tests/helpers/init' import { fileURLToPath } from 'url' +import { createTestUser } from '@/test/authHelper' -const { createStream, createUser, createToken } = initUploadTestEnvironment() +const { createStream, createToken } = initUploadTestEnvironment() const gqlQueryToListFileUploads = `query ($streamId: String!) { stream(id: $streamId) { id @@ -54,7 +55,8 @@ describe('FileUploads @fileuploads integration', () => { process.env['CANONICAL_URL'] = serverAddress process.env['PORT'] = serverPort - userOneId = await createUser(userOne) + const user = await createTestUser(userOne) + userOneId = user.id }) beforeEach(async () => { createdStreamId = await createStream({ ownerId: userOneId }) @@ -267,7 +269,7 @@ describe('FileUploads @fileuploads integration', () => { email: 'user2@example.org', password: 'jdsadjsadasfdsa' } - const userTwoId = await createUser(userTwo) + const { id: userTwoId } = await createTestUser(userTwo) const streamTwoId = await createStream({ ownerId: userTwoId }) const response = await request(app) diff --git a/packages/server/modules/fileuploads/tests/integration/results.graphql.spec.ts b/packages/server/modules/fileuploads/tests/integration/results.graphql.spec.ts index da1acfd20..6fafd4633 100644 --- a/packages/server/modules/fileuploads/tests/integration/results.graphql.spec.ts +++ b/packages/server/modules/fileuploads/tests/integration/results.graphql.spec.ts @@ -15,8 +15,9 @@ import cryptoRandomString from 'crypto-random-string' import type { Server } from 'http' import { initUploadTestEnvironment } from '@/modules/fileuploads/tests/helpers/init' import { createFileUploadJob } from '@/modules/fileuploads/tests/helpers/creation' +import { createTestUser } from '@/test/authHelper' -const { createUser, createStream, createToken } = initUploadTestEnvironment() +const { createStream, createToken } = initUploadTestEnvironment() const { FF_NEXT_GEN_FILE_IMPORTER_ENABLED } = getFeatureFlags() @@ -52,7 +53,8 @@ const { FF_NEXT_GEN_FILE_IMPORTER_ENABLED } = getFeatureFlags() process.env['CANONICAL_URL'] = serverAddress process.env['PORT'] = serverPort - userOneId = await createUser(userOne) + const user = await createTestUser(userOne) + userOneId = user.id }) beforeEach(async () => { @@ -200,7 +202,7 @@ const { FF_NEXT_GEN_FILE_IMPORTER_ENABLED } = getFeatureFlags() }) it('should 403 if the token is for a different user', async () => { - const userTwoId = await createUser({ + const { id: userTwoId } = await createTestUser({ name: createRandomString(), email: createRandomEmail(), password: createRandomPassword() diff --git a/packages/server/modules/fileuploads/tests/unit/fileuploads.spec.ts b/packages/server/modules/fileuploads/tests/unit/fileuploads.spec.ts index 205ec47d1..94c6cb303 100644 --- a/packages/server/modules/fileuploads/tests/unit/fileuploads.spec.ts +++ b/packages/server/modules/fileuploads/tests/unit/fileuploads.spec.ts @@ -24,9 +24,9 @@ import type { JobPayload } from '@speckle/shared/workers/fileimport' import type { EventBusEmit } from '@/modules/shared/services/eventBus' import { FileuploadEvents } from '@/modules/fileuploads/domain/events' import type { BranchRecord } from '@/modules/core/helpers/types' +import { createTestUser } from '@/test/authHelper' -const { createStream, createBranch, createUser, garbageCollector } = - initUploadTestEnvironment() +const { createStream, createBranch, garbageCollector } = initUploadTestEnvironment() const { FF_NEXT_GEN_FILE_IMPORTER_ENABLED } = getFeatureFlags() @@ -42,7 +42,8 @@ describe('FileUploads @fileuploads', () => { let createdBranch: BranchRecord before(async () => { - userOneId = await createUser(userOne) + const user = await createTestUser(userOne) + userOneId = user.id }) beforeEach(async () => { diff --git a/packages/server/modules/multiregion/tests/e2e/serverAdmin.graph.spec.ts b/packages/server/modules/multiregion/tests/e2e/serverAdmin.graph.spec.ts index 818936ea3..915a8674f 100644 --- a/packages/server/modules/multiregion/tests/e2e/serverAdmin.graph.spec.ts +++ b/packages/server/modules/multiregion/tests/e2e/serverAdmin.graph.spec.ts @@ -28,6 +28,7 @@ import type { MultiRegionConfig } from '@speckle/shared/environment/db' import { getConnectionSettings } from '@speckle/shared/environment/db' import { expect } from 'chai' import { merge } from 'lodash-es' +import { resetRegisteredRegions } from '@/modules/multiregion/utils/dbSelector' const isEnabled = isMultiRegionEnabled() @@ -110,6 +111,7 @@ isEnabled after(async () => { setMultiRegionConfig(originalConfig) await truncateRegionsSafely() + resetRegisteredRegions() }) describe('server config', () => { diff --git a/packages/server/modules/multiregion/tests/helpers.ts b/packages/server/modules/multiregion/tests/helpers.ts new file mode 100644 index 000000000..e425a4f09 --- /dev/null +++ b/packages/server/modules/multiregion/tests/helpers.ts @@ -0,0 +1,12 @@ +import { db } from '@/db/knex' +import { getRegisteredRegionClients } from '@/modules/multiregion/utils/dbSelector' +import { isMultiRegionTestMode } from '@/test/speckle-helpers/regions' +import type { Knex } from 'knex' + +export async function getTestRegionClients(): Promise<[Knex, ...Knex[]]> { + if (!isMultiRegionTestMode()) return [db] + + const regionClients = await getRegisteredRegionClients() + const regionDbs = Object.values(regionClients) + return [db, ...regionDbs] +} diff --git a/packages/server/modules/multiregion/tests/integration/repositories/transactions.spec.ts b/packages/server/modules/multiregion/tests/integration/repositories/transactions.spec.ts index e55d71be6..62683530b 100644 --- a/packages/server/modules/multiregion/tests/integration/repositories/transactions.spec.ts +++ b/packages/server/modules/multiregion/tests/integration/repositories/transactions.spec.ts @@ -7,23 +7,32 @@ import { } from '@/modules/shared/helpers/dbHelper' import { wait } from '@speckle/shared' import { expect } from 'chai' +import cryptoRandomString from 'crypto-random-string' describe('prepared transaction repository functions', () => { describe('getStalePreparedTransactionsFactory returns a function, that', () => { - let trx: Knex + let trx: Knex.Transaction let transactionId: string = '' beforeEach(async () => { trx = await db.transaction() - transactionId = await prepareTransaction(trx) + transactionId = cryptoRandomString({ length: 10 }) + + await prepareTransaction(trx, transactionId) + try { + await trx.commit() + } catch {} }) afterEach(async () => { - await rollbackPreparedTransaction(trx, transactionId) + await rollbackPreparedTransaction(db, transactionId) + try { + await trx.rollback() + } catch {} }) it('returns prepared transactions older than a given time interval', async () => { - await wait(5000) + await wait(2000) const result = await getStalePreparedTransactionsFactory({ db })({ interval: '1 second' }) diff --git a/packages/server/modules/multiregion/utils/dbSelector.ts b/packages/server/modules/multiregion/utils/dbSelector.ts index a97167b3b..02ed6e04c 100644 --- a/packages/server/modules/multiregion/utils/dbSelector.ts +++ b/packages/server/modules/multiregion/utils/dbSelector.ts @@ -9,7 +9,8 @@ import { getRegionFactory } from '@/modules/multiregion/repositories' import { DatabaseError, LogicError, - MisconfiguredEnvironmentError + MisconfiguredEnvironmentError, + TestOnlyLogicError } from '@/modules/shared/errors' import { configureClient } from '@/knexfile' import type { InitializeRegion } from '@/modules/multiregion/domain/operations' @@ -19,8 +20,8 @@ import { getMainRegionConfig } from '@/modules/multiregion/regionConfig' import type { MaybeNullOrUndefined } from '@speckle/shared' -import { ensureError } from '@speckle/shared' -import { isDevOrTestEnv, isTestEnv } from '@/modules/shared/helpers/envHelper' +import { ensureError, TIME_MS, wait } from '@speckle/shared' +import { isTestEnv } from '@/modules/shared/helpers/envHelper' import { migrateDbToLatest } from '@/db/migrations' import { getProjectRegionKey, @@ -28,6 +29,7 @@ import { } from '@/modules/multiregion/utils/regionSelector' import { get, mapValues } from 'lodash-es' import { isMultiRegionEnabled } from '@/modules/multiregion/helpers' +import { logger } from '@/observability/logging' let getter: GetProjectDb | undefined = undefined @@ -114,13 +116,10 @@ export const initializeRegisteredRegionClients = async (): Promise migrateDbToLatest({ db, region })) ) - // (re-)set up pub-sub, if needed - // (disabled in prod cause there's too many DBs and connections and the load is too hard to handle) - if (isDevOrTestEnv()) { - await Promise.all( - Object.keys(ret).map((regionKey) => initializeRegion({ regionKey })) - ) - } + // initialize regions + await Promise.all( + Object.keys(ret).map((regionKey) => initializeRegion({ regionKey })) + ) registeredRegionClients = ret return ret @@ -129,6 +128,7 @@ export const initializeRegisteredRegionClients = async (): Promise => { if (!registeredRegionClients) registeredRegionClients = await initializeRegisteredRegionClients() + return registeredRegionClients } @@ -156,6 +156,15 @@ export const getAllRegisteredDbClients = async (): Promise< ] } +export const getAllRegisteredDbs = async (): Promise<[Knex, ...Knex[]]> => { + const mainDb = db + const regionDbs: RegionClients = isMultiRegionEnabled() + ? await getRegisteredRegionClients() + : {} + + return [mainDb, ...Object.entries(regionDbs).map(([, client]) => client)] +} + /** * Idempotently initialize region db */ @@ -181,7 +190,7 @@ export const initializeRegion: InitializeRegion = async ({ regionKey }) => { ? 'require' : 'disable' - await setUpUserReplication({ + await dropUserReplicationIfExists({ from: mainDb, to: regionDb, regionName: regionKey, @@ -210,104 +219,63 @@ interface ReplicationArgs { regionName: string } -const setUpUserReplication = async ({ +const dropUserReplicationIfExists = async ({ from, to, - sslmode, regionName }: ReplicationArgs): Promise => { const subName = createPubSubName(`userssub_${regionName}`) const pubName = createPubSubName('userspub') try { - await from.public.raw(`CREATE PUBLICATION ${pubName} FOR TABLE users;`) - } catch (err) { - if (!(err instanceof Error)) { - throw new DatabaseError( - 'Could not create publication {pubName} when setting up user replication for region {regionName}', - from.public, - { - cause: ensureError( - sanitizeError(err), - 'Unknown database error when creating publication' - ), - info: { pubName, regionName } - } - ) + const { rows: pubExist } = await from.public.raw( + `SELECT pubname FROM pg_publication WHERE pubname = '${pubName}';` + ) + + if (pubExist.length > 0) { + await from.public.raw(`DROP PUBLICATION ${pubName};`) + logger.info({ regionName, pubName }, 'dropped publication') } - - const errorMessage = err.message - - if ( - !['already exists', 'violates unique constraint'].some((message) => - errorMessage.includes(message) - ) - ) - throw new DatabaseError( - 'Unknown error while creating publication {pubName} when setting up user replication for region {regionName}', - from.public, - { - cause: ensureError( - sanitizeError(err), - 'Unknown database error when creating publication' - ), - info: { pubName, regionName } - } - ) + } catch (error) { + logger.warn({ error }, 'while dropping publication') + // silent error as + // dropping pub can have race conditions (n subs - 1 pub) + // and action DROP PUBLICATION does not support if exist for current postgres version } - const fromUrl = new URL( - from.private - ? from.private.client.config.connection.connectionString - : from.public.client.config.connection.connectionString - ) - const port = fromUrl.port ? fromUrl.port : '5432' - const fromDbName = fromUrl.pathname.replace('/', '') - const rawSqeel = `SELECT * FROM aiven_extras.pg_create_subscription( - ?, - ?, - ?, - ?, - TRUE, - TRUE - );` try { - await to.public.raw('CREATE EXTENSION IF NOT EXISTS "aiven_extras"') - await to.public.raw(rawSqeel, [ - subName, - `dbname=${fromDbName} host=${fromUrl.hostname} port=${port} sslmode=${sslmode} user=${fromUrl.username} password=${fromUrl.password}`, - pubName, - subName - ]) - } catch (err) { - if (!(err instanceof Error)) - throw new DatabaseError( - 'Could not create subscription {subName} to {pubName} when setting up user replication for region {regionName}', - to.public, - { - cause: ensureError( - sanitizeError(err), - 'Unknown database error when creating subscription' - ), - info: { subName, pubName, regionName } - } - ) - if ( - !err.message.includes('already exists') && - !err.message.includes('duplicate key value violates unique constraint') + const { rows: aivenExists } = await to.public.raw( + "SELECT * FROM pg_extension WHERE extname = 'aiven_extras';" ) - throw new DatabaseError( - 'Unknown error while creating subscription {subName} to {pubName} when setting up user replication for region {regionName}', - to.public, - { - cause: ensureError( - sanitizeError(err), - 'Unknown database error when creating subscription' - ), - info: { subName, pubName, regionName } - } - ) + + if (!aivenExists) return + + const { + rows: [sub] + } = await to.public.raw<{ rows: { subconninfo: string; subslotname: string }[] }>( + `SELECT subconninfo, subslotname FROM aiven_extras.pg_list_all_subscriptions() WHERE subname = '${subName}';` + ) + + if (!sub) return + + await to.public.raw( + `SELECT * FROM aiven_extras.pg_alter_subscription_disable('${subName}');` + ) + await wait(TIME_MS.second) + await to.public.raw( + `SELECT * FROM aiven_extras.pg_drop_subscription('${subName}');` + ) + await wait(TIME_MS.second) + await to.public.raw( + `SELECT * FROM aiven_extras.dblink_slot_create_or_drop('${sub.subconninfo}', '${sub.subslotname}', 'drop');` + ) + logger.info({ regionName, subName }, 'dropped subscription') + } catch (error) { + logger.error({ error }, 'Failed to drop subscription') + return } + + return } const setUpProjectReplication = async ({ @@ -410,3 +378,11 @@ const sanitizeError = (err: unknown): unknown => { if ((get(err, 'where') as unknown as string).includes('password=')) return { ...err, where: '[REDACTED AS IT CONTAINS CONNECTION STRING]' } } + +export const resetRegisteredRegions = () => { + if (!isTestEnv()) { + throw new TestOnlyLogicError() + } + + registeredRegionClients = undefined +} diff --git a/packages/server/modules/pwdreset/rest/index.ts b/packages/server/modules/pwdreset/rest/index.ts index 861a8bf9b..29c59cc9e 100644 --- a/packages/server/modules/pwdreset/rest/index.ts +++ b/packages/server/modules/pwdreset/rest/index.ts @@ -9,6 +9,7 @@ import { import { changePasswordFactory } from '@/modules/core/services/users/management' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' +import { getAllRegisteredDbs } from '@/modules/multiregion/utils/dbSelector' import { createTokenFactory, deleteTokensFactory, @@ -16,6 +17,7 @@ import { } from '@/modules/pwdreset/repositories' import { finalizePasswordResetFactory } from '@/modules/pwdreset/services/finalize' import { requestPasswordRecoveryFactory } from '@/modules/pwdreset/services/request' +import { asMultiregionalOperation } from '@/modules/shared/command' import { BadRequestError } from '@/modules/shared/errors' import { withOperationLogging } from '@/observability/domain/businessLogging' import { ensureError } from '@speckle/shared' @@ -55,25 +57,34 @@ export default function (app: Express) { app.post('/auth/pwdreset/finalize', async (req, res) => { const logger = req.log try { - const finalizePasswordReset = finalizePasswordResetFactory({ - getUserByEmail, - getPendingToken: getPendingTokenFactory({ db }), - deleteTokens: deleteTokensFactory({ db }), - updateUserPassword: changePasswordFactory({ - getUser: getUserFactory({ db }), - updateUser: updateUserFactory({ db }) - }), - deleteExistingAuthTokens: deleteExistingAuthTokensFactory({ db }) - }) - if (!req.body.tokenId || !req.body.password) throw new BadRequestError('Invalid request.') - await withOperationLogging( - async () => await finalizePasswordReset(req.body.tokenId, req.body.password), + await asMultiregionalOperation( + async ({ mainDb, allDbs }) => { + const finalizePasswordReset = finalizePasswordResetFactory({ + getUserByEmail, + getPendingToken: getPendingTokenFactory({ db: mainDb }), + deleteTokens: deleteTokensFactory({ db: mainDb }), + updateUserPassword: changePasswordFactory({ + getUser: getUserFactory({ db: mainDb }), + updateUser: async (...params) => { + const [res] = await Promise.all( + allDbs.map((db) => updateUserFactory({ db })(...params)) + ) + + return res + } + }), + deleteExistingAuthTokens: deleteExistingAuthTokensFactory({ db: mainDb }) + }) + + return await finalizePasswordReset(req.body.tokenId, req.body.password) + }, { logger, - operationName: 'finalizePasswordReset', - operationDescription: `Finalizing password reset` + dbs: await getAllRegisteredDbs(), + name: 'finalizePasswordReset', + description: `Finalizing password reset` } ) diff --git a/packages/server/modules/pwdreset/tests/pwdrest.spec.ts b/packages/server/modules/pwdreset/tests/pwdrest.spec.ts index fa72cf97f..9d98faf07 100644 --- a/packages/server/modules/pwdreset/tests/pwdrest.spec.ts +++ b/packages/server/modules/pwdreset/tests/pwdrest.spec.ts @@ -6,62 +6,9 @@ import { beforeEachContext } from '@/test/hooks' import { localAuthRestApi } from '@/modules/auth/tests/helpers/registration' import { expectToThrow } from '@/test/assertionHelper' import { expect } from 'chai' -import { - findEmailFactory, - createUserEmailFactory, - ensureNoPrimaryEmailForUserFactory -} from '@/modules/core/repositories/userEmails' -import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' -import { - getUserFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory -} from '@/modules/core/repositories/users' -import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' -import { renderEmail } from '@/modules/emails/services/emailRendering' -import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' -import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' -import { - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} from '@/modules/serverinvites/repositories/serverInvites' -import { getServerInfoFactory } from '@/modules/core/repositories/server' -import { getEventBus } from '@/modules/shared/services/eventBus' -import type { BasicTestUser } from '@/test/authHelper' +import { createTestUser } from '@/test/authHelper' const ResetTokens = () => knex('pwdreset_tokens') -const db = knex -const getServerInfo = getServerInfoFactory({ db }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) describe('Password reset requests @passwordresets', () => { let app: Awaited>['app'] @@ -71,13 +18,12 @@ describe('Password reset requests @passwordresets', () => { }) it('Should carefully send a password request email', async () => { - const userA: BasicTestUser = { + const userA = await createTestUser({ name: 'd1', email: 'd@speckle.systems', password: 'wowwow8charsplease', id: '' - } - userA.id = await createUser(userA) + }) // invalid request await request(app).post('/auth/pwdreset/request').expect(400) @@ -102,13 +48,12 @@ describe('Password reset requests @passwordresets', () => { }) it('Should reset passwords', async () => { - const userB: BasicTestUser = { + const userB = await createTestUser({ name: 'd2', email: 'd2@speckle.systems', password: 'w0ww0w8charsplease', id: '' - } - userB.id = await createUser(userB) + }) const authRestApi = localAuthRestApi({ express: app }) const newPassword = '12345678' diff --git a/packages/server/modules/shared/command.ts b/packages/server/modules/shared/command.ts index 8235b7be4..8236c7d39 100644 --- a/packages/server/modules/shared/command.ts +++ b/packages/server/modules/shared/command.ts @@ -1,5 +1,10 @@ import { mainDb } from '@/db/knex' -import { withTransaction } from '@/modules/shared/helpers/dbHelper' +import { + commitPreparedTransaction as commitPrepared, + prepareTransaction, + rollbackPreparedTransaction as rollbackPrepared, + withTransaction +} from '@/modules/shared/helpers/dbHelper' import type { EmitArg, EventBus, @@ -8,9 +13,12 @@ import type { import { getEventBus } from '@/modules/shared/services/eventBus' import { withOperationLogging } from '@/observability/domain/businessLogging' import type { MaybeAsync } from '@speckle/shared' +import cryptoRandomString from 'crypto-random-string' import type { Knex } from 'knex' import { isBoolean } from 'lodash-es' import type { Logger } from 'pino' +import { wasRejected } from '@/modules/shared/domain/constants' +import { RegionalTransactionFatalError } from '@/modules/shared/errors' /** * @deprecated asOperation does this and more. Also many usages of commandFactory are broken @@ -120,3 +128,142 @@ export const asOperation = async ( } ) } + +/** + * Utility function to execute a command across multiple regions + * works similarly to asOperation, but provides references to every db instance in the dbs array provided + * It opens a transaction for each db, and uses 2PC to ensure consistency at commit moment + * txs represents all the transactions + * dbTx represents the main transaction (Knex) + * regionTxs represents the transactions that were given as regions (Knex[]) + */ +export const asMultiregionalOperation = async ( + operation: (args: { + /** + * @description reference to all dbs involved in the operation + */ + allDbs: Knex[] + /** + * @description reference to the main db (first one passed in the array) + */ + mainDb: Knex + /** + * @description reference for second db (first one not main) + */ + regionDb: Knex + /** + * @description reference for all regions (all dbs except the main one) + */ + regionDbs: Knex[] + emit: EventBusEmit + }) => MaybeAsync, + params: { + name: string + logger: Logger + description?: string + /** + * @description Dbs to open transactions for the operation + */ + dbs: K + /** + * @description Defaults to main event bus + */ + eventBus?: EventBus + } +): Promise => { + const { + eventBus = getEventBus(), + logger, + name, + description, + dbs: [mainDb, ...regionDbs] + } = params + + return await withOperationLogging( + async () => { + const events: EmitArg[] = [] + const emit: EventBusEmit = async ({ eventName, payload }) => { + events.push({ eventName, payload }) + } + + const gid = cryptoRandomString({ length: 10 }) + const trxs: Knex.Transaction[] = [] + + const rollback = async () => { + await Promise.allSettled(trxs.map((trx) => rollbackPrepared(trx, gid))) + await Promise.allSettled(trxs.map((trx) => trx.rollback())) + } + + let result + try { + const mainDbTx = await mainDb.transaction() + trxs.push(mainDbTx) + + const regionDbsTx: Knex.Transaction[] = [] + for (const regionDb of regionDbs) { + const regionTx = await regionDb.transaction() + trxs.push(regionTx) + regionDbsTx.push(regionTx) + } + + result = await operation({ + mainDb: mainDbTx, + allDbs: trxs, + regionDb: regionDbsTx[0], + regionDbs: regionDbsTx, + emit + }) + + // Every transaction is prepared + // - important to do prepare sequentially + // - if a query won't complete, every preparedTransaction is rollbacked (from prepared or unprepared) + // - this applies a lock on the rows to be updated to assure that the commit will succeed. + // - the transactions once prepared, gets written to disk db and is no longer scoped to the connection. + // - this last part knex does not handle well, so no matter what, we need to rollback/commit + // the transaction (the prepared one and the connection transaction) that's why it's wrapped in a transaction block + for (const tx of trxs) await prepareTransaction(tx, gid) + } catch (e) { + await rollback() + throw e + } + + const commits = await Promise.allSettled( + trxs.map(async (trx) => { + await commitPrepared(trx, gid) + try { + await trx.commit() + } catch { + // forcing knex to release connection + // for the db this tx is gone already as its in a prepared state unbinded from the connection + // but knex does not know this, and it won't release the connection until a commit/rollback happen + } + }) + ) + + if (commits.some(wasRejected)) { + // we never should reach this point + // as once a transaction is prepared successfully + // it will commit + + logger.error( + { commits, gid }, + `Failed to commit transactions in 2PC operation.` + ) + + throw new RegionalTransactionFatalError( + 'Failed some or all transactions in 2PC operation.', + { clients: trxs, gid } + ) + } + + for (const event of events) await eventBus.emit(event) + + return result + }, + { + logger, + operationName: name, + operationDescription: description + } + ) +} diff --git a/packages/server/modules/shared/domain/constants.ts b/packages/server/modules/shared/domain/constants.ts index 397281db6..c2c3138bb 100644 --- a/packages/server/modules/shared/domain/constants.ts +++ b/packages/server/modules/shared/domain/constants.ts @@ -1,3 +1,8 @@ import { StringEnum } from '@speckle/shared' export const PromiseAllSettledResultStatus = StringEnum(['rejected', 'fulfilled']) + +export const wasRejected = ( + result: PromiseSettledResult +): result is PromiseRejectedResult => + result.status === PromiseAllSettledResultStatus.rejected diff --git a/packages/server/modules/shared/errors/index.ts b/packages/server/modules/shared/errors/index.ts index b01158531..d374585f1 100644 --- a/packages/server/modules/shared/errors/index.ts +++ b/packages/server/modules/shared/errors/index.ts @@ -163,33 +163,6 @@ export class TestOnlyLogicError extends BaseError { static statusCode = 500 } -const getErrorInfoFromTransactions = ( - preparedTransactions: { knex: Knex; preparedId: string }[] -) => { - return preparedTransactions.map(({ knex, preparedId }) => ({ - db: retrieveMetadataFromDatabaseClient(knex), - gid: preparedId - })) -} - -// 2PC failed but we successfully rolled back all prepared transactions. -export class RegionalTransactionError extends BaseError { - static code = 'REGIONAL_TRANSACTION_ERROR' - static defaultMessage = 'Failed to complete 2PC operation' - static statusCode = 500 - - constructor( - message?: string | null, - preparedTransactions: { knex: Knex; preparedId: string }[] = [] - ) { - super(message, { - info: { - clients: getErrorInfoFromTransactions(preparedTransactions) - } - }) - } -} - // 2PC failed and we failed to rollback. A prepared transaction may have been left behind. export class RegionalTransactionFatalError extends BaseError { static code = 'REGIONAL_TRANSACTION_FATAL_ERROR' @@ -197,12 +170,13 @@ export class RegionalTransactionFatalError extends BaseError { static statusCode = 500 constructor( - message?: string | null, - preparedTransactions: { knex: Knex; preparedId: string }[] = [] + message: string | null, + { gid, clients }: { gid: string; clients: Knex[] } ) { super(message, { info: { - clients: getErrorInfoFromTransactions(preparedTransactions) + gid, + dbs: clients.map(retrieveMetadataFromDatabaseClient) } }) } diff --git a/packages/server/modules/shared/helpers/dbHelper.ts b/packages/server/modules/shared/helpers/dbHelper.ts index 71e2b9681..2b72ffa58 100644 --- a/packages/server/modules/shared/helpers/dbHelper.ts +++ b/packages/server/modules/shared/helpers/dbHelper.ts @@ -1,12 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Knex } from 'knex' import { postgresMaxConnections } from '@/modules/shared/helpers/envHelper' -import { - EnvironmentResourceError, - LogicError, - RegionalTransactionFatalError, - RegionalTransactionError -} from '@/modules/shared/errors' +import { EnvironmentResourceError } from '@/modules/shared/errors' import type { MaybeAsync } from '@speckle/shared' import { isNonNullable } from '@speckle/shared' import { base64Decode, base64Encode } from '@/modules/shared/helpers/cryptoHelper' @@ -15,10 +10,6 @@ import dayjs from 'dayjs' import type { MaybeNullOrUndefined, Nullable } from '@speckle/shared' import type { SchemaConfig } from '@/modules/core/dbSchema' import { has, isObjectLike, isString, mapValues, pick, times } from 'lodash-es' -import cryptoRandomString from 'crypto-random-string' -import { logger } from '@/observability/logging' -import { isEqual } from 'lodash-es' -import { PromiseAllSettledResultStatus } from '@/modules/shared/domain/constants' export type Collection = { cursor: string | null @@ -312,16 +303,8 @@ export const compositeCursorTools = < } } -export const prepareTransaction = async (db: Knex): Promise => { - if (!db.isTransaction) { - throw new LogicError('Cannot PREPARE postgres operation outside of a transaction') - } - - const preparedId = cryptoRandomString({ length: 10 }) - - await db.raw(`PREPARE TRANSACTION '${preparedId}';`) - - return preparedId +export const prepareTransaction = async (db: Knex, gid: string): Promise => { + await db.raw(`PREPARE TRANSACTION '${gid}';`) } export const commitPreparedTransaction = async ( @@ -337,102 +320,3 @@ export const rollbackPreparedTransaction = async ( ): Promise => { await db.raw(`ROLLBACK PREPARED '${gid}';`) } - -export const replicateQuery = ( - dbs: Knex[], - factory: ({ db }: { db: Knex }) => (params: T) => Promise -) => { - return async (params: T) => { - const preparedTransactions: { - knex: Knex - preparedId: string - }[] = [] - - const returnValues: U[] = [] - - try { - // Phase 1: Prepare transaction across all specified db instances - for (const db of dbs) { - const trx = await db.transaction() - const returnValue = await factory({ db: trx })(params) - returnValues.push(returnValue) - const preparedId = await prepareTransaction(trx) - preparedTransactions.push({ knex: db, preparedId }) - } - - // Phase 2: Attempt commit of all prepared transactions - const results = await Promise.allSettled( - preparedTransactions.map(({ knex, preparedId }) => { - return commitPreparedTransaction(knex, preparedId) - }) - ) - - const errors = results.filter((result): result is PromiseRejectedResult => { - return result.status === PromiseAllSettledResultStatus.rejected - }) - - if (errors.length > 0) { - logger.error( - { - params, - errors, - errorCount: errors.length, - resultCount: results.length - }, - `Failed {errorCount} of {resultCount} transactions in 2PC operation.` - ) - throw new RegionalTransactionError( - 'Failed some or all transactions in 2PC operation.', - preparedTransactions - ) - } - - // TODO: Do we need this validation? - if (!returnValues.every((value) => isEqual(value, returnValues[0]))) { - throw new RegionalTransactionError( - 'Return values of 2PC transactions do not match', - preparedTransactions - ) - } - - return returnValues[0] - } catch { - const rollbacks = preparedTransactions.map(async ({ knex, preparedId }) => { - try { - await rollbackPreparedTransaction(knex, preparedId) - } catch (err) { - logger.error( - { preparedId }, - 'Failed to rollback prepared transaction {preparedId}' - ) - throw err - } - }) - - logger.warn( - { - preparedTransactions: preparedTransactions.map(({ preparedId }) => preparedId) - }, - 'Error during 2PC operation. Rolling back all transactions.' - ) - - const results = await Promise.allSettled(rollbacks) - - if ( - results.some( - (result) => result.status === PromiseAllSettledResultStatus.rejected - ) - ) { - throw new RegionalTransactionFatalError( - 'Failed to rollback all transactions.', - preparedTransactions - ) - } - - throw new RegionalTransactionError( - 'Failed to complete 2PC operation but successfully recovered.', - preparedTransactions - ) - } - } -} diff --git a/packages/server/modules/shared/test/dbHelper.spec.ts b/packages/server/modules/shared/test/dbHelper.spec.ts index 38f9eadce..8a8d80fb2 100644 --- a/packages/server/modules/shared/test/dbHelper.spec.ts +++ b/packages/server/modules/shared/test/dbHelper.spec.ts @@ -2,15 +2,18 @@ import { getDb } from '@/modules/multiregion/utils/dbSelector' import { Scopes } from '@/modules/core/dbSchema' import { expect } from 'chai' import type { Knex } from 'knex' -import { replicateQuery } from '@/modules/shared/helpers/dbHelper' import { isMultiRegionTestMode } from '@/test/speckle-helpers/regions' +import { db } from '@/db/knex' +import { sleep } from '@/test/helpers' +import { asMultiregionalOperation } from '@/modules/shared/command' +import { logger } from '@/observability/logging' isMultiRegionTestMode() ? describe('Prepared transaction utils (2PC) @multiregion', async () => { let main: Knex let region1: Knex let region2: Knex - let ALL_DBS: Knex[] = [] + let ALL_DBS: [Knex, ...Knex[]] = [db] const testOperationFactory = ({ db }: { db: Knex }) => @@ -23,12 +26,16 @@ isMultiRegionTestMode() } before(async () => { - main = await getDb({ regionKey: null }) + main = db region1 = await getDb({ regionKey: 'region1' }) region2 = await getDb({ regionKey: 'region2' }) ALL_DBS = [main, region1, region2] }) + beforeEach(async () => { + await db('users').del() + }) + it('successfully replicates operation across all specified db instances', async () => { const testOperationParams = { name: 'test:scope:a', @@ -36,7 +43,17 @@ isMultiRegionTestMode() public: false } - await replicateQuery(ALL_DBS, testOperationFactory)(testOperationParams) + await asMultiregionalOperation( + ({ allDbs }) => + Promise.all( + allDbs.map((db) => testOperationFactory({ db })(testOperationParams)) + ), + { + dbs: ALL_DBS, + name: 'testing regional success', + logger + } + ) const scopeMain = await main .table(Scopes.name) @@ -66,11 +83,17 @@ isMultiRegionTestMode() await testOperationFactory({ db: region2 })(testOperationParams) - const promise = replicateQuery( - ALL_DBS, - testOperationFactory - )(testOperationParams) - + const promise = asMultiregionalOperation( + ({ allDbs }) => + Promise.all( + allDbs.map((db) => testOperationFactory({ db })(testOperationParams)) + ), + { + dbs: ALL_DBS, + name: 'testing regional failure', + logger + } + ) await expect(promise).eventually.to.be.rejected const scopeMain = await main @@ -99,16 +122,20 @@ isMultiRegionTestMode() } const dbThatFails = { - transaction: () => - Promise.resolve(() => ({ - insert: () => Promise.resolve() - })) // will fail on raw call + transaction: async () => Promise.reject(new Error('Transaction failed')) } as unknown as Knex - const promise = replicateQuery( - [...ALL_DBS, dbThatFails], - testOperationFactory - )(testOperationParams) + const promise = asMultiregionalOperation( + ({ allDbs }) => + Promise.all( + allDbs.map((db) => testOperationFactory({ db })(testOperationParams)) + ), + { + dbs: [...ALL_DBS, dbThatFails], + name: 'testing regional success', + logger + } + ) await expect(promise).to.eventually.be.rejected @@ -129,5 +156,32 @@ isMultiRegionTestMode() expect(scopeRegion1).to.be.undefined expect(scopeRegion2).to.be.undefined }) + + it('does not has visibile perfomance issues using 2PC', async () => { + const connectionsUsedBefore = main.client.pool.numUsed() + + const oneKnexInstanceCall = async () => { + const { buildBasicTestUser, createTestUser } = await import( + '@/test/authHelper' + ) + + const user = buildBasicTestUser() + await createTestUser(user) // This uses the asMultireagionOperation helper } + } + + const manyParallelCreates = async () => { + await Promise.allSettled(Array.from({ length: 1000 }, oneKnexInstanceCall)) + } + + await manyParallelCreates() + + const [{ count }] = await db('users').count() + expect(count).to.eql(1000) + + await sleep(1000) // just in case + + const connectionsUsedAfter = main.client.pool.numUsed() + expect(connectionsUsedAfter).to.be.lte(connectionsUsedBefore) + }) }) : null diff --git a/packages/server/modules/stats/tests/stats.spec.ts b/packages/server/modules/stats/tests/stats.spec.ts index f0279febf..f670252eb 100644 --- a/packages/server/modules/stats/tests/stats.spec.ts +++ b/packages/server/modules/stats/tests/stats.spec.ts @@ -52,13 +52,7 @@ import { import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' import { getEventBus } from '@/modules/shared/services/eventBus' -import { - countAdminUsersFactory, - getUserFactory, - getUsersFactory, - storeUserAclFactory, - storeUserFactory -} from '@/modules/core/repositories/users' +import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/users' import { createUserEmailFactory, ensureNoPrimaryEmailForUserFactory, @@ -68,7 +62,6 @@ import { requestNewEmailVerificationFactory } from '@/modules/emails/services/ve import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' import { finalizeInvitedServerRegistrationFactory, @@ -92,6 +85,8 @@ import { validateStreamAccessFactory } from '@/modules/core/services/streams/access' import { authorizeResolver } from '@/modules/shared' +import type { BasicTestUser } from '@/test/authHelper' +import { createTestUser } from '@/test/authHelper' import { storeProjectRoleFactory } from '@/modules/core/repositories/projects' const getServerInfo = getServerInfoFactory({ db }) @@ -191,33 +186,6 @@ const createStream = legacyCreateStreamFactory({ emitEvent: getEventBus().emit }) }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) const createPersonalAccessToken = createPersonalAccessTokenFactory({ storeApiToken: storeApiTokenFactory({ db }), storeTokenScopes: storeTokenScopesFactory({ db }), @@ -253,23 +221,8 @@ describe('Server stats services @stats-services', function () { describe('Server stats api @stats-api', function () { let sendRequest: Awaited>['sendRequest'] - const adminUser = { - name: 'Dimitrie', - password: 'TestPasswordSecure', - email: 'spam@spam.spam', - id: '', // Will be filled in before() - goodToken: '', - badToken: '' - } - - const notAdminUser = { - name: 'Andrei', - password: 'TestPasswordSecure', - email: 'spasm@spam.spam', - id: '', // Will be filled in before() - goodToken: '', - badToken: '' - } + let adminUser: BasicTestUser & { goodToken?: string; badToken?: string } + let notAdminUser: BasicTestUser & { goodToken?: string; badToken?: string } const fullQuery = ` query{ @@ -291,7 +244,12 @@ describe('Server stats api @stats-api', function () { const ctx = await beforeEachContext() ;({ sendRequest } = await initializeTestServer(ctx)) - adminUser.id = await createUser(adminUser) + adminUser = await createTestUser({ + name: 'Dimitrie', + password: 'TestPasswordSecure', + email: 'spam@spam.spam', + id: '' + }) adminUser.goodToken = `Bearer ${await createPersonalAccessToken( adminUser.id, 'test token user A', @@ -303,7 +261,12 @@ describe('Server stats api @stats-api', function () { [Scopes.Streams.Read] )}` - notAdminUser.id = await createUser(notAdminUser) + notAdminUser = await createTestUser({ + name: 'Andrei', + password: 'TestPasswordSecure', + email: 'spasm@spam.spam', + id: '' + }) notAdminUser.goodToken = `Bearer ${await createPersonalAccessToken( notAdminUser.id, 'test token user A', @@ -369,22 +332,20 @@ async function seedDb({ numCommits = 10 } = {}) { // create users - const userPromises = [] + const users = [] for (let i = 0; i < numUsers; i++) { - const promise = createUser({ + const user = await createTestUser({ name: `User ${i}`, password: `SuperSecure${i}${i * 3.14}`, email: `user${i}@speckle.systems` }) - userPromises.push(promise) + users.push(user) } - const userIds = await Promise.all(userPromises) - // create streams const streamPromises: Array> = [] for (let i = 0; i < numStreams; i++) { - const ownerId = userIds[i >= userIds.length ? userIds.length - 1 : i] + const { id: ownerId } = users[i >= users.length ? users.length - 1 : i] const promise = createStream({ name: `Stream ${i}`, ownerId diff --git a/packages/server/modules/webhooks/tests/cleanup.spec.ts b/packages/server/modules/webhooks/tests/cleanup.spec.ts index c6a464614..81621606f 100644 --- a/packages/server/modules/webhooks/tests/cleanup.spec.ts +++ b/packages/server/modules/webhooks/tests/cleanup.spec.ts @@ -16,19 +16,12 @@ import { ensureNoPrimaryEmailForUserFactory, findEmailFactory } from '@/modules/core/repositories/userEmails' -import { - countAdminUsersFactory, - getUserFactory, - getUsersFactory, - storeUserAclFactory, - storeUserFactory -} from '@/modules/core/repositories/users' +import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/users' import { createStreamReturnRecordFactory, legacyCreateStreamFactory } from '@/modules/core/services/streams/management' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { createUserFactory } from '@/modules/core/services/users/management' import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' @@ -63,6 +56,7 @@ import { validateStreamAccessFactory } from '@/modules/core/services/streams/access' import { authorizeResolver } from '@/modules/shared' +import { createTestUser } from '@/test/authHelper' import { storeProjectRoleFactory } from '@/modules/core/repositories/projects' const WEBHOOKS_CONFIG_TABLE = 'webhooks_config' @@ -152,33 +146,6 @@ const createStream = legacyCreateStreamFactory({ emitEvent: getEventBus().emit }) }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) const countWebhooks = async () => { const [{ count }] = await WebhooksConfig().count() @@ -208,7 +175,7 @@ describe('Webhooks cleanup @webhooks', () => { }) it('Cleans orphans, leaves live ones intact', async () => { - const ownerId = await createUser({ + const { id: ownerId } = await createTestUser({ name: 'User', email: createRandomEmail(), password: createRandomPassword() diff --git a/packages/server/modules/webhooks/tests/webhooks.spec.ts b/packages/server/modules/webhooks/tests/webhooks.spec.ts index e5daf55d3..e8949b71c 100644 --- a/packages/server/modules/webhooks/tests/webhooks.spec.ts +++ b/packages/server/modules/webhooks/tests/webhooks.spec.ts @@ -46,13 +46,7 @@ import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/se import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' import { getEventBus } from '@/modules/shared/services/eventBus' import { createBranchFactory } from '@/modules/core/repositories/branches' -import { - getUserFactory, - getUsersFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory -} from '@/modules/core/repositories/users' +import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/users' import { findEmailFactory, createUserEmailFactory, @@ -62,7 +56,6 @@ import { requestNewEmailVerificationFactory } from '@/modules/emails/services/ve import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' -import { createUserFactory } from '@/modules/core/services/users/management' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' import { finalizeInvitedServerRegistrationFactory, @@ -86,6 +79,7 @@ import { } from '@/modules/core/services/streams/access' import { authorizeResolver } from '@/modules/shared' import { omit } from 'lodash-es' +import { createTestUser, type BasicTestUser } from '@/test/authHelper' import { storeProjectRoleFactory } from '@/modules/core/repositories/projects' const getServerInfo = getServerInfoFactory({ db }) @@ -173,33 +167,6 @@ const createStream = legacyCreateStreamFactory({ }) }) const grantPermissionsStream = grantStreamPermissionsFactory({ db }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) const createPersonalAccessToken = createPersonalAccessTokenFactory({ storeApiToken: storeApiTokenFactory({ db }), storeTokenScopes: storeTokenScopesFactory({ db }), @@ -213,13 +180,7 @@ describe('Webhooks @webhooks', () => { const getWebhook = getWebhookByIdFactory({ db }) let sendRequest: Awaited>['sendRequest'] - const userOne = { - name: 'User', - email: 'user@example.org', - password: 'jdsadjsadasfdsa', - id: '', - token: '' - } + let userOne: BasicTestUser & { token?: string } const streamOne = { name: 'streamOne', @@ -243,7 +204,12 @@ describe('Webhooks @webhooks', () => { const ctx = await beforeEachContext() ;({ sendRequest } = await initializeTestServer(ctx)) - userOne.id = await createUser(userOne) + userOne = await createTestUser({ + name: 'User', + email: 'user@example.org', + password: 'jdsadjsadasfdsa', + id: '' + }) streamOne.ownerId = userOne.id streamOne.id = await createStream(streamOne) @@ -380,13 +346,7 @@ describe('Webhooks @webhooks', () => { }) describe('GraphQL API Webhooks @webhooks-api', () => { - const userTwo = { - name: 'User2', - email: 'user2@example.org', - password: 'jdsadjsadasfdsa', - id: '', - token: '' - } + let userTwo: BasicTestUser & { token?: string } const webhookTwo = { streamId: '', @@ -407,7 +367,12 @@ describe('Webhooks @webhooks', () => { } before(async () => { - userTwo.id = await createUser(userTwo) + userTwo = await createTestUser({ + name: 'User2', + email: 'user2@example.org', + password: 'jdsadjsadasfdsa', + id: '' + }) streamTwo.ownerId = userTwo.id streamTwo.id = await createStream(streamTwo) webhookTwo.streamId = streamTwo.id diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index a7b8dfef5..96ea8348f 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -57,7 +57,6 @@ import { findVerifiedEmailsByUserIdFactory, updateUserEmailFactory } from '@/modules/core/repositories/userEmails' -import { withTransaction } from '@/modules/shared/helpers/dbHelper' import type { UserWithOptionalRole } from '@/modules/core/repositories/users' import { countAdminUsersFactory, @@ -142,6 +141,8 @@ import { createWorkspaceSeatFactory, getWorkspaceUserSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat' +import { getAllRegisteredDbs } from '@/modules/multiregion/utils/dbSelector' +import { asMultiregionalOperation } from '@/modules/shared/command' const moveAuthParamsToSessionMiddleware = moveAuthParamsToSessionMiddlewareFactory() const sessionMiddleware = sessionMiddlewareFactory() @@ -275,11 +276,11 @@ export const getSsoRouter = (): Router => { }), async (req, res, next) => { try { - await withTransaction( - async ({ db: trx }) => { + await asMultiregionalOperation( + async ({ mainDb, allDbs }) => { const handleOidcCallback = handleOidcCallbackFactory({ - getWorkspaceRoles: getWorkspaceRolesFactory({ db: trx }), - getWorkspaceBySlug: getWorkspaceBySlugFactory({ db: trx }), + getWorkspaceRoles: getWorkspaceRolesFactory({ db: mainDb }), + getWorkspaceBySlug: getWorkspaceBySlugFactory({ db: mainDb }), createOidcProvider: createOidcProviderFactory({ getOIDCProviderValidationRequest: getOIDCProviderValidationRequestFactory({ @@ -288,60 +289,66 @@ export const getSsoRouter = (): Router => { }), saveSsoProviderRegistration: saveSsoProviderRegistrationFactory({ getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({ - db: trx, + db: mainDb, decrypt: getDecryptor() }), storeProviderRecord: storeSsoProviderRecordFactory({ - db: trx, + db: mainDb, encrypt: getEncryptor() }), associateSsoProviderWithWorkspace: associateSsoProviderWithWorkspaceFactory({ - db: trx + db: mainDb }) }) }), getOidcProvider: getOidcProviderFactory({ getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({ - db: trx, + db: mainDb, decrypt: getDecryptor() }) }), getOidcProviderUserData: getOidcProviderUserDataFactory(), tryGetSpeckleUserData: tryGetSpeckleUserDataFactory({ - findEmail: findEmailFactory({ db: trx }), - getUser: getUserFactory({ db: trx }), - getUserEmails: findEmailsByUserIdFactory({ db: trx }) + findEmail: findEmailFactory({ db: mainDb }), + getUser: getUserFactory({ db: mainDb }), + getUserEmails: findEmailsByUserIdFactory({ db: mainDb }) }), createWorkspaceUserFromSsoProfile: createWorkspaceUserFromSsoProfileFactory({ createUser: createUserFactory({ - getServerInfo: getServerInfoFactory({ db: trx }), - findEmail: findEmailFactory({ db: trx }), - storeUser: storeUserFactory({ db: trx }), - countAdminUsers: countAdminUsersFactory({ db: trx }), - storeUserAcl: storeUserAclFactory({ db: trx }), + getServerInfo: getServerInfoFactory({ db: mainDb }), + findEmail: findEmailFactory({ db: mainDb }), + storeUser: async (...params) => { + const [user] = await Promise.all( + allDbs.map((db) => storeUserFactory({ db })(...params)) + ) + + return user + }, + countAdminUsers: countAdminUsersFactory({ db: mainDb }), + storeUserAcl: storeUserAclFactory({ db: mainDb }), validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db: trx }), + createUserEmail: createUserEmailFactory({ db: mainDb }), ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ - db: trx + db: mainDb }), - findEmail: findEmailFactory({ db: trx }), + findEmail: findEmailFactory({ db: mainDb }), updateEmailInvites: finalizeInvitedServerRegistrationFactory({ deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ - db: trx + db: mainDb }), updateAllInviteTargets: updateAllInviteTargetsFactory({ - db: trx + db: mainDb }) }), requestNewEmailVerification: requestNewEmailVerificationFactory({ - findEmail: findEmailFactory({ db: trx }), - getUser: getUserFactory({ db: trx }), - getServerInfo: getServerInfoFactory({ db: trx }), + findEmail: findEmailFactory({ db: mainDb }), + getUser: getUserFactory({ db: mainDb }), + getServerInfo: getServerInfoFactory({ db: mainDb }), deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ - db: trx + db: mainDb }), renderEmail, sendEmail @@ -351,46 +358,50 @@ export const getSsoRouter = (): Router => { }), addOrUpdateWorkspaceRole: addOrUpdateWorkspaceRoleFactory({ getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ - db: trx + db: mainDb }), findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ - db: trx + db: mainDb }), - getWorkspaceRoles: getWorkspaceRolesFactory({ db: trx }), - upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db: trx }), + getWorkspaceRoles: getWorkspaceRolesFactory({ db: mainDb }), + upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db: mainDb }), emitWorkspaceEvent: getEventBus().emit, ensureValidWorkspaceRoleSeat: ensureValidWorkspaceRoleSeatFactory({ - createWorkspaceSeat: createWorkspaceSeatFactory({ db: trx }), - getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db: trx }), + createWorkspaceSeat: createWorkspaceSeatFactory({ db: mainDb }), + getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db: mainDb }), getWorkspaceDefaultSeatType: getWorkspaceDefaultSeatTypeFactory({ - getWorkspace: getWorkspaceFactory({ db: trx }) + getWorkspace: getWorkspaceFactory({ db: mainDb }) }), eventEmit: getEventBus().emit }), assignWorkspaceSeat: assignWorkspaceSeatFactory({ - createWorkspaceSeat: createWorkspaceSeatFactory({ db: trx }), + createWorkspaceSeat: createWorkspaceSeatFactory({ db: mainDb }), getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ - db: trx + db: mainDb }), eventEmit: getEventBus().emit, - getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db: trx }) + getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db: mainDb }) }) }), - findInvite: findInviteFactory({ db: trx }), - deleteInvite: deleteInviteFactory({ db: trx }) + findInvite: findInviteFactory({ db: mainDb }), + deleteInvite: deleteInviteFactory({ db: mainDb }) }), linkUserWithSsoProvider: linkUserWithSsoProviderFactory({ - findEmailsByUserId: findEmailsByUserIdFactory({ db: trx }), - createUserEmail: createUserEmailFactory({ db: trx }), - updateUserEmail: updateUserEmailFactory({ db: trx }), + findEmailsByUserId: findEmailsByUserIdFactory({ db: mainDb }), + createUserEmail: createUserEmailFactory({ db: mainDb }), + updateUserEmail: updateUserEmailFactory({ db: mainDb }), logger: req.log }), - upsertUserSsoSession: upsertUserSsoSessionFactory({ db: trx }) + upsertUserSsoSession: upsertUserSsoSessionFactory({ db: mainDb }) }) await handleOidcCallback(req, res, next) }, - { db } + { + dbs: await getAllRegisteredDbs(), + logger: req.log, + name: 'oidc callback' + } ) return next() diff --git a/packages/server/modules/workspaces/tests/integration/workspaceSeat.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaceSeat.spec.ts index 67174e0c2..cbc9b7a6a 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaceSeat.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaceSeat.spec.ts @@ -26,6 +26,7 @@ import { beforeEachContext } from '@/test/hooks' import { Roles } from '@speckle/shared' import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' +import { v4 } from 'uuid' describe('Workspace workspaceSeat services', () => { describe('assignWorkspaceSeatFactory', () => { @@ -34,7 +35,9 @@ describe('Workspace workspaceSeat services', () => { name: createRandomString(), email: createRandomEmail(), role: Roles.Server.Admin, - verified: true + verified: true, + suuid: v4(), + createdAt: new Date() } before(async () => { @@ -57,7 +60,9 @@ describe('Workspace workspaceSeat services', () => { name: createRandomString(), email: createRandomEmail(), role: Roles.Server.User, - verified: true + verified: true, + suuid: v4(), + createdAt: new Date() } await createTestUser(user) @@ -85,14 +90,18 @@ describe('Workspace workspaceSeat services', () => { name: createRandomString(), email: createRandomEmail(), role: Roles.Server.Admin, - verified: true + verified: true, + suuid: v4(), + createdAt: new Date() } const testUser: BasicTestUser = { id: '', name: createRandomString(), email: createRandomEmail(), role: Roles.Server.User, - verified: true + verified: true, + suuid: v4(), + createdAt: new Date() } const workspace: BasicTestWorkspace = { ownerId: '', diff --git a/packages/server/scripts/seedUsers.ts b/packages/server/scripts/seedUsers.ts index 2f21b6f1e..5a6a60522 100644 --- a/packages/server/scripts/seedUsers.ts +++ b/packages/server/scripts/seedUsers.ts @@ -1,6 +1,5 @@ /* eslint-disable no-restricted-imports */ import '../bootstrap.js' -import { db } from '@/db/knex' import { logger } from '@/observability/logging' import { getServerInfoFactory } from '@/modules/core/repositories/server' import { @@ -25,40 +24,12 @@ import { updateAllInviteTargetsFactory } from '@/modules/serverinvites/repositories/serverInvites' import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' -import { getEventBus } from '@/modules/shared/services/eventBus' import axios from 'axios' - -const getServerInfo = getServerInfoFactory({ db }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) +import { asMultiregionalOperation } from '@/modules/shared/command.js' +import { getAllRegisteredDbs } from '@/modules/multiregion/utils/dbSelector.js' const main = async () => { - const userInputs: Array[0]> = ( + const userInputs: Array>[0]> = ( await axios.get('https://randomuser.me/api/?results=250') ).data.results.map( (user: { @@ -74,7 +45,55 @@ const main = async () => { } ) - await Promise.all(userInputs.map((userInput) => createUser(userInput))) + await Promise.all( + userInputs.map(async (userInput) => + asMultiregionalOperation( + async ({ mainDb, allDbs, emit }) => { + const createUser = createUserFactory({ + getServerInfo: getServerInfoFactory({ db: mainDb }), + findEmail: findEmailFactory({ db: mainDb }), + storeUser: async (...params) => { + const [user] = await Promise.all( + allDbs.map((db) => storeUserFactory({ db })(...params)) + ) + + return user + }, + countAdminUsers: countAdminUsersFactory({ db: mainDb }), + storeUserAcl: storeUserAclFactory({ db: mainDb }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db: mainDb }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ + db: mainDb + }), + findEmail: findEmailFactory({ db: mainDb }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db: mainDb }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db: mainDb }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db: mainDb }), + getUser: getUserFactory({ db: mainDb }), + getServerInfo: getServerInfoFactory({ db: mainDb }), + deleteOldAndInsertNewVerification: + deleteOldAndInsertNewVerificationFactory({ db: mainDb }), + renderEmail, + sendEmail + }) + }), + emitEvent: emit + }) + + return await createUser(userInput) + }, + { + logger, + name: 'seedUsers', + dbs: await getAllRegisteredDbs() + } + ) + ) + ) } void main() diff --git a/packages/server/test/authHelper.ts b/packages/server/test/authHelper.ts index 4fdd16283..e6d23e0f8 100644 --- a/packages/server/test/authHelper.ts +++ b/packages/server/test/authHelper.ts @@ -29,47 +29,22 @@ import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repos import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' +import { getTestRegionClients } from '@/modules/multiregion/tests/helpers' import { deleteServerOnlyInvitesFactory, updateAllInviteTargetsFactory } from '@/modules/serverinvites/repositories/serverInvites' import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' -import { getEventBus } from '@/modules/shared/services/eventBus' +import { asMultiregionalOperation } from '@/modules/shared/command' +import { logger } from '@/observability/logging' import { createTestContext, testApolloServer } from '@/test/graphqlHelper' import { faker } from '@faker-js/faker' import type { ServerScope } from '@speckle/shared' import { wait } from '@speckle/shared' import cryptoRandomString from 'crypto-random-string' import { assign, isArray, isNumber, omit, times } from 'lodash-es' +import { v4 } from 'uuid' -const getServerInfo = getServerInfoFactory({ db }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - emitEvent: getEventBus().emit -}) const createPersonalAccessToken = createPersonalAccessTokenFactory({ storeApiToken: storeApiTokenFactory({ db }), storeTokenScopes: storeTokenScopesFactory({ db }), @@ -127,10 +102,68 @@ export async function createTestUser(userObj?: Partial) { setVal('email', createRandomEmail().toLowerCase()) } - const id = await createUser(omit(baseUser, ['id', 'allowPersonalEmail']), { - skipPropertyValidation: true, - allowPersonalEmail: baseUser.allowPersonalEmail - }) + if (!baseUser.suuid) { + setVal('suuid', v4()) + } + + if (typeof baseUser.verified !== 'boolean') { + setVal('verified', false) + } + + if (!baseUser.createdAt) { + setVal('createdAt', new Date()) + } + + const id = await asMultiregionalOperation( + async ({ mainDb, allDbs, emit }) => { + const createUser = createUserFactory({ + getServerInfo: getServerInfoFactory({ db: mainDb }), + findEmail: findEmailFactory({ db: mainDb }), + storeUser: async (args) => { + const p = await Promise.all( + allDbs.map(async (db) => storeUserFactory({ db })(args)) + ) + + return p[0] + }, + countAdminUsers: countAdminUsersFactory({ db: mainDb }), + storeUserAcl: storeUserAclFactory({ db: mainDb }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db: mainDb }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ + db: mainDb + }), + findEmail: findEmailFactory({ db: mainDb }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db: mainDb }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db: mainDb }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db: mainDb }), + getUser: getUserFactory({ db: mainDb }), + getServerInfo: getServerInfoFactory({ db: mainDb }), + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory( + { db: mainDb } + ), + renderEmail, + sendEmail + }) + }), + emitEvent: emit + }) + + return await createUser(omit(baseUser, ['id', 'allowPersonalEmail']), { + skipPropertyValidation: true, + allowPersonalEmail: baseUser.allowPersonalEmail + }) + }, + { + dbs: await getTestRegionClients(), + logger, + name: 'createUser' + } + ) + setVal('id', id) return baseUser @@ -162,7 +195,9 @@ export const buildBasicTestUser = (overrides?: Partial): BasicTes id: cryptoRandomString({ length: 10 }), name: cryptoRandomString({ length: 10 }), email: createRandomEmail(), - verified: true + verified: true, + createdAt: new Date(), + suuid: v4() }, overrides ) From 0516cdc06a34e137a486bf0cbe380cfac05f7770 Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Thu, 28 Aug 2025 11:38:53 +0300 Subject: [PATCH 05/25] fix(fe2): model folders showing errors + some TS IDE optimization (#5329) * fix(fe2): dont show upload error for model folder row * chore: vscode TS server speedup --- jsconfig.base.json | 7 ------ .../project/page/models/StructureItem.vue | 9 -------- packages/objectloader/jsconfig.json | 7 ++++-- packages/viewer/jsconfig.json | 4 ---- packages/webhook-service/jsconfig.json | 6 ++++- tsconfig.json | 23 +++++++++++++++++++ workspace.code-workspace | 3 ++- 7 files changed, 35 insertions(+), 24 deletions(-) delete mode 100644 jsconfig.base.json delete mode 100644 packages/viewer/jsconfig.json create mode 100644 tsconfig.json diff --git a/jsconfig.base.json b/jsconfig.base.json deleted file mode 100644 index b1b48e841..000000000 --- a/jsconfig.base.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "compilerOptions": { - "target": "es2021", - "module": "commonJS" - }, - "exclude": ["node_modules"] -} diff --git a/packages/frontend-2/components/project/page/models/StructureItem.vue b/packages/frontend-2/components/project/page/models/StructureItem.vue index 5c6782ca1..cf592ab74 100644 --- a/packages/frontend-2/components/project/page/models/StructureItem.vue +++ b/packages/frontend-2/components/project/page/models/StructureItem.vue @@ -188,15 +188,6 @@
{{ name }}
- - -
diff --git a/packages/objectloader/jsconfig.json b/packages/objectloader/jsconfig.json index 525be0695..daf21af1f 100644 --- a/packages/objectloader/jsconfig.json +++ b/packages/objectloader/jsconfig.json @@ -1,5 +1,8 @@ { - "extends": "../../jsconfig.base.json", - "compilerOptions": {}, + "compilerOptions": { + "target": "es2021", + "module": "commonJS" + }, + "exclude": ["node_modules", "dist"], "include": ["src", "examples"] } diff --git a/packages/viewer/jsconfig.json b/packages/viewer/jsconfig.json deleted file mode 100644 index bc2182701..000000000 --- a/packages/viewer/jsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../jsconfig.base.json", - "include": ["src"] -} diff --git a/packages/webhook-service/jsconfig.json b/packages/webhook-service/jsconfig.json index bc2182701..684011d60 100644 --- a/packages/webhook-service/jsconfig.json +++ b/packages/webhook-service/jsconfig.json @@ -1,4 +1,8 @@ { - "extends": "../../jsconfig.base.json", + "compilerOptions": { + "target": "es2021", + "module": "commonJS" + }, + "exclude": ["node_modules"], "include": ["src"] } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..24731045f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + /* load each package separately, rather than as one giant progream */ + "files": [], + "references": [ + { "path": "packages/fileimport-service" }, + { "path": "packages/frontend-2" }, + { "path": "packages/monitor-deployment" }, + { "path": "packages/objectloader" }, + { "path": "packages/objectloader2" }, + { "path": "packages/objectsender" }, + { "path": "packages/preview-frontend" }, + { "path": "packages/preview-service" }, + { "path": "packages/server" }, + { "path": "packages/shared" }, + { "path": "packages/tailwind-theme" }, + { "path": "packages/ui-components" }, + { "path": "packages/ui-components-nuxt" }, + { "path": "packages/viewer" }, + { "path": "packages/viewer-sandbox" }, + { "path": "packages/webhook-service" } + /* …add all other packages listed in workspace.code-workspace */ + ] +} diff --git a/workspace.code-workspace b/workspace.code-workspace index 88c2425a3..1bdac3a62 100644 --- a/workspace.code-workspace +++ b/workspace.code-workspace @@ -97,7 +97,6 @@ "editor.defaultFormatter": "esbenp.prettier-vscode" }, "files.eol": "\n", - "volar.vueserver.maxOldSpaceSize": 4000, "cSpell.words": [ "Automations", "Bursty", @@ -111,6 +110,8 @@ "OIDC", "Prorotation" ], + "typescript.tsserver.maxTsServerMemory": 8192, + "typescript.disableAutomaticTypeAcquisition": true, "tailwindCSS.experimental.configFile": { "packages/frontend-2/tailwind.config.cjs": "packages/frontend-2/**" }, From bb2903350879fbe317951a02895459cb36790651 Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Thu, 28 Aug 2025 11:39:26 +0300 Subject: [PATCH 06/25] feat(fe2): name saved view groups upon creation (#5330) --- .../components/viewer/saved-views/Panel.vue | 27 ++---- .../saved-views/panel/groups/CreateDialog.vue | 92 +++++++++++++++++++ .../viewer/domain/operations/resources.ts | 7 ++ .../viewer/graph/resolvers/viewerResources.ts | 3 +- .../viewer/services/viewerResources.ts | 24 +++-- .../tests/integration/viewerResources.spec.ts | 40 +++++++- 6 files changed, 163 insertions(+), 30 deletions(-) create mode 100644 packages/frontend-2/components/viewer/saved-views/panel/groups/CreateDialog.vue diff --git a/packages/frontend-2/components/viewer/saved-views/Panel.vue b/packages/frontend-2/components/viewer/saved-views/Panel.vue index a4e4a3778..47de90b11 100644 --- a/packages/frontend-2/components/viewer/saved-views/Panel.vue +++ b/packages/frontend-2/components/viewer/saved-views/Panel.vue @@ -22,7 +22,7 @@ hide-text name="addGroup" :disabled="!canCreateViewOrGroup?.authorized || isLoading" - @click="onAddGroup" + @click="() => (showCreateGroupDialog = true)" />
@@ -95,6 +95,10 @@
+ diff --git a/packages/server/modules/viewer/domain/operations/resources.ts b/packages/server/modules/viewer/domain/operations/resources.ts index bec90c617..2ad7ac8c5 100644 --- a/packages/server/modules/viewer/domain/operations/resources.ts +++ b/packages/server/modules/viewer/domain/operations/resources.ts @@ -17,6 +17,13 @@ export type GetViewerResourceGroupsParams = ViewerUpdateTrackingTarget & { */ savedViewId?: MaybeNullOrUndefined savedViewSettings?: MaybeNullOrUndefined + /** + * If true and no savedViewId specified, we'll resolve if there's an appropriate home view for + * the specified resources and change the resourceIdString accordingly + * + * Default: false + */ + applyHomeView?: boolean } export type GetViewerResourceGroups = ( diff --git a/packages/server/modules/viewer/graph/resolvers/viewerResources.ts b/packages/server/modules/viewer/graph/resolvers/viewerResources.ts index 8fc9a1c3f..10b79e0ac 100644 --- a/packages/server/modules/viewer/graph/resolvers/viewerResources.ts +++ b/packages/server/modules/viewer/graph/resolvers/viewerResources.ts @@ -66,7 +66,8 @@ const extendedViewerResourcesResolver = async ( resourceIdString, loadedVersionsOnly, savedViewId, - savedViewSettings + savedViewSettings, + applyHomeView: true }) } diff --git a/packages/server/modules/viewer/services/viewerResources.ts b/packages/server/modules/viewer/services/viewerResources.ts index 4f668cf92..0c06a860b 100644 --- a/packages/server/modules/viewer/services/viewerResources.ts +++ b/packages/server/modules/viewer/services/viewerResources.ts @@ -477,12 +477,22 @@ const adjustResourceIdStringWithSavedViewSettingsFactory = resourceIdString: string savedViewId: MaybeNullOrUndefined savedViewSettings: MaybeNullOrUndefined + applyHomeView: MaybeNullOrUndefined }): Promise => { - const { savedViewId } = params + const { savedViewId, applyHomeView, resourceIdString } = params - return !isUndefined(savedViewId) - ? adjustResourceIdStringWithSpecificSavedViewSettingsFactory(deps)(params) - : adjustResourceIdStringWithHomeSavedViewSettingsFactory(deps)(params) + if (!isUndefined(savedViewId)) { + return adjustResourceIdStringWithSpecificSavedViewSettingsFactory(deps)(params) + } + + if (applyHomeView) { + return adjustResourceIdStringWithHomeSavedViewSettingsFactory(deps)(params) + } + + return { + resourceIdString, + savedView: undefined + } } /** @@ -501,7 +511,8 @@ export const getViewerResourceGroupsFactory = loadedVersionsOnly, allowEmptyModels, savedViewId, - savedViewSettings + savedViewSettings, + applyHomeView } = params let resourceIdStringWithSavedView: ResourceIdStringWithSavedView = { @@ -514,7 +525,8 @@ export const getViewerResourceGroupsFactory = resourceIdString: params.resourceIdString, projectId, savedViewId, - savedViewSettings + savedViewSettings, + applyHomeView }) } diff --git a/packages/server/modules/viewer/tests/integration/viewerResources.spec.ts b/packages/server/modules/viewer/tests/integration/viewerResources.spec.ts index a2cebdeab..cb11f1531 100644 --- a/packages/server/modules/viewer/tests/integration/viewerResources.spec.ts +++ b/packages/server/modules/viewer/tests/integration/viewerResources.spec.ts @@ -376,7 +376,8 @@ describe('Viewer Resources Collection Service', () => { resourceIdString, savedViewId: secondModelBasicView.id, loadedVersionsOnly: true, - savedViewSettings: { loadOriginal } + savedViewSettings: { loadOriginal }, + applyHomeView: true }) const expectedFinalResourceIdString = resourceBuilder() @@ -445,7 +446,8 @@ describe('Viewer Resources Collection Service', () => { projectId: myProject.id, resourceIdString: resources.toString(), savedViewId: undefined, - loadedVersionsOnly: true + loadedVersionsOnly: true, + applyHomeView: true }) expect(savedView?.id).to.equal(firstModelHomeView.id) @@ -461,6 +463,31 @@ describe('Viewer Resources Collection Service', () => { expect(homeViewGroup!.items[0].objectId).to.be.ok }) + it('dont load model home view, if !applyHomeView', async () => { + const sut = buildSUT() + const resources = resourceBuilder().addModel(homeViewModel().id) + + const { groups, savedView } = await sut({ + projectId: myProject.id, + resourceIdString: resources.toString(), + savedViewId: undefined, + loadedVersionsOnly: true, + applyHomeView: false + }) + + expect(savedView).to.not.be.ok + expect(groups).to.have.length(1) + + const homeViewGroup = groups[0] + expect(homeViewGroup).to.be.ok + expect(homeViewGroup!.items.length).to.equal(1) + expect(homeViewGroup!.items[0].modelId).to.equal(homeViewModel().id) + expect(homeViewGroup!.items[0].versionId).to.equal( + getModelVersions(homeViewModel().id).at(-1)!.id + ) // default: latest one + expect(homeViewGroup!.items[0].objectId).to.be.ok + }) + it("doesn't load home view if savedViewId explicitly null instead", async () => { const sut = buildSUT() const resources = resourceBuilder().addModel(homeViewModel().id) @@ -469,7 +496,8 @@ describe('Viewer Resources Collection Service', () => { projectId: myProject.id, resourceIdString: resources.toString(), savedViewId: null, - loadedVersionsOnly: true + loadedVersionsOnly: true, + applyHomeView: true }) expect(savedView).to.be.not.ok @@ -496,7 +524,8 @@ describe('Viewer Resources Collection Service', () => { projectId: myProject.id, resourceIdString: resources.toString(), savedViewId: undefined, - loadedVersionsOnly: true + loadedVersionsOnly: true, + applyHomeView: true }) expect(savedView).to.be.not.ok @@ -523,7 +552,8 @@ describe('Viewer Resources Collection Service', () => { projectId: myProject.id, resourceIdString: resources.toString(), savedViewId: undefined, - loadedVersionsOnly: true + loadedVersionsOnly: true, + applyHomeView: true }) expect(request.savedViewId).to.not.be.ok From 8dbd342a4035e18e5a24137e4e363e5c66a1ffc6 Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Thu, 28 Aug 2025 11:40:58 +0300 Subject: [PATCH 07/25] feat(fe2): view modes stored in saved views (and elsewhere) (#5320) * feat(fe2): view modes stored in saved views (and elsewhere) * lint fixes --- .../components/viewer/controls/Bottom.vue | 5 +- .../components/viewer/lightControls/Menu.vue | 4 +- .../components/viewer/view-modes/Menu.vue | 27 ++-- .../lib/viewer/composables/serialization.ts | 27 ++-- .../lib/viewer/composables/setup.ts | 41 +++--- .../lib/viewer/composables/setup/dev.ts | 8 +- .../lib/viewer/composables/setup/postSetup.ts | 14 +- .../lib/viewer/composables/setup/viewMode.ts | 127 ++++++++++++++++++ .../frontend-2/lib/viewer/composables/ui.ts | 82 +++-------- .../frontend-2/type-augmentations/window.d.ts | 1 + .../server/modules/comments/services/data.ts | 8 +- .../shared/src/viewer/helpers/state.spec.ts | 24 +++- packages/shared/src/viewer/helpers/state.ts | 27 +++- 13 files changed, 270 insertions(+), 125 deletions(-) create mode 100644 packages/frontend-2/lib/viewer/composables/setup/viewMode.ts diff --git a/packages/frontend-2/components/viewer/controls/Bottom.vue b/packages/frontend-2/components/viewer/controls/Bottom.vue index d86e36a7a..ce170fee3 100644 --- a/packages/frontend-2/components/viewer/controls/Bottom.vue +++ b/packages/frontend-2/components/viewer/controls/Bottom.vue @@ -93,7 +93,10 @@ const { const { getActiveMeasurement, removeMeasurement, enableMeasurements, hasMeasurements } = useMeasurementUtilities() const { resetExplode } = useFilterUtilities() -const { currentViewMode, setViewMode } = useViewModeUtilities() +const { + viewMode: { mode: currentViewMode }, + setViewMode +} = useViewModeUtilities() const { ui: { explodeFactor } } = useInjectedViewerState() diff --git a/packages/frontend-2/components/viewer/lightControls/Menu.vue b/packages/frontend-2/components/viewer/lightControls/Menu.vue index 02a75d454..0fd1fed6c 100644 --- a/packages/frontend-2/components/viewer/lightControls/Menu.vue +++ b/packages/frontend-2/components/viewer/lightControls/Menu.vue @@ -68,7 +68,9 @@ import { useViewModeUtilities } from '~/lib/viewer/composables/ui' import { TIME_MS } from '@speckle/shared' const mp = useMixpanel() -const { currentViewMode } = useViewModeUtilities() +const { + viewMode: { mode: currentViewMode } +} = useViewModeUtilities() const isLightingSupported = computed(() => { const supported = currentViewMode.value === ViewMode.DEFAULT diff --git a/packages/frontend-2/components/viewer/view-modes/Menu.vue b/packages/frontend-2/components/viewer/view-modes/Menu.vue index b1a4f62ea..ef17109c7 100644 --- a/packages/frontend-2/components/viewer/view-modes/Menu.vue +++ b/packages/frontend-2/components/viewer/view-modes/Menu.vue @@ -95,19 +95,15 @@ import { ViewMode } from '@speckle/viewer' import { useViewModeUtilities } from '~~/lib/viewer/composables/ui' import { ViewModeShortcuts } from '~/lib/viewer/helpers/shortcuts/shortcuts' import { FormSwitch } from '@speckle/ui-components' -import { useTheme } from '~/lib/core/composables/theme' +import { defaultEdgeColorValue } from '~/lib/viewer/composables/setup/viewMode' const { setViewMode, - currentViewMode, - edgesEnabled, toggleEdgesEnabled, setEdgesWeight, - edgesWeight, setEdgesColor, - edgesColor + viewMode: { edgesColor, edgesWeight, edgesEnabled, mode: currentViewMode } } = useViewModeUtilities() -const { isLightTheme } = useTheme() const showSettings = ref(false) @@ -115,14 +111,17 @@ const isActiveMode = (mode: ViewMode) => mode === currentViewMode.value const viewModeShortcuts = Object.values(ViewModeShortcuts) -const edgesColorOptions = computed(() => [ - isLightTheme.value || currentViewMode.value !== ViewMode.PEN ? 0x1a1a1a : 0xffffff, // black or white - 0x3b82f6, // blue-500 - 0x8b5cf6, // violet-500 - 0x65a30d, // lime-600 - 0xf97316, // orange-500 - 0xf43f5e //rose-500 -]) +const edgesColorOptions = computed( + () => + [ + defaultEdgeColorValue, // black or white + 0x3b82f6, // blue-500 + 0x8b5cf6, // violet-500 + 0x65a30d, // lime-600 + 0xf97316, // orange-500 + 0xf43f5e //rose-500 + ] as const +) const handleViewModeChange = (mode: ViewMode) => { setViewMode(mode) diff --git a/packages/frontend-2/lib/viewer/composables/serialization.ts b/packages/frontend-2/lib/viewer/composables/serialization.ts index 0d97b07ed..e23c03244 100644 --- a/packages/frontend-2/lib/viewer/composables/serialization.ts +++ b/packages/frontend-2/lib/viewer/composables/serialization.ts @@ -2,7 +2,7 @@ import { useInjectedViewerState, useResetUiState } from '~~/lib/viewer/composables/setup' -import { SpeckleViewer, TimeoutError } from '@speckle/shared' +import { isUndefinedOrVoid, SpeckleViewer, TimeoutError } from '@speckle/shared' import { get } from 'lodash-es' import { Vector3 } from 'three' import { @@ -10,7 +10,7 @@ import { useFilterUtilities, useSelectionUtilities } from '~~/lib/viewer/composables/ui' -import { CameraController, ViewMode, VisualDiffMode } from '@speckle/viewer' +import { CameraController, VisualDiffMode } from '@speckle/viewer' import type { NumericPropertyInfo } from '@speckle/viewer' import type { Merge, PartialDeep } from 'type-fest' import type { SectionBoxData } from '@speckle/shared/viewer/state' @@ -117,7 +117,13 @@ export function useStateSerialization() { isOrthoProjection: state.ui.camera.isOrthoProjection.value, zoom: (get(camControls, '_zoom') as unknown as number) || 1 // kinda hacky, _zoom is a protected prop }, - viewMode: state.ui.viewMode.value, + viewMode: { + mode: state.ui.viewMode.mode.value, + edgesEnabled: state.ui.viewMode.edgesEnabled.value, + edgesWeight: state.ui.viewMode.edgesWeight.value, + outlineOpacity: state.ui.viewMode.outlineOpacity.value, + edgesColor: state.ui.viewMode.edgesColor.value + }, sectionBox: state.ui.sectionBox.value ? box : null, lightConfig: { ...state.ui.lightConfig.value }, explodeFactor: state.ui.explodeFactor.value, @@ -361,11 +367,16 @@ export function useApplySerializedState() { } // Restore view mode - if (state.ui?.viewMode) { - viewMode.value = state.ui.viewMode - } else { - viewMode.value = ViewMode.DEFAULT - } + if (!isUndefinedOrVoid(state.ui?.viewMode?.mode)) + viewMode.mode.value = state.ui!.viewMode!.mode + if (!isUndefinedOrVoid(state.ui?.viewMode?.edgesEnabled)) + viewMode.edgesEnabled.value = state.ui!.viewMode!.edgesEnabled + if (!isUndefinedOrVoid(state.ui?.viewMode?.edgesWeight)) + viewMode.edgesWeight.value = state.ui!.viewMode!.edgesWeight + if (!isUndefinedOrVoid(state.ui?.viewMode?.outlineOpacity)) + viewMode.outlineOpacity.value = state.ui!.viewMode!.outlineOpacity + if (!isUndefinedOrVoid(state.ui?.viewMode?.edgesColor)) + viewMode.edgesColor.value = state.ui!.viewMode!.edgesColor explodeFactor.value = state.ui?.explodeFactor || 0 lightConfig.value = { diff --git a/packages/frontend-2/lib/viewer/composables/setup.ts b/packages/frontend-2/lib/viewer/composables/setup.ts index 96256ceb3..7468fde8f 100644 --- a/packages/frontend-2/lib/viewer/composables/setup.ts +++ b/packages/frontend-2/lib/viewer/composables/setup.ts @@ -6,17 +6,17 @@ import { MeasurementType, FilteringExtension } from '@speckle/viewer' -import { - type FilteringState, - type PropertyInfo, - type SunLightConfiguration, - type SpeckleView, - type MeasurementOptions, - type DiffResult, - type Viewer, - type WorldTree, - type VisualDiffMode, - ViewMode +import type { + ViewMode, + FilteringState, + PropertyInfo, + SunLightConfiguration, + SpeckleView, + MeasurementOptions, + DiffResult, + Viewer, + WorldTree, + VisualDiffMode } from '@speckle/viewer' import { inject, ref, provide } from 'vue' import type { ComputedRef, WritableComputedRef, Raw, Ref, ShallowRef } from 'vue' @@ -85,6 +85,8 @@ import { useBuildSavedViewsUIState, type SavedViewsUIState } from '~/lib/viewer/composables/savedViews/state' +import type { defaultEdgeColorValue } from '~/lib/viewer/composables/setup/viewMode' +import { useViewModesSetup } from '~/lib/viewer/composables/setup/viewMode' export type LoadedModel = NonNullable< Get @@ -313,7 +315,16 @@ export type InjectableViewerState = Readonly<{ target: Ref isOrthoProjection: Ref } - viewMode: Ref + viewMode: { + mode: Ref + edgesEnabled: Ref + edgesWeight: Ref + outlineOpacity: Ref + edgesColor: Ref + finalEdgesColor: ComputedRef + defaultEdgesColor: ComputedRef + resetViewMode: () => void + } diff: { newVersion: ComputedRef oldVersion: ComputedRef @@ -1104,7 +1115,7 @@ function setupInterfaceState( if (propertyFilter.value || isPropertyFilterApplied.value) return true return false }) - const viewMode = ref(ViewMode.DEFAULT) + const { viewMode } = useViewModesSetup() const highlightedObjectIds = ref([] as string[]) const spotlightUserSessionId = ref(null as Nullable) @@ -1143,6 +1154,7 @@ function setupInterfaceState( return { ...state, ui: { + viewMode, diff: { ...diffState }, @@ -1168,7 +1180,6 @@ function setupInterfaceState( target, isOrthoProjection }, - viewMode, sectionBox: ref(null as Nullable), sectionBoxContext: { visible: ref(false), @@ -1263,7 +1274,7 @@ export function useResetUiState() { sectionBox.value = null highlightedObjectIds.value = [] lightConfig.value = { ...DefaultLightConfiguration } - viewMode.value = ViewMode.DEFAULT + viewMode.resetViewMode() resetFilters() endDiff() } diff --git a/packages/frontend-2/lib/viewer/composables/setup/dev.ts b/packages/frontend-2/lib/viewer/composables/setup/dev.ts index 45c360bbd..8a37e02ee 100644 --- a/packages/frontend-2/lib/viewer/composables/setup/dev.ts +++ b/packages/frontend-2/lib/viewer/composables/setup/dev.ts @@ -42,12 +42,18 @@ function useDebugViewer() { // Get current viewer state window.VIEWER_STATE = () => fullViewerState - // Get serialized version of current state + // Get serialized version of current state as string window.VIEWER_SERIALIZED_STATE = (...args: Parameters) => { const serialized = serialize(...args) return JSON.stringify(serialized) } + // Get serialized version of current state as object + window.VIEWER_SERIALIZED_STATE_OBJECT = (...args: Parameters) => { + const serialized = serialize(...args) + return serialized + } + // Apply viewer state window.APPLY_VIEWER_STATE = ( state: SpeckleViewer.ViewerState.SerializedViewerState diff --git a/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts b/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts index b42695167..6677b8040 100644 --- a/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts +++ b/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts @@ -54,8 +54,7 @@ import { areVectorsLooselyEqual } from '~~/lib/viewer/helpers/three' import { SafeLocalStorage, type Nullable } from '@speckle/shared' import { useCameraUtilities, - useMeasurementUtilities, - useViewModeUtilities + useMeasurementUtilities } from '~~/lib/viewer/composables/ui' import { setupDebugMode } from '~~/lib/viewer/composables/setup/dev' import { useEmbed } from '~/lib/viewer/composables/setup/embed' @@ -64,6 +63,7 @@ import type { SectionBoxData } from '@speckle/shared/viewer/state' import { graphql } from '~/lib/common/generated/gql' import { useTreeManagement } from '~~/lib/viewer/composables/tree' import { useViewerSavedViewIntegration } from '~/lib/viewer/composables/savedViews/state' +import { useViewModesPostSetup } from '~/lib/viewer/composables/setup/viewMode' function useViewerLoadCompleteEventHandler() { const state = useInjectedViewerState() @@ -977,14 +977,6 @@ function useViewerCursorIntegration() { }) } -function useViewerViewModesIntegration() { - const { resetViewMode } = useViewModeUtilities() - - onBeforeUnmount(() => { - resetViewMode() - }) -} - export function useViewerPostSetup() { if (import.meta.server) return useViewerObjectAutoLoading() @@ -1005,6 +997,6 @@ export function useViewerPostSetup() { useDisableZoomOnEmbed() useViewerCursorIntegration() useViewerTreeIntegration() - useViewerViewModesIntegration() + useViewModesPostSetup() setupDebugMode() } diff --git a/packages/frontend-2/lib/viewer/composables/setup/viewMode.ts b/packages/frontend-2/lib/viewer/composables/setup/viewMode.ts new file mode 100644 index 000000000..0db57636a --- /dev/null +++ b/packages/frontend-2/lib/viewer/composables/setup/viewMode.ts @@ -0,0 +1,127 @@ +import { defaultViewModeEdgeColorValue } from '@speckle/shared/viewer/state' +import { ViewMode, ViewModes } from '@speckle/viewer' +import { watchTriggerable } from '@vueuse/core' +import { useTheme } from '~/lib/core/composables/theme' +import { useInjectedViewerState } from '~/lib/viewer/composables/setup' +import { useOnViewerLoadComplete } from '~/lib/viewer/composables/viewer' + +export const defaultEdgeColorValue = defaultViewModeEdgeColorValue +export const edgeColorDark = 0x1a1a1a +export const edgeColorLight = 0xffffff + +export const useViewModesSetup = () => { + const { isLightTheme } = useTheme() + + const mode = ref(ViewMode.DEFAULT) + const edgesEnabled = ref(true) + const edgesWeight = ref(1) + const outlineOpacity = ref(0.75) + const edgesColor = ref(defaultEdgeColorValue) + + const defaultEdgesColor = computed(() => { + if (mode.value === ViewMode.PEN) { + return isLightTheme.value ? edgeColorDark : edgeColorLight + } else { + return isLightTheme.value ? edgeColorLight : edgeColorDark + } + }) + + const finalEdgesColor = computed(() => { + if (edgesColor.value !== defaultEdgeColorValue) return edgesColor.value + return defaultEdgesColor.value + }) + + const resetViewMode = () => { + mode.value = ViewMode.DEFAULT + edgesEnabled.value = true + edgesWeight.value = 1 + outlineOpacity.value = 0.75 + edgesColor.value = defaultEdgeColorValue + } + + return { + viewMode: { + mode, + edgesEnabled, + edgesWeight, + outlineOpacity, + edgesColor, + finalEdgesColor, + defaultEdgesColor, + resetViewMode + } + } +} + +export const useViewModesPostSetup = () => { + const { + ui: { viewMode }, + viewer: { instance } + } = useInjectedViewerState() + const { + mode, + edgesEnabled, + edgesWeight, + outlineOpacity, + finalEdgesColor, + resetViewMode + } = viewMode + + const updateViewMode = () => { + const viewModes = instance.getExtension(ViewModes) + if (viewModes) { + viewModes.setViewMode(mode.value, { + edges: edgesEnabled.value, + outlineThickness: edgesWeight.value, + outlineOpacity: outlineOpacity.value, + outlineColor: finalEdgesColor.value + }) + } + } + + // state -> viewer + useOnViewerLoadComplete( + () => { + updateViewMode() + }, + { initialOnly: true } + ) + + const { ignoreUpdates: ignoreEdgesEnabledUpdates } = watchTriggerable( + edgesEnabled, + (newVal, oldVal) => { + if (oldVal === newVal) return + updateViewMode() + } + ) + watchTriggerable(edgesWeight, (newVal, oldVal) => { + if (oldVal === newVal) return + updateViewMode() + }) + const { ignoreUpdates: ignoreOutlineOpacityUpdates } = watchTriggerable( + outlineOpacity, + (newVal, oldVal) => { + if (oldVal === newVal) return + updateViewMode() + } + ) + watchTriggerable(finalEdgesColor, (newVal, oldVal) => { + if (oldVal === newVal) return + updateViewMode() + }) + watchTriggerable(mode, (newVal, oldVal) => { + if (oldVal === newVal) return + + if (newVal === ViewMode.PEN) { + ignoreOutlineOpacityUpdates(() => (outlineOpacity.value = 1)) + ignoreEdgesEnabledUpdates(() => (edgesEnabled.value = true)) + } else { + ignoreOutlineOpacityUpdates(() => (outlineOpacity.value = 0.75)) + } + updateViewMode() + }) + + onBeforeUnmount(() => { + resetViewMode() + }) +} diff --git a/packages/frontend-2/lib/viewer/composables/ui.ts b/packages/frontend-2/lib/viewer/composables/ui.ts index 4a85f803f..17f55d7ef 100644 --- a/packages/frontend-2/lib/viewer/composables/ui.ts +++ b/packages/frontend-2/lib/viewer/composables/ui.ts @@ -1,11 +1,11 @@ import { SpeckleViewer, TIME_MS, timeoutAt } from '@speckle/shared' -import { - type TreeNode, - type MeasurementOptions, - type PropertyInfo, +import type { + TreeNode, + MeasurementOptions, + PropertyInfo, ViewMode } from '@speckle/viewer' -import { MeasurementsExtension, ViewModes, MeasurementEvent } from '@speckle/viewer' +import { MeasurementsExtension, MeasurementEvent } from '@speckle/viewer' import { until } from '@vueuse/shared' import { useActiveElement } from '@vueuse/core' import { difference, isString, uniq } from 'lodash-es' @@ -25,9 +25,9 @@ import type { ViewerShortcut, ViewerShortcutAction } from '~/lib/viewer/helpers/shortcuts/types' -import { useTheme } from '~/lib/core/composables/theme' import { useMixpanel } from '~/lib/core/composables/mp' import { isStringPropertyInfo } from '~/lib/viewer/helpers/sceneExplorer' +import type { defaultEdgeColorValue } from '~/lib/viewer/composables/setup/viewMode' export function useSectionBoxUtilities() { const { instance } = useInjectedViewer() @@ -754,49 +754,11 @@ export function useHighlightedObjectsUtilities() { } export function useViewModeUtilities() { - const { instance } = useInjectedViewer() const { viewMode } = useInjectedViewerInterfaceState() - const { isLightTheme } = useTheme() const mp = useMixpanel() - const edgesEnabled = ref(true) - const edgesWeight = ref(1) - const outlineOpacity = ref(0.75) - const defaultColor = ref(0x1a1a1a) - const edgesColor = ref(defaultColor.value) - - const currentViewMode = computed(() => viewMode.value) - - const updateViewMode = () => { - const viewModes = instance.getExtension(ViewModes) - if (viewModes) { - viewModes.setViewMode(currentViewMode.value, { - edges: edgesEnabled.value, - outlineThickness: edgesWeight.value, - outlineOpacity: outlineOpacity.value, - outlineColor: edgesColor.value - }) - } - } - const setViewMode = (mode: ViewMode) => { - viewMode.value = mode - if (mode === ViewMode.PEN) { - outlineOpacity.value = 1 - edgesEnabled.value = true - if (edgesColor.value === defaultColor.value) { - if (!isLightTheme.value) { - edgesColor.value = 0xffffff - } - } - } else { - outlineOpacity.value = 0.75 - if (edgesColor.value === 0xffffff) { - edgesColor.value = isLightTheme.value ? 0xffffff : defaultColor.value - } - } - - updateViewMode() + viewMode.mode.value = mode mp.track('Viewer Action', { type: 'action', name: 'set-view-mode', @@ -805,28 +767,25 @@ export function useViewModeUtilities() { } const toggleEdgesEnabled = () => { - edgesEnabled.value = !edgesEnabled.value - updateViewMode() + viewMode.edgesEnabled.value = !viewMode.edgesEnabled.value mp.track('Viewer Action', { type: 'action', name: 'toggle-edges', - enabled: edgesEnabled.value + enabled: viewMode.edgesEnabled.value }) } const setEdgesWeight = (weight: number) => { - edgesWeight.value = Number(weight) - updateViewMode() + viewMode.edgesWeight.value = Number(weight) mp.track('Viewer Action', { type: 'action', name: 'set-edges-weight', - weight: edgesWeight.value + weight: viewMode.edgesWeight.value }) } - const setEdgesColor = (color: number) => { - edgesColor.value = color - updateViewMode() + const setEdgesColor = (color: number | typeof defaultEdgeColorValue) => { + viewMode.edgesColor.value = color mp.track('Viewer Action', { type: 'action', name: 'set-edges-color', @@ -834,24 +793,13 @@ export function useViewModeUtilities() { }) } - const resetViewMode = () => { - setViewMode(ViewMode.DEFAULT) - edgesEnabled.value = true - edgesWeight.value = 1 - outlineOpacity.value = 0.75 - edgesColor.value = defaultColor.value - } - return { - currentViewMode, + viewMode, setViewMode, - edgesEnabled, toggleEdgesEnabled, - edgesWeight, setEdgesWeight, setEdgesColor, - edgesColor, - resetViewMode + resetViewMode: viewMode.resetViewMode } } diff --git a/packages/frontend-2/type-augmentations/window.d.ts b/packages/frontend-2/type-augmentations/window.d.ts index 9ef805522..4aeeebfdb 100644 --- a/packages/frontend-2/type-augmentations/window.d.ts +++ b/packages/frontend-2/type-augmentations/window.d.ts @@ -14,6 +14,7 @@ declare global { VIEWER?: any VIEWER_STATE?: any VIEWER_SERIALIZED_STATE?: any + VIEWER_SERIALIZED_STATE_OBJECT?: any APPLY_VIEWER_STATE?: any APPLY_VIEWER_DD_EVENT?: any } diff --git a/packages/server/modules/comments/services/data.ts b/packages/server/modules/comments/services/data.ts index d6028d0a1..0007c7dc3 100644 --- a/packages/server/modules/comments/services/data.ts +++ b/packages/server/modules/comments/services/data.ts @@ -148,7 +148,13 @@ export const convertLegacyDataToStateFactory = isOrthoProjection: !!data.camPos?.[6], zoom: data.camPos?.[7] || 1 }, - viewMode: 0, + viewMode: { + mode: 0, + edgesColor: 0, + edgesEnabled: true, + outlineOpacity: 0.75, + edgesWeight: 1 + }, sectionBox: sectionBox ? { min: (sectionBox.min as number[]) || [0, 0, 0], diff --git a/packages/shared/src/viewer/helpers/state.spec.ts b/packages/shared/src/viewer/helpers/state.spec.ts index bd4af48e5..c2ceda6e6 100644 --- a/packages/shared/src/viewer/helpers/state.spec.ts +++ b/packages/shared/src/viewer/helpers/state.spec.ts @@ -43,7 +43,13 @@ describe('Viewer State helpers', () => { isOrthoProjection: false, zoom: 1 }, - viewMode: 0, + viewMode: { + mode: 0, + edgesColor: 0, + edgesEnabled: true, + outlineOpacity: 0, + edgesWeight: 0 + }, sectionBox: null, lightConfig: {}, explodeFactor: 0, @@ -149,7 +155,13 @@ describe('Viewer State helpers', () => { isOrthoProjection: false, zoom: 1 }, - viewMode: 0, + viewMode: { + mode: 0, + edgesColor: 0, + edgesEnabled: true, + outlineOpacity: 0, + edgesWeight: 0 + }, sectionBox: null, lightConfig: {}, explodeFactor: 0, @@ -202,7 +214,13 @@ describe('Viewer State helpers', () => { isOrthoProjection: false, zoom: 1 }, - viewMode: 0, + viewMode: { + mode: 0, + edgesColor: 0, + edgesEnabled: true, + outlineOpacity: 0, + edgesWeight: 0 + }, sectionBox: null, lightConfig: {}, explodeFactor: 0, diff --git a/packages/shared/src/viewer/helpers/state.ts b/packages/shared/src/viewer/helpers/state.ts index ef586728a..3bca59a27 100644 --- a/packages/shared/src/viewer/helpers/state.ts +++ b/packages/shared/src/viewer/helpers/state.ts @@ -1,9 +1,11 @@ -import { has, intersection, isObjectLike } from '#lodash' +import { has, intersection, isNumber, isObjectLike } from '#lodash' import type { MaybeNullOrUndefined, Nullable } from '../../core/helpers/utilityTypes.js' import type { PartialDeep } from 'type-fest' import { UnformattableSerializedViewerStateError } from '../errors/index.js' import { coerceUndefinedValuesToNull } from '../../core/index.js' +export const defaultViewModeEdgeColorValue = 'DEFAULT_EDGE_COLOR' + /** Redefining these is unfortunate. Especially since they are not part of viewer-core */ enum MeasurementType { PERPENDICULAR = 0, @@ -35,6 +37,9 @@ export interface SectionBoxData { * - ui.diff added * v1.2 -> v1.3 * - ui.filters.selectedObjectIds removed in favor of ui.filters.selectedObjectApplicationIds + * v1.3 -> 1.4 + * - ui.viewMode -> ui.viewMode.mode + * - ui.viewMode has new keys: edgesEnabled, edgesWeight, outlineOpacity, edgesColor */ export const SERIALIZED_VIEWER_STATE_VERSION = 1.3 @@ -88,7 +93,13 @@ export type SerializedViewerState = { isOrthoProjection: boolean zoom: number } - viewMode: number + viewMode: { + mode: number + edgesEnabled: boolean + edgesWeight: number + outlineOpacity: number + edgesColor: typeof defaultViewModeEdgeColorValue | number + } sectionBox: Nullable lightConfig: { intensity?: number @@ -174,6 +185,10 @@ const initializeMissingData = (state: UnformattedState): SerializedViewerState = ) } + const viewMode = isNumber(state.ui?.viewMode) + ? state.ui.viewMode + : state.ui?.viewMode?.mode + return { projectId: state.projectId || throwInvalidError('projectId'), sessionId: state.sessionId || `nullSessionId-${Math.random() * 1000}`, @@ -236,7 +251,13 @@ const initializeMissingData = (state: UnformattedState): SerializedViewerState = isOrthoProjection: state.ui?.camera?.isOrthoProjection || false, zoom: state.ui?.camera?.zoom || 1 }, - viewMode: state.ui?.viewMode || 0, + viewMode: { + mode: viewMode || 0, + edgesEnabled: state.ui?.viewMode?.edgesEnabled || false, + edgesWeight: state.ui?.viewMode?.edgesWeight || 1, + outlineOpacity: state.ui?.viewMode?.outlineOpacity || 0.75, + edgesColor: state.ui?.viewMode?.edgesColor || defaultViewModeEdgeColorValue + }, sectionBox: state.ui?.sectionBox?.min?.length && state.ui?.sectionBox.max?.length ? // Complains otherwise From 74ebb21594ffb2c87a9ee6724bc57da4e3569758 Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Thu, 28 Aug 2025 13:06:55 +0300 Subject: [PATCH 08/25] feat(fe2): updated saved view tabs (#5332) --- .../components/error/page/Renderer.vue | 6 +- .../components/viewer/saved-views/Panel.vue | 14 +---- .../viewer/saved-views/panel/views/Group.vue | 17 +++--- packages/frontend-2/composables/routing.ts | 55 ++++++++++++------- .../composables/savedViews/management.ts | 25 +++++---- .../lib/viewer/helpers/savedViews.ts | 17 +++--- 6 files changed, 69 insertions(+), 65 deletions(-) diff --git a/packages/frontend-2/components/error/page/Renderer.vue b/packages/frontend-2/components/error/page/Renderer.vue index a674d9749..f1acd6f68 100644 --- a/packages/frontend-2/components/error/page/Renderer.vue +++ b/packages/frontend-2/components/error/page/Renderer.vue @@ -25,10 +25,10 @@ const props = defineProps<{ isGenericErrorPage?: boolean }>() -const route = useRoute() +const route = useCurrentRouteTillNavigated() -const isProjectRoute = computed(() => route.path.match(/\/projects\/[^/]+/)) -const isWorkspaceRoute = computed(() => route.path.match(/\/workspaces\/[^/]+/)) +const isProjectRoute = computed(() => route.value.path.match(/\/projects\/[^/]+/)) +const isWorkspaceRoute = computed(() => route.value.path.match(/\/workspaces\/[^/]+/)) const finalError = computed(() => formatAppError(props.error)) const isNoProjectAccessError = computed( diff --git a/packages/frontend-2/components/viewer/saved-views/Panel.vue b/packages/frontend-2/components/viewer/saved-views/Panel.vue index 47de90b11..cd417cffd 100644 --- a/packages/frontend-2/components/viewer/saved-views/Panel.vue +++ b/packages/frontend-2/components/viewer/saved-views/Panel.vue @@ -106,10 +106,7 @@ import { useMutationLoading } from '@vue/apollo-composable' import { Search, FolderPlus, Plus, X } from 'lucide-vue-next' import { useSynchronizedCookie } from '~/lib/common/composables/reactiveCookie' import { graphql } from '~/lib/common/generated/gql' -import { - SavedViewVisibility, - WorkspaceSeatType -} from '~/lib/common/generated/gql/graphql' +import { WorkspaceSeatType } from '~/lib/common/generated/gql/graphql' import { useCreateSavedView } from '~/lib/viewer/composables/savedViews/management' import { useInjectedViewerState } from '~/lib/viewer/composables/setup' import { ViewsType, viewsTypeLabels } from '~/lib/viewer/helpers/savedViews' @@ -147,7 +144,7 @@ const createSavedView = useCreateSavedView() const isLoading = useMutationLoading() const { on, bind, value: search } = useDebouncedTextInput() -const selectedViewsType = ref(ViewsType.Personal) +const selectedViewsType = ref(ViewsType.All) const hideViewerSeatDisclaimer = useSynchronizedCookie( 'hideViewerSeatSavedViewsDisclaimer', { @@ -167,12 +164,7 @@ const isLowerPlan = computed(() => !project.value?.workspace?.planSupportsSavedV const onAddView = async () => { if (isLoading.value) return - const view = await createSavedView({ - visibility: - selectedViewsType.value === ViewsType.Shared - ? SavedViewVisibility.Public - : undefined - }) + const view = await createSavedView({}) if (view) { // Auto-open the group that the view created to openedGroupState.value.set(view.group.id, true) diff --git a/packages/frontend-2/components/viewer/saved-views/panel/views/Group.vue b/packages/frontend-2/components/viewer/saved-views/panel/views/Group.vue index e4b8f37ba..ad4361807 100644 --- a/packages/frontend-2/components/viewer/saved-views/panel/views/Group.vue +++ b/packages/frontend-2/components/viewer/saved-views/panel/views/Group.vue @@ -63,18 +63,17 @@ import type { LayoutMenuItem } from '@speckle/ui-components' import { useMutationLoading } from '@vue/apollo-composable' import { Ellipsis, Plus } from 'lucide-vue-next' import { graphql } from '~/lib/common/generated/gql' -import { - SavedViewVisibility, - type UseUpdateSavedViewGroup_SavedViewGroupFragment, - type ViewerSavedViewsPanelViewsGroup_ProjectFragment, - type ViewerSavedViewsPanelViewsGroup_SavedViewGroupFragment, - type ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroupFragment +import type { + UseUpdateSavedViewGroup_SavedViewGroupFragment, + ViewerSavedViewsPanelViewsGroup_ProjectFragment, + ViewerSavedViewsPanelViewsGroup_SavedViewGroupFragment, + ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroupFragment } from '~/lib/common/generated/gql/graphql' import { useCreateSavedView, useUpdateSavedViewGroup } from '~/lib/viewer/composables/savedViews/management' -import { ViewsType } from '~/lib/viewer/helpers/savedViews' +import type { ViewsType } from '~/lib/viewer/helpers/savedViews' const MenuItems = StringEnum(['Delete', 'Rename']) type MenuItems = StringEnumValues @@ -182,9 +181,7 @@ const onActionChosen = async (item: LayoutMenuItem) => { const onAddGroupView = async () => { await createView({ - groupId: props.group.id, - visibility: - props.viewsType === ViewsType.Shared ? SavedViewVisibility.Public : undefined + groupId: props.group.id }) open.value = true } diff --git a/packages/frontend-2/composables/routing.ts b/packages/frontend-2/composables/routing.ts index 642acdf00..00a2bcd38 100644 --- a/packages/frontend-2/composables/routing.ts +++ b/packages/frontend-2/composables/routing.ts @@ -4,9 +4,10 @@ import type { RouteLocationAsPathGeneric } from '#vue-router' import { buildManualPromise } from '@speckle/shared' +import { useScopedState } from '~/lib/common/composables/scopedState' const useRouterNavigatingState = () => - useState('use_router_navigating_state', () => ({ + useScopedState('use_router_navigating_state', () => ({ allActiveWaits: >>[], /** * Used for debugging to assign an incrementing id to each invocation @@ -20,8 +21,8 @@ const useRouterNavigatingDevUtils = () => { const ret = { getLogId: () => { - const newVal = state.value.logId + 1 - state.value.logId = newVal + const newVal = state.logId + 1 + state.logId = newVal return newVal + '' }, @@ -40,6 +41,16 @@ const useRouterNavigatingDevUtils = () => { devTrace(...args) }) }, + waitForNavigationsClear: async () => + await until($isNavigating) + .toBe(false, { + throwOnTimeout: true, + timeout: 500 + }) + .catch((err) => { + // Swallow throw, just log and continue + $logger.error({ err }, 'Waiting for nuxt navigations to clear timed out') + }), isNuxtNavigating: $isNavigating, logger: $logger } @@ -69,7 +80,7 @@ type SafeRouterNavigationOptions< * Supports debugRoutes=1 query param for debug logs */ export const useSafeRouter = () => { - const { getLogId, debugLog, debugTrace, isNuxtNavigating, logger } = + const { getLogId, debugLog, debugTrace, waitForNavigationsClear } = useRouterNavigatingDevUtils() const router = useRouter() const state = useRouterNavigatingState() @@ -87,27 +98,16 @@ export const useSafeRouter = () => { const waitPromise = buildManualPromise() const logId = getLogId() - const waitForNavigationsClear = async () => - await until(isNuxtNavigating) - .toBe(false, { - throwOnTimeout: true, - timeout: 500 - }) - .catch((err) => { - // Swallow throw, just log and continue - logger.error({ err }, 'Waiting for nuxt navigations to clear timed out') - }) - debugTrace(`[{logId}] Safe router ${action} registered`, { initialTo: to(), logId }) try { - const activeWaits = state.value.allActiveWaits.slice() + const activeWaits = state.allActiveWaits.slice() // Queue up another wait - state.value.allActiveWaits = [...state.value.allActiveWaits, waitPromise.promise] + state.allActiveWaits = [...state.allActiveWaits, waitPromise.promise] // Wait for all previously queued up waits await Promise.allSettled(activeWaits) @@ -150,7 +150,7 @@ export const useSafeRouter = () => { logId, navResult }) - state.value.allActiveWaits = state.value.allActiveWaits.filter( + state.allActiveWaits = state.allActiveWaits.filter( (p) => p !== waitPromise.promise ) waitPromise.resolve() @@ -160,7 +160,7 @@ export const useSafeRouter = () => { waitPromise.reject(e) throw e } finally { - state.value.allActiveWaits = state.value.allActiveWaits.filter( + state.allActiveWaits = state.allActiveWaits.filter( (p) => p !== waitPromise.promise ) } @@ -186,3 +186,20 @@ export const useSafeRouter = () => { return { ...router, push, replace } } + +/** + * Similar to useRoute, but will not change the value until the new/incoming route has fully finished navigating + */ +export const useCurrentRouteTillNavigated = () => { + const baseRoute = useRoute() + const { $isNavigating } = useNuxtApp() + const route = shallowRef({ ...toRaw(baseRoute) }) + + watch($isNavigating, (newVal, oldVal) => { + if (!newVal && oldVal) { + route.value = { ...toRaw(baseRoute) } + } + }) + + return route +} diff --git a/packages/frontend-2/lib/viewer/composables/savedViews/management.ts b/packages/frontend-2/lib/viewer/composables/savedViews/management.ts index 0d6586061..ecbba4aad 100644 --- a/packages/frontend-2/lib/viewer/composables/savedViews/management.ts +++ b/packages/frontend-2/lib/viewer/composables/savedViews/management.ts @@ -240,7 +240,7 @@ export const useUpdateSavedView = () => { const { input } = params const oldGroupId = params.view.group.id - const oldVisibility = params.view.visibility + // const oldVisibility = params.view.visibility const result = await mutate( { input }, @@ -267,17 +267,18 @@ export const useUpdateSavedView = () => { }) } - const newVisibility = update.visibility - const visibilityChanged = oldVisibility !== newVisibility - if (visibilityChanged) { - // Update all SavedViewGroup.views to see if it now should appear in there or not - modifyObjectField( - cache, - getCacheId('SavedViewGroup', newGroupId), - 'views', - ({ helpers: { evict } }) => evict() - ) - } + // W/ current filter setup, if u can change visibility, you're gonna see it in all filtered groups + // const newVisibility = update.visibility + // const visibilityChanged = oldVisibility !== newVisibility + // if (visibilityChanged) { + // // Update all SavedViewGroup.views to see if it now should appear in there or not + // modifyObjectField( + // cache, + // getCacheId('SavedViewGroup', newGroupId), + // 'views', + // ({ helpers: { evict } }) => evict() + // ) + // } } } ).catch(convertThrowIntoFetchResult) diff --git a/packages/frontend-2/lib/viewer/helpers/savedViews.ts b/packages/frontend-2/lib/viewer/helpers/savedViews.ts index a3c1285aa..f53e5eb00 100644 --- a/packages/frontend-2/lib/viewer/helpers/savedViews.ts +++ b/packages/frontend-2/lib/viewer/helpers/savedViews.ts @@ -1,16 +1,15 @@ import { throwUncoveredError, type StringEnumValues } from '@speckle/shared' import { isObjectLike, isString } from 'lodash-es' -import { SavedViewVisibility } from '~/lib/common/generated/gql/graphql' export const ViewsType = { - Personal: 'personal', - Shared: 'shared' + All: 'all', + Mine: 'mine' } as const export type ViewsType = StringEnumValues export const viewsTypeLabels: Record = { - [ViewsType.Personal]: 'Personal', - [ViewsType.Shared]: 'Shared' + [ViewsType.All]: 'All views', + [ViewsType.Mine]: 'My views' } /** @@ -45,14 +44,12 @@ export const serializeSavedViewUrlSettings = ( } export const viewsTypeToFilters = (type: ViewsType) => { - if (type === ViewsType.Personal) { + if (type === ViewsType.Mine) { return { onlyAuthored: true } - } else if (type === ViewsType.Shared) { - return { - onlyVisibility: SavedViewVisibility.Public - } + } else if (type === ViewsType.All) { + return {} } else { throwUncoveredError(type) } From dbb3c4a374f47d466e344ef6d224f0a159dcf7c5 Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Thu, 28 Aug 2025 13:07:02 +0300 Subject: [PATCH 09/25] feat: make saved views shared by default + copy/ui changes (#5331) * make default visibility public * show icon for private * make view shared/private menu * Public -> Shared --- .../components/viewer/saved-views/panel/View.vue | 9 ++++----- .../viewer/saved-views/panel/view/EditDialog.vue | 2 +- .../modules/viewer/services/savedViewsManagement.ts | 2 +- .../tests/integration/savedViewsCrud.graph.spec.ts | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/frontend-2/components/viewer/saved-views/panel/View.vue b/packages/frontend-2/components/viewer/saved-views/panel/View.vue index 3b3a43818..844679624 100644 --- a/packages/frontend-2/components/viewer/saved-views/panel/View.vue +++ b/packages/frontend-2/components/viewer/saved-views/panel/View.vue @@ -64,8 +64,8 @@
- [][] => [ }, { id: MenuItems.ChangeVisibility, - title: 'Share view to workspace', - active: !isOnlyVisibleToMe.value, + title: isOnlyVisibleToMe.value ? 'Make view shared' : 'Make view private', disabled: !canUpdate.value?.authorized || isLoading.value, disabledTooltip: canUpdate.value.errorMessage } diff --git a/packages/frontend-2/components/viewer/saved-views/panel/view/EditDialog.vue b/packages/frontend-2/components/viewer/saved-views/panel/view/EditDialog.vue index 872bcff84..25cb32d91 100644 --- a/packages/frontend-2/components/viewer/saved-views/panel/view/EditDialog.vue +++ b/packages/frontend-2/components/viewer/saved-views/panel/view/EditDialog.vue @@ -111,7 +111,7 @@ const buttons = computed((): LayoutDialogButton[] => [ const radioOptions = computed((): FormRadioGroupItem[] => [ { value: SavedViewVisibility.Public, - title: 'Public', + title: 'Shared', introduction: 'Visible to anyone with access to the model.', icon: Globe }, diff --git a/packages/server/modules/viewer/services/savedViewsManagement.ts b/packages/server/modules/viewer/services/savedViewsManagement.ts index 443623949..b90da59b8 100644 --- a/packages/server/modules/viewer/services/savedViewsManagement.ts +++ b/packages/server/modules/viewer/services/savedViewsManagement.ts @@ -196,7 +196,7 @@ export const createSavedViewFactory = }): CreateSavedView => async ({ input, authorId }) => { const { resourceIdString, projectId } = input - const visibility = input.visibility || SavedViewVisibility.authorOnly + const visibility = input.visibility || SavedViewVisibility.public // default to public const position = 0 // TODO: Resolve based on existing views const groupId = input.groupId?.trim() || null const description = input.description?.trim() || null diff --git a/packages/server/modules/viewer/tests/integration/savedViewsCrud.graph.spec.ts b/packages/server/modules/viewer/tests/integration/savedViewsCrud.graph.spec.ts index eb0ff1c3f..da8df80ba 100644 --- a/packages/server/modules/viewer/tests/integration/savedViewsCrud.graph.spec.ts +++ b/packages/server/modules/viewer/tests/integration/savedViewsCrud.graph.spec.ts @@ -541,7 +541,7 @@ const fakeViewerState = (overrides?: PartialDeep r.toString()) ) expect(view!.isHomeView).to.be.false - expect(view!.visibility).to.equal(SavedViewVisibility.authorOnly) // default + expect(view!.visibility).to.equal(SavedViewVisibility.public) // default expect(view!.viewerState).to.deep.equalInAnyOrder(viewerState) expect(view!.screenshot).to.equal(fakeScreenshot) expect(view!.position).to.equal(0) // default position From e7ed024d52822fbe3ef971aac761e6ced5306ae4 Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Thu, 28 Aug 2025 14:32:35 +0300 Subject: [PATCH 10/25] fix: prevent making a home view personal (#5334) --- .../viewer/saved-views/panel/View.vue | 49 +++---- .../saved-views/panel/view/EditDialog.vue | 37 ++--- .../lib/common/generated/gql/gql.ts | 18 ++- .../lib/common/generated/gql/graphql.ts | 19 +-- .../composables/savedViews/validation.ts | 134 ++++++++++++++++++ .../viewer/services/savedViewsManagement.ts | 6 +- .../integration/savedViewsCrud.graph.spec.ts | 26 ++++ packages/shared/src/core/helpers/utility.ts | 12 ++ workspace.code-workspace | 1 - 9 files changed, 227 insertions(+), 75 deletions(-) create mode 100644 packages/frontend-2/lib/viewer/composables/savedViews/validation.ts diff --git a/packages/frontend-2/components/viewer/saved-views/panel/View.vue b/packages/frontend-2/components/viewer/saved-views/panel/View.vue index 844679624..8b3101891 100644 --- a/packages/frontend-2/components/viewer/saved-views/panel/View.vue +++ b/packages/frontend-2/components/viewer/saved-views/panel/View.vue @@ -108,6 +108,7 @@ import { useCollectNewSavedViewViewerData, useUpdateSavedView } from '~/lib/viewer/composables/savedViews/management' +import { useSavedViewValidationHelpers } from '~/lib/viewer/composables/savedViews/validation' import { useInjectedViewerState } from '~/lib/viewer/composables/setup' const MenuItems = StringEnum([ @@ -143,6 +144,7 @@ graphql(` ...UseDeleteSavedView_SavedView ...UseUpdateSavedView_SavedView ...ViewerSavedViewsPanelViewEditDialog_SavedView + ...UseSavedViewValidationHelpers_SavedView } `) @@ -161,15 +163,19 @@ const isLoading = useMutationLoading() const { copyLink, applyView } = useViewerSavedViewsUtils() const eventBus = useEventBus() const { formattedRelativeDate, formattedFullDate } = useDateFormatters() +const { + canUpdate, + isOnlyVisibleToMe, + canSetHomeView, + isHomeView, + canToggleVisibility +} = useSavedViewValidationHelpers({ + view: computed(() => props.view) +}) const showMenu = ref(false) const menuId = useId() -const canUpdate = computed(() => props.view.permissions.canUpdate) -const isOnlyVisibleToMe = computed( - () => props.view.visibility === SavedViewVisibility.AuthorOnly -) -const isHomeView = computed(() => props.view.isHomeView) const isActive = computed(() => props.view.id === savedView.value?.id) const isOriginalVersionAlreadyLoaded = computed(() => { @@ -188,29 +194,6 @@ const canLoadOriginal = computed( } ) -const canSetHomeView = computed( - (): { authorized: boolean; message: Optional } => { - if (!canUpdate.value?.authorized || isLoading.value) { - return { authorized: false, message: canUpdate.value.errorMessage || undefined } - } - - if (isFederatedView.value) { - return { - authorized: false, - message: "Home view settings can't be updated while in a federated view" - } - } - - if (isOnlyVisibleToMe.value) { - return { - authorized: false, - message: 'A view must be shared to be set as home view' - } - } - - return { authorized: true, message: undefined } - } -) const menuItems = computed((): LayoutMenuItem[][] => [ [ { @@ -223,13 +206,13 @@ const menuItems = computed((): LayoutMenuItem[][] => [ id: MenuItems.ReplaceView, title: 'Replace view', disabled: !canUpdate.value?.authorized || isLoading.value, - disabledTooltip: canUpdate.value.errorMessage + disabledTooltip: canUpdate.value?.errorMessage }, { id: MenuItems.MoveToGroup, title: 'Move to group', disabled: !canUpdate.value?.authorized || isLoading.value, - disabledTooltip: canUpdate.value.errorMessage + disabledTooltip: canUpdate.value?.errorMessage }, { id: MenuItems.CopyLink, @@ -247,8 +230,8 @@ const menuItems = computed((): LayoutMenuItem[][] => [ { id: MenuItems.ChangeVisibility, title: isOnlyVisibleToMe.value ? 'Make view shared' : 'Make view private', - disabled: !canUpdate.value?.authorized || isLoading.value, - disabledTooltip: canUpdate.value.errorMessage + disabled: !canToggleVisibility.value.authorized, + disabledTooltip: canToggleVisibility.value.message } ], [ @@ -256,7 +239,7 @@ const menuItems = computed((): LayoutMenuItem[][] => [ id: MenuItems.Delete, title: 'Delete', disabled: !canUpdate.value?.authorized || isLoading.value, - disabledTooltip: canUpdate.value.errorMessage + disabledTooltip: canUpdate.value?.errorMessage } ] ]) diff --git a/packages/frontend-2/components/viewer/saved-views/panel/view/EditDialog.vue b/packages/frontend-2/components/viewer/saved-views/panel/view/EditDialog.vue index 25cb32d91..996cf62c0 100644 --- a/packages/frontend-2/components/viewer/saved-views/panel/view/EditDialog.vue +++ b/packages/frontend-2/components/viewer/saved-views/panel/view/EditDialog.vue @@ -31,28 +31,28 @@ :rules="[isRequired]" />
diff --git a/packages/frontend-2/components/viewer/saved-views/panel/views/Group.vue b/packages/frontend-2/components/viewer/saved-views/panel/views/Group.vue index ad4361807..7a4703b6a 100644 --- a/packages/frontend-2/components/viewer/saved-views/panel/views/Group.vue +++ b/packages/frontend-2/components/viewer/saved-views/panel/views/Group.vue @@ -37,6 +37,7 @@
@@ -151,7 +154,7 @@ const menuItems = computed((): LayoutMenuItem[][] => [ [ { id: MenuItems.Rename, - title: 'Rename', + title: 'Rename group', disabled: !canUpdate.value?.authorized || isLoading.value, disabledTooltip: canUpdate.value.errorMessage } @@ -159,7 +162,7 @@ const menuItems = computed((): LayoutMenuItem[][] => [ [ { id: MenuItems.Delete, - title: 'Delete', + title: 'Delete group...', disabled: !canUpdate.value?.authorized || isLoading.value, disabledTooltip: canUpdate.value.errorMessage } diff --git a/packages/frontend-2/components/viewer/saved-views/panel/views/group/Inner.vue b/packages/frontend-2/components/viewer/saved-views/panel/views/group/Inner.vue index 3eed66f96..6b2d0aa71 100644 --- a/packages/frontend-2/components/viewer/saved-views/panel/views/group/Inner.vue +++ b/packages/frontend-2/components/viewer/saved-views/panel/views/group/Inner.vue @@ -24,7 +24,7 @@ - -
- Saved Views -
-
Save custom views
-
-

Upgrade to a business plan to save, organise and present

-
    -
  • It's cool
  • -
  • It's nice
  • -
  • It's got enough spice
  • -
-
-
-
- Upgrade - Learn more -
-
- diff --git a/packages/server/modules/viewer/tests/integration/savedViewsCrud.graph.spec.ts b/packages/server/modules/viewer/tests/integration/savedViewsCrud.graph.spec.ts index 5482647cf..db7d11c23 100644 --- a/packages/server/modules/viewer/tests/integration/savedViewsCrud.graph.spec.ts +++ b/packages/server/modules/viewer/tests/integration/savedViewsCrud.graph.spec.ts @@ -74,7 +74,7 @@ import { Roles, WorkspacePlans } from '@speckle/shared' import { ProjectNotEnoughPermissionsError, SavedViewNoAccessError, - WorkspacePlanNoFeatureAccessError + WorkspaceNoAccessError } from '@speckle/shared/authz' import * as ViewerRoute from '@speckle/shared/viewer/route' import { resourceBuilder } from '@speckle/shared/viewer/route' @@ -121,7 +121,6 @@ const fakeViewerState = (overrides?: PartialDeep { - const res = await createSavedView( - buildCreateInput({ - projectId: myLackingProject.id, - resourceIdString: 'abc' - }) - ) - expect(res).to.haveGraphQLErrors({ code: ForbiddenError.code }) - expect(res.data?.projectMutations.savedViewMutations.createView).to.not.be.ok - }) - - it('should fail with ForbiddenError to create a saved view group if user lacks access (free plan)', async () => { - const resourceIds = ViewerRoute.resourceBuilder().addModel( - myLackingProject.id - ) - const resourceIdString = resourceIds.toString() - - const res = await createSavedViewGroup({ - input: { - projectId: myLackingProject.id, - resourceIdString, - groupName: 'Should Not Work' - } - }) - - expect(res).to.haveGraphQLErrors({ code: ForbiddenError.code }) - expect(res.data?.projectMutations.savedViewMutations.createGroup).to.not.be.ok - }) - - it('should fail with ForbiddenError to create a saved view if user lacks access (free plan)', async () => { - const resourceIds = ViewerRoute.resourceBuilder().addModel( - myLackingProject.id - ) - const resourceIdString = resourceIds.toString() - const viewerState = fakeViewerState({ - projectId: myLackingProject.id, - resources: { - request: { - resourceIdString - } - } - }) - - const res = await createSavedView( - buildCreateInput({ - projectId: myLackingProject.id, - resourceIdString, - viewerState - }) - ) - - expect(res).to.haveGraphQLErrors({ code: ForbiddenError.code }) - expect(res.data?.projectMutations.savedViewMutations.createView).to.not.be.ok - }) - it('should support dedicated auth policy check', async () => { const res = await canCreateSavedView({ projectId: myLackingProject.id @@ -419,7 +363,7 @@ const fakeViewerState = (overrides?: PartialDeep { const result = await sut({ projectId: 'project-id', - feature: WorkspacePlanFeatures.SavedViews + feature: WorkspacePlanFeatures.HideSpeckleBranding }) expect(result).toBeAuthOKResult() @@ -990,7 +990,7 @@ describe('ensureCanUseProjectWorkspacePlanFeatureFragment', () => { const result = await sut({ projectId: 'project-id', - feature: WorkspacePlanFeatures.SavedViews + feature: WorkspacePlanFeatures.HideSpeckleBranding }) expect(result).toBeAuthErrorResult({ @@ -1008,7 +1008,7 @@ describe('ensureCanUseProjectWorkspacePlanFeatureFragment', () => { const result = await sut({ projectId: 'project-id', - feature: WorkspacePlanFeatures.SavedViews + feature: WorkspacePlanFeatures.HideSpeckleBranding }) expect(result).toBeAuthErrorResult({ @@ -1026,7 +1026,7 @@ describe('ensureCanUseProjectWorkspacePlanFeatureFragment', () => { const result = await sut({ projectId: 'project-id', - feature: WorkspacePlanFeatures.SavedViews, + feature: WorkspacePlanFeatures.HideSpeckleBranding, allowUnworkspaced: true }) @@ -1044,7 +1044,7 @@ describe('ensureCanUseProjectWorkspacePlanFeatureFragment', () => { const result = await sut({ projectId: 'project-id', - feature: WorkspacePlanFeatures.SavedViews + feature: WorkspacePlanFeatures.HideSpeckleBranding }) expect(result).toBeAuthErrorResult({ diff --git a/packages/shared/src/authz/fragments/savedViews.spec.ts b/packages/shared/src/authz/fragments/savedViews.spec.ts index 68684726f..22254094f 100644 --- a/packages/shared/src/authz/fragments/savedViews.spec.ts +++ b/packages/shared/src/authz/fragments/savedViews.spec.ts @@ -20,8 +20,7 @@ import { SavedViewNoAccessError, SavedViewNotFoundError, UngroupedSavedViewGroupLockError, - WorkspaceNoAccessError, - WorkspacePlanNoFeatureAccessError + WorkspaceNoAccessError } from '../domain/authErrors.js' import { nanoid } from 'nanoid' @@ -191,11 +190,17 @@ describe('ensureCanAccessSavedViewFragment', () => { ) it.each(['read', 'write'])( - 'fails when workspace plan is too cheap (%s)', + 'succeeds to %s even on free plan', async (access) => { const sut = buildWorkspaceSUT({ getWorkspacePlan: getWorkspacePlanFake({ - name: 'team' + name: 'free' + }), + getSavedView: getSavedViewFake({ + id: savedViewId, + projectId, + visibility: SavedViewVisibility.public, + authorId: userId }) }) @@ -205,9 +210,7 @@ describe('ensureCanAccessSavedViewFragment', () => { savedViewId, access }) - expect(result).toBeAuthErrorResult({ - code: WorkspacePlanNoFeatureAccessError.code - }) + expect(result).toBeAuthOKResult() } ) @@ -413,27 +416,6 @@ describe('ensureCanAccessSavedViewGroupFragment', () => { }) }) - it.each(['read', 'write'])( - 'fails when workspace plan is too cheap (%s)', - async (access) => { - const sut = buildWorkspaceSUT({ - getWorkspacePlan: getWorkspacePlanFake({ - name: 'team' - }) - }) - - const result = await sut({ - userId, - projectId, - savedViewGroupId, - access - }) - expect(result).toBeAuthErrorResult({ - code: WorkspacePlanNoFeatureAccessError.code - }) - } - ) - it.each(['read', 'write'])( 'fails if view doesnt exist (%s)', async (access) => { diff --git a/packages/shared/src/authz/fragments/workspaces.spec.ts b/packages/shared/src/authz/fragments/workspaces.spec.ts index 34b3eb796..09112e94b 100644 --- a/packages/shared/src/authz/fragments/workspaces.spec.ts +++ b/packages/shared/src/authz/fragments/workspaces.spec.ts @@ -347,7 +347,7 @@ describe('ensureCanUseWorkspacePlanFeatureFragment', () => { const result = await sut({ workspaceId: cryptoRandomString({ length: 10 }), - feature: WorkspacePlanFeatures.SavedViews + feature: WorkspacePlanFeatures.HideSpeckleBranding }) expect(result).toBeOKResult() @@ -362,7 +362,7 @@ describe('ensureCanUseWorkspacePlanFeatureFragment', () => { const result = await sut({ workspaceId: cryptoRandomString({ length: 10 }), - feature: WorkspacePlanFeatures.SavedViews + feature: WorkspacePlanFeatures.HideSpeckleBranding }) expect(result).toBeAuthErrorResult({ @@ -380,7 +380,7 @@ describe('ensureCanUseWorkspacePlanFeatureFragment', () => { const result = await sut({ workspaceId: cryptoRandomString({ length: 10 }), - feature: WorkspacePlanFeatures.SavedViews + feature: WorkspacePlanFeatures.HideSpeckleBranding }) expect(result).toBeAuthErrorResult({ @@ -395,7 +395,7 @@ describe('ensureCanUseWorkspacePlanFeatureFragment', () => { const result = await sut({ workspaceId: cryptoRandomString({ length: 10 }), - feature: WorkspacePlanFeatures.SavedViews + feature: WorkspacePlanFeatures.HideSpeckleBranding }) expect(result).toBeAuthErrorResult({ @@ -413,7 +413,7 @@ describe('ensureCanUseWorkspacePlanFeatureFragment', () => { const result = await sut({ workspaceId: cryptoRandomString({ length: 10 }), - feature: WorkspacePlanFeatures.SavedViews + feature: WorkspacePlanFeatures.HideSpeckleBranding }) expect(result).toBeAuthErrorResult({ diff --git a/packages/shared/src/authz/policies/project/savedViews/canCreate.spec.ts b/packages/shared/src/authz/policies/project/savedViews/canCreate.spec.ts index c55da40d4..895303f36 100644 --- a/packages/shared/src/authz/policies/project/savedViews/canCreate.spec.ts +++ b/packages/shared/src/authz/policies/project/savedViews/canCreate.spec.ts @@ -14,10 +14,9 @@ import { ProjectNotEnoughPermissionsError, ServerNoAccessError, WorkspaceNoAccessError, - WorkspacePlanNoFeatureAccessError, WorkspaceReadOnlyError } from '../../../domain/authErrors.js' -import { PaidWorkspacePlans } from '../../../../workspaces/index.js' +import { WorkspacePlans } from '../../../../workspaces/index.js' const buildSUT = (overrides?: OverridesOf) => canCreateSavedViewPolicy({ @@ -71,7 +70,7 @@ describe('canCreateSavedViewPolicy', () => { id: 'workspace-id' }), getWorkspacePlan: getWorkspacePlanFake({ - name: PaidWorkspacePlans.Pro + name: WorkspacePlans.Pro }), getWorkspaceSsoProvider: async () => ({ providerId: 'provider-id' @@ -153,10 +152,10 @@ describe('canCreateSavedViewPolicy', () => { }) }) - it('fails if not on pro/business plan', async () => { + it('succeeds even on free plan', async () => { const canCreate = buildWorkspaceSUT({ getWorkspacePlan: getWorkspacePlanFake({ - name: PaidWorkspacePlans.Team + name: WorkspacePlans.Free }) }) @@ -164,15 +163,13 @@ describe('canCreateSavedViewPolicy', () => { userId: 'user-id', projectId: 'project-id' }) - expect(result).toBeAuthErrorResult({ - code: WorkspacePlanNoFeatureAccessError.code - }) + expect(result).toBeAuthOKResult() }) it('fails if workspace readonly', async () => { const canCreate = buildWorkspaceSUT({ getWorkspacePlan: getWorkspacePlanFake({ - name: PaidWorkspacePlans.Pro, + name: WorkspacePlans.Pro, status: 'canceled' }) }) diff --git a/packages/shared/src/workspaces/helpers/features.ts b/packages/shared/src/workspaces/helpers/features.ts index 3d66b8b94..bca4a54a8 100644 --- a/packages/shared/src/workspaces/helpers/features.ts +++ b/packages/shared/src/workspaces/helpers/features.ts @@ -134,135 +134,137 @@ export const WorkspacePaidPlanConfigs: (params: { featureFlags: Partial | undefined }) => { [plan in PaidWorkspacePlans]: WorkspacePlanConfig -} = (params) => ({ - [PaidWorkspacePlans.Team]: { - plan: PaidWorkspacePlans.Team, - features: [...baseFeatures], - limits: { - projectCount: 5, - modelCount: 25, - versionsHistory: { value: 30, unit: 'day' }, - commentHistory: { value: 30, unit: 'day' } - } - }, - [PaidWorkspacePlans.TeamUnlimited]: { - plan: PaidWorkspacePlans.TeamUnlimited, - features: [...baseFeatures], - limits: { - projectCount: null, - modelCount: null, - versionsHistory: { value: 30, unit: 'day' }, - commentHistory: { value: 30, unit: 'day' } - } - }, - [PaidWorkspacePlans.Pro]: { - plan: PaidWorkspacePlans.Pro, - features: [ - ...baseFeatures, - WorkspacePlanFeatures.DomainSecurity, - WorkspacePlanFeatures.SSO, - WorkspacePlanFeatures.CustomDataRegion, - WorkspacePlanFeatures.HideSpeckleBranding, - ...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED - ? [WorkspacePlanFeatures.SavedViews] - : []) - ], - limits: { - projectCount: 10, - modelCount: 50, - versionsHistory: null, - commentHistory: null - } - }, - [PaidWorkspacePlans.ProUnlimited]: { - plan: PaidWorkspacePlans.ProUnlimited, - features: [ - ...baseFeatures, - WorkspacePlanFeatures.DomainSecurity, - WorkspacePlanFeatures.SSO, - WorkspacePlanFeatures.CustomDataRegion, - WorkspacePlanFeatures.HideSpeckleBranding, - ...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED - ? [WorkspacePlanFeatures.SavedViews] - : []) - ], - limits: { - projectCount: null, - modelCount: null, - versionsHistory: null, - commentHistory: null +} = (params) => { + const finalBaseFeatures = [ + ...baseFeatures, + ...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED + ? [WorkspacePlanFeatures.SavedViews] + : []) + ] + + return { + [PaidWorkspacePlans.Team]: { + plan: PaidWorkspacePlans.Team, + features: [...finalBaseFeatures], + limits: { + projectCount: 5, + modelCount: 25, + versionsHistory: { value: 30, unit: 'day' }, + commentHistory: { value: 30, unit: 'day' } + } + }, + [PaidWorkspacePlans.TeamUnlimited]: { + plan: PaidWorkspacePlans.TeamUnlimited, + features: [...finalBaseFeatures], + limits: { + projectCount: null, + modelCount: null, + versionsHistory: { value: 30, unit: 'day' }, + commentHistory: { value: 30, unit: 'day' } + } + }, + [PaidWorkspacePlans.Pro]: { + plan: PaidWorkspacePlans.Pro, + features: [ + ...finalBaseFeatures, + WorkspacePlanFeatures.DomainSecurity, + WorkspacePlanFeatures.SSO, + WorkspacePlanFeatures.CustomDataRegion, + WorkspacePlanFeatures.HideSpeckleBranding + ], + limits: { + projectCount: 10, + modelCount: 50, + versionsHistory: null, + commentHistory: null + } + }, + [PaidWorkspacePlans.ProUnlimited]: { + plan: PaidWorkspacePlans.ProUnlimited, + features: [ + ...finalBaseFeatures, + WorkspacePlanFeatures.DomainSecurity, + WorkspacePlanFeatures.SSO, + WorkspacePlanFeatures.CustomDataRegion, + WorkspacePlanFeatures.HideSpeckleBranding + ], + limits: { + projectCount: null, + modelCount: null, + versionsHistory: null, + commentHistory: null + } } } -}) +} export const WorkspaceUnpaidPlanConfigs: (params: { featureFlags: Partial | undefined }) => { [plan in UnpaidWorkspacePlans]: WorkspacePlanConfig -} = (params) => ({ - [UnpaidWorkspacePlans.Enterprise]: { - plan: UnpaidWorkspacePlans.Enterprise, - features: [ - ...baseFeatures, - WorkspacePlanFeatures.DomainSecurity, - WorkspacePlanFeatures.SSO, - WorkspacePlanFeatures.CustomDataRegion, - WorkspacePlanFeatures.HideSpeckleBranding, - WorkspacePlanFeatures.ExclusiveMembership, - ...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED - ? [WorkspacePlanFeatures.SavedViews] - : []) - ], - limits: unlimited - }, - [UnpaidWorkspacePlans.Unlimited]: { - plan: UnpaidWorkspacePlans.Unlimited, - features: [ - ...baseFeatures, - WorkspacePlanFeatures.DomainSecurity, - WorkspacePlanFeatures.SSO, - WorkspacePlanFeatures.CustomDataRegion, - WorkspacePlanFeatures.HideSpeckleBranding, - WorkspacePlanFeatures.ExclusiveMembership, - ...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED - ? [WorkspacePlanFeatures.SavedViews] - : []) - ], - limits: unlimited - }, - [UnpaidWorkspacePlans.Academia]: { - plan: UnpaidWorkspacePlans.Academia, - features: [ - ...baseFeatures, - WorkspacePlanFeatures.DomainSecurity, - WorkspacePlanFeatures.SSO, - WorkspacePlanFeatures.CustomDataRegion, - WorkspacePlanFeatures.HideSpeckleBranding, - ...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED - ? [WorkspacePlanFeatures.SavedViews] - : []) - ], - limits: unlimited - }, - [UnpaidWorkspacePlans.TeamUnlimitedInvoiced]: { - ...WorkspacePaidPlanConfigs(params).teamUnlimited, - plan: UnpaidWorkspacePlans.TeamUnlimitedInvoiced - }, - [UnpaidWorkspacePlans.ProUnlimitedInvoiced]: { - ...WorkspacePaidPlanConfigs(params).proUnlimited, - plan: UnpaidWorkspacePlans.ProUnlimitedInvoiced - }, - [UnpaidWorkspacePlans.Free]: { - plan: UnpaidWorkspacePlans.Free, - features: baseFeatures, - limits: { - projectCount: 1, - modelCount: 5, - versionsHistory: { value: 7, unit: 'day' }, - commentHistory: { value: 7, unit: 'day' } +} = (params) => { + const finalBaseFeatures = [ + ...baseFeatures, + ...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED + ? [WorkspacePlanFeatures.SavedViews] + : []) + ] + return { + [UnpaidWorkspacePlans.Enterprise]: { + plan: UnpaidWorkspacePlans.Enterprise, + features: [ + ...finalBaseFeatures, + WorkspacePlanFeatures.DomainSecurity, + WorkspacePlanFeatures.SSO, + WorkspacePlanFeatures.CustomDataRegion, + WorkspacePlanFeatures.HideSpeckleBranding, + WorkspacePlanFeatures.ExclusiveMembership + ], + limits: unlimited + }, + [UnpaidWorkspacePlans.Unlimited]: { + plan: UnpaidWorkspacePlans.Unlimited, + features: [ + ...finalBaseFeatures, + WorkspacePlanFeatures.DomainSecurity, + WorkspacePlanFeatures.SSO, + WorkspacePlanFeatures.CustomDataRegion, + WorkspacePlanFeatures.HideSpeckleBranding, + WorkspacePlanFeatures.ExclusiveMembership + ], + limits: unlimited + }, + [UnpaidWorkspacePlans.Academia]: { + plan: UnpaidWorkspacePlans.Academia, + features: [ + ...finalBaseFeatures, + WorkspacePlanFeatures.DomainSecurity, + WorkspacePlanFeatures.SSO, + WorkspacePlanFeatures.CustomDataRegion, + WorkspacePlanFeatures.HideSpeckleBranding + ], + limits: unlimited + }, + [UnpaidWorkspacePlans.TeamUnlimitedInvoiced]: { + ...WorkspacePaidPlanConfigs(params).teamUnlimited, + plan: UnpaidWorkspacePlans.TeamUnlimitedInvoiced + }, + [UnpaidWorkspacePlans.ProUnlimitedInvoiced]: { + ...WorkspacePaidPlanConfigs(params).proUnlimited, + plan: UnpaidWorkspacePlans.ProUnlimitedInvoiced + }, + [UnpaidWorkspacePlans.Free]: { + plan: UnpaidWorkspacePlans.Free, + features: finalBaseFeatures, + limits: { + projectCount: 1, + modelCount: 5, + versionsHistory: { value: 7, unit: 'day' }, + commentHistory: { value: 7, unit: 'day' } + } } } -}) +} export const WorkspacePlanConfigs = (params: { featureFlags: Partial | undefined diff --git a/tsconfig.json b/tsconfig.json index 24731045f..d946318dc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,23 +1,3 @@ { - /* load each package separately, rather than as one giant progream */ - "files": [], - "references": [ - { "path": "packages/fileimport-service" }, - { "path": "packages/frontend-2" }, - { "path": "packages/monitor-deployment" }, - { "path": "packages/objectloader" }, - { "path": "packages/objectloader2" }, - { "path": "packages/objectsender" }, - { "path": "packages/preview-frontend" }, - { "path": "packages/preview-service" }, - { "path": "packages/server" }, - { "path": "packages/shared" }, - { "path": "packages/tailwind-theme" }, - { "path": "packages/ui-components" }, - { "path": "packages/ui-components-nuxt" }, - { "path": "packages/viewer" }, - { "path": "packages/viewer-sandbox" }, - { "path": "packages/webhook-service" } - /* …add all other packages listed in workspace.code-workspace */ - ] + "files": [] } diff --git a/workspace.code-workspace b/workspace.code-workspace index 95140e340..1bdac3a62 100644 --- a/workspace.code-workspace +++ b/workspace.code-workspace @@ -111,6 +111,7 @@ "Prorotation" ], "typescript.tsserver.maxTsServerMemory": 8192, + "typescript.disableAutomaticTypeAcquisition": true, "tailwindCSS.experimental.configFile": { "packages/frontend-2/tailwind.config.cjs": "packages/frontend-2/**" }, From 54ca063810264c996cad8b08588e1213759a1935 Mon Sep 17 00:00:00 2001 From: Mike Date: Mon, 1 Sep 2025 11:32:19 +0200 Subject: [PATCH 20/25] Feat: Block non work emails (#5347) --- .../auth/RegisterWithEmailBlock.vue | 26 +++++++------------ packages/frontend-2/composables/globals.ts | 8 ++++++ .../frontend-2/lib/auth/helpers/validation.ts | 7 +++++ 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/frontend-2/components/auth/RegisterWithEmailBlock.vue b/packages/frontend-2/components/auth/RegisterWithEmailBlock.vue index 6ad390fea..e3286ea8d 100644 --- a/packages/frontend-2/components/auth/RegisterWithEmailBlock.vue +++ b/packages/frontend-2/components/auth/RegisterWithEmailBlock.vue @@ -14,13 +14,7 @@ show-label :disabled="isEmailDisabled" auto-focus - :help=" - emailIsBlocked - ? 'A work email makes it easier to discover and collaborate with your coworkers on Speckle.' - : '' - " autocomplete="email" - @blur="onEmailChange" /> ('newsletterConsent', { required: true }) const loading = ref(false) const password = ref('') const email = ref('') -const emailIsBlocked = ref(false) -const emailRules = [isEmail] +const emailRules = computed(() => + inviteToken.value || !isNoPersonalEmailsEnabled.value + ? [isEmail] + : [isEmail, doesNotContainBlockedDomain] +) const nameRules = [isRequired] const isEmailDisabled = computed(() => !!props.inviteEmail?.length || loading.value) @@ -121,11 +120,6 @@ const finalLoginRoute = computed(() => { return result.fullPath }) -const onEmailChange = () => { - if (!isWorkspacesEnabled.value) return - emailIsBlocked.value = checkIfEmailIsBlocked(email.value) -} - const onSubmit = handleSubmit(async (fullUser) => { try { loading.value = true diff --git a/packages/frontend-2/composables/globals.ts b/packages/frontend-2/composables/globals.ts index 0016401cc..9dd8268cb 100644 --- a/packages/frontend-2/composables/globals.ts +++ b/packages/frontend-2/composables/globals.ts @@ -84,4 +84,12 @@ export const useIsRhinoFileImporterEnabled = () => { return ref(FF_RHINO_FILE_IMPORTER_ENABLED) } +export const useIsNoPersonalEmailsEnabled = () => { + const { + public: { FF_NO_PERSONAL_EMAILS_ENABLED } + } = useRuntimeConfig() + + return ref(FF_NO_PERSONAL_EMAILS_ENABLED) +} + export { useGlobalToast, useActiveUser, usePageQueryStandardFetchPolicy, useEventBus } diff --git a/packages/frontend-2/lib/auth/helpers/validation.ts b/packages/frontend-2/lib/auth/helpers/validation.ts index 9c877d077..27a9aa1aa 100644 --- a/packages/frontend-2/lib/auth/helpers/validation.ts +++ b/packages/frontend-2/lib/auth/helpers/validation.ts @@ -1,4 +1,5 @@ import { isStringOfLength, stringContains } from '~~/lib/common/helpers/validation' +import { blockedDomains } from '@speckle/shared' export const passwordLongEnough = isStringOfLength({ minLength: 8 }) export const passwordHasAtLeastOneNumber = stringContains({ @@ -13,6 +14,12 @@ export const passwordHasAtLeastOneUppercaseLetter = stringContains({ match: /[A-Z]/, message: 'Must have at least one uppercase letter' }) +export const doesNotContainBlockedDomain = (val: string) => { + const domain = val.split('@')[1]?.toLowerCase() + return domain && blockedDomains.includes(domain) + ? 'Please use your work email instead of a personal email address' + : true +} export const passwordRules = [ passwordLongEnough, From 3a0829aa937d3f2e7c8e0ad676520d83bf4d5c85 Mon Sep 17 00:00:00 2001 From: Mike Date: Mon, 1 Sep 2025 11:59:25 +0200 Subject: [PATCH 21/25] Feat: Add Intelligence SU Promo banner (#5348) --- .../dashboard/IntelligencePromo.vue | 66 +++++++++++++++++++ .../components/dashboard/Sidebar.vue | 10 +++ .../lib/common/generated/gql/gql.ts | 12 +++- .../lib/common/generated/gql/graphql.ts | 21 +++++- .../frontend-2/lib/user/composables/meta.ts | 36 +++++++++- .../assets/core/typedefs/userMeta.graphql | 2 + packages/server/modules/core/dbSchema.ts | 1 + .../modules/core/graph/generated/graphql.ts | 23 +++++++ .../modules/core/graph/resolvers/users.ts | 17 +++++ .../modules/core/tests/helpers/graphql.ts | 20 ++++++ .../tests/integration/userMeta.graph.spec.ts | 35 +++++++++- 11 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 packages/frontend-2/components/dashboard/IntelligencePromo.vue diff --git a/packages/frontend-2/components/dashboard/IntelligencePromo.vue b/packages/frontend-2/components/dashboard/IntelligencePromo.vue new file mode 100644 index 000000000..cebc14212 --- /dev/null +++ b/packages/frontend-2/components/dashboard/IntelligencePromo.vue @@ -0,0 +1,66 @@ + + + diff --git a/packages/frontend-2/components/dashboard/Sidebar.vue b/packages/frontend-2/components/dashboard/Sidebar.vue index dc399e2b1..83ab29a3f 100644 --- a/packages/frontend-2/components/dashboard/Sidebar.vue +++ b/packages/frontend-2/components/dashboard/Sidebar.vue @@ -142,6 +142,9 @@
+
@@ -168,6 +171,8 @@ import { useMixpanel } from '~~/lib/core/composables/mp' import { useActiveWorkspaceSlug } from '~/lib/user/composables/activeWorkspace' import { graphql } from '~/lib/common/generated/gql' import { useQuery } from '@vue/apollo-composable' +import dayjs from 'dayjs' +import { useActiveUserMeta } from '~/lib/user/composables/meta' const dashboardSidebarQuery = graphql(` query DashboardSidebar { @@ -190,10 +195,15 @@ const mixpanel = useMixpanel() const { result } = useQuery(dashboardSidebarQuery, () => ({}), { enabled: isWorkspacesEnabled.value }) +const { hasDismissedIntelligenceCommunityStandUpBanner } = useActiveUserMeta() const isOpenMobile = ref(false) const showExplainerVideoDialog = ref(false) +const showIntelligenceCommunityStandUpPromo = computed(() => { + if (hasDismissedIntelligenceCommunityStandUpBanner.value) return false + return dayjs().isBefore('2025-09-10', 'day') +}) const activeWorkspace = computed(() => result.value?.activeUser?.activeWorkspace) const showProjectsLink = computed(() => { return isWorkspacesEnabled.value diff --git a/packages/frontend-2/lib/common/generated/gql/gql.ts b/packages/frontend-2/lib/common/generated/gql/gql.ts index f47a15d7f..fa9ee345f 100644 --- a/packages/frontend-2/lib/common/generated/gql/gql.ts +++ b/packages/frontend-2/lib/common/generated/gql/gql.ts @@ -396,8 +396,9 @@ type Documents = { "\n fragment AppAuthorAvatar on AppAuthor {\n id\n name\n avatar\n }\n": typeof types.AppAuthorAvatarFragmentDoc, "\n fragment LimitedUserAvatar on LimitedUser {\n id\n name\n avatar\n }\n": typeof types.LimitedUserAvatarFragmentDoc, "\n fragment ActiveUserAvatar on User {\n id\n name\n avatar\n }\n": typeof types.ActiveUserAvatarFragmentDoc, - "\n query ActiveUserMeta {\n activeUser {\n meta {\n legacyProjectsExplainerCollapsed\n }\n }\n }\n": typeof types.ActiveUserMetaDocument, + "\n query ActiveUserMeta {\n activeUser {\n meta {\n legacyProjectsExplainerCollapsed\n intelligenceCommunityStandUpBannerDismissed\n }\n }\n }\n": typeof types.ActiveUserMetaDocument, "\n mutation UpdateLegacyProjectsExplainer($value: Boolean!) {\n activeUserMutations {\n meta {\n setLegacyProjectsExplainerCollapsed(value: $value)\n }\n }\n }\n": typeof types.UpdateLegacyProjectsExplainerDocument, + "\n mutation UpdateIntelligenceCommunityStandUpBannerDismissed($value: Boolean!) {\n activeUserMutations {\n meta {\n setIntelligenceCommunityStandUpBannerDismissed(value: $value)\n }\n }\n }\n": typeof types.UpdateIntelligenceCommunityStandUpBannerDismissedDocument, "\n subscription OnUserProjectsUpdate {\n userProjectsUpdated {\n type\n id\n project {\n ...ProjectDashboardItem\n workspaceId\n }\n }\n }\n ": typeof types.OnUserProjectsUpdateDocument, "\n mutation UpdateUser($input: UserUpdateInput!) {\n activeUserMutations {\n update(user: $input) {\n id\n name\n bio\n company\n avatar\n }\n }\n }\n": typeof types.UpdateUserDocument, "\n mutation UpdateNotificationPreferences($input: JSONObject!) {\n userNotificationPreferencesUpdate(preferences: $input)\n }\n": typeof types.UpdateNotificationPreferencesDocument, @@ -901,8 +902,9 @@ const documents: Documents = { "\n fragment AppAuthorAvatar on AppAuthor {\n id\n name\n avatar\n }\n": types.AppAuthorAvatarFragmentDoc, "\n fragment LimitedUserAvatar on LimitedUser {\n id\n name\n avatar\n }\n": types.LimitedUserAvatarFragmentDoc, "\n fragment ActiveUserAvatar on User {\n id\n name\n avatar\n }\n": types.ActiveUserAvatarFragmentDoc, - "\n query ActiveUserMeta {\n activeUser {\n meta {\n legacyProjectsExplainerCollapsed\n }\n }\n }\n": types.ActiveUserMetaDocument, + "\n query ActiveUserMeta {\n activeUser {\n meta {\n legacyProjectsExplainerCollapsed\n intelligenceCommunityStandUpBannerDismissed\n }\n }\n }\n": types.ActiveUserMetaDocument, "\n mutation UpdateLegacyProjectsExplainer($value: Boolean!) {\n activeUserMutations {\n meta {\n setLegacyProjectsExplainerCollapsed(value: $value)\n }\n }\n }\n": types.UpdateLegacyProjectsExplainerDocument, + "\n mutation UpdateIntelligenceCommunityStandUpBannerDismissed($value: Boolean!) {\n activeUserMutations {\n meta {\n setIntelligenceCommunityStandUpBannerDismissed(value: $value)\n }\n }\n }\n": types.UpdateIntelligenceCommunityStandUpBannerDismissedDocument, "\n subscription OnUserProjectsUpdate {\n userProjectsUpdated {\n type\n id\n project {\n ...ProjectDashboardItem\n workspaceId\n }\n }\n }\n ": types.OnUserProjectsUpdateDocument, "\n mutation UpdateUser($input: UserUpdateInput!) {\n activeUserMutations {\n update(user: $input) {\n id\n name\n bio\n company\n avatar\n }\n }\n }\n": types.UpdateUserDocument, "\n mutation UpdateNotificationPreferences($input: JSONObject!) {\n userNotificationPreferencesUpdate(preferences: $input)\n }\n": types.UpdateNotificationPreferencesDocument, @@ -2569,11 +2571,15 @@ export function graphql(source: "\n fragment ActiveUserAvatar on User {\n id /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query ActiveUserMeta {\n activeUser {\n meta {\n legacyProjectsExplainerCollapsed\n }\n }\n }\n"): (typeof documents)["\n query ActiveUserMeta {\n activeUser {\n meta {\n legacyProjectsExplainerCollapsed\n }\n }\n }\n"]; +export function graphql(source: "\n query ActiveUserMeta {\n activeUser {\n meta {\n legacyProjectsExplainerCollapsed\n intelligenceCommunityStandUpBannerDismissed\n }\n }\n }\n"): (typeof documents)["\n query ActiveUserMeta {\n activeUser {\n meta {\n legacyProjectsExplainerCollapsed\n intelligenceCommunityStandUpBannerDismissed\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n mutation UpdateLegacyProjectsExplainer($value: Boolean!) {\n activeUserMutations {\n meta {\n setLegacyProjectsExplainerCollapsed(value: $value)\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateLegacyProjectsExplainer($value: Boolean!) {\n activeUserMutations {\n meta {\n setLegacyProjectsExplainerCollapsed(value: $value)\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation UpdateIntelligenceCommunityStandUpBannerDismissed($value: Boolean!) {\n activeUserMutations {\n meta {\n setIntelligenceCommunityStandUpBannerDismissed(value: $value)\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateIntelligenceCommunityStandUpBannerDismissed($value: Boolean!) {\n activeUserMutations {\n meta {\n setIntelligenceCommunityStandUpBannerDismissed(value: $value)\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index b5649f51d..f19fa3f63 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -4769,6 +4769,7 @@ export type UserGendoAiCredits = { export type UserMeta = { __typename?: 'UserMeta'; + intelligenceCommunityStandUpBannerDismissed: Scalars['Boolean']['output']; legacyProjectsExplainerCollapsed: Scalars['Boolean']['output']; newWorkspaceExplainerDismissed: Scalars['Boolean']['output']; speckleConBannerDismissed: Scalars['Boolean']['output']; @@ -4776,12 +4777,18 @@ export type UserMeta = { export type UserMetaMutations = { __typename?: 'UserMetaMutations'; + setIntelligenceCommunityStandUpBannerDismissed: Scalars['Boolean']['output']; setLegacyProjectsExplainerCollapsed: Scalars['Boolean']['output']; setNewWorkspaceExplainerDismissed: Scalars['Boolean']['output']; setSpeckleConBannerDismissed: Scalars['Boolean']['output']; }; +export type UserMetaMutationsSetIntelligenceCommunityStandUpBannerDismissedArgs = { + value: Scalars['Boolean']['input']; +}; + + export type UserMetaMutationsSetLegacyProjectsExplainerCollapsedArgs = { value: Scalars['Boolean']['input']; }; @@ -7478,7 +7485,7 @@ export type ActiveUserAvatarFragment = { __typename?: 'User', id: string, name: export type ActiveUserMetaQueryVariables = Exact<{ [key: string]: never; }>; -export type ActiveUserMetaQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', meta: { __typename?: 'UserMeta', legacyProjectsExplainerCollapsed: boolean } } | null }; +export type ActiveUserMetaQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', meta: { __typename?: 'UserMeta', legacyProjectsExplainerCollapsed: boolean, intelligenceCommunityStandUpBannerDismissed: boolean } } | null }; export type UpdateLegacyProjectsExplainerMutationVariables = Exact<{ value: Scalars['Boolean']['input']; @@ -7487,6 +7494,13 @@ export type UpdateLegacyProjectsExplainerMutationVariables = Exact<{ export type UpdateLegacyProjectsExplainerMutation = { __typename?: 'Mutation', activeUserMutations: { __typename?: 'ActiveUserMutations', meta: { __typename?: 'UserMetaMutations', setLegacyProjectsExplainerCollapsed: boolean } } }; +export type UpdateIntelligenceCommunityStandUpBannerDismissedMutationVariables = Exact<{ + value: Scalars['Boolean']['input']; +}>; + + +export type UpdateIntelligenceCommunityStandUpBannerDismissedMutation = { __typename?: 'Mutation', activeUserMutations: { __typename?: 'ActiveUserMutations', meta: { __typename?: 'UserMetaMutations', setIntelligenceCommunityStandUpBannerDismissed: boolean } } }; + export type OnUserProjectsUpdateSubscriptionVariables = Exact<{ [key: string]: never; }>; @@ -8563,8 +8577,9 @@ export const SettingsWorkspacesInvitesSearchDocument = {"kind":"Document","defin export const SettingsWorkspacesProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SettingsWorkspacesProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"slug"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceProjectsFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceBySlug"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"slug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"slug"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesProjects_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"projects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesProjects_ProjectCollection"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FullPermissionCheckResult"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PermissionCheckResult"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authorized"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"payload"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectsDeleteDialog_Project"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"models"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"0"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"versions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"0"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canDelete"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsSharedProjects_Project"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectsDeleteDialog_Project"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"models"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"0"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"versions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"0"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canDelete"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canReadSettings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canRead"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesProjects_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canCreateProject"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesProjects_ProjectCollection"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectCollection"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsSharedProjects_Project"}}]}}]}}]} as unknown as DocumentNode; export const SettingsWorkspaceSecurityDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SettingsWorkspaceSecurity"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"slug"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceBySlug"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"slug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"slug"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurity_Workspace"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDefaultSeat_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"defaultSeatType"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityAutoJoinEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainManagement_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","alias":{"kind":"Name","value":"hasAccessToDomainBasedSecurityPolicies"},"name":{"kind":"Name","value":"hasAccessToFeature"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"featureName"},"value":{"kind":"EnumValue","value":"domainBasedSecurityPolicies"}}]},{"kind":"Field","alias":{"kind":"Name","value":"hasAccessToSSO"},"name":{"kind":"Name","value":"hasAccessToFeature"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"featureName"},"value":{"kind":"EnumValue","value":"oidcSso"}}]},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDiscoverability_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}}]}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityAutoJoinEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"defaultSeatType"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecuritySsoWrapper_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"sso"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"clientId"}},{"kind":"Field","name":{"kind":"Name","value":"issuerUrl"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"hasAccessToSSO"},"name":{"kind":"Name","value":"hasAccessToFeature"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"featureName"},"value":{"kind":"EnumValue","value":"oidcSso"}}]}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainProtection_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","alias":{"kind":"Name","value":"hasAccessToDomainBasedSecurityPolicies"},"name":{"kind":"Name","value":"hasAccessToFeature"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"featureName"},"value":{"kind":"EnumValue","value":"domainBasedSecurityPolicies"}}]},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurityWorkspaceCreation_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"isExclusive"}},{"kind":"Field","alias":{"kind":"Name","value":"hasAccessToExclusiveMembership"},"name":{"kind":"Name","value":"hasAccessToFeature"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"featureName"},"value":{"kind":"EnumValue","value":"exclusiveMembership"}}]},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canMakeWorkspaceExclusive"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authorized"}},{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurity_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDefaultSeat_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainManagement_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDiscoverability_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecuritySsoWrapper_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainProtection_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurityWorkspaceCreation_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]} as unknown as DocumentNode; export const SettingsWorkspaceAutomationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SettingsWorkspaceAutomation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"slug"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}},"defaultValue":{"kind":"NullValue"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceBySlug"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"slug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"slug"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"automateFunctions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"10"}},{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"includeFeatured"},"value":{"kind":"BooleanValue","value":false}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesAutomationFunctions_AutomateFunction"}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FullPermissionCheckResult"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PermissionCheckResult"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authorized"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"payload"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesAutomationTableRowActions_AutomateFunctionPermissionChecks"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AutomateFunctionPermissionChecks"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canRegenerateToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesAutomationRegenerateTokenDialog_AutomateFunction"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AutomateFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesAutomationTableRowActions_AutomateFunction"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AutomateFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesAutomationTableRowActions_AutomateFunctionPermissionChecks"}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesAutomationRegenerateTokenDialog_AutomateFunction"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesAutomationFunctions_AutomateFunction"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AutomateFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"creator"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesAutomationTableRowActions_AutomateFunction"}}]}}]} as unknown as DocumentNode; -export const ActiveUserMetaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ActiveUserMeta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"legacyProjectsExplainerCollapsed"}}]}}]}}]}}]} as unknown as DocumentNode; +export const ActiveUserMetaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ActiveUserMeta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"legacyProjectsExplainerCollapsed"}},{"kind":"Field","name":{"kind":"Name","value":"intelligenceCommunityStandUpBannerDismissed"}}]}}]}}]}}]} as unknown as DocumentNode; export const UpdateLegacyProjectsExplainerDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateLegacyProjectsExplainer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"value"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUserMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setLegacyProjectsExplainerCollapsed"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"value"}}}]}]}}]}}]}}]} as unknown as DocumentNode; +export const UpdateIntelligenceCommunityStandUpBannerDismissedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateIntelligenceCommunityStandUpBannerDismissed"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"value"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUserMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setIntelligenceCommunityStandUpBannerDismissed"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"value"}}}]}]}}]}}]}}]} as unknown as DocumentNode; export const OnUserProjectsUpdateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnUserProjectsUpdate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userProjectsUpdated"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectDashboardItem"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FullPermissionCheckResult"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PermissionCheckResult"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authorized"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"payload"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectsModelPageEmbed_Project"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canCreateEmbedTokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"embedOptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hideSpeckleBranding"}}]}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canEditEmbedOptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectPageModelsActions_Project"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectsModelPageEmbed_Project"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UseFileImport_Project"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectCardImportFileArea_Project"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canCreateModel"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"UseFileImport_Project"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectPageModelsCardProject"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageModelsActions_Project"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectCardImportFileArea_Project"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canCreateModel"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectDashboardItemNoModels"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageModelsCardProject"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PendingFileUpload"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FileUpload"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"modelName"}},{"kind":"Field","name":{"kind":"Name","value":"convertedStatus"}},{"kind":"Field","name":{"kind":"Name","value":"convertedMessage"}},{"kind":"Field","name":{"kind":"Name","value":"uploadDate"}},{"kind":"Field","name":{"kind":"Name","value":"convertedLastUpdate"}},{"kind":"Field","name":{"kind":"Name","value":"fileType"}},{"kind":"Field","name":{"kind":"Name","value":"fileName"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectPageModelsCard_Model"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Model"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"homeView"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIds"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"lastUpload"},"name":{"kind":"Name","value":"uploads"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}},{"kind":"ObjectField","name":{"kind":"Name","value":"cursor"},"value":{"kind":"NullValue"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"convertedStatus"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"lastVersion"},"name":{"kind":"Name","value":"versions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}},{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"NullValue"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectPageModelsCardRenameDialog"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Model"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectPageModelsCardDeleteDialog"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Model"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"GetModelItemRoute_Model"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Model"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"homeView"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIds"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UseCopyModelLink_Model"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Model"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"GetModelItemRoute_Model"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectPageModelsActions"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Model"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canUpdate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canDelete"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canCreateVersion"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"UseCopyModelLink_Model"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UseFileImport_Model"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Model"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectCardImportFileArea_Model"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Model"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canCreateVersion"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"UseFileImport_Model"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FunctionRunStatusForSummary"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AutomateFunctionRun"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TriggeredAutomationsStatusSummary"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TriggeredAutomationsStatus"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"automationRuns"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"functionRuns"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"FunctionRunStatusForSummary"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AutomateRunsTriggerStatusDialogFunctionRun_AutomateFunctionRun"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AutomateFunctionRun"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"results"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"statusMessage"}},{"kind":"Field","name":{"kind":"Name","value":"contextView"}},{"kind":"Field","name":{"kind":"Name","value":"function"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AutomationsStatusOrderedRuns_AutomationRun"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AutomateRun"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"automation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"functionRuns"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AutomateRunsTriggerStatusDialogRunsRows_AutomateRun"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AutomateRun"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"functionRuns"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"AutomateRunsTriggerStatusDialogFunctionRun_AutomateFunctionRun"}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"AutomationsStatusOrderedRuns_AutomationRun"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AutomateRunsTriggerStatusDialog_TriggeredAutomationsStatus"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TriggeredAutomationsStatus"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"automationRuns"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"AutomateRunsTriggerStatusDialogRunsRows_AutomateRun"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AutomateRunsTriggerStatus_TriggeredAutomationsStatus"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TriggeredAutomationsStatus"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"TriggeredAutomationsStatusSummary"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"AutomateRunsTriggerStatusDialog_TriggeredAutomationsStatus"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectPageLatestItemsModelItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Model"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","alias":{"kind":"Name","value":"versionCount"},"name":{"kind":"Name","value":"versions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"0"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"commentThreadCount"},"name":{"kind":"Name","value":"commentThreads"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"0"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"pendingImportedVersions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PendingFileUpload"}}]}},{"kind":"Field","name":{"kind":"Name","value":"previewUrl"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageModelsCard_Model"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageModelsCardRenameDialog"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageModelsCardDeleteDialog"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageModelsActions"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectCardImportFileArea_Model"}},{"kind":"Field","name":{"kind":"Name","value":"automationsStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AutomateRunsTriggerStatus_TriggeredAutomationsStatus"}}]}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canUpdate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canDelete"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectDashboardItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectDashboardItemNoModels"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectCardImportFileArea_Project"}},{"kind":"Field","name":{"kind":"Name","value":"models"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"3"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageLatestItemsModelItem"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}},{"kind":"Field","name":{"kind":"Name","value":"pendingImportedModels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"3"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PendingFileUpload"}}]}}]}}]} as unknown as DocumentNode; export const UpdateUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UserUpdateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUserMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"update"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"user"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]}}]}}]} as unknown as DocumentNode; export const UpdateNotificationPreferencesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateNotificationPreferences"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSONObject"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userNotificationPreferencesUpdate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"preferences"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; @@ -10113,11 +10128,13 @@ export type UserGendoAiCreditsFieldArgs = { used: {}, } export type UserMetaFieldArgs = { + intelligenceCommunityStandUpBannerDismissed: {}, legacyProjectsExplainerCollapsed: {}, newWorkspaceExplainerDismissed: {}, speckleConBannerDismissed: {}, } export type UserMetaMutationsFieldArgs = { + setIntelligenceCommunityStandUpBannerDismissed: UserMetaMutationsSetIntelligenceCommunityStandUpBannerDismissedArgs, setLegacyProjectsExplainerCollapsed: UserMetaMutationsSetLegacyProjectsExplainerCollapsedArgs, setNewWorkspaceExplainerDismissed: UserMetaMutationsSetNewWorkspaceExplainerDismissedArgs, setSpeckleConBannerDismissed: UserMetaMutationsSetSpeckleConBannerDismissedArgs, diff --git a/packages/frontend-2/lib/user/composables/meta.ts b/packages/frontend-2/lib/user/composables/meta.ts index 4c376221f..15f9d103d 100644 --- a/packages/frontend-2/lib/user/composables/meta.ts +++ b/packages/frontend-2/lib/user/composables/meta.ts @@ -6,6 +6,7 @@ export const activeUserMetaQuery = graphql(` activeUser { meta { legacyProjectsExplainerCollapsed + intelligenceCommunityStandUpBannerDismissed } } } @@ -21,11 +22,24 @@ export const updateLegacyProjectsExplainerMutation = graphql(` } `) +export const updateIntelligenceCommunityStandUpBannerDismissedMutation = graphql(` + mutation UpdateIntelligenceCommunityStandUpBannerDismissed($value: Boolean!) { + activeUserMutations { + meta { + setIntelligenceCommunityStandUpBannerDismissed(value: $value) + } + } + } +`) + export function useActiveUserMeta() { const { result } = useQuery(activeUserMetaQuery) const { mutate: updateLegacyProjectsExplainer } = useMutation( updateLegacyProjectsExplainerMutation ) + const { mutate: updateIntelligenceCommunityStandUpBanner } = useMutation( + updateIntelligenceCommunityStandUpBannerDismissedMutation + ) const apollo = useApolloClient().client const cache = apollo.cache const { activeUser } = useActiveUser() @@ -37,6 +51,10 @@ export function useActiveUserMeta() { () => meta.value?.legacyProjectsExplainerCollapsed ) + const hasDismissedIntelligenceCommunityStandUpBanner = computed( + () => meta.value?.intelligenceCommunityStandUpBannerDismissed + ) + const updateLegacyProjectsExplainerCollapsed = async (value: boolean) => { await updateLegacyProjectsExplainer({ value }) @@ -51,8 +69,24 @@ export function useActiveUserMeta() { ) } + const updateIntelligenceCommunityStandUpBannerDismissed = async (value: boolean) => { + await updateIntelligenceCommunityStandUpBanner({ value }) + + modifyObjectField( + cache, + getCacheId('User', activeUserId.value), + 'meta', + ({ helpers: { createUpdatedValue } }) => + createUpdatedValue(({ update }) => { + update('intelligenceCommunityStandUpBannerDismissed', () => value) + }) + ) + } + return { hasCollapsedLegacyProjectsExplainer, - updateLegacyProjectsExplainerCollapsed + updateLegacyProjectsExplainerCollapsed, + hasDismissedIntelligenceCommunityStandUpBanner, + updateIntelligenceCommunityStandUpBannerDismissed } } diff --git a/packages/server/assets/core/typedefs/userMeta.graphql b/packages/server/assets/core/typedefs/userMeta.graphql index b5c13d4fb..63fbdc9f0 100644 --- a/packages/server/assets/core/typedefs/userMeta.graphql +++ b/packages/server/assets/core/typedefs/userMeta.graphql @@ -1,12 +1,14 @@ type UserMeta { newWorkspaceExplainerDismissed: Boolean! speckleConBannerDismissed: Boolean! + intelligenceCommunityStandUpBannerDismissed: Boolean! legacyProjectsExplainerCollapsed: Boolean! } type UserMetaMutations { setNewWorkspaceExplainerDismissed(value: Boolean!): Boolean! setSpeckleConBannerDismissed(value: Boolean!): Boolean! + setIntelligenceCommunityStandUpBannerDismissed(value: Boolean!): Boolean! setLegacyProjectsExplainerCollapsed(value: Boolean!): Boolean! } diff --git a/packages/server/modules/core/dbSchema.ts b/packages/server/modules/core/dbSchema.ts index e5d53add2..ecdc0a274 100644 --- a/packages/server/modules/core/dbSchema.ts +++ b/packages/server/modules/core/dbSchema.ts @@ -308,6 +308,7 @@ export const UsersMeta = buildMetaTableHelper( 'isProjectsActive', 'newWorkspaceExplainerDismissed', 'speckleConBannerDismissed', + 'intelligenceCommunityStandUpBannerDismissed', 'legacyProjectsExplainerCollapsed', // Used in tests 'foo', diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index bafe1b822..12b981ca7 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -4795,6 +4795,7 @@ export type UserGendoAiCredits = { export type UserMeta = { __typename?: 'UserMeta'; + intelligenceCommunityStandUpBannerDismissed: Scalars['Boolean']['output']; legacyProjectsExplainerCollapsed: Scalars['Boolean']['output']; newWorkspaceExplainerDismissed: Scalars['Boolean']['output']; speckleConBannerDismissed: Scalars['Boolean']['output']; @@ -4802,12 +4803,18 @@ export type UserMeta = { export type UserMetaMutations = { __typename?: 'UserMetaMutations'; + setIntelligenceCommunityStandUpBannerDismissed: Scalars['Boolean']['output']; setLegacyProjectsExplainerCollapsed: Scalars['Boolean']['output']; setNewWorkspaceExplainerDismissed: Scalars['Boolean']['output']; setSpeckleConBannerDismissed: Scalars['Boolean']['output']; }; +export type UserMetaMutationsSetIntelligenceCommunityStandUpBannerDismissedArgs = { + value: Scalars['Boolean']['input']; +}; + + export type UserMetaMutationsSetLegacyProjectsExplainerCollapsedArgs = { value: Scalars['Boolean']['input']; }; @@ -8270,6 +8277,7 @@ export type UserGendoAiCreditsResolvers = { + intelligenceCommunityStandUpBannerDismissed?: Resolver; legacyProjectsExplainerCollapsed?: Resolver; newWorkspaceExplainerDismissed?: Resolver; speckleConBannerDismissed?: Resolver; @@ -8277,6 +8285,7 @@ export type UserMetaResolvers = { + setIntelligenceCommunityStandUpBannerDismissed?: Resolver>; setLegacyProjectsExplainerCollapsed?: Resolver>; setNewWorkspaceExplainerDismissed?: Resolver>; setSpeckleConBannerDismissed?: Resolver>; @@ -9069,6 +9078,18 @@ export type SetSpeckleConBannerDismissedMutationVariables = Exact<{ export type SetSpeckleConBannerDismissedMutation = { __typename?: 'Mutation', activeUserMutations: { __typename?: 'ActiveUserMutations', meta: { __typename?: 'UserMetaMutations', setSpeckleConBannerDismissed: boolean } } }; +export type GetIntelligenceCommunityStandUpBannerDismissedQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetIntelligenceCommunityStandUpBannerDismissedQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', meta: { __typename?: 'UserMeta', intelligenceCommunityStandUpBannerDismissed: boolean } } | null }; + +export type SetIntelligenceCommunityStandUpBannerDismissedMutationVariables = Exact<{ + input: Scalars['Boolean']['input']; +}>; + + +export type SetIntelligenceCommunityStandUpBannerDismissedMutation = { __typename?: 'Mutation', activeUserMutations: { __typename?: 'ActiveUserMutations', meta: { __typename?: 'UserMetaMutations', setIntelligenceCommunityStandUpBannerDismissed: boolean } } }; + export type GetLegacyProjectsExplainerCollapsedQueryVariables = Exact<{ [key: string]: never; }>; @@ -10407,6 +10428,8 @@ export const GetNewWorkspaceExplainerDismissedDocument = {"kind":"Document","def export const SetNewWorkspaceExplainerDismissedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetNewWorkspaceExplainerDismissed"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUserMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setNewWorkspaceExplainerDismissed"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]}}]} as unknown as DocumentNode; export const GetSpeckleConBannerDismissedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSpeckleConBannerDismissed"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"speckleConBannerDismissed"}}]}}]}}]}}]} as unknown as DocumentNode; export const SetSpeckleConBannerDismissedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetSpeckleConBannerDismissed"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUserMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setSpeckleConBannerDismissed"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]}}]} as unknown as DocumentNode; +export const GetIntelligenceCommunityStandUpBannerDismissedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetIntelligenceCommunityStandUpBannerDismissed"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"intelligenceCommunityStandUpBannerDismissed"}}]}}]}}]}}]} as unknown as DocumentNode; +export const SetIntelligenceCommunityStandUpBannerDismissedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetIntelligenceCommunityStandUpBannerDismissed"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUserMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setIntelligenceCommunityStandUpBannerDismissed"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]}}]} as unknown as DocumentNode; export const GetLegacyProjectsExplainerCollapsedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLegacyProjectsExplainerCollapsed"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"legacyProjectsExplainerCollapsed"}}]}}]}}]}}]} as unknown as DocumentNode; export const SetLegacyProjectsExplainerCollapsedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetLegacyProjectsExplainerCollapsed"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUserMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setLegacyProjectsExplainerCollapsed"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]}}]} as unknown as DocumentNode; export const GetLimitedPersonalProjectVersionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLimitedPersonalProjectVersions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"versions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedPersonalProjectVersion"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalProjectComment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"rawText"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"doc"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalProjectVersion"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Version"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}},{"kind":"Field","name":{"kind":"Name","value":"commentThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedPersonalProjectComment"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/server/modules/core/graph/resolvers/users.ts b/packages/server/modules/core/graph/resolvers/users.ts index b302e0794..be137e83c 100644 --- a/packages/server/modules/core/graph/resolvers/users.ts +++ b/packages/server/modules/core/graph/resolvers/users.ts @@ -224,6 +224,13 @@ export default { }) return !!metaVal?.value }, + intelligenceCommunityStandUpBannerDismissed: async (parent, _args, ctx) => { + const metaVal = await ctx.loaders.users.getUserMeta.load({ + userId: parent.userId, + key: UsersMeta.metaKey.intelligenceCommunityStandUpBannerDismissed + }) + return !!metaVal?.value + }, legacyProjectsExplainerCollapsed: async (parent, _args, ctx) => { const metaVal = await ctx.loaders.users.getUserMeta.load({ userId: parent.userId, @@ -497,6 +504,16 @@ export default { args.value ) + return !!res.value + }, + setIntelligenceCommunityStandUpBannerDismissed: async (_parent, args, ctx) => { + const meta = metaHelpers(Users, db) + const res = await meta.set( + ctx.userId!, + UsersMeta.metaKey.intelligenceCommunityStandUpBannerDismissed, + args.value + ) + return !!res.value } } diff --git a/packages/server/modules/core/tests/helpers/graphql.ts b/packages/server/modules/core/tests/helpers/graphql.ts index 0855be4df..2d25ed860 100644 --- a/packages/server/modules/core/tests/helpers/graphql.ts +++ b/packages/server/modules/core/tests/helpers/graphql.ts @@ -230,6 +230,26 @@ export const setSpeckleConBannerDismissedMutation = gql` } ` +export const getIntelligenceCommunityStandUpBannerDismissedQuery = gql` + query GetIntelligenceCommunityStandUpBannerDismissed { + activeUser { + meta { + intelligenceCommunityStandUpBannerDismissed + } + } + } +` + +export const setIntelligenceCommunityStandUpBannerDismissedMutation = gql` + mutation SetIntelligenceCommunityStandUpBannerDismissed($input: Boolean!) { + activeUserMutations { + meta { + setIntelligenceCommunityStandUpBannerDismissed(value: $input) + } + } + } +` + export const getLegacyProjectsExplainerCollapsedQuery = gql` query GetLegacyProjectsExplainerCollapsed { activeUser { diff --git a/packages/server/modules/core/tests/integration/userMeta.graph.spec.ts b/packages/server/modules/core/tests/integration/userMeta.graph.spec.ts index 304fc41ef..e8ffb8f2a 100644 --- a/packages/server/modules/core/tests/integration/userMeta.graph.spec.ts +++ b/packages/server/modules/core/tests/integration/userMeta.graph.spec.ts @@ -6,7 +6,9 @@ import { GetSpeckleConBannerDismissedDocument, SetLegacyProjectsExplainerCollapsedDocument, SetNewWorkspaceExplainerDismissedDocument, - SetSpeckleConBannerDismissedDocument + SetSpeckleConBannerDismissedDocument, + GetIntelligenceCommunityStandUpBannerDismissedDocument, + SetIntelligenceCommunityStandUpBannerDismissedDocument } from '@/modules/core/graph/generated/graphql' import type { TestApolloServer } from '@/test/graphqlHelper' import { testApolloServer } from '@/test/graphqlHelper' @@ -62,6 +64,37 @@ describe('UserMeta GraphQL', () => { expect(getRes2.data?.activeUser?.meta.speckleConBannerDismissed).to.be.true }) + it('intelligenceCommunityStandUpBannerDismissed get/set works', async () => { + const getRes = await apollo.execute( + GetIntelligenceCommunityStandUpBannerDismissedDocument, + {} + ) + expect(getRes).to.not.haveGraphQLErrors() + expect(getRes.data?.activeUser?.meta.intelligenceCommunityStandUpBannerDismissed).to + .be.false + + const setRes = await apollo.execute( + SetIntelligenceCommunityStandUpBannerDismissedDocument, + { + input: true + } + ) + + expect(setRes).to.not.haveGraphQLErrors() + expect( + setRes.data?.activeUserMutations?.meta + .setIntelligenceCommunityStandUpBannerDismissed + ).to.be.true + + const getRes2 = await apollo.execute( + GetIntelligenceCommunityStandUpBannerDismissedDocument, + {} + ) + expect(getRes2).to.not.haveGraphQLErrors() + expect(getRes2.data?.activeUser?.meta.intelligenceCommunityStandUpBannerDismissed) + .to.be.true + }) + it('setLegacyProjectsExplainerCollapsed get/set works', async () => { const getRes = await apollo.execute(GetLegacyProjectsExplainerCollapsedDocument, {}) expect(getRes).to.not.haveGraphQLErrors() From 1fe74181debb4bf5faabacf3d575ed261223e069 Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Mon, 1 Sep 2025 14:59:39 +0300 Subject: [PATCH 22/25] fix: default viewMode.edgesEnabled to true if not set (#5352) --- packages/shared/src/viewer/helpers/state.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/viewer/helpers/state.ts b/packages/shared/src/viewer/helpers/state.ts index 3bca59a27..fc3caa5e1 100644 --- a/packages/shared/src/viewer/helpers/state.ts +++ b/packages/shared/src/viewer/helpers/state.ts @@ -185,10 +185,12 @@ const initializeMissingData = (state: UnformattedState): SerializedViewerState = ) } - const viewMode = isNumber(state.ui?.viewMode) + const viewModeType = isNumber(state.ui?.viewMode) ? state.ui.viewMode : state.ui?.viewMode?.mode + const viewModeSettings = isNumber(state.ui?.viewMode) ? {} : state.ui?.viewMode + return { projectId: state.projectId || throwInvalidError('projectId'), sessionId: state.sessionId || `nullSessionId-${Math.random() * 1000}`, @@ -252,11 +254,11 @@ const initializeMissingData = (state: UnformattedState): SerializedViewerState = zoom: state.ui?.camera?.zoom || 1 }, viewMode: { - mode: viewMode || 0, - edgesEnabled: state.ui?.viewMode?.edgesEnabled || false, - edgesWeight: state.ui?.viewMode?.edgesWeight || 1, - outlineOpacity: state.ui?.viewMode?.outlineOpacity || 0.75, - edgesColor: state.ui?.viewMode?.edgesColor || defaultViewModeEdgeColorValue + mode: viewModeType ?? 0, + edgesEnabled: viewModeSettings?.edgesEnabled ?? true, + edgesWeight: viewModeSettings?.edgesWeight ?? 1, + outlineOpacity: viewModeSettings?.outlineOpacity ?? 0.75, + edgesColor: viewModeSettings?.edgesColor ?? defaultViewModeEdgeColorValue }, sectionBox: state.ui?.sectionBox?.min?.length && state.ui?.sectionBox.max?.length From 08eb1f7a1dbc32288cf63fb72525aeccb5c687d7 Mon Sep 17 00:00:00 2001 From: Mike Date: Mon, 1 Sep 2025 14:24:17 +0200 Subject: [PATCH 23/25] Feat: Bashboards in app (#5333) --- packages/frontend-2/.env.example | 3 + .../components/common/ConfirmDialog.vue | 8 +- .../components/dashboard/Sidebar.vue | 49 +++- .../frontend-2/components/dashboards/Card.vue | 103 +++++++ .../components/dashboards/CreateDialog.vue | 70 +++++ .../components/dashboards/EditDialog.vue | 85 ++++++ .../frontend-2/components/dashboards/List.vue | 105 +++++++ .../components/dashboards/Share.vue | 133 +++++++++ .../frontend-2/components/header/Empty.vue | 3 + .../frontend-2/components/header/nav/Bar.vue | 6 +- .../components/header/nav/Share.vue | 18 +- packages/frontend-2/composables/globals.ts | 7 + packages/frontend-2/layouts/dashboard.vue | 28 ++ .../frontend-2/lib/auth/composables/auth.ts | 11 +- .../lib/common/generated/gql/gql.ts | 72 +++++ .../lib/common/generated/gql/graphql.ts | 275 +++++++++++++++++- .../frontend-2/lib/common/helpers/route.ts | 9 +- .../lib/dashboards/composables/embed.ts | 52 ++++ .../lib/dashboards/composables/management.ts | 148 ++++++++++ .../lib/dashboards/graphql/mutations.ts | 36 +++ .../lib/dashboards/graphql/queries.ts | 25 ++ packages/frontend-2/nuxt.config.ts | 1 + .../workspaces/[slug]/dashboards/[id].vue | 126 ++++++++ .../workspaces/[slug]/dashboards/index.vue | 29 ++ .../dashboards/typedefs/dashboards.graphql | 47 +++ .../dashboards/typedefs/permissions.graphql | 15 + .../assets/dashboards/typedefs/tokens.graphql | 33 +++ .../typedefs/workspaces.graphql | 5 + packages/server/codegen.ts | 9 +- .../modules/core/graph/generated/graphql.ts | 219 ++++++++++++++ .../modules/dashboards/authz/loaders/index.ts | 13 + .../server/modules/dashboards/dbSchema.ts | 18 ++ .../modules/dashboards/domain/operations.ts | 22 ++ .../dashboards/domain/tokens/operations.ts | 6 + .../modules/dashboards/domain/tokens/types.ts | 11 + .../server/modules/dashboards/domain/types.ts | 13 + .../modules/dashboards/errors/dashboards.ts | 26 ++ .../dashboards/graph/resolvers/dashboards.ts | 155 ++++++++++ .../dashboards/graph/resolvers/permissions.ts | 56 ++++ .../dashboards/graph/resolvers/tokens.ts | 90 ++++++ .../modules/dashboards/helpers/graphTypes.ts | 8 + packages/server/modules/dashboards/index.ts | 14 + .../migrations/20250826113850_dashboards.ts | 23 ++ .../20250826161638_dashboard_tokens.ts | 29 ++ .../dashboards/repositories/management.ts | 75 +++++ .../modules/dashboards/repositories/tokens.ts | 21 ++ .../modules/dashboards/services/management.ts | 132 +++++++++ .../modules/dashboards/services/tokens.ts | 80 +++++ .../tests/integration/management.spec.ts | 100 +++++++ .../dashboards/tests/unit/management.spec.ts | 47 +++ .../dashboards/tests/unit/tokens.spec.ts | 79 +++++ packages/server/modules/index.ts | 1 + .../modules/shared/helpers/errorHelper.ts | 6 + .../workspacesCore/helpers/graphHelpers.ts | 32 ++ .../src/authz/checks/dashboards.spec.ts | 46 +++ .../shared/src/authz/checks/dashboards.ts | 14 + .../shared/src/authz/domain/authErrors.ts | 25 ++ packages/shared/src/authz/domain/context.ts | 2 + .../src/authz/domain/dashboards/operations.ts | 3 + .../src/authz/domain/dashboards/types.ts | 6 + packages/shared/src/authz/domain/loaders.ts | 3 + .../shared/src/authz/fragments/dashboards.ts | 90 ++++++ .../policies/dashboard/canCreateToken.ts | 86 ++++++ .../src/authz/policies/dashboard/canDelete.ts | 76 +++++ .../src/authz/policies/dashboard/canEdit.ts | 71 +++++ .../src/authz/policies/dashboard/canRead.ts | 61 ++++ packages/shared/src/authz/policies/index.ts | 16 +- .../policies/workspace/canCreateDashboards.ts | 63 ++++ .../policies/workspace/canListDashboards.ts | 53 ++++ .../shared/src/environment/featureFlags.ts | 1 + packages/shared/src/environment/index.ts | 5 + .../speckle-server/templates/_helpers.tpl | 3 + .../templates/frontend_2/deployment.yml | 2 + utils/helm/speckle-server/values.schema.json | 5 + utils/helm/speckle-server/values.yaml | 2 + 75 files changed, 3400 insertions(+), 20 deletions(-) create mode 100644 packages/frontend-2/components/dashboards/Card.vue create mode 100644 packages/frontend-2/components/dashboards/CreateDialog.vue create mode 100644 packages/frontend-2/components/dashboards/EditDialog.vue create mode 100644 packages/frontend-2/components/dashboards/List.vue create mode 100644 packages/frontend-2/components/dashboards/Share.vue create mode 100644 packages/frontend-2/layouts/dashboard.vue create mode 100644 packages/frontend-2/lib/dashboards/composables/embed.ts create mode 100644 packages/frontend-2/lib/dashboards/composables/management.ts create mode 100644 packages/frontend-2/lib/dashboards/graphql/mutations.ts create mode 100644 packages/frontend-2/lib/dashboards/graphql/queries.ts create mode 100644 packages/frontend-2/pages/workspaces/[slug]/dashboards/[id].vue create mode 100644 packages/frontend-2/pages/workspaces/[slug]/dashboards/index.vue create mode 100644 packages/server/assets/dashboards/typedefs/dashboards.graphql create mode 100644 packages/server/assets/dashboards/typedefs/permissions.graphql create mode 100644 packages/server/assets/dashboards/typedefs/tokens.graphql create mode 100644 packages/server/modules/dashboards/authz/loaders/index.ts create mode 100644 packages/server/modules/dashboards/dbSchema.ts create mode 100644 packages/server/modules/dashboards/domain/operations.ts create mode 100644 packages/server/modules/dashboards/domain/tokens/operations.ts create mode 100644 packages/server/modules/dashboards/domain/tokens/types.ts create mode 100644 packages/server/modules/dashboards/domain/types.ts create mode 100644 packages/server/modules/dashboards/errors/dashboards.ts create mode 100644 packages/server/modules/dashboards/graph/resolvers/dashboards.ts create mode 100644 packages/server/modules/dashboards/graph/resolvers/permissions.ts create mode 100644 packages/server/modules/dashboards/graph/resolvers/tokens.ts create mode 100644 packages/server/modules/dashboards/helpers/graphTypes.ts create mode 100644 packages/server/modules/dashboards/index.ts create mode 100644 packages/server/modules/dashboards/migrations/20250826113850_dashboards.ts create mode 100644 packages/server/modules/dashboards/migrations/20250826161638_dashboard_tokens.ts create mode 100644 packages/server/modules/dashboards/repositories/management.ts create mode 100644 packages/server/modules/dashboards/repositories/tokens.ts create mode 100644 packages/server/modules/dashboards/services/management.ts create mode 100644 packages/server/modules/dashboards/services/tokens.ts create mode 100644 packages/server/modules/dashboards/tests/integration/management.spec.ts create mode 100644 packages/server/modules/dashboards/tests/unit/management.spec.ts create mode 100644 packages/server/modules/dashboards/tests/unit/tokens.spec.ts create mode 100644 packages/server/modules/workspacesCore/helpers/graphHelpers.ts create mode 100644 packages/shared/src/authz/checks/dashboards.spec.ts create mode 100644 packages/shared/src/authz/checks/dashboards.ts create mode 100644 packages/shared/src/authz/domain/dashboards/operations.ts create mode 100644 packages/shared/src/authz/domain/dashboards/types.ts create mode 100644 packages/shared/src/authz/fragments/dashboards.ts create mode 100644 packages/shared/src/authz/policies/dashboard/canCreateToken.ts create mode 100644 packages/shared/src/authz/policies/dashboard/canDelete.ts create mode 100644 packages/shared/src/authz/policies/dashboard/canEdit.ts create mode 100644 packages/shared/src/authz/policies/dashboard/canRead.ts create mode 100644 packages/shared/src/authz/policies/workspace/canCreateDashboards.ts create mode 100644 packages/shared/src/authz/policies/workspace/canListDashboards.ts diff --git a/packages/frontend-2/.env.example b/packages/frontend-2/.env.example index a96acaa88..4a36b7b4b 100644 --- a/packages/frontend-2/.env.example +++ b/packages/frontend-2/.env.example @@ -40,6 +40,9 @@ NUXT_PUBLIC_INTERCOM_APP_ID= # Enable Autodesk construction cloud integration NUXT_PUBLIC_FF_ACC_INTEGRATION_ENABLED=false +# Local or remote URL for dashboards +NUXT_PUBLIC_DASHBOARDS_ORIGIN=http://localhost:8083 + ########################################################## # Local dev settings ########################################################## diff --git a/packages/frontend-2/components/common/ConfirmDialog.vue b/packages/frontend-2/components/common/ConfirmDialog.vue index 8011670ee..f8bc9ac30 100644 --- a/packages/frontend-2/components/common/ConfirmDialog.vue +++ b/packages/frontend-2/components/common/ConfirmDialog.vue @@ -1,6 +1,6 @@