diff --git a/.circleci/build.sh b/.circleci/build.sh
index 517318343..18ea3ba6e 100755
--- a/.circleci/build.sh
+++ b/.circleci/build.sh
@@ -10,27 +10,16 @@ fi
# enables building the test-deployment container with the same script
# defaults to packages for minimal intervention in the ci config
FOLDER="${FOLDER:-packages}"
-SHOULD_PUBLISH="${SHOULD_PUBLISH:-false}"
-DOCKER_IMAGE_TAG="speckle/speckle-${SPECKLE_SERVER_PACKAGE}"
+SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
+# shellcheck disable=SC1090,SC1091
+source "${SCRIPT_DIR}/common.sh"
-# IMAGE_VERSION_TAG=$(./.circleci/get_version.sh)
-# if there is not image version tag, uses the SHA1 of the last git commit of the branch that triggered this build
-IMAGE_VERSION_TAG="${IMAGE_VERSION_TAG:-${CIRCLE_SHA1}}"
-echo "${IMAGE_VERSION_TAG}"
+echo "Building image: ${DOCKER_IMAGE_TAG}:${IMAGE_VERSION_TAG}"
export DOCKER_BUILDKIT=1
-docker build --build-arg SPECKLE_SERVER_VERSION="${IMAGE_VERSION_TAG}" -t "${DOCKER_IMAGE_TAG}:${IMAGE_VERSION_TAG}" . --file "${FOLDER}/${SPECKLE_SERVER_PACKAGE}/Dockerfile"
+docker build --build-arg SPECKLE_SERVER_VERSION="${IMAGE_VERSION_TAG}" --tag "${DOCKER_IMAGE_TAG}:${IMAGE_VERSION_TAG}" --file "${FOLDER}/${SPECKLE_SERVER_PACKAGE}/Dockerfile" .
-if [[ "${SHOULD_PUBLISH}" == "true" ]]; then
- echo "publishing images"
- docker tag "${DOCKER_IMAGE_TAG}:${IMAGE_VERSION_TAG}" "${DOCKER_IMAGE_TAG}:latest"
-
- if [[ "${IMAGE_VERSION_TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
- docker tag "${DOCKER_IMAGE_TAG}:${IMAGE_VERSION_TAG}" "${DOCKER_IMAGE_TAG}:2"
- fi
-
- echo "${DOCKER_REG_PASS}" | docker login -u "${DOCKER_REG_USER}" --password-stdin "${DOCKER_REG_URL}"
- docker push --all-tags "${DOCKER_IMAGE_TAG}"
-fi
+echo " Saving image: ${DOCKER_FILE_NAME}"
+docker save --output "/tmp/ci/workspace/${DOCKER_FILE_NAME}" "${DOCKER_IMAGE_TAG}:${IMAGE_VERSION_TAG}"
diff --git a/.circleci/common.sh b/.circleci/common.sh
new file mode 100755
index 000000000..4abeb82fc
--- /dev/null
+++ b/.circleci/common.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+set -eo pipefail
+
+DOCKER_IMAGE_TAG="speckle/speckle-${SPECKLE_SERVER_PACKAGE}"
+IMAGE_VERSION_TAG="${IMAGE_VERSION_TAG:-${CIRCLE_SHA1}}"
+# shellcheck disable=SC2034,SC2086
+DOCKER_FILE_NAME="$(echo ${DOCKER_IMAGE_TAG}_${IMAGE_VERSION_TAG} | sed -e 's/[^A-Za-z0-9._-]/_/g')"
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 28c6e8c90..0cd52fe36 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -23,9 +23,7 @@ workflows:
- pre-commit:
filters: *filters-everything
- - docker-build-and-publish-server:
- context: &docker-hub-context
- - docker-hub
+ - docker-build-server:
filters: &filters-build
tags:
only: /.*/
@@ -33,59 +31,114 @@ workflows:
- test-server
- get-version
- should-build
- - should-publish
- - docker-build-and-publish-frontend:
- context: *docker-hub-context
+ - docker-build-frontend:
filters: *filters-build
requires:
- get-version
- should-build
- - should-publish
- - docker-build-and-publish-webhooks:
- context: *docker-hub-context
+ - docker-build-webhooks:
filters: *filters-build
requires:
- get-version
- test-server
- should-build
- - should-publish
- - docker-build-and-publish-file-imports:
- context: *docker-hub-context
+ - docker-build-file-imports:
filters: *filters-build
requires:
- get-version
- test-server
- should-build
- - should-publish
- - docker-build-and-publish-previews:
- context: *docker-hub-context
+ - docker-build-previews:
filters: *filters-build
requires:
- get-version
- test-server
- should-build
- - should-publish
- - docker-build-and-publish-test-container:
- context: *docker-hub-context
+ - docker-build-test-container:
filters: *filters-build
requires:
- get-version
- test-server
- should-build
- - should-publish
- - docker-build-and-publish-monitor-container:
- context: *docker-hub-context
+ - docker-build-monitor-container:
filters: *filters-build
requires:
- get-version
- should-build
+
+ - docker-publish-server:
+ context: &docker-hub-context
+ - docker-hub
+ filters: &filters-publish
+ branches:
+ ignore: /pull\/[0-9]+/
+ tags:
+ only: /.*/
+ requires:
+ - get-version
- should-publish
+ - docker-build-server
+ - pre-commit
+
+ - docker-publish-frontend:
+ context: *docker-hub-context
+ filters: *filters-publish
+ requires:
+ - get-version
+ - should-publish
+ - docker-build-frontend
+ - pre-commit
+
+ - docker-publish-webhooks:
+ context: *docker-hub-context
+ filters: *filters-publish
+ requires:
+ - get-version
+ - should-publish
+ - docker-build-webhooks
+ - pre-commit
+
+ - docker-publish-file-imports:
+ context: *docker-hub-context
+ filters: *filters-publish
+ requires:
+ - get-version
+ - should-publish
+ - docker-build-file-imports
+ - pre-commit
+
+ - docker-publish-previews:
+ context: *docker-hub-context
+ filters: *filters-publish
+ requires:
+ - get-version
+ - should-publish
+ - docker-build-previews
+ - pre-commit
+
+ - docker-publish-test-container:
+ context: *docker-hub-context
+ filters: *filters-publish
+ requires:
+ - get-version
+ - should-publish
+ - docker-build-test-container
+ - pre-commit
+
+ - docker-publish-monitor-container:
+ context: *docker-hub-context
+ filters: *filters-publish
+ requires:
+ - get-version
+ - should-publish
+ - docker-build-monitor-container
+ - pre-commit
- publish-helm-chart:
filters: &filters-publish
@@ -96,16 +149,15 @@ workflows:
tags:
only: &filters-tag /^[0-9]+\.[0-9]+\.[0-9]+$/
requires:
- - test-server
- get-version
- should-publish
- - docker-build-and-publish-server
- - docker-build-and-publish-frontend
- - docker-build-and-publish-webhooks
- - docker-build-and-publish-file-imports
- - docker-build-and-publish-previews
- - docker-build-and-publish-monitor-container
- - docker-build-and-publish-test-container
+ - docker-publish-server
+ - docker-publish-frontend
+ - docker-publish-webhooks
+ - docker-publish-file-imports
+ - docker-publish-previews
+ - docker-publish-monitor-container
+ - docker-publish-test-container
- publish-npm:
filters:
@@ -125,7 +177,6 @@ jobs:
working_directory: &work-dir /tmp/ci
steps:
- checkout
- - run: pwd
- run: mkdir -p workspace
- run:
name: set version
@@ -142,13 +193,12 @@ jobs:
docker:
- image: cimg/base:2022.08
working_directory: *work-dir
- environment:
- # £ delimited strings of regex for matches which should be published
+ environment: &publishable-tags-branches
PUBLISHABLE_TAGS: '^[0-9]+\.[0-9]+\.[0-9]+$'
+ # £ delimited strings of regex for matches which should be published
PUBLISHABLE_BRANCHES: '^main$£^hotfix.*£^alpha.*'
steps:
- checkout
- - run: pwd
- run: mkdir -p workspace
- run:
name: determine whether to publish
@@ -165,9 +215,9 @@ jobs:
docker:
- image: cimg/base:2022.08
working_directory: *work-dir
+ environment: *publishable-tags-branches
steps:
- checkout
- - run: pwd
- run: mkdir -p workspace
- run:
name: determine whether to build
@@ -197,7 +247,7 @@ jobs:
type: string
docker:
- image: speckle/pre-commit-runner:latest
- resource_class: large
+ resource_class: medium
working_directory: *work-dir
steps:
- checkout
@@ -259,6 +309,7 @@ jobs:
S3_SECRET_KEY: 'minioadmin'
S3_BUCKET: 'speckle-server'
S3_CREATE_BUCKET: 'true'
+ REDIS_URL: 'redis://localhost:6379'
S3_REGION: '' # optional, defaults to 'us-east-1'
steps:
- checkout
@@ -304,10 +355,10 @@ jobs:
path: packages/server/coverage/lcov-report
destination: package/server/coverage
- docker-build-and-publish: &docker-job
+ docker-build: &build-job
docker: &docker-image
- image: cimg/node:16.15
- resource_class: xlarge
+ resource_class: medium
working_directory: *work-dir
steps:
- checkout
@@ -315,7 +366,11 @@ jobs:
at: /tmp/ci/workspace
- run: cat workspace/env-vars >> $BASH_ENV
- run: cat workspace/should-build >> $BASH_ENV
- - run: cat workspace/should-publish >> $BASH_ENV
+ - run:
+ name: 'Check if should proceed'
+ command: |
+ [[ "${SHOULD_BUILD}" != true ]] && echo "Should not build, stopping" && circleci-agent step halt
+ echo 'Proceeding with build'
- setup_remote_docker:
# a weird issue with yarn installing packages throwing EPERM errors
# this fixes it
@@ -324,40 +379,106 @@ jobs:
- run:
name: Build and Publish
command: ./.circleci/build.sh
+ - persist_to_workspace:
+ root: workspace
+ paths:
+ - speckle*
- docker-build-and-publish-server:
- <<: *docker-job
+ docker-build-server:
+ <<: *build-job
environment:
SPECKLE_SERVER_PACKAGE: server
- docker-build-and-publish-frontend:
- <<: *docker-job
+ docker-build-frontend:
+ <<: *build-job
environment:
SPECKLE_SERVER_PACKAGE: frontend
- docker-build-and-publish-previews:
- <<: *docker-job
+ docker-build-previews:
+ <<: *build-job
environment:
SPECKLE_SERVER_PACKAGE: preview-service
- docker-build-and-publish-webhooks:
- <<: *docker-job
+ docker-build-webhooks:
+ <<: *build-job
environment:
SPECKLE_SERVER_PACKAGE: webhook-service
- docker-build-and-publish-file-imports:
- <<: *docker-job
+ docker-build-file-imports:
+ <<: *build-job
environment:
SPECKLE_SERVER_PACKAGE: fileimport-service
- docker-build-and-publish-test-container:
- <<: *docker-job
+ docker-build-test-container:
+ <<: *build-job
environment:
FOLDER: utils
SPECKLE_SERVER_PACKAGE: test-deployment
- docker-build-and-publish-monitor-container:
- <<: *docker-job
+ docker-build-monitor-container:
+ <<: *build-job
+ environment:
+ FOLDER: utils
+ SPECKLE_SERVER_PACKAGE: monitor-deployment
+
+ docker-publish: &publish-job
+ docker: &base-image
+ - image: cimg/base:2022.08
+ resource_class: medium
+ working_directory: *work-dir
+ steps:
+ - checkout
+ - attach_workspace:
+ at: /tmp/ci/workspace
+ - run: cat workspace/env-vars >> $BASH_ENV
+ - run: cat workspace/should-publish >> $BASH_ENV
+ - run:
+ name: 'Check if should proceed'
+ command: |
+ [[ "${SHOULD_PUBLISH}" != true ]] && echo "Should not publish, stopping" && circleci-agent step halt
+ echo 'Proceeding with publish'
+ - setup_remote_docker:
+ # a weird issue with yarn installing packages throwing EPERM errors
+ # this fixes it
+ version: 20.10.12
+ docker_layer_caching: true
+ - run:
+ name: Publish
+ command: ./.circleci/publish.sh
+
+ docker-publish-server:
+ <<: *publish-job
+ environment:
+ SPECKLE_SERVER_PACKAGE: server
+
+ docker-publish-frontend:
+ <<: *publish-job
+ environment:
+ SPECKLE_SERVER_PACKAGE: frontend
+
+ docker-publish-previews:
+ <<: *publish-job
+ environment:
+ SPECKLE_SERVER_PACKAGE: preview-service
+
+ docker-publish-webhooks:
+ <<: *publish-job
+ environment:
+ SPECKLE_SERVER_PACKAGE: webhook-service
+
+ docker-publish-file-imports:
+ <<: *publish-job
+ environment:
+ SPECKLE_SERVER_PACKAGE: fileimport-service
+
+ docker-publish-test-container:
+ <<: *publish-job
+ environment:
+ FOLDER: utils
+ SPECKLE_SERVER_PACKAGE: test-deployment
+
+ docker-publish-monitor-container:
+ <<: *publish-job
environment:
FOLDER: utils
SPECKLE_SERVER_PACKAGE: monitor-deployment
@@ -389,7 +510,7 @@ jobs:
- run:
name: auth to npm as Speckle
command: |
- echo "npmRegistryServer: https://registry.npmjs.org/" >> .yarnrc.yml
+ echo "npmRegistryServer: https://registry.npmjs.org/" >> .yarnrc.yml
echo "npmAuthToken: ${NPM_TOKEN}" >> .yarnrc.yml
- run:
name: try login to npm
@@ -407,14 +528,6 @@ jobs:
name: publish to npm
command: 'yarn workspaces foreach -pv --no-private npm publish --access public'
- # - run:
- # name: commit changes
- # command: |
- # yarn prettier:fix
- # git add .
- # git commit -m '[ci skip] bump version to $IMAGE_VERSION_TAG'
- # git push
-
publish-helm-chart:
docker: *docker-image
working_directory: *work-dir
diff --git a/.circleci/publish.sh b/.circleci/publish.sh
new file mode 100755
index 000000000..b7a915db8
--- /dev/null
+++ b/.circleci/publish.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+set -eo pipefail
+
+SHOULD_PUBLISH="${SHOULD_PUBLISH:-false}"
+
+if [[ "${SHOULD_PUBLISH}" != "true" ]]; then
+ echo "Not publishing as the SHOULD_PUBLISH environment variable is not 'true'."
+ exit 0
+fi
+
+SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
+# shellcheck disable=SC1090,SC1091
+source "${SCRIPT_DIR}/common.sh"
+
+echo "Publishing: ${DOCKER_IMAGE_TAG}:${IMAGE_VERSION_TAG}"
+
+echo "💾 Loading image"
+docker load --input "/tmp/ci/workspace/${DOCKER_FILE_NAME}"
+
+echo "🐳 Publishing image"
+docker tag "${DOCKER_IMAGE_TAG}:${IMAGE_VERSION_TAG}" "${DOCKER_IMAGE_TAG}:latest"
+
+if [[ "${IMAGE_VERSION_TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ docker tag "${DOCKER_IMAGE_TAG}:${IMAGE_VERSION_TAG}" "${DOCKER_IMAGE_TAG}:2"
+fi
+
+echo "${DOCKER_REG_PASS}" | docker login -u "${DOCKER_REG_USER}" --password-stdin "${DOCKER_REG_URL}"
+docker push --all-tags "${DOCKER_IMAGE_TAG}"
diff --git a/.circleci/should_build.sh b/.circleci/should_build.sh
index 9fcf29a06..22d81c8fd 100755
--- a/.circleci/should_build.sh
+++ b/.circleci/should_build.sh
@@ -1,6 +1,12 @@
#!/bin/bash
set -eo pipefail
+IFS='£' read -r -a PUB_TAGS <<< "${PUBLISHABLE_TAGS}"
+# shellcheck disable=SC2068
+for item in ${PUB_TAGS[@]}; do
+ [[ "${CIRCLE_TAG}" =~ ${item} ]] && echo "true" && exit 0
+done
+
# it's on the main branch
[[ "${CIRCLE_BRANCH}" == "main" ]] && echo "true" && exit 0
diff --git a/.gitguardian.yml b/.gitguardian.yml
index 662fb6565..fc3f2171e 100644
--- a/.gitguardian.yml
+++ b/.gitguardian.yml
@@ -1,3 +1,3 @@
matches-ignore:
- name: MIXPANEL_TOKEN
- match: acd87c5a50b56df91a795e999812a3a4
+ - name: MIXPANEL_TOKEN
+ match: acd87c5a50b56df91a795e999812a3a4
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9e7e912a6..1b743d9c9 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -16,5 +16,10 @@ repos:
hooks:
- id: helmlint
+ - repo: https://github.com/syntaqx/git-hooks
+ rev: 'v0.0.17'
+ hooks:
+ - id: circleci-config-validate
+
ci:
autoupdate_schedule: quarterly
diff --git a/package.json b/package.json
index 179923c85..5567eba87 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"dev": "yarn workspaces foreach -piv -j unlimited run dev",
"dev:no-server": "yarn workspaces foreach --exclude @speckle/server -piv -j unlimited run dev",
"dev:minimal": "yarn workspaces foreach -piv -j unlimited --include '{@speckle/server,@speckle/frontend}' run dev",
+ "gqlgen": "yarn workspaces foreach -piv -j unlimited --include '{@speckle/server,@speckle/frontend}' run gqlgen",
"dev:server": "yarn workspace @speckle/server dev",
"dev:frontend": "yarn workspace @speckle/frontend dev",
"prepare": "husky install",
@@ -40,7 +41,7 @@
"resolutions": {
"tslib": "^2.3.1",
"core-js": "3.22.4",
- "vue-cli-plugin-apollo/graphql": "^15",
+ "graphql": "^15",
"typescript": "^4.5.4",
"vue-loader": "^15.10.0"
},
diff --git a/packages/frontend/.eslintrc.js b/packages/frontend/.eslintrc.js
index 2a67cf571..a55211aa6 100644
--- a/packages/frontend/.eslintrc.js
+++ b/packages/frontend/.eslintrc.js
@@ -26,7 +26,8 @@ const config = {
extends: ['plugin:vue/recommended', '@vue/eslint-config-typescript', 'prettier'],
rules: {
'no-unused-vars': 'off',
- '@typescript-eslint/no-unused-vars': ['error']
+ '@typescript-eslint/no-unused-vars': ['error'],
+ 'vue/component-name-in-template-casing': ['warn', 'kebab-case']
}
},
{
diff --git a/packages/frontend/codegen.yml b/packages/frontend/codegen.yml
index 9fc3aa0ca..b9e456eb1 100644
--- a/packages/frontend/codegen.yml
+++ b/packages/frontend/codegen.yml
@@ -15,3 +15,5 @@ generates:
config:
scalars:
JSONObject: Record
+ DateTime: string
+ dedupeFragments: true
diff --git a/packages/frontend/src/config/apolloConfig.ts b/packages/frontend/src/config/apolloConfig.ts
index 5bb5282db..bb8dc7365 100644
--- a/packages/frontend/src/config/apolloConfig.ts
+++ b/packages/frontend/src/config/apolloConfig.ts
@@ -110,6 +110,9 @@ function createCache(): InMemoryCache {
},
pendingCollaborators: {
merge: incomingOverwritesExistingMergeFunction
+ },
+ pendingAccessRequests: {
+ merge: incomingOverwritesExistingMergeFunction
}
}
},
diff --git a/packages/frontend/src/graphql/accessRequests.ts b/packages/frontend/src/graphql/accessRequests.ts
new file mode 100644
index 000000000..c8e40d40f
--- /dev/null
+++ b/packages/frontend/src/graphql/accessRequests.ts
@@ -0,0 +1,32 @@
+import { basicStreamAccessRequestFieldsFragment } from '@/graphql/fragments/accessRequests'
+import { gql } from '@apollo/client/core'
+
+export const getStreamAccessRequestQuery = gql`
+ query GetStreamAccessRequest($streamId: String!) {
+ streamAccessRequest(streamId: $streamId) {
+ ...BasicStreamAccessRequestFields
+ }
+ }
+
+ ${basicStreamAccessRequestFieldsFragment}
+`
+
+export const createStreamAccessRequestMutation = gql`
+ mutation CreateStreamAccessRequest($streamId: String!) {
+ streamAccessRequestCreate(streamId: $streamId) {
+ ...BasicStreamAccessRequestFields
+ }
+ }
+
+ ${basicStreamAccessRequestFieldsFragment}
+`
+
+export const useStreamAccessRequestMutation = gql`
+ mutation UseStreamAccessRequest(
+ $requestId: String!
+ $accept: Boolean!
+ $role: StreamRole = STREAM_CONTRIBUTOR
+ ) {
+ streamAccessRequestUse(requestId: $requestId, accept: $accept, role: $role)
+ }
+`
diff --git a/packages/frontend/src/graphql/fragments/accessRequests.ts b/packages/frontend/src/graphql/fragments/accessRequests.ts
new file mode 100644
index 000000000..4cf58aa73
--- /dev/null
+++ b/packages/frontend/src/graphql/fragments/accessRequests.ts
@@ -0,0 +1,22 @@
+import { limitedUserFieldsFragment } from '@/graphql/fragments/user'
+import { gql } from '@apollo/client/core'
+
+export const basicStreamAccessRequestFieldsFragment = gql`
+ fragment BasicStreamAccessRequestFields on StreamAccessRequest {
+ id
+ streamId
+ createdAt
+ }
+`
+
+export const fullStreamAccessRequestFieldsFragment = gql`
+ fragment FullStreamAccessRequestFields on StreamAccessRequest {
+ ...BasicStreamAccessRequestFields
+ requester {
+ ...LimitedUserFields
+ }
+ }
+
+ ${limitedUserFieldsFragment}
+ ${basicStreamAccessRequestFieldsFragment}
+`
diff --git a/packages/frontend/src/graphql/fragments/streams.ts b/packages/frontend/src/graphql/fragments/streams.ts
new file mode 100644
index 000000000..713ba5bb6
--- /dev/null
+++ b/packages/frontend/src/graphql/fragments/streams.ts
@@ -0,0 +1,12 @@
+import { fullStreamAccessRequestFieldsFragment } from '@/graphql/fragments/accessRequests'
+import { gql } from '@apollo/client/core'
+
+export const streamPendingAccessRequestsFragment = gql`
+ fragment StreamPendingAccessRequests on Stream {
+ pendingAccessRequests {
+ ...FullStreamAccessRequestFields
+ }
+ }
+
+ ${fullStreamAccessRequestFieldsFragment}
+`
diff --git a/packages/frontend/src/graphql/generated/graphql.ts b/packages/frontend/src/graphql/generated/graphql.ts
index 8bc6dfe55..0e0bb1cdb 100644
--- a/packages/frontend/src/graphql/generated/graphql.ts
+++ b/packages/frontend/src/graphql/generated/graphql.ts
@@ -15,7 +15,7 @@ export type Scalars = {
/** The `BigInt` scalar type represents non-fractional signed whole numeric values. */
BigInt: any;
/** A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. */
- DateTime: any;
+ DateTime: string;
EmailAddress: any;
/** The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */
JSONObject: Record;
@@ -393,6 +393,16 @@ export type CommitUpdateInput = {
streamId: Scalars['String'];
};
+export enum DiscoverableStreamsSortType {
+ CreatedDate = 'CREATED_DATE',
+ FavoritesCount = 'FAVORITES_COUNT'
+}
+
+export type DiscoverableStreamsSortingInput = {
+ direction: SortDirection;
+ type: DiscoverableStreamsSortType;
+};
+
export type FileUpload = {
__typename?: 'FileUpload';
branchName?: Maybe;
@@ -467,10 +477,16 @@ export type Mutation = {
/** Re-send a pending invite */
inviteResend: Scalars['Boolean'];
objectCreate: Array>;
+ /** (Re-)send the account verification e-mail */
+ requestVerification: Scalars['Boolean'];
serverInfoUpdate?: Maybe;
serverInviteBatchCreate: Scalars['Boolean'];
/** Invite a new user to the speckle server and return the invite ID */
serverInviteCreate: Scalars['Boolean'];
+ /** Request access to a specific stream */
+ streamAccessRequestCreate: StreamAccessRequest;
+ /** Accept or decline a stream access request. Must be a stream owner to invoke this. */
+ streamAccessRequestUse: Scalars['Boolean'];
/** Creates a new stream. */
streamCreate?: Maybe;
/** Deletes an existing stream. */
@@ -639,6 +655,18 @@ export type MutationServerInviteCreateArgs = {
};
+export type MutationStreamAccessRequestCreateArgs = {
+ streamId: Scalars['String'];
+};
+
+
+export type MutationStreamAccessRequestUseArgs = {
+ accept: Scalars['Boolean'];
+ requestId: Scalars['String'];
+ role?: StreamRole;
+};
+
+
export type MutationStreamCreateArgs = {
stream: StreamCreateInput;
};
@@ -797,6 +825,27 @@ export type ObjectCreateInput = {
streamId: Scalars['String'];
};
+export type PasswordStrengthCheckFeedback = {
+ __typename?: 'PasswordStrengthCheckFeedback';
+ suggestions: Array;
+ warning?: Maybe;
+};
+
+export type PasswordStrengthCheckResults = {
+ __typename?: 'PasswordStrengthCheckResults';
+ /** Verbal feedback to help choose better passwords. set when score <= 2. */
+ feedback: PasswordStrengthCheckFeedback;
+ /**
+ * Integer from 0-4 (useful for implementing a strength bar):
+ * 0 too guessable: risky password. (guesses < 10^3)
+ * 1 very guessable: protection from throttled online attacks. (guesses < 10^6)
+ * 2 somewhat guessable: protection from unthrottled online attacks. (guesses < 10^8)
+ * 3 safely unguessable: moderate protection from offline slow-hash scenario. (guesses < 10^10)
+ * 4 very unguessable: strong protection from offline slow-hash scenario. (guesses >= 10^10)
+ */
+ score: Scalars['Int'];
+};
+
export type PendingStreamCollaborator = {
__typename?: 'PendingStreamCollaborator';
id: Scalars['String'];
@@ -817,6 +866,7 @@ export type Query = {
__typename?: 'Query';
/** Stare into the void. */
_?: Maybe;
+ /** All the streams of the server. Available to admins only. */
adminStreams?: Maybe;
/**
* Get all (or search for specific) users, registered or invited, from the server in a paginated view.
@@ -836,6 +886,8 @@ export type Query = {
comments?: Maybe;
/** Commit/Object viewer state (local-only) */
commitObjectViewerState: CommitObjectViewerState;
+ /** All of the discoverable streams of the server */
+ discoverableStreams?: Maybe;
serverInfo: ServerInfo;
serverStats: ServerStats;
/**
@@ -843,6 +895,8 @@ export type Query = {
* to see it.
*/
stream?: Maybe;
+ /** Get authed user's stream access request */
+ streamAccessRequest?: Maybe;
/**
* Look for an invitation to a stream, for the current user (authed or not). If token
* isn't specified, the server will look for any valid invite.
@@ -852,12 +906,10 @@ export type Query = {
streamInvites: Array;
/** All the streams of the current user, pass in the `query` parameter to search by name, description or ID. */
streams?: Maybe;
- /**
- * Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header).
- * If ID is provided, admin access is required
- */
+ /** Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header). */
user?: Maybe;
- userPwdStrength?: Maybe;
+ /** Validate password strength */
+ userPwdStrength: PasswordStrengthCheckResults;
/**
* Search for users and return limited metadata about them, if you have the server:user role.
* The query looks for matches in name & email
@@ -902,11 +954,23 @@ export type QueryCommentsArgs = {
};
+export type QueryDiscoverableStreamsArgs = {
+ cursor?: InputMaybe;
+ limit?: Scalars['Int'];
+ sort?: InputMaybe;
+};
+
+
export type QueryStreamArgs = {
id: Scalars['String'];
};
+export type QueryStreamAccessRequestArgs = {
+ streamId: Scalars['String'];
+};
+
+
export type QueryStreamInviteArgs = {
streamId: Scalars['String'];
token?: InputMaybe;
@@ -1084,6 +1148,11 @@ export type SmartTextEditorValue = {
version: Scalars['String'];
};
+export enum SortDirection {
+ Asc = 'ASC',
+ Desc = 'DESC'
+}
+
export type Stream = {
__typename?: 'Stream';
/** All the recent activity on this stream in chronological order */
@@ -1119,9 +1188,17 @@ export type Stream = {
/** Returns a list of all the file uploads for this stream. */
fileUploads?: Maybe>>;
id: Scalars['String'];
+ /**
+ * Whether the stream (if public) can be found on public stream exploration pages
+ * and searches
+ */
+ isDiscoverable: Scalars['Boolean'];
+ /** Whether the stream can be viewed by non-contributors */
isPublic: Scalars['Boolean'];
name: Scalars['String'];
object?: Maybe
@@ -61,7 +67,8 @@ const UpdatedInfoKeys = {
Name: 'name',
Description: 'description',
Message: 'message',
- IsPublic: 'isPublic'
+ IsPublic: 'isPublic',
+ IsDiscoverable: 'isDiscoverable'
}
export default {
diff --git a/packages/frontend/src/main/components/auth/AuthStrategies.vue b/packages/frontend/src/main/components/auth/AuthStrategies.vue
index ffb51073b..753b3fa59 100644
--- a/packages/frontend/src/main/components/auth/AuthStrategies.vue
+++ b/packages/frontend/src/main/components/auth/AuthStrategies.vue
@@ -18,8 +18,8 @@
block
:color="s.color"
:href="`${s.url}?appId=${appId}&challenge=${challenge}${
- suuid ? '&suuid=' + suuid : ''
- }${token ? '&token=' + token : ''}`"
+ token ? '&token=' + token : ''
+ }`"
>
{{ s.icon }}
{{ s.name }}
@@ -45,10 +45,6 @@ export default {
challenge: {
type: String,
default: () => null
- },
- suuid: {
- type: String,
- default: () => null
}
},
computed: {
diff --git a/packages/frontend/src/main/components/comments/CommentThreadReplyAttachments.vue b/packages/frontend/src/main/components/comments/CommentThreadReplyAttachments.vue
index 1cd782bd9..b2059a0be 100644
--- a/packages/frontend/src/main/components/comments/CommentThreadReplyAttachments.vue
+++ b/packages/frontend/src/main/components/comments/CommentThreadReplyAttachments.vue
@@ -3,7 +3,7 @@
{
return {
showAttachmentPreview: false,
- selectedAttachment: null
+ selectedAttachment: null as Nullable
}
},
methods: {
diff --git a/packages/frontend/src/main/components/common/GlobalToast.vue b/packages/frontend/src/main/components/common/GlobalToast.vue
index 1c88f906b..b29c5318f 100644
--- a/packages/frontend/src/main/components/common/GlobalToast.vue
+++ b/packages/frontend/src/main/components/common/GlobalToast.vue
@@ -12,47 +12,16 @@
diff --git a/packages/frontend/src/main/components/common/NoDataPlaceholder.vue b/packages/frontend/src/main/components/common/NoDataPlaceholder.vue
index 00c1a9753..1d9629d1b 100644
--- a/packages/frontend/src/main/components/common/NoDataPlaceholder.vue
+++ b/packages/frontend/src/main/components/common/NoDataPlaceholder.vue
@@ -26,7 +26,8 @@
link
class="primary mb-4"
dark
- @click="downloadManager"
+ href="https://releases.speckle.systems/"
+ target="_blank"
>
mdi-download
@@ -183,23 +184,6 @@ export default {
},
beforeDestroy() {
clearInterval(this.checkAccountTimer)
- },
- methods: {
- async downloadManager() {
- this.$mixpanel.track('Manager Download', {
- type: 'action'
- })
-
- const url = `https://releases.speckle.dev/manager/SpeckleManager Setup.exe`
-
- const a = document.createElement('a')
- document.body.appendChild(a)
- a.style = 'display: none'
- a.href = url
- a.download = 'SpeckleManager Setup.exe'
- a.click()
- document.body.removeChild(a)
- }
}
}
diff --git a/packages/frontend/src/main/components/common/PreviewImage.vue b/packages/frontend/src/main/components/common/PreviewImage.vue
index 3da0b5e50..2e2b6fd87 100644
--- a/packages/frontend/src/main/components/common/PreviewImage.vue
+++ b/packages/frontend/src/main/components/common/PreviewImage.vue
@@ -49,7 +49,7 @@ export default {
props: {
url: {
type: String,
- default: ''
+ default: () => ''
},
color: {
type: Boolean,
diff --git a/packages/frontend/src/main/components/common/UserAvatar.vue b/packages/frontend/src/main/components/common/UserAvatar.vue
index 51f78a88a..7d2ef1ee6 100644
--- a/packages/frontend/src/main/components/common/UserAvatar.vue
+++ b/packages/frontend/src/main/components/common/UserAvatar.vue
@@ -69,18 +69,27 @@
>
-
diff --git a/packages/frontend/src/main/components/common/UserAvatarIcon.vue b/packages/frontend/src/main/components/common/UserAvatarIcon.vue
index 22153ffcb..c4bda1fb4 100644
--- a/packages/frontend/src/main/components/common/UserAvatarIcon.vue
+++ b/packages/frontend/src/main/components/common/UserAvatarIcon.vue
@@ -17,6 +17,9 @@ export default {
required: true
},
avatar: {
+ /**
+ * @type {import('vue').PropType}
+ */
type: String,
default: null
}
diff --git a/packages/frontend/src/main/components/common/layout/BasicPanel.vue b/packages/frontend/src/main/components/common/layout/BasicPanel.vue
new file mode 100644
index 000000000..82fc731cf
--- /dev/null
+++ b/packages/frontend/src/main/components/common/layout/BasicPanel.vue
@@ -0,0 +1,3 @@
+
+
+
diff --git a/packages/frontend/src/main/components/common/layout/RoundedButtonList.vue b/packages/frontend/src/main/components/common/layout/RoundedButtonList.vue
new file mode 100644
index 000000000..f9558d0de
--- /dev/null
+++ b/packages/frontend/src/main/components/common/layout/RoundedButtonList.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
diff --git a/packages/frontend/src/main/components/common/layout/rounded-button-list/RoundedButtonListItem.vue b/packages/frontend/src/main/components/common/layout/rounded-button-list/RoundedButtonListItem.vue
new file mode 100644
index 000000000..77e1723db
--- /dev/null
+++ b/packages/frontend/src/main/components/common/layout/rounded-button-list/RoundedButtonListItem.vue
@@ -0,0 +1,69 @@
+
+
+
+ {{ icon }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/src/main/components/feed/FeedTimeline.vue b/packages/frontend/src/main/components/feed/FeedTimeline.vue
index e44440a51..b5170fd81 100644
--- a/packages/frontend/src/main/components/feed/FeedTimeline.vue
+++ b/packages/frontend/src/main/components/feed/FeedTimeline.vue
@@ -82,6 +82,7 @@ import { UserTimelineDocument } from '@/graphql/generated/graphql'
import { useQuery } from '@vue/apollo-composable'
import { computed } from 'vue'
import { AppLocalStorage } from '@/utils/localStorage'
+import { SKIPPABLE_ACTION_TYPES } from '@/main/lib/feed/helpers/activityStream'
export default {
name: 'FeedTimeline',
@@ -112,7 +113,7 @@ export default {
const data = timelineResult.value
if (!data) return []
- const skippableActionTypes = ['stream_invite_sent', 'stream_invite_declined']
+ const skippableActionTypes = SKIPPABLE_ACTION_TYPES
const groupedTimeline = data.user.timeline.items.reduce(function (prev, curr) {
if (skippableActionTypes.includes(curr.actionType)) {
return prev
diff --git a/packages/frontend/src/main/components/feed/LatestBlogposts.vue b/packages/frontend/src/main/components/feed/LatestBlogposts.vue
index 39a0f6b26..9e0d8dbe1 100644
--- a/packages/frontend/src/main/components/feed/LatestBlogposts.vue
+++ b/packages/frontend/src/main/components/feed/LatestBlogposts.vue
@@ -1,11 +1,7 @@
-
-
-
+
+
diff --git a/packages/frontend/src/main/components/stream/StreamAccessRequestBanner.vue b/packages/frontend/src/main/components/stream/StreamAccessRequestBanner.vue
new file mode 100644
index 000000000..1e2187322
--- /dev/null
+++ b/packages/frontend/src/main/components/stream/StreamAccessRequestBanner.vue
@@ -0,0 +1,176 @@
+
+
+
+
+
+
+ {{ requester.name }}
+ has requested access to this stream
+
+
+
+
+
+ Add
+
+
+ Ignore
+
+
+
+
+
+
+
diff --git a/packages/frontend/src/main/components/stream/StreamActivity.vue b/packages/frontend/src/main/components/stream/StreamActivity.vue
index d93abd99f..7f150118f 100644
--- a/packages/frontend/src/main/components/stream/StreamActivity.vue
+++ b/packages/frontend/src/main/components/stream/StreamActivity.vue
@@ -41,6 +41,7 @@ import { StreamWithActivityDocument } from '@/graphql/generated/graphql'
import { useQuery } from '@vue/apollo-composable'
import { useRoute } from '@/main/lib/core/composables/router'
import { computed } from 'vue'
+import { SKIPPABLE_ACTION_TYPES } from '@/main/lib/feed/helpers/activityStream'
export default {
name: 'StreamActivity',
@@ -61,7 +62,7 @@ export default {
}))
const stream = computed(() => result.value?.stream || null)
- const skippableActionTypes = ['stream_invite_sent', 'stream_invite_declined']
+ const skippableActionTypes = SKIPPABLE_ACTION_TYPES
const groupedActivity = computed(() =>
(stream.value?.activity?.items || []).reduce(function (prev, curr) {
if (skippableActionTypes.includes(curr.actionType)) {
diff --git a/packages/frontend/src/main/components/stream/editor/StreamVisibilityToggle.vue b/packages/frontend/src/main/components/stream/editor/StreamVisibilityToggle.vue
new file mode 100644
index 000000000..1f3b70f74
--- /dev/null
+++ b/packages/frontend/src/main/components/stream/editor/StreamVisibilityToggle.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/src/main/components/user/EmailVerificationBanner.vue b/packages/frontend/src/main/components/user/EmailVerificationBanner.vue
index 2432238bf..3da8d0e82 100644
--- a/packages/frontend/src/main/components/user/EmailVerificationBanner.vue
+++ b/packages/frontend/src/main/components/user/EmailVerificationBanner.vue
@@ -1,7 +1,7 @@
-
+
- Your email {{ user.email }} is not verified.
+ {{ verifyBannerText }}
- Send verification
+ {{ verifyBannerCtaText }}
- Verification email sent, please check you inbox.
+ Verification e-mail sent, please check you inbox.
- Email verification failed.{{ errorMessage ? ` Reason: ${errorMessage}` : '' }}
+ E-mail verification failed.{{ errorMessage ? ` Reason: ${errorMessage}` : '' }}
-
diff --git a/packages/frontend/src/main/components/user/UserInfoCard.vue b/packages/frontend/src/main/components/user/UserInfoCard.vue
index f63bae17a..76fd0ab1d 100644
--- a/packages/frontend/src/main/components/user/UserInfoCard.vue
+++ b/packages/frontend/src/main/components/user/UserInfoCard.vue
@@ -64,8 +64,6 @@
id:
{{ user.id }}
- , suuid:
- {{ user.suuid }}
diff --git a/packages/frontend/src/main/dialogs/BranchEditDialog.vue b/packages/frontend/src/main/dialogs/BranchEditDialog.vue
index a5e9c7d16..177b10539 100644
--- a/packages/frontend/src/main/dialogs/BranchEditDialog.vue
+++ b/packages/frontend/src/main/dialogs/BranchEditDialog.vue
@@ -11,7 +11,7 @@
{{ error }}
-
+
-
-
Invite collaborators
@@ -100,7 +96,7 @@
color="primary"
block
large
- :disabled="!valid"
+ :disabled="!valid || isLoading"
:loading="isLoading"
elevation="0"
type="submit"
@@ -115,10 +111,13 @@
import { gql } from '@apollo/client/core'
import { userSearchQuery } from '@/graphql/user'
import { AppLocalStorage } from '@/utils/localStorage'
+import StreamVisibilityToggle from '@/main/components/stream/editor/StreamVisibilityToggle.vue'
+import UserAvatar from '@/main/components/common/UserAvatar.vue'
export default {
components: {
- UserAvatar: () => import('@/main/components/common/UserAvatar')
+ UserAvatar,
+ StreamVisibilityToggle
},
props: {
open: {
@@ -156,6 +155,7 @@ export default {
search: null,
nameRules: [],
isPublic: true,
+ isDiscoverable: false,
collabs: [],
isLoading: false,
users: null
@@ -210,6 +210,7 @@ export default {
myStream: {
name: this.name,
isPublic: this.isPublic,
+ isDiscoverable: this.isDiscoverable,
description: this.description,
withContributors: collabIds
}
@@ -223,8 +224,9 @@ export default {
this.$eventHub.$emit('notification', {
text: e.message
})
+ } finally {
+ this.isLoading = false
}
- this.isLoading = false
}
}
}
diff --git a/packages/frontend/src/main/layouts/TheMain.vue b/packages/frontend/src/main/layouts/TheMain.vue
index cc0666796..1e45613aa 100644
--- a/packages/frontend/src/main/layouts/TheMain.vue
+++ b/packages/frontend/src/main/layouts/TheMain.vue
@@ -46,8 +46,7 @@
@@ -62,9 +61,9 @@
`
- let emailParams
- mailerMock.enable()
- mailerMock.mockFunction('sendEmail', (params) => {
- emailParams = params
- })
+ const sendEmailInvocations = mailerMock.hijackFunction(
+ 'sendEmail',
+ async () => true
+ )
const result = await createInvite({
email: targetEmail,
@@ -176,6 +163,7 @@ describe('[Stream & Server Invites]', () => {
expect(result.errors).to.be.not.ok
// Check that email was sent out
+ const emailParams = sendEmailInvocations.args[0][0]
expect(emailParams).to.be.ok
expect(emailParams.to).to.eq(targetEmail)
expect(emailParams.subject).to.be.ok
@@ -307,11 +295,10 @@ describe('[Stream & Server Invites]', () => {
const unsanitaryMessage = `${messagePart1} `
const targetEmail = email || user.email
- let emailParams
- mailerMock.enable()
- mailerMock.mockFunction('sendEmail', (params) => {
- emailParams = params
- })
+ const sendEmailInvocations = mailerMock.hijackFunction(
+ 'sendEmail',
+ async () => true
+ )
const result = await createInvite({
email,
@@ -326,6 +313,7 @@ describe('[Stream & Server Invites]', () => {
expect(result.errors).to.be.not.ok
// Check that email was sent out
+ const emailParams = sendEmailInvocations.args[0][0]
expect(emailParams).to.be.ok
expect(emailParams.to).to.eq(targetEmail)
expect(emailParams.subject).to.be.ok
@@ -429,11 +417,11 @@ describe('[Stream & Server Invites]', () => {
})
it('they can resend pre-existing invites irregardless of type', async () => {
- const emailParamsArr = []
- mailerMock.enable()
- mailerMock.mockFunction('sendEmail', (params) => {
- emailParamsArr.push(params)
- })
+ const sendEmailInvocations = mailerMock.hijackFunction(
+ 'sendEmail',
+ async () => true,
+ { times: invites.length }
+ )
const inviteIds = invites.map((i) => i.inviteId)
@@ -446,7 +434,7 @@ describe('[Stream & Server Invites]', () => {
expect(result.errors).to.not.be.ok
}
- expect(emailParamsArr).to.have.length(inviteIds.length)
+ expect(sendEmailInvocations.length()).to.eq(inviteIds.length)
})
it('they can delete pre-existing invites irregardless of type', async () => {
@@ -493,11 +481,11 @@ describe('[Stream & Server Invites]', () => {
const emails = ['abababa1@mail.com', 'abababa2@mail.com', 'abababa3@mail.com']
const message = 'ayyoyoyoyoy'
- const emailParamsArr = []
- mailerMock.enable()
- mailerMock.mockFunction('sendEmail', (params) => {
- emailParamsArr.push(params)
- })
+ const sendEmailInvocations = mailerMock.hijackFunction(
+ 'sendEmail',
+ async () => true,
+ { times: emails.length }
+ )
const result = await batchCreateServerInvites(apollo, {
message,
@@ -507,9 +495,9 @@ describe('[Stream & Server Invites]', () => {
expect(result.data?.serverInviteBatchCreate).to.be.ok
expect(result.errors).to.not.be.ok
- expect(emailParamsArr).to.have.length(emails.length)
+ expect(sendEmailInvocations.length()).to.eq(emails.length)
for (const email of emails) {
- const emailParams = emailParamsArr.find((p) => p.to === email)
+ const emailParams = sendEmailInvocations.args.find(([p]) => p.to === email)[0]
expect(emailParams).to.be.ok
expect(emailParams.html).to.contain(message)
@@ -544,22 +532,22 @@ describe('[Stream & Server Invites]', () => {
}
]
- const emailParamsArr = []
- mailerMock.enable()
- mailerMock.mockFunction('sendEmail', (params) => {
- emailParamsArr.push(params)
- })
+ const sendEmailInvocations = mailerMock.hijackFunction(
+ 'sendEmail',
+ async () => false,
+ { times: inputs.length }
+ )
const result = await batchCreateStreamInvites(apollo, inputs)
expect(result.data?.streamInviteBatchCreate).to.be.ok
expect(result.errors).to.not.be.ok
- expect(emailParamsArr).to.have.length(inputs.length)
+ expect(sendEmailInvocations.length()).to.eq(inputs.length)
for (const inputData of inputs) {
- const emailParams = emailParamsArr.find((p) =>
+ const emailParams = sendEmailInvocations.args.find(([p]) =>
inputData.email ? p.to === inputData.email : p.to === otherGuy.email
- )
+ )[0]
expect(emailParams).to.be.ok
expect(emailParams.html).to.contain(inputData.message)
expect(emailParams.text).to.contain(inputData.message)
diff --git a/packages/server/modules/shared/errors/base.ts b/packages/server/modules/shared/errors/base.ts
index ea5730f1f..dc2575671 100644
--- a/packages/server/modules/shared/errors/base.ts
+++ b/packages/server/modules/shared/errors/base.ts
@@ -18,7 +18,7 @@ export class BaseError extends VError {
static defaultMessage = 'Unexpected error occurred!'
constructor(
- message: string | null | undefined,
+ message?: string | null | undefined,
options: Options | Error | undefined = undefined
) {
// Resolve options correctly
@@ -52,6 +52,6 @@ export class BaseError extends VError {
* Get collected info of this object and previous errors
*/
info() {
- return BaseError.info(this as unknown as Error)
+ return BaseError.info(this)
}
}
diff --git a/packages/server/modules/shared/errors/index.ts b/packages/server/modules/shared/errors/index.ts
index f87a62662..13a8b1d31 100644
--- a/packages/server/modules/shared/errors/index.ts
+++ b/packages/server/modules/shared/errors/index.ts
@@ -55,4 +55,20 @@ export class ContextError extends BaseError {
static defaultMessage = 'The context is missing from the request'
}
+export class MisconfiguredEnvironmentError extends BaseError {
+ static code = 'MISCONFIGURED_ENVIRONMENT_ERROR'
+ static defaultMessage =
+ 'An error occurred due to the server environment being misconfigured'
+}
+
+export class UninitializedResourceAccessError extends BaseError {
+ static code = 'UNINITIALIZED_RESOURCE_ACCESS_ERROR'
+ static defaultMessage = 'Attempted to use uninitialized resources'
+}
+
+export class UnexpectedErrorStructureError extends BaseError {
+ static code = 'UNEXPECTED_ERROR_STRUCTURE_ERROR'
+ static defaultMessage = 'An unexpected error type was thrown'
+}
+
export { BaseError }
diff --git a/packages/server/modules/shared/helpers/bullHelper.ts b/packages/server/modules/shared/helpers/bullHelper.ts
new file mode 100644
index 000000000..c1534eb2f
--- /dev/null
+++ b/packages/server/modules/shared/helpers/bullHelper.ts
@@ -0,0 +1,20 @@
+import Redis from 'ioredis'
+import Bull from 'bull'
+import { getRedisUrl } from '@/modules/shared/helpers/envHelper'
+
+export function buildBaseQueueOptions(): Bull.QueueOptions {
+ return {
+ createClient: (type) => {
+ // @see https://github.com/OptimalBits/bull/issues/1873
+ const client = new Redis(getRedisUrl(), {
+ ...(['bclient', 'subscriber'].includes(type)
+ ? {
+ enableReadyCheck: false,
+ maxRetriesPerRequest: null
+ }
+ : {})
+ })
+ return client
+ }
+ }
+}
diff --git a/packages/server/modules/shared/helpers/cryptoHelper.js b/packages/server/modules/shared/helpers/cryptoHelper.js
deleted file mode 100644
index a78010f20..000000000
--- a/packages/server/modules/shared/helpers/cryptoHelper.js
+++ /dev/null
@@ -1,12 +0,0 @@
-const crypto = require('crypto')
-
-function md5(stringValue) {
- return crypto
- .createHash('md5')
- .update(stringValue || '')
- .digest('hex')
-}
-
-module.exports = {
- md5
-}
diff --git a/packages/server/modules/shared/helpers/cryptoHelper.ts b/packages/server/modules/shared/helpers/cryptoHelper.ts
new file mode 100644
index 000000000..90f1c1064
--- /dev/null
+++ b/packages/server/modules/shared/helpers/cryptoHelper.ts
@@ -0,0 +1,18 @@
+import crypto from 'crypto'
+
+export function md5(val: string): string {
+ return crypto
+ .createHash('md5')
+ .update(val || '')
+ .digest('hex')
+}
+
+export function base64Encode(val: string): string {
+ const bufferObj = Buffer.from(val, 'utf8')
+ return bufferObj.toString('base64')
+}
+
+export function base64Decode(val: string): string {
+ const bufferObj = Buffer.from(val, 'base64')
+ return bufferObj.toString('utf8')
+}
diff --git a/packages/server/modules/shared/helpers/envHelper.ts b/packages/server/modules/shared/helpers/envHelper.ts
index 1c5a0d937..16c5a4e2f 100644
--- a/packages/server/modules/shared/helpers/envHelper.ts
+++ b/packages/server/modules/shared/helpers/envHelper.ts
@@ -1,3 +1,5 @@
+import { MisconfiguredEnvironmentError } from '@/modules/shared/errors'
+
export function isTestEnv() {
return process.env.NODE_ENV === 'test'
}
@@ -25,3 +27,31 @@ export function getApolloServerVersion() {
export function getFileSizeLimitMB() {
return parseInt(process.env.FILE_SIZE_LIMIT_MB || '100')
}
+
+export function getRedisUrl() {
+ if (!process.env.REDIS_URL) {
+ throw new MisconfiguredEnvironmentError('REDIS_URL env var not configured')
+ }
+
+ return process.env.REDIS_URL
+}
+
+/**
+ * Get app base url / canonical url / origin
+ */
+export function getBaseUrl() {
+ if (!process.env.CANONICAL_URL) {
+ throw new MisconfiguredEnvironmentError('CANONICAL_URL env var not configured')
+ }
+
+ return process.env.CANONICAL_URL
+}
+
+/**
+ * Whether notification job consumption & handling should be disabled
+ */
+export function shouldDisableNotificationsConsumption() {
+ return ['1', 'true'].includes(
+ process.env.DISABLE_NOTIFICATIONS_CONSUMPTION || 'false'
+ )
+}
diff --git a/packages/server/modules/shared/helpers/errorHelper.ts b/packages/server/modules/shared/helpers/errorHelper.ts
new file mode 100644
index 000000000..a4dfa9dcf
--- /dev/null
+++ b/packages/server/modules/shared/helpers/errorHelper.ts
@@ -0,0 +1,30 @@
+import { BaseError, UnexpectedErrorStructureError } from '@/modules/shared/errors'
+import { VError } from 'verror'
+
+/**
+ * In JS catch clauses can receive not only Errors, but pretty much any other kind of data type, so
+ * you can use this helper to ensure that whatever is passed in is a real error
+ */
+export function ensureError(
+ e: Error | unknown,
+ fallbackMessage?: string
+): Error | BaseError {
+ if (e instanceof Error) return e
+ return new UnexpectedErrorStructureError(fallbackMessage, {
+ info: {
+ originalError: e
+ }
+ })
+}
+
+/**
+ * Resolve cause correctly depending on whether its a VError or basic Error
+ * object
+ */
+export function getCause(e: Error) {
+ if (e instanceof VError) {
+ return VError.cause(e)
+ } else {
+ return e.cause
+ }
+}
diff --git a/packages/server/modules/shared/helpers/graphqlHelper.ts b/packages/server/modules/shared/helpers/graphqlHelper.ts
new file mode 100644
index 000000000..cda162446
--- /dev/null
+++ b/packages/server/modules/shared/helpers/graphqlHelper.ts
@@ -0,0 +1,15 @@
+import { base64Decode, base64Encode } from '@/modules/shared/helpers/cryptoHelper'
+
+/**
+ * Encode cursor to turn it into an opaque & obfuscated value
+ */
+export function encodeCursor(value: string): string {
+ return base64Encode(value)
+}
+
+/**
+ * Decode obfuscated cursor value
+ */
+export function decodeCursor(value: string): string {
+ return base64Decode(value)
+}
diff --git a/packages/server/modules/shared/helpers/typeHelper.ts b/packages/server/modules/shared/helpers/typeHelper.ts
index 7a218b36c..86f9b3567 100644
--- a/packages/server/modules/shared/helpers/typeHelper.ts
+++ b/packages/server/modules/shared/helpers/typeHelper.ts
@@ -1,10 +1,41 @@
+import { RequestDataLoaders } from '@/modules/core/loaders'
+import { AuthContext } from '@/modules/shared/authz'
import { Express } from 'express'
export type Nullable = T | null
export type Optional = T | undefined
+export type MaybeNullOrUndefined = T | null | undefined
export type MaybeAsync = T | Promise
+export type MaybeFalsy = T | null | undefined | false | '' | 0
-export type SpeckleModule = {
- init: (app: Express) => MaybeAsync
- finalize: (app: Express) => MaybeAsync
+export type SpeckleModule = Record> =
+ {
+ /**
+ * Initialize the module
+ * @param app The Express instance
+ * @param isInitial Whether this initialization method is being invoked for the first time in this
+ * process. In tests modules can be initialized multiple times.
+ */
+ init: (app: Express, isInitial: boolean) => MaybeAsync
+ /**
+ * Finalize initialization. This is only invoked once all of the other modules' `init()`
+ * hooks are run.
+ * @param app The Express instance
+ * @param isInitial Whether this initialization method is being invoked for the first time in this
+ * process. In tests modules can be initialized multiple times.
+ */
+ finalize?: (app: Express, isInitial: boolean) => MaybeAsync
+
+ /**
+ * Cleanup resources before the server shuts down
+ */
+ shutdown?: () => MaybeAsync
+ } & T
+
+export type GraphQLContext = AuthContext & {
+ /**
+ * Request-scoped GraphQL dataloaders
+ * @see https://github.com/graphql/dataloader
+ */
+ loaders: RequestDataLoaders
}
diff --git a/packages/server/modules/shared/index.js b/packages/server/modules/shared/index.js
index 88cf6c808..6061ad6a0 100644
--- a/packages/server/modules/shared/index.js
+++ b/packages/server/modules/shared/index.js
@@ -13,13 +13,16 @@ const StreamPubsubEvents = Object.freeze({
StreamDeleted: 'STREAM_DELETED'
})
+/**
+ * GraphQL Subscription PubSub instance
+ */
const pubsub = new RedisPubSub({
publisher: new Redis(process.env.REDIS_URL),
subscriber: new Redis(process.env.REDIS_URL)
})
/**
- * @typedef {import('@/modules/shared/authz').AuthContext & {loaders: import('@/modules/core/loaders').RequestDataLoaders}} GraphQLContext
+ * @typedef {import('@/modules/shared/helpers/typeHelper').GraphQLContext} GraphQLContext
*/
/**
diff --git a/packages/server/modules/shared/services/moduleEventEmitterSetup.ts b/packages/server/modules/shared/services/moduleEventEmitterSetup.ts
new file mode 100644
index 000000000..559c7a068
--- /dev/null
+++ b/packages/server/modules/shared/services/moduleEventEmitterSetup.ts
@@ -0,0 +1,81 @@
+import { MaybeAsync } from '@/modules/shared/helpers/typeHelper'
+import { modulesDebug } from '@/modules/shared/utils/logger'
+import EventEmitter from 'eventemitter2'
+
+export type ModuleEventEmitterParams = {
+ moduleName: string
+ /**
+ * If you have multiple emitters in a single module, you can use this identify
+ * each of them differently
+ */
+ namespace?: string
+}
+
+/**
+ * Initialize Speckle Module scoped event emitter. These can be used to make code more SOLID - instead of
+ * modifying some code that does X every time you want to do something extra when X occurs, just emit an event
+ * there and specify the listening code in a more appropriate module.
+ *
+ * Example: Instead of comment mentions being sent out from the comment repository's "createComment" function,
+ * this repo function emits a COMMENT_CREATED event, that is then handled in a more appropriate module - the speckle
+ * Notifications module.
+ */
+export function initializeModuleEventEmitter>(
+ params: ModuleEventEmitterParams
+) {
+ const { moduleName, namespace } = params
+ const identifier = namespace ? `${moduleName}-${namespace}` : moduleName
+
+ const debug = modulesDebug.extend(identifier).extend('events')
+
+ const errHandler = (e: unknown) => {
+ debug(`Unhandled ${identifier} event emitter error`, e)
+ }
+
+ const emitter = new EventEmitter()
+ emitter.on('uncaughtException', errHandler)
+ emitter.on('error', errHandler)
+
+ return {
+ /**
+ * Emit a module event. This function must be awaited to ensure all listeners
+ * execute. Any errors thrown in the listeners will bubble up and throw from
+ * the part of code that triggers this emit() call.
+ */
+ emit: async (eventName: K, payload: P[K]) => {
+ return await emitter.emitAsync(eventName, payload)
+ },
+
+ /**
+ * Listen for module events. Any errors thrown here will bubble out of where
+ * emit() was invoked.
+ *
+ * @returns Callback for stopping listening
+ */
+ listen: (
+ eventName: K,
+ handler: (payload: P[K]) => MaybeAsync
+ ) => {
+ emitter.on(eventName, handler, {
+ async: true,
+ promisify: true
+ })
+
+ return () => {
+ emitter.removeListener(eventName, handler)
+ }
+ },
+
+ /**
+ * Destroy event emitter
+ */
+ destroy() {
+ emitter.removeAllListeners()
+ },
+
+ /**
+ * Debugger scoped to this module event emitter
+ */
+ debug
+ }
+}
diff --git a/packages/server/modules/shared/utils/logger.ts b/packages/server/modules/shared/utils/logger.ts
new file mode 100644
index 000000000..2f4328b6e
--- /dev/null
+++ b/packages/server/modules/shared/utils/logger.ts
@@ -0,0 +1,7 @@
+import dbg from 'debug'
+
+const debug = dbg('speckle')
+
+export const modulesDebug = debug.extend('modules')
+export const notificationsDebug = debug.extend('notifications')
+export const cliDebug = debug.extend('cli')
diff --git a/packages/server/nyc.config.js b/packages/server/nyc.config.js
index 50d0ebb5f..e557e603a 100644
--- a/packages/server/nyc.config.js
+++ b/packages/server/nyc.config.js
@@ -3,6 +3,7 @@ const testFileExtensions = ['ts', 'js']
module.exports = {
exclude: [
`**/migrations/*.{${testFileExtensions}}`,
+ `**/modules/cli/**/*.{${testFileExtensions}}`,
'**/*.spec.{js,ts}',
// Default exclusions: https://github.com/istanbuljs/schema/blob/master/default-exclude.js
diff --git a/packages/server/package.json b/packages/server/package.json
index 6e7980a53..4428ecd45 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -19,13 +19,13 @@
"build:watch": "tsc -p ./tsconfig.build.json -w",
"run:watch": "cross-env NODE_ENV=development DEBUG='speckle:*' nodemon ./bin/www --watch ./dist --watch ./assets --watch ./bin/www -e js,ts,graphql,env,gql",
"dev": "concurrently -r \"npm:build:watch\" \"npm:run:watch\"",
- "dev:server:test": "cross-env NODE_ENV=test DEBUG='speckle:*' node ./bin/ts-www",
+ "dev:server:test": "cross-env DISABLE_NOTIFICATIONS_CONSUMPTION=true NODE_ENV=test DEBUG='speckle:*' node ./bin/ts-www",
"test": "cross-env NODE_ENV=test mocha",
"test:coverage": "cross-env NODE_ENV=test nyc --reporter lcov mocha",
"test:report": "yarn test:coverage -- --reporter mocha-junit-reporter --reporter-options mochaFile=reports/test-results.xml",
"lint": "eslint . --ext .js,.ts",
"lint:tsc": "tsc --noEmit",
- "cli": "ts-node ./modules/cli/index.js",
+ "cli": "cross-env NODE_ENV=development DEBUG='speckle:*' ts-node ./modules/cli/index.js",
"migrate": "yarn cli db migrate",
"gqlgen": "graphql-codegen --config codegen.yml"
},
@@ -38,15 +38,18 @@
"apollo-server-express": "^2.19.0",
"apollo-server-testing": "^2.19.0",
"bcrypt": "^5.0.0",
+ "bull": "^4.8.5",
"busboy": "^1.4.0",
"compression": "^1.7.4",
"connect-redis": "^6.1.1",
"cors": "^2.8.5",
"crypto-random-string": "^3.2.0",
"dataloader": "^2.0.0",
+ "dayjs": "^1.11.5",
"debug": "^4.3.1",
"dotenv": "^8.2.0",
"ejs": "^3.1.8",
+ "eventemitter2": "^6.4.7",
"express": "^4.17.3",
"express-async-errors": "^3.1.1",
"express-session": "^1.17.1",
@@ -56,7 +59,7 @@
"graphql-subscriptions": "^2.0.0",
"graphql-tag": "^2.11.0",
"graphql-tools": "^4.0.7",
- "ioredis": "^4.19.4",
+ "ioredis": "^5.2.2",
"knex": "^2.0.0",
"lodash": "^4.17.21",
"module-alias": "^2.2.2",
@@ -85,21 +88,30 @@
"zxcvbn": "^4.4.2"
},
"devDependencies": {
+ "@bull-board/express": "^4.2.2",
"@faker-js/faker": "^7.1.0",
"@graphql-codegen/cli": "2.11.3",
"@graphql-codegen/typescript": "2.7.2",
"@graphql-codegen/typescript-operations": "^2.5.2",
"@graphql-codegen/typescript-resolvers": "2.7.2",
"@swc/core": "^1.2.222",
+ "@tiptap/core": "^2.0.0-beta.176",
+ "@types/bull": "^3.15.9",
"@types/compression": "^1.7.2",
"@types/debug": "^4.1.7",
+ "@types/deep-equal-in-any-order": "^1.0.1",
"@types/ejs": "^3.1.1",
"@types/express": "^4.17.13",
"@types/lodash": "^4.14.180",
"@types/mocha": "^7.0.2",
+ "@types/mock-require": "^2.0.1",
"@types/module-alias": "^2.0.1",
+ "@types/nodemailer": "^6.4.5",
+ "@types/sanitize-html": "^2.6.2",
+ "@types/supertest": "^2.0.12",
"@types/verror": "^1.10.6",
"@types/yargs": "^17.0.10",
+ "@types/zxcvbn": "^4.4.1",
"@typescript-eslint/eslint-plugin": "^5.32.0",
"@typescript-eslint/parser": "^5.32.0",
"apollo-cache-inmemory": "^1.6.6",
@@ -125,6 +137,7 @@
"supertest": "^4.0.2",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.0.0",
+ "type-fest": "^2.19.0",
"typescript": "^4.6.4",
"ws": "^7.5.7",
"yargs": "^17.3.1"
diff --git a/packages/server/readme.md b/packages/server/readme.md
index 596f555ab..71713cec7 100644
--- a/packages/server/readme.md
+++ b/packages/server/readme.md
@@ -60,6 +60,14 @@ You can get the best DX by typing your resolvers with the `Resolvers` type and t
To create new migrations use `yarn migrate create`. Note that migrations are only ever read from the `./dist` folder to avoid scenarious when both the TS and JS version of the same migration is executed, so if you ever create a new migration make sure
you build the app into `/dist` if you want it to be applied.
+### CLI
+
+We've got a yargs based dev-only CLI that you can run and extend with useful commands. Run it through `yarn cli` and add new commands under `./modules/cli`
+
+### Bull queue monitoring
+
+Use `yarn cli bull monitor` to serve a Web UI for our Bull queues (e.g. Notifications queues). In the prod env we don't retain old jobs, but locally these older results aren't deleted and you'll see them in this Web UI.
+
## Server & Apps
### Frontend
diff --git a/packages/server/test/authHelper.js b/packages/server/test/authHelper.js
deleted file mode 100644
index 42c3be5c3..000000000
--- a/packages/server/test/authHelper.js
+++ /dev/null
@@ -1,16 +0,0 @@
-const { AllScopes } = require('@/modules/core/helpers/mainConstants')
-const { createPersonalAccessToken } = require('@/modules/core/services/tokens')
-
-/**
- * Create an auth token for the specified user (use only during tests, of course)
- * @param {string} userId User's ID
- * @param {string[]} scopes Specify scopes you want to allow. Defaults to all scopes.
- * @returns {Promise}
- */
-async function createAuthTokenForUser(userId, scopes = AllScopes) {
- return await createPersonalAccessToken(userId, 'test-runner-token', scopes)
-}
-
-module.exports = {
- createAuthTokenForUser
-}
diff --git a/packages/server/test/authHelper.ts b/packages/server/test/authHelper.ts
new file mode 100644
index 000000000..b2768b6a5
--- /dev/null
+++ b/packages/server/test/authHelper.ts
@@ -0,0 +1,52 @@
+import { AllScopes } from '@/modules/core/helpers/mainConstants'
+import { UserRecord } from '@/modules/core/helpers/types'
+import { createPersonalAccessToken } from '@/modules/core/services/tokens'
+import { createUser } from '@/modules/core/services/users'
+import { kebabCase, omit } from 'lodash'
+
+export type BasicTestUser = {
+ name: string
+ email: string
+ password?: string
+ /**
+ * Will be set by createTestUser(), but you need to set a default value to ''
+ * so that you don't have to check if its empty cause of TS
+ */
+ id: string
+} & Partial
+
+/**
+ * Create basic user for tests and on success mutate the input object to have
+ * the new ID
+ */
+export async function createTestUser(userObj: BasicTestUser) {
+ if (!userObj.password) {
+ userObj.password = 'some-random-password-123456789#!@'
+ }
+
+ if (!userObj.email) {
+ userObj.email = `${kebabCase(userObj.name)}@someemail.com`
+ }
+
+ const id = await createUser(omit(userObj, ['id']))
+ userObj.id = id
+}
+
+/**
+ * Create multiple users for tests and update them to include their ID
+ */
+export async function createTestUsers(userObjs: BasicTestUser[]) {
+ await Promise.all(userObjs.map((o) => createTestUser(o)))
+}
+
+/**
+ * Create an auth token for the specified user (use only during tests, of course)
+ * @param userId User's ID
+ * @param Specify scopes you want to allow. Defaults to all scopes.
+ */
+export async function createAuthTokenForUser(
+ userId: string,
+ scopes: string[] = AllScopes
+): Promise {
+ return await createPersonalAccessToken(userId, 'test-runner-token', scopes)
+}
diff --git a/packages/server/test/graphql/accessRequests.ts b/packages/server/test/graphql/accessRequests.ts
new file mode 100644
index 000000000..bbf8e301b
--- /dev/null
+++ b/packages/server/test/graphql/accessRequests.ts
@@ -0,0 +1,110 @@
+import {
+ CreateStreamAccessRequestMutation,
+ CreateStreamAccessRequestMutationVariables,
+ GetPendingStreamAccessRequestsQuery,
+ GetPendingStreamAccessRequestsQueryVariables,
+ GetStreamAccessRequestQuery,
+ GetStreamAccessRequestQueryVariables,
+ UseStreamAccessRequestMutation,
+ UseStreamAccessRequestMutationVariables
+} from '@/test/graphql/generated/graphql'
+import { executeOperation } from '@/test/graphqlHelper'
+import { ApolloServer, gql } from 'apollo-server-express'
+
+const basicStreamAccessRequestFragment = gql`
+ fragment BasicStreamAccessRequestFields on StreamAccessRequest {
+ id
+ requester {
+ id
+ name
+ }
+ requesterId
+ streamId
+ createdAt
+ }
+`
+
+const createStreamAccessRequestMutation = gql`
+ mutation CreateStreamAccessRequest($streamId: String!) {
+ streamAccessRequestCreate(streamId: $streamId) {
+ ...BasicStreamAccessRequestFields
+ }
+ }
+
+ ${basicStreamAccessRequestFragment}
+`
+
+const getStreamAccessRequestQuery = gql`
+ query GetStreamAccessRequest($streamId: String!) {
+ streamAccessRequest(streamId: $streamId) {
+ ...BasicStreamAccessRequestFields
+ }
+ }
+
+ ${basicStreamAccessRequestFragment}
+`
+
+const getPendingStreamAccessRequestsQuery = gql`
+ query GetPendingStreamAccessRequests($streamId: String!) {
+ stream(id: $streamId) {
+ id
+ name
+ pendingAccessRequests {
+ ...BasicStreamAccessRequestFields
+ stream {
+ id
+ name
+ }
+ }
+ }
+ }
+
+ ${basicStreamAccessRequestFragment}
+`
+
+const useStreamAccessRequestMutation = gql`
+ mutation UseStreamAccessRequest(
+ $requestId: String!
+ $accept: Boolean!
+ $role: StreamRole! = STREAM_CONTRIBUTOR
+ ) {
+ streamAccessRequestUse(requestId: $requestId, accept: $accept, role: $role)
+ }
+`
+
+export const createStreamAccessRequest = (
+ apollo: ApolloServer,
+ variables: CreateStreamAccessRequestMutationVariables
+) =>
+ executeOperation<
+ CreateStreamAccessRequestMutation,
+ CreateStreamAccessRequestMutationVariables
+ >(apollo, createStreamAccessRequestMutation, variables)
+
+export const getStreamAccessRequest = (
+ apollo: ApolloServer,
+ variables: GetStreamAccessRequestQueryVariables
+) =>
+ executeOperation(
+ apollo,
+ getStreamAccessRequestQuery,
+ variables
+ )
+
+export const getPendingStreamAccessRequests = (
+ apollo: ApolloServer,
+ variables: GetPendingStreamAccessRequestsQueryVariables
+) =>
+ executeOperation<
+ GetPendingStreamAccessRequestsQuery,
+ GetPendingStreamAccessRequestsQueryVariables
+ >(apollo, getPendingStreamAccessRequestsQuery, variables)
+
+export const useStreamAccessRequest = (
+ apollo: ApolloServer,
+ variables: UseStreamAccessRequestMutationVariables
+) =>
+ executeOperation<
+ UseStreamAccessRequestMutation,
+ UseStreamAccessRequestMutationVariables
+ >(apollo, useStreamAccessRequestMutation, variables)
diff --git a/packages/server/test/graphql/comments.ts b/packages/server/test/graphql/comments.ts
new file mode 100644
index 000000000..e698eeb04
--- /dev/null
+++ b/packages/server/test/graphql/comments.ts
@@ -0,0 +1,112 @@
+import {
+ CreateCommentMutation,
+ CreateCommentMutationVariables,
+ CreateReplyMutation,
+ CreateReplyMutationVariables,
+ GetCommentQuery,
+ GetCommentQueryVariables,
+ GetCommentsQuery,
+ GetCommentsQueryVariables
+} from '@/test/graphql/generated/graphql'
+import { executeOperation } from '@/test/graphqlHelper'
+import { ApolloServer, gql } from 'apollo-server-express'
+
+const commentWithRepliesFragment = gql`
+ fragment CommentWithReplies on Comment {
+ id
+ text {
+ doc
+ attachments {
+ id
+ fileName
+ streamId
+ }
+ }
+ replies(limit: 10) {
+ items {
+ id
+ text {
+ doc
+ attachments {
+ id
+ fileName
+ streamId
+ }
+ }
+ }
+ }
+ }
+`
+
+const createCommentMutation = gql`
+ mutation CreateComment($input: CommentCreateInput!) {
+ commentCreate(input: $input)
+ }
+`
+
+const createReplyMutation = gql`
+ mutation CreateReply($input: ReplyCreateInput!) {
+ commentReply(input: $input)
+ }
+`
+
+const getCommentQuery = gql`
+ query GetComment($id: String!, $streamId: String!) {
+ comment(id: $id, streamId: $streamId) {
+ ...CommentWithReplies
+ }
+ }
+
+ ${commentWithRepliesFragment}
+`
+
+const getCommentsQuery = gql`
+ query GetComments($streamId: String!, $cursor: String) {
+ comments(streamId: $streamId, limit: 10, cursor: $cursor) {
+ totalCount
+ cursor
+ items {
+ ...CommentWithReplies
+ }
+ }
+ }
+
+ ${commentWithRepliesFragment}
+`
+
+export const createComment = (
+ apollo: ApolloServer,
+ variables: CreateCommentMutationVariables
+) =>
+ executeOperation(
+ apollo,
+ createCommentMutation,
+ variables
+ )
+
+export const createReply = (
+ apollo: ApolloServer,
+ variables: CreateReplyMutationVariables
+) =>
+ executeOperation(
+ apollo,
+ createReplyMutation,
+ variables
+ )
+
+export const getComment = (apollo: ApolloServer, variables: GetCommentQueryVariables) =>
+ executeOperation(
+ apollo,
+ getCommentQuery,
+ variables
+ )
+
+export const getComments = (
+ apollo: ApolloServer,
+ variables: GetCommentsQueryVariables
+) =>
+ executeOperation(
+ apollo,
+ getCommentsQuery,
+ variables
+ )
diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts
index 7cd2dc5c4..92f3c659d 100644
--- a/packages/server/test/graphql/generated/graphql.ts
+++ b/packages/server/test/graphql/generated/graphql.ts
@@ -11,9 +11,9 @@ export type Scalars = {
Int: number;
Float: number;
BigInt: any;
- DateTime: any;
+ DateTime: string;
EmailAddress: any;
- JSONObject: any;
+ JSONObject: Record;
};
export type Activity = {
@@ -181,6 +181,78 @@ export type BranchUpdateInput = {
streamId: Scalars['String'];
};
+export type Comment = {
+ __typename?: 'Comment';
+ archived: Scalars['Boolean'];
+ authorId: Scalars['String'];
+ createdAt?: Maybe;
+ data?: Maybe;
+ id: Scalars['String'];
+ reactions?: Maybe>>;
+ /** Gets the replies to this comment. */
+ replies?: Maybe;
+ /** Resources that this comment targets. Can be a mixture of either one stream, or multiple commits and objects. */
+ resources: Array>;
+ screenshot?: Maybe;
+ text: SmartTextEditorValue;
+ /** The time this comment was last updated. Corresponds also to the latest reply to this comment, if any. */
+ updatedAt?: Maybe;
+ /** The last time you viewed this comment. Present only if an auth'ed request. Relevant only if a top level commit. */
+ viewedAt?: Maybe;
+};
+
+
+export type CommentRepliesArgs = {
+ cursor?: InputMaybe;
+ limit?: InputMaybe;
+};
+
+export type CommentActivityMessage = {
+ __typename?: 'CommentActivityMessage';
+ comment: Comment;
+ type: Scalars['String'];
+};
+
+export type CommentCollection = {
+ __typename?: 'CommentCollection';
+ cursor?: Maybe;
+ items: Array;
+ totalCount: Scalars['Int'];
+};
+
+export type CommentCreateInput = {
+ /** IDs of uploaded blobs that should be attached to this comment */
+ blobIds: Array;
+ data: Scalars['JSONObject'];
+ /**
+ * Specifies the resources this comment is linked to. There are several use cases:
+ * - a comment targets only one resource (commit or object)
+ * - a comment targets one or more resources (commits or objects)
+ * - a comment targets only a stream
+ */
+ resources: Array>;
+ screenshot?: InputMaybe;
+ streamId: Scalars['String'];
+ /** ProseMirror document object */
+ text?: InputMaybe;
+};
+
+export type CommentEditInput = {
+ /** IDs of uploaded blobs that should be attached to this comment */
+ blobIds: Array;
+ id: Scalars['String'];
+ streamId: Scalars['String'];
+ /** ProseMirror document object */
+ text?: InputMaybe;
+};
+
+export type CommentThreadActivityMessage = {
+ __typename?: 'CommentThreadActivityMessage';
+ data?: Maybe;
+ reply?: Maybe;
+ type: Scalars['String'];
+};
+
export type Commit = {
__typename?: 'Commit';
/** All the recent activity on this commit in chronological order */
@@ -189,6 +261,17 @@ export type Commit = {
authorId?: Maybe;
authorName?: Maybe;
branchName?: Maybe;
+ /**
+ * The total number of comments for this commit. To actually get the comments, use the comments query and pass in a resource array consisting of of this commit's id.
+ * E.g.,
+ * ```
+ * query{
+ * comments(streamId:"streamId" resources:[{resourceType: commit, resourceId:"commitId"}] ){
+ * ...
+ * }
+ * ```
+ */
+ commentCount: Scalars['Int'];
createdAt?: Maybe;
id: Scalars['String'];
message?: Maybe;
@@ -224,6 +307,17 @@ export type CommitCollectionUser = {
export type CommitCollectionUserNode = {
__typename?: 'CommitCollectionUserNode';
branchName?: Maybe;
+ /**
+ * The total number of comments for this commit. To actually get the comments, use the comments query and pass in a resource array consisting of of this commit's id.
+ * E.g.,
+ * ```
+ * query{
+ * comments(streamId:"streamId" resources:[{resourceType: commit, resourceId:"commitId"}] ){
+ * ...
+ * }
+ * ```
+ */
+ commentCount: Scalars['Int'];
createdAt?: Maybe;
id: Scalars['String'];
message?: Maybe;
@@ -267,6 +361,16 @@ export type CommitUpdateInput = {
streamId: Scalars['String'];
};
+export enum DiscoverableStreamsSortType {
+ CreatedDate = 'CREATED_DATE',
+ FavoritesCount = 'FAVORITES_COUNT'
+}
+
+export type DiscoverableStreamsSortingInput = {
+ direction: SortDirection;
+ type: DiscoverableStreamsSortType;
+};
+
export type FileUpload = {
__typename?: 'FileUpload';
branchName?: Maybe;
@@ -322,6 +426,16 @@ export type Mutation = {
branchCreate: Scalars['String'];
branchDelete: Scalars['Boolean'];
branchUpdate: Scalars['Boolean'];
+ /** Archives a comment. */
+ commentArchive: Scalars['Boolean'];
+ /** Creates a comment */
+ commentCreate: Scalars['String'];
+ /** Edits a comment. */
+ commentEdit: Scalars['Boolean'];
+ /** Adds a reply to a comment. */
+ commentReply: Scalars['String'];
+ /** Flags a comment as viewed by you (the logged in user). */
+ commentView: Scalars['Boolean'];
commitCreate: Scalars['String'];
commitDelete: Scalars['Boolean'];
commitReceive: Scalars['Boolean'];
@@ -331,10 +445,16 @@ export type Mutation = {
/** Re-send a pending invite */
inviteResend: Scalars['Boolean'];
objectCreate: Array>;
+ /** (Re-)send the account verification e-mail */
+ requestVerification: Scalars['Boolean'];
serverInfoUpdate?: Maybe;
serverInviteBatchCreate: Scalars['Boolean'];
/** Invite a new user to the speckle server and return the invite ID */
serverInviteCreate: Scalars['Boolean'];
+ /** Request access to a specific stream */
+ streamAccessRequestCreate: StreamAccessRequest;
+ /** Accept or decline a stream access request. Must be a stream owner to invoke this. */
+ streamAccessRequestUse: Scalars['Boolean'];
/** Creates a new stream. */
streamCreate?: Maybe;
/** Deletes an existing stream. */
@@ -356,11 +476,15 @@ export type Mutation = {
/** Update permissions of a user on a given stream. */
streamUpdatePermission?: Maybe;
streamsDelete: Scalars['Boolean'];
+ /** Used for broadcasting real time typing status in comment threads. Does not persist any info. */
+ userCommentThreadActivityBroadcast: Scalars['Boolean'];
/** Delete a user's account. */
userDelete: Scalars['Boolean'];
userRoleChange: Scalars['Boolean'];
/** Edits a user's profile. */
userUpdate: Scalars['Boolean'];
+ /** Used for broadcasting real time chat head bubbles and status. Does not persist any info. */
+ userViewerActivityBroadcast: Scalars['Boolean'];
/** Creates a new webhook on a stream */
webhookCreate: Scalars['String'];
/** Deletes an existing webhook */
@@ -420,6 +544,34 @@ export type MutationBranchUpdateArgs = {
};
+export type MutationCommentArchiveArgs = {
+ archived?: Scalars['Boolean'];
+ commentId: Scalars['String'];
+ streamId: Scalars['String'];
+};
+
+
+export type MutationCommentCreateArgs = {
+ input: CommentCreateInput;
+};
+
+
+export type MutationCommentEditArgs = {
+ input: CommentEditInput;
+};
+
+
+export type MutationCommentReplyArgs = {
+ input: ReplyCreateInput;
+};
+
+
+export type MutationCommentViewArgs = {
+ commentId: Scalars['String'];
+ streamId: Scalars['String'];
+};
+
+
export type MutationCommitCreateArgs = {
commit: CommitCreateInput;
};
@@ -470,6 +622,18 @@ export type MutationServerInviteCreateArgs = {
};
+export type MutationStreamAccessRequestCreateArgs = {
+ streamId: Scalars['String'];
+};
+
+
+export type MutationStreamAccessRequestUseArgs = {
+ accept: Scalars['Boolean'];
+ requestId: Scalars['String'];
+ role?: StreamRole;
+};
+
+
export type MutationStreamCreateArgs = {
stream: StreamCreateInput;
};
@@ -534,6 +698,13 @@ export type MutationStreamsDeleteArgs = {
};
+export type MutationUserCommentThreadActivityBroadcastArgs = {
+ commentId: Scalars['String'];
+ data?: InputMaybe;
+ streamId: Scalars['String'];
+};
+
+
export type MutationUserDeleteArgs = {
userConfirmation: UserDeleteInput;
};
@@ -549,6 +720,13 @@ export type MutationUserUpdateArgs = {
};
+export type MutationUserViewerActivityBroadcastArgs = {
+ data?: InputMaybe;
+ resourceId: Scalars['String'];
+ streamId: Scalars['String'];
+};
+
+
export type MutationWebhookCreateArgs = {
webhook: WebhookCreateInput;
};
@@ -571,6 +749,17 @@ export type Object = {
* **NOTE**: Providing any of the two last arguments ( `query`, `orderBy` ) will trigger a different code branch that executes a much more expensive SQL query. It is not recommended to do so for basic clients that are interested in purely getting all the objects of a given commit.
*/
children: ObjectCollection;
+ /**
+ * The total number of comments for this commit. To actually get the comments, use the comments query and pass in a resource array consisting of of this object's id.
+ * E.g.,
+ * ```
+ * query{
+ * comments(streamId:"streamId" resources:[{resourceType: object, resourceId:"objectId"}] ){
+ * ...
+ * }
+ * ```
+ */
+ commentCount: Scalars['Int'];
createdAt?: Maybe;
/** The full object, with all its props & other things. **NOTE:** If you're requesting objects for the purpose of recreating & displaying, you probably only want to request this specific field. */
data?: Maybe;
@@ -603,6 +792,27 @@ export type ObjectCreateInput = {
streamId: Scalars['String'];
};
+export type PasswordStrengthCheckFeedback = {
+ __typename?: 'PasswordStrengthCheckFeedback';
+ suggestions: Array;
+ warning?: Maybe;
+};
+
+export type PasswordStrengthCheckResults = {
+ __typename?: 'PasswordStrengthCheckResults';
+ /** Verbal feedback to help choose better passwords. set when score <= 2. */
+ feedback: PasswordStrengthCheckFeedback;
+ /**
+ * Integer from 0-4 (useful for implementing a strength bar):
+ * 0 too guessable: risky password. (guesses < 10^3)
+ * 1 very guessable: protection from throttled online attacks. (guesses < 10^6)
+ * 2 somewhat guessable: protection from unthrottled online attacks. (guesses < 10^8)
+ * 3 safely unguessable: moderate protection from offline slow-hash scenario. (guesses < 10^10)
+ * 4 very unguessable: strong protection from offline slow-hash scenario. (guesses >= 10^10)
+ */
+ score: Scalars['Int'];
+};
+
export type PendingStreamCollaborator = {
__typename?: 'PendingStreamCollaborator';
id: Scalars['String'];
@@ -623,6 +833,7 @@ export type Query = {
__typename?: 'Query';
/** Stare into the void. */
_?: Maybe;
+ /** All the streams of the server. Available to admins only. */
adminStreams?: Maybe;
/**
* Get all (or search for specific) users, registered or invited, from the server in a paginated view.
@@ -633,12 +844,24 @@ export type Query = {
app?: Maybe;
/** Returns all the publicly available apps on this server. */
apps?: Maybe>>;
+ comment?: Maybe;
+ /**
+ * This query can be used in the following ways:
+ * - get all the comments for a stream: **do not pass in any resource identifiers**.
+ * - get the comments targeting any of a set of provided resources (comments/objects): **pass in an array of resources.**
+ */
+ comments?: Maybe;
+ /** All of the discoverable streams of the server */
+ discoverableStreams?: Maybe;
serverInfo: ServerInfo;
+ serverStats: ServerStats;
/**
* Returns a specific stream. Will throw an authorization error if active user isn't authorized
* to see it.
*/
stream?: Maybe;
+ /** Get authed user's stream access request */
+ streamAccessRequest?: Maybe;
/**
* Look for an invitation to a stream, for the current user (authed or not). If token
* isn't specified, the server will look for any valid invite.
@@ -648,12 +871,10 @@ export type Query = {
streamInvites: Array;
/** All the streams of the current user, pass in the `query` parameter to search by name, description or ID. */
streams?: Maybe;
- /**
- * Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header).
- * If ID is provided, admin access is required
- */
+ /** Gets the profile of a user. If no id argument is provided, will return the current authenticated user's profile (as extracted from the authorization header). */
user?: Maybe;
- userPwdStrength?: Maybe;
+ /** Validate password strength */
+ userPwdStrength: PasswordStrengthCheckResults;
/**
* Search for users and return limited metadata about them, if you have the server:user role.
* The query looks for matches in name & email
@@ -683,11 +904,38 @@ export type QueryAppArgs = {
};
+export type QueryCommentArgs = {
+ id: Scalars['String'];
+ streamId: Scalars['String'];
+};
+
+
+export type QueryCommentsArgs = {
+ archived?: Scalars['Boolean'];
+ cursor?: InputMaybe;
+ limit?: InputMaybe;
+ resources?: InputMaybe>>;
+ streamId: Scalars['String'];
+};
+
+
+export type QueryDiscoverableStreamsArgs = {
+ cursor?: InputMaybe;
+ limit?: Scalars['Int'];
+ sort?: InputMaybe;
+};
+
+
export type QueryStreamArgs = {
id: Scalars['String'];
};
+export type QueryStreamAccessRequestArgs = {
+ streamId: Scalars['String'];
+};
+
+
export type QueryStreamInviteArgs = {
streamId: Scalars['String'];
token?: InputMaybe;
@@ -718,6 +966,34 @@ export type QueryUserSearchArgs = {
query: Scalars['String'];
};
+export type ReplyCreateInput = {
+ /** IDs of uploaded blobs that should be attached to this reply */
+ blobIds: Array;
+ data?: InputMaybe;
+ parentComment: Scalars['String'];
+ streamId: Scalars['String'];
+ /** ProseMirror document object */
+ text?: InputMaybe;
+};
+
+export type ResourceIdentifier = {
+ __typename?: 'ResourceIdentifier';
+ resourceId: Scalars['String'];
+ resourceType: ResourceType;
+};
+
+export type ResourceIdentifierInput = {
+ resourceId: Scalars['String'];
+ resourceType: ResourceType;
+};
+
+export enum ResourceType {
+ Comment = 'comment',
+ Commit = 'commit',
+ Object = 'object',
+ Stream = 'stream'
+}
+
/** Available roles. */
export type Role = {
__typename?: 'Role';
@@ -800,6 +1076,22 @@ export type ServerInviteCreateInput = {
message?: InputMaybe;
};
+export type ServerStats = {
+ __typename?: 'ServerStats';
+ /** An array of objects currently structured as { created_month: Date, count: int }. */
+ commitHistory?: Maybe>>;
+ /** An array of objects currently structured as { created_month: Date, count: int }. */
+ objectHistory?: Maybe>>;
+ /** An array of objects currently structured as { created_month: Date, count: int }. */
+ streamHistory?: Maybe>>;
+ totalCommitCount: Scalars['Int'];
+ totalObjectCount: Scalars['Int'];
+ totalStreamCount: Scalars['Int'];
+ totalUserCount: Scalars['Int'];
+ /** An array of objects currently structured as { created_month: Date, count: int }. */
+ userHistory?: Maybe>>;
+};
+
export type SmartTextEditorValue = {
__typename?: 'SmartTextEditorValue';
/** File attachments, if any */
@@ -815,6 +1107,11 @@ export type SmartTextEditorValue = {
version: Scalars['String'];
};
+export enum SortDirection {
+ Asc = 'ASC',
+ Desc = 'DESC'
+}
+
export type Stream = {
__typename?: 'Stream';
/** All the recent activity on this stream in chronological order */
@@ -826,6 +1123,17 @@ export type Stream = {
branch?: Maybe;
branches?: Maybe;
collaborators: Array;
+ /**
+ * The total number of comments for this stream. To actually get the comments, use the comments query without passing in a resource array. E.g.:
+ *
+ * ```
+ * query{
+ * comments(streamId:"streamId"){
+ * ...
+ * }
+ * ```
+ */
+ commentCount: Scalars['Int'];
commit?: Maybe;
commits?: Maybe;
createdAt: Scalars['DateTime'];
@@ -838,9 +1146,17 @@ export type Stream = {
/** Returns a list of all the file uploads for this stream. */
fileUploads?: Maybe>>;
id: Scalars['String'];
+ /**
+ * Whether the stream (if public) can be found on public stream exploration pages
+ * and searches
+ */
+ isDiscoverable: Scalars['Boolean'];
+ /** Whether the stream can be viewed by non-contributors */
isPublic: Scalars['Boolean'];
name: Scalars['String'];
object?: Maybe