From cd3ef0e951d34692451ea6b255425d9c0b4f67fa Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Fri, 16 Aug 2024 08:49:34 +0100 Subject: [PATCH 001/178] chore(node): bump from 18 to 22 --- .circleci/config.yml | 10 +++++----- .circleci/deployment/helm-chart-shell.nix | 4 ++-- README.md | 2 +- package.json | 2 +- packages/dui3/package.json | 2 +- packages/fileimport-service/Dockerfile | 6 +++--- packages/fileimport-service/package.json | 2 +- packages/frontend-2/Dockerfile | 4 ++-- packages/preview-service/Dockerfile | 4 ++-- packages/preview-service/package.json | 2 +- packages/server/Dockerfile | 6 +++--- packages/server/modules/blobstorage/index.js | 4 +--- packages/server/package.json | 2 +- packages/tailwind-theme/package.json | 2 +- packages/webhook-service/Dockerfile | 6 +++--- packages/webhook-service/package.json | 2 +- 16 files changed, 29 insertions(+), 31 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7425ed357..0ceddcafd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -438,7 +438,7 @@ jobs: test-server: &test-server-job docker: - - image: cimg/node:18.19.0 + - image: cimg/node:22.6.0 - image: cimg/redis:7.2.4 - image: cimg/postgres:14.11 environment: @@ -558,7 +558,7 @@ jobs: test-frontend-2: docker: &docker-node-browsers-image - - image: cimg/node:18.19.0-browsers + - image: cimg/node:22.6.0-browsers resource_class: xlarge steps: - checkout @@ -592,7 +592,7 @@ jobs: test-viewer: docker: &docker-node-browsers-image - - image: cimg/node:18.19.0-browsers + - image: cimg/node:22.6.0-browsers resource_class: large steps: - checkout @@ -631,7 +631,7 @@ jobs: test-objectsender: docker: &docker-node-browsers-image - - image: cimg/node:18.19.0-browsers + - image: cimg/node:22.6.0-browsers resource_class: large steps: - checkout @@ -719,7 +719,7 @@ jobs: ui-components-chromatic: resource_class: medium+ docker: &docker-node-image - - image: cimg/node:18.19.0 + - image: cimg/node:22.6.0 steps: - checkout - restore_cache: diff --git a/.circleci/deployment/helm-chart-shell.nix b/.circleci/deployment/helm-chart-shell.nix index 477908bdc..dff15565d 100644 --- a/.circleci/deployment/helm-chart-shell.nix +++ b/.circleci/deployment/helm-chart-shell.nix @@ -3,7 +3,7 @@ let corepack = pkgs.stdenv.mkDerivation { name = "corepack"; - buildInputs = [ pkgs.nodejs-18_x ]; + buildInputs = [ pkgs.nodejs-22_x ]; phases = [ "installPhase" ]; installPhase = '' mkdir -p $out/bin @@ -15,7 +15,7 @@ in pkgs.mkShell { pkgs.docker pkgs.kind pkgs.kubectl - pkgs.nodejs-18_x + pkgs.nodejs-22_x pkgs.ctlptl pkgs.crane pkgs.kubernetes-helm diff --git a/README.md b/README.md index 23bec59e2..3334c0f21 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ We have a detailed section on [deploying a Speckle server](https://speckle.guide ## TL;DR We're using yarn and its workspaces functionalities to manage the monorepo. -Make sure you are using [Node](https://nodejs.org/en) version 18. +Make sure you are using [Node](https://nodejs.org/en) version 22. To get started, run: 1. `corepack enable` diff --git a/package.json b/package.json index 9fa9db991..a071a729f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "name": "root", "private": true, "engines": { - "node": "^18.19.0" + "node": "^22.6.0" }, "scripts": { "build": "yarn workspaces foreach -ptvW run build", diff --git a/packages/dui3/package.json b/packages/dui3/package.json index 40206e0c3..08268234f 100644 --- a/packages/dui3/package.json +++ b/packages/dui3/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "private": true, "engines": { - "node": "^18.19.0" + "node": "^22.6.0" }, "scripts": { "build": "nuxt build", diff --git a/packages/fileimport-service/Dockerfile b/packages/fileimport-service/Dockerfile index a19df84f1..5bc82a807 100644 --- a/packages/fileimport-service/Dockerfile +++ b/packages/fileimport-service/Dockerfile @@ -1,6 +1,6 @@ ARG NODE_ENV=production -FROM node:18-bookworm-slim@sha256:408f8cbbb7b33a5bb94bdb8862795a94d2b64c2d516856824fd86c4a5594a443 as build-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as build-stage ARG NODE_ENV ENV NODE_ENV=${NODE_ENV} @@ -47,7 +47,7 @@ RUN apt-get update && \ COPY packages/fileimport-service/requirements.txt /speckle-server/ RUN /venv/bin/pip install --disable-pip-version-check --no-cache-dir --requirement /speckle-server/requirements.txt -FROM node:18-bookworm-slim@sha256:408f8cbbb7b33a5bb94bdb8862795a94d2b64c2d516856824fd86c4a5594a443 as dependency-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as dependency-stage # installing just the production dependencies # separate stage to avoid including development dependencies ARG NODE_ENV @@ -67,7 +67,7 @@ RUN yarn workspaces focus --production FROM gcr.io/distroless/python3-debian12:nonroot@sha256:14c62b8925d3bb30319de2f346bde203fe18103a68898284a62db9d4aa54c794 as python-image -FROM gcr.io/distroless/nodejs18-debian12:nonroot@sha256:afdea027580f7afcaf1f316b2b3806690c297cb3ce6ddc5cf6a15804dc1c790f as distributable-stage +FROM gcr.io/distroless/nodejs22-debian12:nonroot@sha256:ed26b3ab750110c51d9dbdfd6c697561dc40a01c296460c3494d47b550ef4126 as distributable-stage ARG NODE_ENV ENV NODE_ENV=${NODE_ENV} diff --git a/packages/fileimport-service/package.json b/packages/fileimport-service/package.json index 86cdbd6cc..f92e2151f 100644 --- a/packages/fileimport-service/package.json +++ b/packages/fileimport-service/package.json @@ -12,7 +12,7 @@ "url": "git+https://github.com/specklesystems/speckle-server.git" }, "engines": { - "node": "^18.19.0" + "node": "^22.6.0" }, "scripts": { "dev": "cross-env POSTGRES_URL=postgres://speckle:speckle@127.0.0.1/speckle NODE_ENV=development LOG_PRETTY=true SPECKLE_SERVER_URL=http://127.0.0.1:3000 nodemon --no-experimental-fetch ./src/daemon.js", diff --git a/packages/frontend-2/Dockerfile b/packages/frontend-2/Dockerfile index f4ff94763..30cff7cb3 100644 --- a/packages/frontend-2/Dockerfile +++ b/packages/frontend-2/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-bookworm-slim@sha256:408f8cbbb7b33a5bb94bdb8862795a94d2b64c2d516856824fd86c4a5594a443 as build-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as build-stage ARG NODE_ENV=production ARG SPECKLE_SERVER_VERSION=custom @@ -37,7 +37,7 @@ ENV TINI_VERSION v0.19.0 ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini RUN chmod +x /tini -FROM gcr.io/distroless/nodejs18-debian12:nonroot@sha256:afdea027580f7afcaf1f316b2b3806690c297cb3ce6ddc5cf6a15804dc1c790f as production-stage +FROM gcr.io/distroless/nodejs22-debian12:nonroot@sha256:ed26b3ab750110c51d9dbdfd6c697561dc40a01c296460c3494d47b550ef4126 as production-stage ARG NODE_ENV=production ENV NODE_ENV=${NODE_ENV} diff --git a/packages/preview-service/Dockerfile b/packages/preview-service/Dockerfile index 1d27c3951..8780280cf 100644 --- a/packages/preview-service/Dockerfile +++ b/packages/preview-service/Dockerfile @@ -1,7 +1,7 @@ # NOTE: Docker context should be set to git root directory, to include the viewer ARG NODE_ENV=production -FROM node:18-bookworm-slim@sha256:408f8cbbb7b33a5bb94bdb8862795a94d2b64c2d516856824fd86c4a5594a443 as build-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as build-stage ARG NODE_ENV ENV NODE_ENV=${NODE_ENV} @@ -36,7 +36,7 @@ COPY packages/preview-service ./packages/preview-service/ # This way the foreach only builds the frontend and its deps RUN yarn workspaces foreach -W run build -FROM node:18-bookworm-slim@sha256:408f8cbbb7b33a5bb94bdb8862795a94d2b64c2d516856824fd86c4a5594a443 as node +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as node SHELL ["/bin/bash", "-o", "pipefail", "-c"] # hadolint ignore=DL3008,DL3015 diff --git a/packages/preview-service/package.json b/packages/preview-service/package.json index cc2bc47e0..51c24881a 100644 --- a/packages/preview-service/package.json +++ b/packages/preview-service/package.json @@ -11,7 +11,7 @@ "directory": "packages/preview-service" }, "engines": { - "node": "^18.19.0" + "node": "^22.6.0" }, "scripts": { "dev": "LOG_PRETTY=true nodemon --trace-deprecation ./bin/www", diff --git a/packages/server/Dockerfile b/packages/server/Dockerfile index d29d19d17..aa24f603d 100644 --- a/packages/server/Dockerfile +++ b/packages/server/Dockerfile @@ -1,7 +1,7 @@ ARG NODE_ENV=production ARG SPECKLE_SERVER_VERSION=custom -FROM node:18-bookworm-slim@sha256:408f8cbbb7b33a5bb94bdb8862795a94d2b64c2d516856824fd86c4a5594a443 as build-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as build-stage ARG NODE_ENV ARG SPECKLE_SERVER_VERSION WORKDIR /speckle-server @@ -39,7 +39,7 @@ RUN yarn workspaces foreach -W run build # install only production dependencies # we need a clean environment, free of build dependencies -FROM node:18-bookworm-slim@sha256:408f8cbbb7b33a5bb94bdb8862795a94d2b64c2d516856824fd86c4a5594a443 as dependency-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as dependency-stage ARG NODE_ENV ARG SPECKLE_SERVER_VERSION @@ -56,7 +56,7 @@ COPY packages/objectloader/package.json ./packages/objectloader/ WORKDIR /speckle-server/packages/server RUN yarn workspaces focus --production -FROM node:18-bookworm-slim@sha256:408f8cbbb7b33a5bb94bdb8862795a94d2b64c2d516856824fd86c4a5594a443 as production-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as production-stage ARG NODE_ENV ARG SPECKLE_SERVER_VERSION ARG FILE_SIZE_LIMIT_MB=100 diff --git a/packages/server/modules/blobstorage/index.js b/packages/server/modules/blobstorage/index.js index 69010a9c3..e45fb22ff 100644 --- a/packages/server/modules/blobstorage/index.js +++ b/packages/server/modules/blobstorage/index.js @@ -29,8 +29,6 @@ const { getFileSizeLimit } = require('@/modules/blobstorage/services') -const { isArray } = require('lodash') - const { NotFoundError, ResourceMismatch, @@ -178,7 +176,7 @@ exports.init = async (app) => { allowAnonymousUsersOnPublicStreams ]), async (req, res) => { - if (!isArray(req.body)) { + if (!Array.isArray(req.body)) { return res .status(400) .json({ error: 'An array of blob IDs expected in the body.' }) diff --git a/packages/server/package.json b/packages/server/package.json index f0523c91c..a34937949 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/specklesystems/Server.git" }, "engines": { - "node": "^18.19.0" + "node": "^22.6.0" }, "scripts": { "build": "tsc -p ./tsconfig.build.json", diff --git a/packages/tailwind-theme/package.json b/packages/tailwind-theme/package.json index e6e0e5bbf..ec8eb9077 100644 --- a/packages/tailwind-theme/package.json +++ b/packages/tailwind-theme/package.json @@ -34,7 +34,7 @@ "./fonts/*": "./fonts/*" }, "engines": { - "node": "^18.19.0" + "node": "^22.6.0" }, "peerDependencies": { "postcss": "^8.4.18", diff --git a/packages/webhook-service/Dockerfile b/packages/webhook-service/Dockerfile index f05eec8b3..e32d19bb0 100644 --- a/packages/webhook-service/Dockerfile +++ b/packages/webhook-service/Dockerfile @@ -1,6 +1,6 @@ ARG NODE_ENV=production -FROM node:18-bookworm-slim@sha256:408f8cbbb7b33a5bb94bdb8862795a94d2b64c2d516856824fd86c4a5594a443 as build-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as build-stage ARG NODE_ENV ENV NODE_ENV=${NODE_ENV} @@ -32,7 +32,7 @@ ENV TINI_VERSION=${TINI_VERSION} ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini ./tini RUN chmod +x ./tini -FROM node:18-bookworm-slim@sha256:408f8cbbb7b33a5bb94bdb8862795a94d2b64c2d516856824fd86c4a5594a443 as dependency-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as dependency-stage # yarn install ARG NODE_ENV ENV NODE_ENV=${NODE_ENV} @@ -50,7 +50,7 @@ COPY packages/shared/package.json ./packages/shared/ WORKDIR /speckle-server/packages/webhook-service RUN yarn workspaces focus --production -FROM gcr.io/distroless/nodejs18-debian12:nonroot@sha256:afdea027580f7afcaf1f316b2b3806690c297cb3ce6ddc5cf6a15804dc1c790f as production-stage +FROM gcr.io/distroless/nodejs22-debian12:nonroot@sha256:ed26b3ab750110c51d9dbdfd6c697561dc40a01c296460c3494d47b550ef4126 as production-stage ARG NODE_ENV ENV NODE_ENV=${NODE_ENV} diff --git a/packages/webhook-service/package.json b/packages/webhook-service/package.json index f948eaa8b..fc884f86b 100644 --- a/packages/webhook-service/package.json +++ b/packages/webhook-service/package.json @@ -13,7 +13,7 @@ }, "homepage": "https://github.com/specklesystems/speckle-server#readme", "engines": { - "node": "^18.19.0" + "node": "^22.6.0" }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", From 44a18a41f33454a43b9a57f46757bcd99a5f62d0 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Fri, 16 Aug 2024 08:51:53 +0100 Subject: [PATCH 002/178] Revert accidentally committed change --- packages/server/modules/blobstorage/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/server/modules/blobstorage/index.js b/packages/server/modules/blobstorage/index.js index e45fb22ff..69010a9c3 100644 --- a/packages/server/modules/blobstorage/index.js +++ b/packages/server/modules/blobstorage/index.js @@ -29,6 +29,8 @@ const { getFileSizeLimit } = require('@/modules/blobstorage/services') +const { isArray } = require('lodash') + const { NotFoundError, ResourceMismatch, @@ -176,7 +178,7 @@ exports.init = async (app) => { allowAnonymousUsersOnPublicStreams ]), async (req, res) => { - if (!Array.isArray(req.body)) { + if (!isArray(req.body)) { return res .status(400) .json({ error: 'An array of blob IDs expected in the body.' }) From 2991984a7ec30b4bf066818ada7d53d032589dfd Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Fri, 16 Aug 2024 09:33:32 +0100 Subject: [PATCH 003/178] fix nix package name --- .circleci/deployment/helm-chart-shell.nix | 4 ++-- docker/postgres/Dockerfile | 2 +- packages/fileimport-service/Dockerfile | 8 ++++---- packages/frontend-2/Dockerfile | 4 ++-- packages/frontend/Dockerfile | 4 ++-- packages/preview-service/Dockerfile | 2 +- packages/server/Dockerfile | 6 +++--- packages/webhook-service/Dockerfile | 6 +++--- utils/monitor-deployment/Dockerfile | 2 +- utils/test-deployment/Dockerfile | 2 +- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.circleci/deployment/helm-chart-shell.nix b/.circleci/deployment/helm-chart-shell.nix index dff15565d..4c173903e 100644 --- a/.circleci/deployment/helm-chart-shell.nix +++ b/.circleci/deployment/helm-chart-shell.nix @@ -3,7 +3,7 @@ let corepack = pkgs.stdenv.mkDerivation { name = "corepack"; - buildInputs = [ pkgs.nodejs-22_x ]; + buildInputs = [ pkgs.nodejs_22 ]; phases = [ "installPhase" ]; installPhase = '' mkdir -p $out/bin @@ -15,7 +15,7 @@ in pkgs.mkShell { pkgs.docker pkgs.kind pkgs.kubectl - pkgs.nodejs-22_x + pkgs.nodejs_22 pkgs.ctlptl pkgs.crane pkgs.kubernetes-helm diff --git a/docker/postgres/Dockerfile b/docker/postgres/Dockerfile index 9da6a4538..ebeaffd09 100644 --- a/docker/postgres/Dockerfile +++ b/docker/postgres/Dockerfile @@ -1,4 +1,4 @@ -FROM postgres:14.5-alpine as builder +FROM postgres:14.5-alpine AS builder RUN apk add --no-cache 'git=~2.36' \ 'build-base=~0.5' \ diff --git a/packages/fileimport-service/Dockerfile b/packages/fileimport-service/Dockerfile index 5bc82a807..5a42901e4 100644 --- a/packages/fileimport-service/Dockerfile +++ b/packages/fileimport-service/Dockerfile @@ -1,6 +1,6 @@ ARG NODE_ENV=production -FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as build-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 AS build-stage ARG NODE_ENV ENV NODE_ENV=${NODE_ENV} @@ -47,7 +47,7 @@ RUN apt-get update && \ COPY packages/fileimport-service/requirements.txt /speckle-server/ RUN /venv/bin/pip install --disable-pip-version-check --no-cache-dir --requirement /speckle-server/requirements.txt -FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as dependency-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 AS dependency-stage # installing just the production dependencies # separate stage to avoid including development dependencies ARG NODE_ENV @@ -65,9 +65,9 @@ COPY packages/fileimport-service/package.json ./packages/fileimport-service/ WORKDIR /speckle-server/packages/fileimport-service RUN yarn workspaces focus --production -FROM gcr.io/distroless/python3-debian12:nonroot@sha256:14c62b8925d3bb30319de2f346bde203fe18103a68898284a62db9d4aa54c794 as python-image +FROM gcr.io/distroless/python3-debian12:nonroot@sha256:14c62b8925d3bb30319de2f346bde203fe18103a68898284a62db9d4aa54c794 AS python-image -FROM gcr.io/distroless/nodejs22-debian12:nonroot@sha256:ed26b3ab750110c51d9dbdfd6c697561dc40a01c296460c3494d47b550ef4126 as distributable-stage +FROM gcr.io/distroless/nodejs22-debian12:nonroot@sha256:ed26b3ab750110c51d9dbdfd6c697561dc40a01c296460c3494d47b550ef4126 AS distributable-stage ARG NODE_ENV ENV NODE_ENV=${NODE_ENV} diff --git a/packages/frontend-2/Dockerfile b/packages/frontend-2/Dockerfile index 30cff7cb3..4f66af0f7 100644 --- a/packages/frontend-2/Dockerfile +++ b/packages/frontend-2/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as build-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 AS build-stage ARG NODE_ENV=production ARG SPECKLE_SERVER_VERSION=custom @@ -37,7 +37,7 @@ ENV TINI_VERSION v0.19.0 ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini RUN chmod +x /tini -FROM gcr.io/distroless/nodejs22-debian12:nonroot@sha256:ed26b3ab750110c51d9dbdfd6c697561dc40a01c296460c3494d47b550ef4126 as production-stage +FROM gcr.io/distroless/nodejs22-debian12:nonroot@sha256:ed26b3ab750110c51d9dbdfd6c697561dc40a01c296460c3494d47b550ef4126 AS production-stage ARG NODE_ENV=production ENV NODE_ENV=${NODE_ENV} diff --git a/packages/frontend/Dockerfile b/packages/frontend/Dockerfile index 67949c017..6dc01254d 100644 --- a/packages/frontend/Dockerfile +++ b/packages/frontend/Dockerfile @@ -2,7 +2,7 @@ ARG NODE_ENV=production ARG SPECKLE_SERVER_VERSION=custom # build stage -FROM node:18-bullseye-slim@sha256:8cc7dcd5aa06715247f8f2f258332f188d4221e2685b1a0159e4e6c3382e4918 as build-stage +FROM node:18-bullseye-slim@sha256:8cc7dcd5aa06715247f8f2f258332f188d4221e2685b1a0159e4e6c3382e4918 AS build-stage ARG NODE_ENV ARG SPECKLE_SERVER_VERSION @@ -39,7 +39,7 @@ RUN DEBIAN_FRONTEND=noninteractive \ && rm -rf /var/lib/apt/lists/* # production stage -FROM bitnami/openresty:1.21.4-3-debian-11-r3@sha256:456f29ba40fb4b5591ded0666c50c5026e3e0f97397440b9c5f2246813de9ec8 as production-stage +FROM bitnami/openresty:1.21.4-3-debian-11-r3@sha256:456f29ba40fb4b5591ded0666c50c5026e3e0f97397440b9c5f2246813de9ec8 AS production-stage ARG NODE_ENV ARG SPECKLE_SERVER_VERSION diff --git a/packages/preview-service/Dockerfile b/packages/preview-service/Dockerfile index 8780280cf..f90bc289a 100644 --- a/packages/preview-service/Dockerfile +++ b/packages/preview-service/Dockerfile @@ -1,7 +1,7 @@ # NOTE: Docker context should be set to git root directory, to include the viewer ARG NODE_ENV=production -FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as build-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 AS build-stage ARG NODE_ENV ENV NODE_ENV=${NODE_ENV} diff --git a/packages/server/Dockerfile b/packages/server/Dockerfile index aa24f603d..593290fbd 100644 --- a/packages/server/Dockerfile +++ b/packages/server/Dockerfile @@ -1,7 +1,7 @@ ARG NODE_ENV=production ARG SPECKLE_SERVER_VERSION=custom -FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as build-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 AS build-stage ARG NODE_ENV ARG SPECKLE_SERVER_VERSION WORKDIR /speckle-server @@ -39,7 +39,7 @@ RUN yarn workspaces foreach -W run build # install only production dependencies # we need a clean environment, free of build dependencies -FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as dependency-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 AS dependency-stage ARG NODE_ENV ARG SPECKLE_SERVER_VERSION @@ -56,7 +56,7 @@ COPY packages/objectloader/package.json ./packages/objectloader/ WORKDIR /speckle-server/packages/server RUN yarn workspaces focus --production -FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as production-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 AS production-stage ARG NODE_ENV ARG SPECKLE_SERVER_VERSION ARG FILE_SIZE_LIMIT_MB=100 diff --git a/packages/webhook-service/Dockerfile b/packages/webhook-service/Dockerfile index e32d19bb0..7b9a87960 100644 --- a/packages/webhook-service/Dockerfile +++ b/packages/webhook-service/Dockerfile @@ -1,6 +1,6 @@ ARG NODE_ENV=production -FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as build-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 AS build-stage ARG NODE_ENV ENV NODE_ENV=${NODE_ENV} @@ -32,7 +32,7 @@ ENV TINI_VERSION=${TINI_VERSION} ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini ./tini RUN chmod +x ./tini -FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as dependency-stage +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 AS dependency-stage # yarn install ARG NODE_ENV ENV NODE_ENV=${NODE_ENV} @@ -50,7 +50,7 @@ COPY packages/shared/package.json ./packages/shared/ WORKDIR /speckle-server/packages/webhook-service RUN yarn workspaces focus --production -FROM gcr.io/distroless/nodejs22-debian12:nonroot@sha256:ed26b3ab750110c51d9dbdfd6c697561dc40a01c296460c3494d47b550ef4126 as production-stage +FROM gcr.io/distroless/nodejs22-debian12:nonroot@sha256:ed26b3ab750110c51d9dbdfd6c697561dc40a01c296460c3494d47b550ef4126 AS production-stage ARG NODE_ENV ENV NODE_ENV=${NODE_ENV} diff --git a/utils/monitor-deployment/Dockerfile b/utils/monitor-deployment/Dockerfile index 361fd25be..441533ac9 100644 --- a/utils/monitor-deployment/Dockerfile +++ b/utils/monitor-deployment/Dockerfile @@ -19,7 +19,7 @@ RUN apt-get update && \ COPY utils/monitor-deployment/requirements.txt /requirements.txt RUN /venv/bin/pip install --disable-pip-version-check --requirement /requirements.txt -FROM gcr.io/distroless/python3-debian12:nonroot@sha256:14c62b8925d3bb30319de2f346bde203fe18103a68898284a62db9d4aa54c794 as production-stage +FROM gcr.io/distroless/python3-debian12:nonroot@sha256:14c62b8925d3bb30319de2f346bde203fe18103a68898284a62db9d4aa54c794 AS production-stage ARG PG_CONNECTION_STRING ARG NODE_EXTRA_CA_CERTS ENV PG_CONNECTION_STRING=${PG_CONNECTION_STRING} \ diff --git a/utils/test-deployment/Dockerfile b/utils/test-deployment/Dockerfile index 853e1f819..f70f13dad 100644 --- a/utils/test-deployment/Dockerfile +++ b/utils/test-deployment/Dockerfile @@ -9,7 +9,7 @@ RUN apt-get update && \ COPY utils/test-deployment/requirements.txt /requirements.txt RUN /venv/bin/pip install --disable-pip-version-check --requirement /requirements.txt -FROM gcr.io/distroless/python3-debian12:nonroot@sha256:14c62b8925d3bb30319de2f346bde203fe18103a68898284a62db9d4aa54c794 as production-stage +FROM gcr.io/distroless/python3-debian12:nonroot@sha256:14c62b8925d3bb30319de2f346bde203fe18103a68898284a62db9d4aa54c794 AS production-stage ARG SPECKLE_SERVER ARG SPECKLE_VERSION ENV SPECKLE_SERVER=${SPECKLE_SERVER} \ From efbdd16fdd29f0fe22ce4ea03c6065b1f55323d2 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Fri, 16 Aug 2024 12:42:23 +0100 Subject: [PATCH 004/178] small fixes to the docker file --- packages/frontend-2/Dockerfile | 2 +- packages/preview-service/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/frontend-2/Dockerfile b/packages/frontend-2/Dockerfile index 4f66af0f7..853fe3b5a 100644 --- a/packages/frontend-2/Dockerfile +++ b/packages/frontend-2/Dockerfile @@ -33,7 +33,7 @@ RUN yarn workspaces foreach -W run build # hadolint ignore=DL3059 RUN find ./packages/frontend-2/.output/ -type f \( -name "*.js.map" -o -name "*.mjs.map" -o -name "*.cjs.map" \) -exec rm -f {} \; -ENV TINI_VERSION v0.19.0 +ENV TINI_VERSION=v0.19.0 ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini RUN chmod +x /tini diff --git a/packages/preview-service/Dockerfile b/packages/preview-service/Dockerfile index f90bc289a..dd9d83828 100644 --- a/packages/preview-service/Dockerfile +++ b/packages/preview-service/Dockerfile @@ -36,7 +36,7 @@ COPY packages/preview-service ./packages/preview-service/ # This way the foreach only builds the frontend and its deps RUN yarn workspaces foreach -W run build -FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 as node +FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 AS node SHELL ["/bin/bash", "-o", "pipefail", "-c"] # hadolint ignore=DL3008,DL3015 From 0d430f870d23f2b21e2ebe8b58107b045ecd71e3 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Tue, 15 Oct 2024 15:44:12 +0100 Subject: [PATCH 005/178] Bump nix package --- .circleci/deployment/helm-chart-shell.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/deployment/helm-chart-shell.nix b/.circleci/deployment/helm-chart-shell.nix index 4c173903e..3ecb1a317 100644 --- a/.circleci/deployment/helm-chart-shell.nix +++ b/.circleci/deployment/helm-chart-shell.nix @@ -1,4 +1,4 @@ -{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/5b7cd5c39befee629be284970415b6eb3b0ff000.tar.gz") {} }: +{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/63dacb46bf939521bdc93981b4cbb7ecb58427a0.tar.gz") {} }: let corepack = pkgs.stdenv.mkDerivation { From 220b9bb5ac5b513aafbba09690f1185ae1e3f16c Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Tue, 18 Feb 2025 12:29:13 +0000 Subject: [PATCH 006/178] fix tailwind-theme engine version --- packages/tailwind-theme/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwind-theme/package.json b/packages/tailwind-theme/package.json index 7596ffb49..18392f190 100644 --- a/packages/tailwind-theme/package.json +++ b/packages/tailwind-theme/package.json @@ -34,7 +34,7 @@ "./fonts/*": "./fonts/*" }, "engines": { - "node": "^22.6.0" + "node": ">=18.19.0" }, "peerDependencies": { "postcss": "^8.4.18", From 6afad05430204c8833e5c421cb1ede60c9e8dec3 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Wed, 5 Mar 2025 14:21:50 +0000 Subject: [PATCH 007/178] refactor(blobstorage): separate REST components and services --- packages/server/modules/blobstorage/index.ts | 390 +----------------- .../modules/blobstorage/rest/errorHandler.ts | 27 ++ .../server/modules/blobstorage/rest/router.ts | 224 ++++++++++ .../modules/blobstorage/services/streams.ts | 168 ++++++++ packages/server/modules/fileuploads/index.ts | 9 +- .../modules/shared/helpers/envHelper.ts | 7 + 6 files changed, 442 insertions(+), 383 deletions(-) create mode 100644 packages/server/modules/blobstorage/rest/errorHandler.ts create mode 100644 packages/server/modules/blobstorage/rest/router.ts create mode 100644 packages/server/modules/blobstorage/services/streams.ts diff --git a/packages/server/modules/blobstorage/index.ts b/packages/server/modules/blobstorage/index.ts index fb6e05ca7..ea95f1eb6 100644 --- a/packages/server/modules/blobstorage/index.ts +++ b/packages/server/modules/blobstorage/index.ts @@ -1,397 +1,31 @@ -import Busboy from 'busboy' -import { - allowForAllRegisteredUsersOnPublicStreamsWithPublicComments, - allowForRegisteredUsersOnPublicStreamsEvenWithoutRole, - allowAnonymousUsersOnPublicStreams, - streamWritePermissionsPipelineFactory, - streamReadPermissionsPipelineFactory -} from '@/modules/shared/authz' -import crs from 'crypto-random-string' -import { authMiddlewareCreator } from '@/modules/shared/middleware' -import { isArray } from 'lodash' - -import { - NotFoundError, - ResourceMismatch, - BadRequestError -} from '@/modules/shared/errors' import { moduleLogger } from '@/observability/logging' import { - getAllStreamBlobIdsFactory, - upsertBlobFactory, - updateBlobFactory, - getBlobMetadataFactory, - getBlobMetadataCollectionFactory, - deleteBlobFactory -} from '@/modules/blobstorage/repositories' -import { db } from '@/db/knex' -import { - uploadFileStreamFactory, - getFileStreamFactory, - getFileSizeLimit, - markUploadSuccessFactory, - markUploadErrorFactory, - markUploadOverFileSizeLimitFactory, - fullyDeleteBlobFactory -} from '@/modules/blobstorage/services/management' -import { getRolesFactory } from '@/modules/shared/repositories/roles' -import { - adminOverrideEnabled, - createS3Bucket + createS3Bucket, + isFileUploadsEnabled } from '@/modules/shared/helpers/envHelper' -import { getStreamFactory } from '@/modules/core/repositories/streams' -import { Request, Response } from 'express' -import { ensureError } from '@speckle/shared' import { SpeckleModule } from '@/modules/shared/helpers/typeHelper' -import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' -import { - deleteObjectFactory, - ensureStorageAccessFactory, - getObjectAttributesFactory, - getObjectStreamFactory, - storeFileStreamFactory -} from '@/modules/blobstorage/repositories/blobs' +import { ensureStorageAccessFactory } from '@/modules/blobstorage/repositories/blobs' import { getMainObjectStorage } from '@/modules/blobstorage/clients/objectStorage' -import { getProjectObjectStorage } from '@/modules/multiregion/utils/blobStorageSelector' +import { blobStorageRouterFactory } from '@/modules/blobstorage/rest/router' const ensureConditions = async () => { - if (process.env.DISABLE_FILE_UPLOADS) { + if (!isFileUploadsEnabled()) { moduleLogger.info('๐Ÿ“ฆ Blob storage is DISABLED') return - } else { - moduleLogger.info('๐Ÿ“ฆ Init BlobStorage module') - const storage = getMainObjectStorage() - const ensureStorageAccess = ensureStorageAccessFactory({ storage }) - await ensureStorageAccess({ - createBucketIfNotExists: createS3Bucket() - }) } - if (!process.env.S3_BUCKET) { - moduleLogger.warn( - 'S3_BUCKET env variable was not specified. ๐Ÿ“ฆ BlobStorage will be DISABLED.' - ) - return - } -} - -type ErrorHandler = ( - req: Request, - res: Response, - callback: (req: Request, res: Response) => Promise -) => Promise -const errorHandler: ErrorHandler = async (req, res, callback) => { - try { - await callback(req, res) - } catch (err) { - if (err instanceof NotFoundError) { - res.status(404).send({ error: err.message }) - } else if (err instanceof ResourceMismatch || err instanceof BadRequestError) { - res.status(400).send({ error: err.message }) - } else { - res.status(500).send({ error: ensureError(err, 'Unknown error').message }) - } - } + moduleLogger.info('๐Ÿ“ฆ Init BlobStorage module') + const storage = getMainObjectStorage() + const ensureStorageAccess = ensureStorageAccessFactory({ storage }) + await ensureStorageAccess({ + createBucketIfNotExists: createS3Bucket() + }) } export const init: SpeckleModule['init'] = async (app) => { await ensureConditions() - const createStreamWritePermissions = () => - streamWritePermissionsPipelineFactory({ - getRoles: getRolesFactory({ db }), - getStream: getStreamFactory({ db }) - }) - const createStreamReadPermissions = () => - streamReadPermissionsPipelineFactory({ - adminOverrideEnabled, - getRoles: getRolesFactory({ db }), - getStream: getStreamFactory({ db }) - }) - app.post( - '/api/stream/:streamId/blob', - async (req, res, next) => { - await authMiddlewareCreator([ - ...createStreamWritePermissions(), - // todo should we add public comments upload escape hatch? - allowForAllRegisteredUsersOnPublicStreamsWithPublicComments - ])(req, res, next) - }, - async (req, res) => { - const streamId = req.params.streamId - req.log = req.log.child({ streamId, userId: req.context.userId }) - req.log.debug('Uploading blob.') - // no checking of startup conditions, just dont init the endpoints if not configured right - //authorize request - const uploadOperations: Record = {} - const finalizePromises: Promise<{ - uploadStatus?: number - uploadError?: Error | null | string - formKey: string - }>[] = [] - let busboy: Busboy.Busboy - try { - // Busboy does some validation of user input (headers) on creation - busboy = Busboy({ - headers: req.headers, - limits: { fileSize: getFileSizeLimit() } - }) - } catch (err) { - throw new BadRequestError( - err instanceof Error ? err.message : 'Error while uploading blob', - ensureError(err, 'Unknown error while uploading blob') - ) - } - - const [projectDb, projectStorage] = await Promise.all([ - getProjectDbClient({ projectId: streamId }), - getProjectObjectStorage({ projectId: streamId }) - ]) - - const storeFileStream = storeFileStreamFactory({ storage: projectStorage }) - const updateBlob = updateBlobFactory({ db: projectDb }) - const getBlobMetadata = getBlobMetadataFactory({ db: projectDb }) - - const uploadFileStream = uploadFileStreamFactory({ - storeFileStream, - upsertBlob: upsertBlobFactory({ db: projectDb }), - updateBlob - }) - - const markUploadSuccess = markUploadSuccessFactory({ - getBlobMetadata, - updateBlob - }) - const markUploadError = markUploadErrorFactory({ getBlobMetadata, updateBlob }) - const markUploadOverFileSizeLimit = markUploadOverFileSizeLimitFactory({ - getBlobMetadata, - updateBlob - }) - - const getObjectAttributes = getObjectAttributesFactory({ - storage: projectStorage - }) - const deleteObject = deleteObjectFactory({ storage: projectStorage }) - - busboy.on('file', (formKey, file, info) => { - const { filename: fileName } = info - const fileType = fileName?.split('.')?.pop()?.toLowerCase() - req.log = req.log.child({ fileName, fileType }) - const registerUploadResult = ( - processingPromise: Promise<{ - uploadStatus?: number - uploadError?: Error | null | string - }> - ) => { - finalizePromises.push( - processingPromise.then((resultItem) => ({ ...resultItem, formKey })) - ) - } - - let blobId = crs({ length: 10 }) - let clientHash = null - if (formKey.includes('hash:')) { - clientHash = formKey.split(':')[1] - if (clientHash && clientHash !== '') { - // logger.debug(`I have a client hash (${clientHash})`) - blobId = clientHash - } - } - - req.log = req.log.child({ blobId }) - - uploadOperations[blobId] = uploadFileStream( - { streamId, userId: req.context.userId }, - { blobId, fileName, fileType, fileStream: file } - ) - - //this file level 'close' is fired when a single file upload finishes - //this way individual upload statuses can be updated, when done - file.on('close', async () => { - //this is handled by the file.on('limit', ...) event - if (file.truncated) return - await uploadOperations[blobId] - - registerUploadResult(markUploadSuccess(getObjectAttributes, streamId, blobId)) - }) - - file.on('limit', async () => { - await uploadOperations[blobId] - registerUploadResult( - markUploadOverFileSizeLimit(deleteObject, streamId, blobId) - ) - }) - - file.on('error', (err) => { - registerUploadResult( - markUploadError(deleteObject, streamId, blobId, err.message) - ) - }) - }) - - busboy.on('finish', async () => { - // make sure all upload operations have been awaited, - // otherwise the finish even can fire before all async operations finish - //resulting in missing return values - await Promise.all(Object.values(uploadOperations)) - // have to make sure all finalize promises have been awaited - const uploadResults = await Promise.all(finalizePromises) - res.status(201).send({ uploadResults }) - }) - - busboy.on('error', async (err) => { - req.log.info({ err }, 'Upload request error.') - //delete all started uploads - await Promise.all( - Object.keys(uploadOperations).map((blobId) => - markUploadError( - deleteObject, - streamId, - blobId, - ensureError(err, 'Unknown error while uploading blob').message - ) - ) - ) - - res.contentType('application/json') - res - .status(400) - .end( - '{ "error": "Upload request error. The server logs may have more details." }' - ) - }) - - req.pipe(busboy) - } - ) - - app.post( - '/api/stream/:streamId/blob/diff', - async (req, res, next) => { - await authMiddlewareCreator([ - ...createStreamReadPermissions(), - allowForAllRegisteredUsersOnPublicStreamsWithPublicComments, - allowForRegisteredUsersOnPublicStreamsEvenWithoutRole, - allowAnonymousUsersOnPublicStreams - ])(req, res, next) - }, - async (req, res) => { - if (!isArray(req.body)) { - return res - .status(400) - .json({ error: 'An array of blob IDs expected in the body.' }) - } - - const projectDb = await getProjectDbClient({ projectId: req.params.streamId }) - - const getAllStreamBlobIds = getAllStreamBlobIdsFactory({ db: projectDb }) - const bq = await getAllStreamBlobIds({ streamId: req.params.streamId }) - const unknownBlobIds = [...req.body].filter( - (id) => bq.findIndex((bInfo) => bInfo.id === id) === -1 - ) - res.send(unknownBlobIds) - } - ) - - app.get( - '/api/stream/:streamId/blob/:blobId', - async (req, res, next) => { - await authMiddlewareCreator([ - ...createStreamReadPermissions(), - allowForAllRegisteredUsersOnPublicStreamsWithPublicComments, - allowForRegisteredUsersOnPublicStreamsEvenWithoutRole, - allowAnonymousUsersOnPublicStreams - ])(req, res, next) - }, - async (req, res) => { - errorHandler(req, res, async (req, res) => { - const streamId = req.params.streamId - const [projectDb, projectStorage] = await Promise.all([ - getProjectDbClient({ projectId: streamId }), - getProjectObjectStorage({ projectId: streamId }) - ]) - - const getBlobMetadata = getBlobMetadataFactory({ db: projectDb }) - const getFileStream = getFileStreamFactory({ getBlobMetadata }) - const getObjectStream = getObjectStreamFactory({ storage: projectStorage }) - - const { fileName } = await getBlobMetadata({ - streamId: req.params.streamId, - blobId: req.params.blobId - }) - const fileStream = await getFileStream({ - getObjectStream, - streamId: req.params.streamId, - blobId: req.params.blobId - }) - res.writeHead(200, { - 'Content-Type': 'application/octet-stream', - 'Content-Disposition': `attachment; filename="${fileName}"` - }) - fileStream.pipe(res) - }) - } - ) - - app.delete( - '/api/stream/:streamId/blob/:blobId', - async (req, res, next) => { - await authMiddlewareCreator(createStreamReadPermissions())(req, res, next) - }, - async (req, res) => { - errorHandler(req, res, async (req, res) => { - const streamId = req.params.streamId - const [projectDb, projectStorage] = await Promise.all([ - getProjectDbClient({ projectId: streamId }), - getProjectObjectStorage({ projectId: streamId }) - ]) - - const getBlobMetadata = getBlobMetadataFactory({ db: projectDb }) - const deleteBlob = fullyDeleteBlobFactory({ - getBlobMetadata, - deleteBlob: deleteBlobFactory({ db: projectDb }) - }) - const deleteObject = deleteObjectFactory({ storage: projectStorage }) - - await deleteBlob({ - streamId: req.params.streamId, - blobId: req.params.blobId, - deleteObject - }) - res.status(204).send() - }) - } - ) - - app.get( - '/api/stream/:streamId/blobs', - async (req, res, next) => { - await authMiddlewareCreator(createStreamReadPermissions())(req, res, next) - }, - async (req, res) => { - let fileName = req.query.fileName - if (isArray(fileName)) { - fileName = fileName[0] - } - - const projectDb = await getProjectDbClient({ projectId: req.params.streamId }) - const getBlobMetadataCollection = getBlobMetadataCollectionFactory({ - db: projectDb - }) - errorHandler(req, res, async (req, res) => { - const blobMetadataCollection = await getBlobMetadataCollection({ - streamId: req.params.streamId, - query: fileName as string - }) - - res.status(200).send(blobMetadataCollection) - }) - } - ) - - app.delete('/api/stream/:streamId/blobs', async (req, res) => { - res.status(501).send('This method is not implemented yet.') - }) + app.use(blobStorageRouterFactory()) } export const finalize: SpeckleModule['finalize'] = () => {} diff --git a/packages/server/modules/blobstorage/rest/errorHandler.ts b/packages/server/modules/blobstorage/rest/errorHandler.ts new file mode 100644 index 000000000..944dd27af --- /dev/null +++ b/packages/server/modules/blobstorage/rest/errorHandler.ts @@ -0,0 +1,27 @@ +import type { Request, Response } from 'express' +import { + NotFoundError, + ResourceMismatch, + BadRequestError +} from '@/modules/shared/errors' +import { ensureError } from '@speckle/shared' + +type ErrorHandler = ( + req: Request, + res: Response, + callback: (req: Request, res: Response) => Promise +) => Promise +export const errorHandler: ErrorHandler = async (req, res, callback) => { + try { + await callback(req, res) + } catch (err) { + //TODO we can probably delegate to the default error handler, but need to verify where this is called and whether we can refactor the callbacks + if (err instanceof NotFoundError) { + res.status(404).send({ error: err.message }) + } else if (err instanceof ResourceMismatch || err instanceof BadRequestError) { + res.status(400).send({ error: err.message }) + } else { + res.status(500).send({ error: ensureError(err, 'Unknown error').message }) + } + } +} diff --git a/packages/server/modules/blobstorage/rest/router.ts b/packages/server/modules/blobstorage/rest/router.ts new file mode 100644 index 000000000..c524ad314 --- /dev/null +++ b/packages/server/modules/blobstorage/rest/router.ts @@ -0,0 +1,224 @@ +import Busboy from 'busboy' +import { + allowForAllRegisteredUsersOnPublicStreamsWithPublicComments, + allowForRegisteredUsersOnPublicStreamsEvenWithoutRole, + allowAnonymousUsersOnPublicStreams, + streamWritePermissionsPipelineFactory, + streamReadPermissionsPipelineFactory +} from '@/modules/shared/authz' +import { authMiddlewareCreator } from '@/modules/shared/middleware' +import { isArray } from 'lodash' +import { BadRequestError, UnauthorizedError } from '@/modules/shared/errors' +import { + getAllStreamBlobIdsFactory, + getBlobMetadataFactory, + getBlobMetadataCollectionFactory, + deleteBlobFactory +} from '@/modules/blobstorage/repositories' +import { db } from '@/db/knex' +import { + getFileStreamFactory, + getFileSizeLimit, + fullyDeleteBlobFactory +} from '@/modules/blobstorage/services/management' +import { getRolesFactory } from '@/modules/shared/repositories/roles' +import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper' +import { Router } from 'express' +import { getProjectObjectStorage } from '@/modules/multiregion/utils/blobStorageSelector' +import { + deleteObjectFactory, + getObjectStreamFactory +} from '@/modules/blobstorage/repositories/blobs' +import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' +import { getStreamFactory } from '@/modules/core/repositories/streams' +import { ensureError } from '@speckle/shared' +import { errorHandler } from '@/modules/blobstorage/rest/errorHandler' +import { processNewFileStreamFactory } from '@/modules/blobstorage/services/streams' + +export const blobStorageRouterFactory = (): Router => { + const createStreamWritePermissions = () => + streamWritePermissionsPipelineFactory({ + getRoles: getRolesFactory({ db }), + getStream: getStreamFactory({ db }) + }) + const createStreamReadPermissions = () => + streamReadPermissionsPipelineFactory({ + adminOverrideEnabled, + getRoles: getRolesFactory({ db }), + getStream: getStreamFactory({ db }) + }) + + const processNewFileStream = processNewFileStreamFactory() + + const app = Router() + + app.post( + '/api/stream/:streamId/blob', + authMiddlewareCreator([ + ...createStreamWritePermissions(), + // todo should we add public comments upload escape hatch? + allowForAllRegisteredUsersOnPublicStreamsWithPublicComments + ]), + async (req, res) => { + const streamId = req.params.streamId + const userId = req.context.userId + if (!userId) throw new UnauthorizedError() + req.log = req.log.child({ streamId, userId }) + req.log.debug('Uploading blob.') + + let busboy: Busboy.Busboy + try { + // Busboy does some validation of user input (headers) on creation + busboy = Busboy({ + headers: req.headers, + limits: { fileSize: getFileSizeLimit() } + }) + } catch (err) { + throw new BadRequestError( + err instanceof Error ? err.message : 'Error while uploading blob', + ensureError(err, 'Unknown error while uploading blob') + ) + } + const newFileStreamProcessor = await processNewFileStream({ + writeable: busboy, + streamId, + userId, + logger: req.log, + onFinishAllFileUploads: (uploadResults) => { + res.status(201).send({ uploadResults }) + }, + onError: () => { + res.contentType('application/json') + res + .status(400) + .end( + '{ "error": "Upload request error. The server logs may have more details." }' + ) + } + }) + req.pipe(newFileStreamProcessor) + } + ) + + app.post( + '/api/stream/:streamId/blob/diff', + authMiddlewareCreator([ + ...createStreamReadPermissions(), + allowForAllRegisteredUsersOnPublicStreamsWithPublicComments, + allowForRegisteredUsersOnPublicStreamsEvenWithoutRole, + allowAnonymousUsersOnPublicStreams + ]), + async (req, res) => { + if (!isArray(req.body)) { + return res + .status(400) + .json({ error: 'An array of blob IDs expected in the body.' }) + } + + const projectDb = await getProjectDbClient({ projectId: req.params.streamId }) + + const getAllStreamBlobIds = getAllStreamBlobIdsFactory({ db: projectDb }) + const bq = await getAllStreamBlobIds({ streamId: req.params.streamId }) + const unknownBlobIds = [...req.body].filter( + (id) => bq.findIndex((bInfo) => bInfo.id === id) === -1 + ) + res.send(unknownBlobIds) + } + ) + + app.get( + '/api/stream/:streamId/blob/:blobId', + authMiddlewareCreator([ + ...createStreamReadPermissions(), + allowForAllRegisteredUsersOnPublicStreamsWithPublicComments, + allowForRegisteredUsersOnPublicStreamsEvenWithoutRole, + allowAnonymousUsersOnPublicStreams + ]), + async (req, res) => { + errorHandler(req, res, async (req, res) => { + const streamId = req.params.streamId + const [projectDb, projectStorage] = await Promise.all([ + getProjectDbClient({ projectId: streamId }), + getProjectObjectStorage({ projectId: streamId }) + ]) + + const getBlobMetadata = getBlobMetadataFactory({ db: projectDb }) + const getFileStream = getFileStreamFactory({ getBlobMetadata }) + const getObjectStream = getObjectStreamFactory({ storage: projectStorage }) + + const { fileName } = await getBlobMetadata({ + streamId: req.params.streamId, + blobId: req.params.blobId + }) + const fileStream = await getFileStream({ + getObjectStream, + streamId: req.params.streamId, + blobId: req.params.blobId + }) + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${fileName}"` + }) + fileStream.pipe(res) + }) + } + ) + + app.delete( + '/api/stream/:streamId/blob/:blobId', + authMiddlewareCreator(createStreamReadPermissions()), + async (req, res) => { + errorHandler(req, res, async (req, res) => { + const streamId = req.params.streamId + const [projectDb, projectStorage] = await Promise.all([ + getProjectDbClient({ projectId: streamId }), + getProjectObjectStorage({ projectId: streamId }) + ]) + + const getBlobMetadata = getBlobMetadataFactory({ db: projectDb }) + const deleteBlob = fullyDeleteBlobFactory({ + getBlobMetadata, + deleteBlob: deleteBlobFactory({ db: projectDb }) + }) + const deleteObject = deleteObjectFactory({ storage: projectStorage }) + + await deleteBlob({ + streamId: req.params.streamId, + blobId: req.params.blobId, + deleteObject + }) + res.status(204).send() + }) + } + ) + + app.get( + '/api/stream/:streamId/blobs', + authMiddlewareCreator(createStreamReadPermissions()), + async (req, res) => { + let fileName = req.query.fileName + if (isArray(fileName)) { + fileName = fileName[0] + } + + const projectDb = await getProjectDbClient({ projectId: req.params.streamId }) + const getBlobMetadataCollection = getBlobMetadataCollectionFactory({ + db: projectDb + }) + errorHandler(req, res, async (req, res) => { + const blobMetadataCollection = await getBlobMetadataCollection({ + streamId: req.params.streamId, + query: fileName as string + }) + + res.status(200).send(blobMetadataCollection) + }) + } + ) + + app.delete('/api/stream/:streamId/blobs', async (_req, res) => { + res.status(501).send('This method is not implemented yet.') + }) + + return app +} diff --git a/packages/server/modules/blobstorage/services/streams.ts b/packages/server/modules/blobstorage/services/streams.ts new file mode 100644 index 000000000..b226b2b70 --- /dev/null +++ b/packages/server/modules/blobstorage/services/streams.ts @@ -0,0 +1,168 @@ +import crs from 'crypto-random-string' +import { + upsertBlobFactory, + updateBlobFactory, + getBlobMetadataFactory +} from '@/modules/blobstorage/repositories' +import { + uploadFileStreamFactory, + markUploadSuccessFactory, + markUploadErrorFactory, + markUploadOverFileSizeLimitFactory +} from '@/modules/blobstorage/services/management' +import { + deleteObjectFactory, + getObjectAttributesFactory, + storeFileStreamFactory +} from '@/modules/blobstorage/repositories/blobs' +import { ensureError } from '@speckle/shared' +import { getProjectObjectStorage } from '@/modules/multiregion/utils/blobStorageSelector' +import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' +import type { Logger } from '@/observability/logging' +import type { Writable } from 'stream' +import { get } from 'lodash' + +type NewFileStreamProcessor = (params: { + writeable: Writable + streamId: string + userId: string + onFinishAllFileUploads: (results: UploadResult[]) => void + onError: (err: unknown) => void + logger: Logger +}) => Promise + +type UploadResult = { + uploadStatus?: number + uploadError?: Error | null | string + formKey: string +} + +export const processNewFileStreamFactory = (): NewFileStreamProcessor => { + return async (params) => { + const { writeable, streamId, userId, onFinishAllFileUploads, onError } = params + let { logger } = params + const uploadOperations: Record = {} + const finalizePromises: Promise<{ + uploadStatus?: number + uploadError?: Error | null | string + formKey: string + }>[] = [] + + const [projectDb, projectStorage] = await Promise.all([ + getProjectDbClient({ projectId: streamId }), + getProjectObjectStorage({ projectId: streamId }) + ]) + + const storeFileStream = storeFileStreamFactory({ storage: projectStorage }) + const updateBlob = updateBlobFactory({ db: projectDb }) + const getBlobMetadata = getBlobMetadataFactory({ db: projectDb }) + + const uploadFileStream = uploadFileStreamFactory({ + storeFileStream, + upsertBlob: upsertBlobFactory({ db: projectDb }), + updateBlob + }) + + const markUploadSuccess = markUploadSuccessFactory({ + getBlobMetadata, + updateBlob + }) + const markUploadError = markUploadErrorFactory({ getBlobMetadata, updateBlob }) + const markUploadOverFileSizeLimit = markUploadOverFileSizeLimitFactory({ + getBlobMetadata, + updateBlob + }) + + const getObjectAttributes = getObjectAttributesFactory({ + storage: projectStorage + }) + const deleteObject = deleteObjectFactory({ storage: projectStorage }) + + writeable.on('file', (formKey, file, info) => { + const { filename: fileName } = info + const fileType = fileName?.split('.')?.pop()?.toLowerCase() + logger = logger.child({ fileName, fileType }) + const registerUploadResult = ( + processingPromise: Promise<{ + uploadStatus?: number + uploadError?: Error | null | string + }> + ) => { + finalizePromises.push( + processingPromise.then((resultItem) => ({ ...resultItem, formKey })) + ) + } + + let blobId = crs({ length: 10 }) + let clientHash = null + if (formKey.includes('hash:')) { + clientHash = formKey.split(':')[1] + if (clientHash && clientHash !== '') { + // logger.debug(`I have a client hash (${clientHash})`) + blobId = clientHash + } + } + + logger = logger.child({ blobId }) + + uploadOperations[blobId] = uploadFileStream( + { streamId, userId }, + { blobId, fileName, fileType, fileStream: file } + ) + + //this file level 'close' is fired when a single file upload finishes + //this way individual upload statuses can be updated, when done + file.on('close', async () => { + //this is handled by the file.on('limit', ...) event + if (file.truncated) return + await uploadOperations[blobId] + + registerUploadResult(markUploadSuccess(getObjectAttributes, streamId, blobId)) + }) + + file.on('limit', async () => { + await uploadOperations[blobId] + registerUploadResult( + markUploadOverFileSizeLimit(deleteObject, streamId, blobId) + ) + }) + + file.on('error', (err: unknown) => { + registerUploadResult( + markUploadError(deleteObject, streamId, blobId, get(err, 'message')) + ) + }) + }) + + writeable.on('finish', async () => { + // make sure all upload operations have been awaited, + // otherwise the finish even can fire before all async operations finish + //resulting in missing return values + await Promise.all(Object.values(uploadOperations)) + // have to make sure all finalize promises have been awaited + const uploadResults = await Promise.all(finalizePromises) + onFinishAllFileUploads(uploadResults) + return + }) + + writeable.on('error', async (err) => { + logger.info({ err }, 'Upload request error.') + //delete all started uploads + await Promise.all( + Object.keys(uploadOperations).map((blobId) => + markUploadError( + deleteObject, + streamId, + blobId, + ensureError(err, 'Unknown error while uploading blob').message + ) + ) + ) + + onError(err) + return + }) + + return writeable + } +} diff --git a/packages/server/modules/fileuploads/index.ts b/packages/server/modules/fileuploads/index.ts index add682414..ba81c07df 100644 --- a/packages/server/modules/fileuploads/index.ts +++ b/packages/server/modules/fileuploads/index.ts @@ -19,18 +19,17 @@ import { streamWritePermissionsPipelineFactory } from '@/modules/shared/authz' import { getRolesFactory } from '@/modules/shared/repositories/roles' import { getStreamBranchByNameFactory } from '@/modules/core/repositories/branches' import { getStreamFactory } from '@/modules/core/repositories/streams' -import { getPort } from '@/modules/shared/helpers/envHelper' +import { getPort, isFileUploadsEnabled } from '@/modules/shared/helpers/envHelper' import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' import { listenFor } from '@/modules/core/utils/dbNotificationListener' import { getEventBus } from '@/modules/shared/services/eventBus' export const init: SpeckleModule['init'] = async (app, isInitial) => { - if (process.env.DISABLE_FILE_UPLOADS) { + if (!isFileUploadsEnabled()) { moduleLogger.warn('๐Ÿ“„ FileUploads module is DISABLED') return - } else { - moduleLogger.info('๐Ÿ“„ Init FileUploads module') } + moduleLogger.info('๐Ÿ“„ Init FileUploads module') app.post( '/api/file/:fileType/:streamId/:branchName?', @@ -85,7 +84,7 @@ export const init: SpeckleModule['init'] = async (app, isInitial) => { }) ) } - //TODO refactor packages/server/modules/blobstorage/index.js to use the service pattern, and then refactor this to call the service directly from here without the http overhead + //TODO refactor packages/server/modules/blobstorage/index.ts to use the service pattern, and then refactor this to call the service directly from here without the http overhead const pipedReq = request( // we call this same server on localhost (IPv4) to upload the blob and do not make an external call `http://127.0.0.1:${getPort()}/api/stream/${req.params.streamId}/blob`, diff --git a/packages/server/modules/shared/helpers/envHelper.ts b/packages/server/modules/shared/helpers/envHelper.ts index 72602e43b..6038720a6 100644 --- a/packages/server/modules/shared/helpers/envHelper.ts +++ b/packages/server/modules/shared/helpers/envHelper.ts @@ -379,6 +379,13 @@ export function maximumObjectUploadFileSizeMb() { return getIntFromEnv('MAX_OBJECT_UPLOAD_FILE_SIZE_MB', '100') } +export function isFileUploadsEnabled() { + // the env var should ideally be written as a positive + // (e.g. ENABLE_FILE_UPLOADS), + // but for legacy reasons is the negation. + return !getBooleanFromEnv('DISABLE_FILE_UPLOADS', false) +} + export function getS3AccessKey() { return getStringFromEnv('S3_ACCESS_KEY') } From 2d73a75cf839cd02556110345cb0505fe1d250eb Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Wed, 5 Mar 2025 14:30:13 +0000 Subject: [PATCH 008/178] custom error handler can be removed as functionality is already provided by our existing middleware --- .../modules/blobstorage/rest/errorHandler.ts | 27 ------ .../server/modules/blobstorage/rest/router.ts | 94 +++++++++---------- 2 files changed, 44 insertions(+), 77 deletions(-) delete mode 100644 packages/server/modules/blobstorage/rest/errorHandler.ts diff --git a/packages/server/modules/blobstorage/rest/errorHandler.ts b/packages/server/modules/blobstorage/rest/errorHandler.ts deleted file mode 100644 index 944dd27af..000000000 --- a/packages/server/modules/blobstorage/rest/errorHandler.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Request, Response } from 'express' -import { - NotFoundError, - ResourceMismatch, - BadRequestError -} from '@/modules/shared/errors' -import { ensureError } from '@speckle/shared' - -type ErrorHandler = ( - req: Request, - res: Response, - callback: (req: Request, res: Response) => Promise -) => Promise -export const errorHandler: ErrorHandler = async (req, res, callback) => { - try { - await callback(req, res) - } catch (err) { - //TODO we can probably delegate to the default error handler, but need to verify where this is called and whether we can refactor the callbacks - if (err instanceof NotFoundError) { - res.status(404).send({ error: err.message }) - } else if (err instanceof ResourceMismatch || err instanceof BadRequestError) { - res.status(400).send({ error: err.message }) - } else { - res.status(500).send({ error: ensureError(err, 'Unknown error').message }) - } - } -} diff --git a/packages/server/modules/blobstorage/rest/router.ts b/packages/server/modules/blobstorage/rest/router.ts index c524ad314..cdf40a6c7 100644 --- a/packages/server/modules/blobstorage/rest/router.ts +++ b/packages/server/modules/blobstorage/rest/router.ts @@ -32,7 +32,6 @@ import { import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' import { getStreamFactory } from '@/modules/core/repositories/streams' import { ensureError } from '@speckle/shared' -import { errorHandler } from '@/modules/blobstorage/rest/errorHandler' import { processNewFileStreamFactory } from '@/modules/blobstorage/services/streams' export const blobStorageRouterFactory = (): Router => { @@ -135,32 +134,30 @@ export const blobStorageRouterFactory = (): Router => { allowAnonymousUsersOnPublicStreams ]), async (req, res) => { - errorHandler(req, res, async (req, res) => { - const streamId = req.params.streamId - const [projectDb, projectStorage] = await Promise.all([ - getProjectDbClient({ projectId: streamId }), - getProjectObjectStorage({ projectId: streamId }) - ]) + const streamId = req.params.streamId + const [projectDb, projectStorage] = await Promise.all([ + getProjectDbClient({ projectId: streamId }), + getProjectObjectStorage({ projectId: streamId }) + ]) - const getBlobMetadata = getBlobMetadataFactory({ db: projectDb }) - const getFileStream = getFileStreamFactory({ getBlobMetadata }) - const getObjectStream = getObjectStreamFactory({ storage: projectStorage }) + const getBlobMetadata = getBlobMetadataFactory({ db: projectDb }) + const getFileStream = getFileStreamFactory({ getBlobMetadata }) + const getObjectStream = getObjectStreamFactory({ storage: projectStorage }) - const { fileName } = await getBlobMetadata({ - streamId: req.params.streamId, - blobId: req.params.blobId - }) - const fileStream = await getFileStream({ - getObjectStream, - streamId: req.params.streamId, - blobId: req.params.blobId - }) - res.writeHead(200, { - 'Content-Type': 'application/octet-stream', - 'Content-Disposition': `attachment; filename="${fileName}"` - }) - fileStream.pipe(res) + const { fileName } = await getBlobMetadata({ + streamId: req.params.streamId, + blobId: req.params.blobId }) + const fileStream = await getFileStream({ + getObjectStream, + streamId: req.params.streamId, + blobId: req.params.blobId + }) + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${fileName}"` + }) + fileStream.pipe(res) } ) @@ -168,27 +165,25 @@ export const blobStorageRouterFactory = (): Router => { '/api/stream/:streamId/blob/:blobId', authMiddlewareCreator(createStreamReadPermissions()), async (req, res) => { - errorHandler(req, res, async (req, res) => { - const streamId = req.params.streamId - const [projectDb, projectStorage] = await Promise.all([ - getProjectDbClient({ projectId: streamId }), - getProjectObjectStorage({ projectId: streamId }) - ]) + const streamId = req.params.streamId + const [projectDb, projectStorage] = await Promise.all([ + getProjectDbClient({ projectId: streamId }), + getProjectObjectStorage({ projectId: streamId }) + ]) - const getBlobMetadata = getBlobMetadataFactory({ db: projectDb }) - const deleteBlob = fullyDeleteBlobFactory({ - getBlobMetadata, - deleteBlob: deleteBlobFactory({ db: projectDb }) - }) - const deleteObject = deleteObjectFactory({ storage: projectStorage }) - - await deleteBlob({ - streamId: req.params.streamId, - blobId: req.params.blobId, - deleteObject - }) - res.status(204).send() + const getBlobMetadata = getBlobMetadataFactory({ db: projectDb }) + const deleteBlob = fullyDeleteBlobFactory({ + getBlobMetadata, + deleteBlob: deleteBlobFactory({ db: projectDb }) }) + const deleteObject = deleteObjectFactory({ storage: projectStorage }) + + await deleteBlob({ + streamId: req.params.streamId, + blobId: req.params.blobId, + deleteObject + }) + res.status(204).send() } ) @@ -205,19 +200,18 @@ export const blobStorageRouterFactory = (): Router => { const getBlobMetadataCollection = getBlobMetadataCollectionFactory({ db: projectDb }) - errorHandler(req, res, async (req, res) => { - const blobMetadataCollection = await getBlobMetadataCollection({ - streamId: req.params.streamId, - query: fileName as string - }) - res.status(200).send(blobMetadataCollection) + const blobMetadataCollection = await getBlobMetadataCollection({ + streamId: req.params.streamId, + query: fileName as string }) + + return res.status(200).send(blobMetadataCollection) } ) app.delete('/api/stream/:streamId/blobs', async (_req, res) => { - res.status(501).send('This method is not implemented yet.') + return res.status(501).send('This method is not implemented yet.') }) return app From 334a7e4988cbd51721390196720ac2f0f903d608 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Wed, 5 Mar 2025 15:02:18 +0000 Subject: [PATCH 009/178] Tidy deletion endpoint --- .../modules/blobstorage/domain/operations.ts | 5 +++++ .../server/modules/blobstorage/rest/router.ts | 19 ++++++++++--------- .../blobstorage/services/management.ts | 17 +++++++---------- .../blobstorage/tests/blobstorage.spec.ts | 7 ++++--- 4 files changed, 26 insertions(+), 22 deletions(-) diff --git a/packages/server/modules/blobstorage/domain/operations.ts b/packages/server/modules/blobstorage/domain/operations.ts index 8b90d3c01..853cf58b4 100644 --- a/packages/server/modules/blobstorage/domain/operations.ts +++ b/packages/server/modules/blobstorage/domain/operations.ts @@ -21,6 +21,11 @@ export type UpdateBlob = (params: { export type DeleteBlob = (params: { id: string; streamId?: string }) => Promise +export type DeleteBlobAndAssociatedObject = (params: { + blobId: string + streamId: string +}) => Promise + export type GetBlobMetadata = (params: { blobId: string streamId: string diff --git a/packages/server/modules/blobstorage/rest/router.ts b/packages/server/modules/blobstorage/rest/router.ts index cdf40a6c7..273afe414 100644 --- a/packages/server/modules/blobstorage/rest/router.ts +++ b/packages/server/modules/blobstorage/rest/router.ts @@ -33,6 +33,7 @@ import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' import { getStreamFactory } from '@/modules/core/repositories/streams' import { ensureError } from '@speckle/shared' import { processNewFileStreamFactory } from '@/modules/blobstorage/services/streams' +import { UserInputError } from '@/modules/core/errors/userinput' export const blobStorageRouterFactory = (): Router => { const createStreamWritePermissions = () => @@ -109,9 +110,7 @@ export const blobStorageRouterFactory = (): Router => { ]), async (req, res) => { if (!isArray(req.body)) { - return res - .status(400) - .json({ error: 'An array of blob IDs expected in the body.' }) + throw new UserInputError('An array of blob IDs expected in the body.') } const projectDb = await getProjectDbClient({ projectId: req.params.streamId }) @@ -172,16 +171,16 @@ export const blobStorageRouterFactory = (): Router => { ]) const getBlobMetadata = getBlobMetadataFactory({ db: projectDb }) + const deleteObject = deleteObjectFactory({ storage: projectStorage }) const deleteBlob = fullyDeleteBlobFactory({ getBlobMetadata, - deleteBlob: deleteBlobFactory({ db: projectDb }) + deleteBlob: deleteBlobFactory({ db: projectDb }), + deleteObject }) - const deleteObject = deleteObjectFactory({ storage: projectStorage }) await deleteBlob({ streamId: req.params.streamId, - blobId: req.params.blobId, - deleteObject + blobId: req.params.blobId }) res.status(204).send() } @@ -191,18 +190,20 @@ export const blobStorageRouterFactory = (): Router => { '/api/stream/:streamId/blobs', authMiddlewareCreator(createStreamReadPermissions()), async (req, res) => { - let fileName = req.query.fileName + let fileName = req.query.fileName //filename can be undefined or null, and that returns all blobs if (isArray(fileName)) { fileName = fileName[0] } + const streamId = req.params.streamId + const projectDb = await getProjectDbClient({ projectId: req.params.streamId }) const getBlobMetadataCollection = getBlobMetadataCollectionFactory({ db: projectDb }) const blobMetadataCollection = await getBlobMetadataCollection({ - streamId: req.params.streamId, + streamId, query: fileName as string }) diff --git a/packages/server/modules/blobstorage/services/management.ts b/packages/server/modules/blobstorage/services/management.ts index 60b46a2a9..bf9499ab0 100644 --- a/packages/server/modules/blobstorage/services/management.ts +++ b/packages/server/modules/blobstorage/services/management.ts @@ -1,5 +1,6 @@ import { DeleteBlob, + DeleteBlobAndAssociatedObject, GetBlobMetadata, StoreFileStream, UpdateBlob, @@ -137,20 +138,16 @@ export const markUploadOverFileSizeLimitFactory = } export const fullyDeleteBlobFactory = - (deps: { getBlobMetadata: GetBlobMetadata; deleteBlob: DeleteBlob }) => - async ({ - streamId, - blobId, - deleteObject - }: { - streamId: string - blobId: string + (deps: { + getBlobMetadata: GetBlobMetadata + deleteBlob: DeleteBlob deleteObject: (params: ObjectKeyPayload) => MaybeAsync - }) => { + }): DeleteBlobAndAssociatedObject => + async ({ streamId, blobId }) => { const { objectKey } = await deps.getBlobMetadata({ streamId, blobId }) - await deleteObject({ objectKey: objectKey! }) + await deps.deleteObject({ objectKey: objectKey! }) await deps.deleteBlob({ id: blobId, streamId }) } diff --git a/packages/server/modules/blobstorage/tests/blobstorage.spec.ts b/packages/server/modules/blobstorage/tests/blobstorage.spec.ts index 31de237ea..48af8ccb4 100644 --- a/packages/server/modules/blobstorage/tests/blobstorage.spec.ts +++ b/packages/server/modules/blobstorage/tests/blobstorage.spec.ts @@ -68,7 +68,8 @@ const markUploadOverFileSizeLimit = markUploadOverFileSizeLimitFactory({ }) const deleteBlob = fullyDeleteBlobFactory({ getBlobMetadata, - deleteBlob: deleteBlobFactory({ db }) + deleteBlob: deleteBlobFactory({ db }), + deleteObject: async () => {} }) describe('Blob storage @blobstorage', () => { @@ -338,8 +339,8 @@ describe('Blob storage @blobstorage', () => { const blobId = blob.id const { objectKey } = await getBlobMetadata({ streamId, blobId }) expect(objectKey).to.equal(blob.objectKey) - const deleteObject = async () => {} - await deleteBlob({ streamId, blobId, deleteObject }) + + await deleteBlob({ streamId, blobId }) try { await getBlobMetadata({ streamId, blobId }) throw new Error('This should have thrown') From 00357839324abd7ffb5376175b5a9a9dcdf2f31f Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Wed, 5 Mar 2025 15:20:12 +0000 Subject: [PATCH 010/178] place busboy instantiation in callable function --- .../server/modules/blobstorage/rest/busboy.ts | 22 +++++++++++++++++++ .../server/modules/blobstorage/rest/router.ts | 20 +++-------------- 2 files changed, 25 insertions(+), 17 deletions(-) create mode 100644 packages/server/modules/blobstorage/rest/busboy.ts diff --git a/packages/server/modules/blobstorage/rest/busboy.ts b/packages/server/modules/blobstorage/rest/busboy.ts new file mode 100644 index 000000000..e9ec5dc8e --- /dev/null +++ b/packages/server/modules/blobstorage/rest/busboy.ts @@ -0,0 +1,22 @@ +import { BadRequestError } from '@/modules/shared/errors' +import { ensureError } from '@speckle/shared' +import Busboy from 'busboy' +import { Request } from 'express' +import { getFileSizeLimit } from '@/modules/blobstorage/services/management' + +export const createBusboy = (req: Request) => { + let busboy: Busboy.Busboy + try { + // Busboy does some validation of user input (headers) on creation + busboy = Busboy({ + headers: req.headers, + limits: { fileSize: getFileSizeLimit() } + }) + return busboy + } catch (err) { + throw new BadRequestError( + err instanceof Error ? err.message : 'Error while uploading blob', + ensureError(err, 'Unknown error while uploading blob') + ) + } +} diff --git a/packages/server/modules/blobstorage/rest/router.ts b/packages/server/modules/blobstorage/rest/router.ts index 273afe414..88801b282 100644 --- a/packages/server/modules/blobstorage/rest/router.ts +++ b/packages/server/modules/blobstorage/rest/router.ts @@ -1,4 +1,3 @@ -import Busboy from 'busboy' import { allowForAllRegisteredUsersOnPublicStreamsWithPublicComments, allowForRegisteredUsersOnPublicStreamsEvenWithoutRole, @@ -8,7 +7,7 @@ import { } from '@/modules/shared/authz' import { authMiddlewareCreator } from '@/modules/shared/middleware' import { isArray } from 'lodash' -import { BadRequestError, UnauthorizedError } from '@/modules/shared/errors' +import { UnauthorizedError } from '@/modules/shared/errors' import { getAllStreamBlobIdsFactory, getBlobMetadataFactory, @@ -18,7 +17,6 @@ import { import { db } from '@/db/knex' import { getFileStreamFactory, - getFileSizeLimit, fullyDeleteBlobFactory } from '@/modules/blobstorage/services/management' import { getRolesFactory } from '@/modules/shared/repositories/roles' @@ -31,9 +29,9 @@ import { } from '@/modules/blobstorage/repositories/blobs' import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' import { getStreamFactory } from '@/modules/core/repositories/streams' -import { ensureError } from '@speckle/shared' import { processNewFileStreamFactory } from '@/modules/blobstorage/services/streams' import { UserInputError } from '@/modules/core/errors/userinput' +import { createBusboy } from '@/modules/blobstorage/rest/busboy' export const blobStorageRouterFactory = (): Router => { const createStreamWritePermissions = () => @@ -66,19 +64,7 @@ export const blobStorageRouterFactory = (): Router => { req.log = req.log.child({ streamId, userId }) req.log.debug('Uploading blob.') - let busboy: Busboy.Busboy - try { - // Busboy does some validation of user input (headers) on creation - busboy = Busboy({ - headers: req.headers, - limits: { fileSize: getFileSizeLimit() } - }) - } catch (err) { - throw new BadRequestError( - err instanceof Error ? err.message : 'Error while uploading blob', - ensureError(err, 'Unknown error while uploading blob') - ) - } + const busboy = createBusboy(req) const newFileStreamProcessor = await processNewFileStream({ writeable: busboy, streamId, From 7c6ebc95dc90cdd6d0e90f418236ef3a205ae4aa Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:39:23 +0000 Subject: [PATCH 011/178] refactor fileuploads to avoid http request --- .../modules/blobstorage/domain/types.ts | 12 ++ .../server/modules/blobstorage/rest/router.ts | 2 +- .../blobstorage/services/management.ts | 24 +++- .../modules/blobstorage/services/streams.ts | 24 ++-- packages/server/modules/fileuploads/index.ts | 120 +----------------- .../server/modules/fileuploads/rest/router.ts | 111 ++++++++++++++++ 6 files changed, 156 insertions(+), 137 deletions(-) create mode 100644 packages/server/modules/fileuploads/rest/router.ts diff --git a/packages/server/modules/blobstorage/domain/types.ts b/packages/server/modules/blobstorage/domain/types.ts index 484498783..9a6c4a557 100644 --- a/packages/server/modules/blobstorage/domain/types.ts +++ b/packages/server/modules/blobstorage/domain/types.ts @@ -19,3 +19,15 @@ export type BlobStorageItemInput = SetOptional< BlobStorageItem, 'fileSize' | 'fileType' | 'uploadStatus' | 'uploadError' | 'createdAt' | 'fileHash' > + +export type UploadResult = ProcessingResult & { + formKey: string +} + +export type ProcessingResult = { + uploadStatus?: number + uploadError?: Nullable + blobId: string + fileName: string + fileSize: Nullable +} diff --git a/packages/server/modules/blobstorage/rest/router.ts b/packages/server/modules/blobstorage/rest/router.ts index 88801b282..66d1f02b1 100644 --- a/packages/server/modules/blobstorage/rest/router.ts +++ b/packages/server/modules/blobstorage/rest/router.ts @@ -70,7 +70,7 @@ export const blobStorageRouterFactory = (): Router => { streamId, userId, logger: req.log, - onFinishAllFileUploads: (uploadResults) => { + onFinishAllFileUploads: async (uploadResults) => { res.status(201).send({ uploadResults }) }, onError: () => { diff --git a/packages/server/modules/blobstorage/services/management.ts b/packages/server/modules/blobstorage/services/management.ts index bf9499ab0..948883df7 100644 --- a/packages/server/modules/blobstorage/services/management.ts +++ b/packages/server/modules/blobstorage/services/management.ts @@ -1,4 +1,4 @@ -import { +import type { DeleteBlob, DeleteBlobAndAssociatedObject, GetBlobMetadata, @@ -7,11 +7,12 @@ import { UploadFileStream, UpsertBlob } from '@/modules/blobstorage/domain/operations' -import { BlobStorageItem } from '@/modules/blobstorage/domain/types' +import type { BlobStorageItem } from '@/modules/blobstorage/domain/types' import { getObjectKey } from '@/modules/blobstorage/helpers/blobs' import { BadRequestError } from '@/modules/shared/errors' import { getFileSizeLimitMB } from '@/modules/shared/helpers/envHelper' -import { MaybeAsync } from '@speckle/shared' +import type { MaybeAsync } from '@speckle/shared' +import type { ProcessingResult } from '@/modules/blobstorage/domain/types' /** * File size limit in bytes @@ -90,7 +91,12 @@ const updateBlobMetadataFactory = }) const updateData = await updateCallback({ objectKey: objectKey! }) await deps.updateBlob({ id: blobId, item: updateData, streamId }) - return { blobId, fileName, ...updateData } + return { + blobId, + ...updateData, + fileName: updateData.fileName ?? fileName, // ensure the fileName is not undefined + fileSize: updateData.fileSize ?? null // ensure the fileSize is not undefined + } } export const markUploadSuccessFactory = @@ -99,7 +105,7 @@ export const markUploadSuccessFactory = getObjectAttributes: (params: ObjectKeyPayload) => MaybeAsync<{ fileSize: number }>, streamId: string, blobId: string - ) => { + ): Promise => { const updateBlobMetadata = updateBlobMetadataFactory(deps) return await updateBlobMetadata(streamId, blobId, async ({ objectKey }) => { const { fileSize } = await getObjectAttributes({ objectKey }) @@ -117,7 +123,7 @@ export const markUploadErrorFactory = streamId: string, blobId: string, error: string - ) => { + ): Promise => { const updateBlobMetadata = updateBlobMetadataFactory(deps) return await updateBlobMetadata(streamId, blobId, async ({ objectKey }) => { await deleteObject({ objectKey }) @@ -127,7 +133,11 @@ export const markUploadErrorFactory = export const markUploadOverFileSizeLimitFactory = (deps: UpdateBlobMetadataDeps) => - async (deleteObject: DeleteObjectFromStorage, streamId: string, blobId: string) => { + async ( + deleteObject: DeleteObjectFromStorage, + streamId: string, + blobId: string + ): Promise => { const markUploadError = markUploadErrorFactory(deps) return await markUploadError( deleteObject, diff --git a/packages/server/modules/blobstorage/services/streams.ts b/packages/server/modules/blobstorage/services/streams.ts index b226b2b70..e63a07ea2 100644 --- a/packages/server/modules/blobstorage/services/streams.ts +++ b/packages/server/modules/blobstorage/services/streams.ts @@ -15,28 +15,24 @@ import { getObjectAttributesFactory, storeFileStreamFactory } from '@/modules/blobstorage/repositories/blobs' -import { ensureError } from '@speckle/shared' +import { ensureError, Nullable } from '@speckle/shared' import { getProjectObjectStorage } from '@/modules/multiregion/utils/blobStorageSelector' import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' import type { Logger } from '@/observability/logging' import type { Writable } from 'stream' import { get } from 'lodash' +import { UploadResult } from '@/modules/blobstorage/domain/types' +import { ProcessingResult } from '@/modules/blobstorage/domain/types' type NewFileStreamProcessor = (params: { writeable: Writable streamId: string userId: string - onFinishAllFileUploads: (results: UploadResult[]) => void + onFinishAllFileUploads: (results: Array) => Promise onError: (err: unknown) => void logger: Logger }) => Promise -type UploadResult = { - uploadStatus?: number - uploadError?: Error | null | string - formKey: string -} - export const processNewFileStreamFactory = (): NewFileStreamProcessor => { return async (params) => { const { writeable, streamId, userId, onFinishAllFileUploads, onError } = params @@ -46,6 +42,9 @@ export const processNewFileStreamFactory = (): NewFileStreamProcessor => { uploadStatus?: number uploadError?: Error | null | string formKey: string + blobId: string + fileName: string + fileSize: Nullable }>[] = [] const [projectDb, projectStorage] = await Promise.all([ @@ -82,12 +81,7 @@ export const processNewFileStreamFactory = (): NewFileStreamProcessor => { const { filename: fileName } = info const fileType = fileName?.split('.')?.pop()?.toLowerCase() logger = logger.child({ fileName, fileType }) - const registerUploadResult = ( - processingPromise: Promise<{ - uploadStatus?: number - uploadError?: Error | null | string - }> - ) => { + const registerUploadResult = (processingPromise: Promise) => { finalizePromises.push( processingPromise.then((resultItem) => ({ ...resultItem, formKey })) ) @@ -141,7 +135,7 @@ export const processNewFileStreamFactory = (): NewFileStreamProcessor => { await Promise.all(Object.values(uploadOperations)) // have to make sure all finalize promises have been awaited const uploadResults = await Promise.all(finalizePromises) - onFinishAllFileUploads(uploadResults) + await onFinishAllFileUploads(uploadResults) return }) diff --git a/packages/server/modules/fileuploads/index.ts b/packages/server/modules/fileuploads/index.ts index ba81c07df..5ca76b596 100644 --- a/packages/server/modules/fileuploads/index.ts +++ b/packages/server/modules/fileuploads/index.ts @@ -1,28 +1,18 @@ /* istanbul ignore file */ -import { insertNewUploadAndNotifyFactory } from '@/modules/fileuploads/services/management' -import request from 'request' -import { authMiddlewareCreator } from '@/modules/shared/middleware' import { moduleLogger } from '@/observability/logging' import { onFileImportProcessedFactory, - onFileProcessingFactory, parseMessagePayload } from '@/modules/fileuploads/services/resultListener' -import { - getFileInfoFactory, - saveUploadFileFactory -} from '@/modules/fileuploads/repositories/fileUploads' -import { db } from '@/db/knex' -import { publish } from '@/modules/shared/utils/subscriptions' +import { getFileInfoFactory } from '@/modules/fileuploads/repositories/fileUploads' +import { FileImportSubscriptions, publish } from '@/modules/shared/utils/subscriptions' import { SpeckleModule } from '@/modules/shared/helpers/typeHelper' -import { streamWritePermissionsPipelineFactory } from '@/modules/shared/authz' -import { getRolesFactory } from '@/modules/shared/repositories/roles' import { getStreamBranchByNameFactory } from '@/modules/core/repositories/branches' -import { getStreamFactory } from '@/modules/core/repositories/streams' -import { getPort, isFileUploadsEnabled } from '@/modules/shared/helpers/envHelper' +import { isFileUploadsEnabled } from '@/modules/shared/helpers/envHelper' import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' import { listenFor } from '@/modules/core/utils/dbNotificationListener' import { getEventBus } from '@/modules/shared/services/eventBus' +import { fileuploadRouterFactory } from '@/modules/fileuploads/rest/router' export const init: SpeckleModule['init'] = async (app, isInitial) => { if (!isFileUploadsEnabled()) { @@ -31,99 +21,10 @@ export const init: SpeckleModule['init'] = async (app, isInitial) => { } moduleLogger.info('๐Ÿ“„ Init FileUploads module') - app.post( - '/api/file/:fileType/:streamId/:branchName?', - async (req, res, next) => { - await authMiddlewareCreator( - streamWritePermissionsPipelineFactory({ - getRoles: getRolesFactory({ db }), - getStream: getStreamFactory({ db }) - }) - )(req, res, next) - }, - async (req, res) => { - const branchName = req.params.branchName || 'main' - req.log = req.log.child({ - streamId: req.params.streamId, - userId: req.context.userId, - branchName - }) - - const projectDb = await getProjectDbClient({ projectId: req.params.streamId }) - const insertNewUploadAndNotify = insertNewUploadAndNotifyFactory({ - getStreamBranchByName: getStreamBranchByNameFactory({ db: projectDb }), - saveUploadFile: saveUploadFileFactory({ db: projectDb }), - publish - }) - const saveFileUploads = async ({ - userId, - streamId, - branchName, - uploadResults - }: { - userId: string - streamId: string - branchName: string - uploadResults: Array<{ - blobId: string - fileName: string - fileSize: number - }> - }) => { - await Promise.all( - uploadResults.map(async (upload) => { - await insertNewUploadAndNotify({ - fileId: upload.blobId, - streamId, - branchName, - userId, - fileName: upload.fileName, - fileType: upload.fileName.split('.').pop()!, - fileSize: upload.fileSize - }) - }) - ) - } - //TODO refactor packages/server/modules/blobstorage/index.ts to use the service pattern, and then refactor this to call the service directly from here without the http overhead - const pipedReq = request( - // we call this same server on localhost (IPv4) to upload the blob and do not make an external call - `http://127.0.0.1:${getPort()}/api/stream/${req.params.streamId}/blob`, - async (err, response, body) => { - if (err) { - res.log.error(err, 'Error while uploading blob.') - res.status(500).send(err.message) - return - } - if (response.statusCode === 201) { - const { uploadResults } = JSON.parse(body) - await saveFileUploads({ - userId: req.context.userId!, - streamId: req.params.streamId, - branchName, - uploadResults - }) - } else { - res.log.error( - { - statusCode: response.statusCode, - path: `http://127.0.0.1:${getPort()}/api/stream/${ - req.params.streamId - }/blob` - }, - 'Error while uploading file.' - ) - } - res.contentType('application/json') - res.status(response.statusCode).send(body) - } - ) - - req.pipe(pipedReq as unknown as NodeJS.WritableStream) - } - ) + app.use(fileuploadRouterFactory()) if (isInitial) { - listenFor('file_import_update', async (msg) => { + listenFor(FileImportSubscriptions.ProjectFileImportUpdated, async (msg) => { const parsedMessage = parseMessagePayload(msg.payload) if (!parsedMessage.streamId) return const projectDb = await getProjectDbClient({ projectId: parsedMessage.streamId }) @@ -134,14 +35,5 @@ export const init: SpeckleModule['init'] = async (app, isInitial) => { eventEmit: getEventBus().emit })(parsedMessage) }) - listenFor('file_import_started', async (msg) => { - const parsedMessage = parseMessagePayload(msg.payload) - if (!parsedMessage.streamId) return - const projectDb = await getProjectDbClient({ projectId: parsedMessage.streamId }) - await onFileProcessingFactory({ - getFileInfo: getFileInfoFactory({ db: projectDb }), - publish - })(parsedMessage) - }) } } diff --git a/packages/server/modules/fileuploads/rest/router.ts b/packages/server/modules/fileuploads/rest/router.ts new file mode 100644 index 000000000..294c2fd4f --- /dev/null +++ b/packages/server/modules/fileuploads/rest/router.ts @@ -0,0 +1,111 @@ +import { Router } from 'express' +import { insertNewUploadAndNotifyFactory } from '@/modules/fileuploads/services/management' +import { authMiddlewareCreator } from '@/modules/shared/middleware' +import { saveUploadFileFactory } from '@/modules/fileuploads/repositories/fileUploads' +import { db } from '@/db/knex' +import { publish } from '@/modules/shared/utils/subscriptions' +import { streamWritePermissionsPipelineFactory } from '@/modules/shared/authz' +import { getRolesFactory } from '@/modules/shared/repositories/roles' +import { getStreamBranchByNameFactory } from '@/modules/core/repositories/branches' +import { getStreamFactory } from '@/modules/core/repositories/streams' +import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' +import { createBusboy } from '@/modules/blobstorage/rest/busboy' +import { processNewFileStreamFactory } from '@/modules/blobstorage/services/streams' +import { UnauthorizedError } from '@/modules/shared/errors' +import { Nullable } from '@speckle/shared' + +export const fileuploadRouterFactory = (): Router => { + const processNewFileStream = processNewFileStreamFactory() + + const app = Router() + + app.post( + '/api/file/:fileType/:streamId/:branchName?', + async (req, res, next) => { + await authMiddlewareCreator( + streamWritePermissionsPipelineFactory({ + getRoles: getRolesFactory({ db }), + getStream: getStreamFactory({ db }) + }) + )(req, res, next) + }, + async (req, res) => { + const branchName = req.params.branchName || 'main' + const streamId = req.params.streamId + const userId = req.context.userId + if (!userId) { + throw new UnauthorizedError('User not authenticated.') + } + const logger = req.log.child({ + streamId, + userId, + branchName + }) + + const projectDb = await getProjectDbClient({ projectId: streamId }) + const insertNewUploadAndNotify = insertNewUploadAndNotifyFactory({ + getStreamBranchByName: getStreamBranchByNameFactory({ db: projectDb }), + saveUploadFile: saveUploadFileFactory({ db: projectDb }), + publish + }) + const saveFileUploads = async ({ + userId, + streamId, + branchName, + uploadResults + }: { + userId: string + streamId: string + branchName: string + uploadResults: Array<{ + blobId: string + fileName: string + fileSize: Nullable + }> + }) => { + await Promise.all( + uploadResults.map(async (upload) => { + await insertNewUploadAndNotify({ + fileId: upload.blobId, + streamId, + branchName, + userId, + fileName: upload.fileName, + fileType: upload.fileName?.split('.').pop() || '', //FIXME + fileSize: upload.fileSize + }) + }) + ) + } + + const busboy = createBusboy(req) + const newFileStreamProcessor = await processNewFileStream({ + writeable: busboy, + streamId, + userId, + logger, + onFinishAllFileUploads: async (uploadResults) => { + await saveFileUploads({ + userId: req.context.userId!, + streamId: req.params.streamId, + branchName, + uploadResults + }) + res.status(201).send({ uploadResults }) + }, + onError: () => { + res.contentType('application/json') + res + .status(400) + .end( + '{ "error": "Upload request error. The server logs may have more details." }' + ) + } + }) + + req.pipe(newFileStreamProcessor) + } + ) + + return app +} From c9fff01dbf6ac4eac886edae7a54998f9204e4a1 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:50:01 +0000 Subject: [PATCH 012/178] fix test --- packages/server/modules/blobstorage/tests/blobstorage.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/modules/blobstorage/tests/blobstorage.spec.ts b/packages/server/modules/blobstorage/tests/blobstorage.spec.ts index 48af8ccb4..8621197b6 100644 --- a/packages/server/modules/blobstorage/tests/blobstorage.spec.ts +++ b/packages/server/modules/blobstorage/tests/blobstorage.spec.ts @@ -367,6 +367,7 @@ describe('Blob storage @blobstorage', () => { expect(markResult).to.deep.equal({ blobId, fileName: blob.fileName, + fileSize: null, uploadStatus: 2, uploadError: 'File size limit reached' }) From 89d61eedc069296b73b16bd928157ef01be90315 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Wed, 5 Mar 2025 21:20:56 +0000 Subject: [PATCH 013/178] misunderstood this event listener, reverted --- packages/server/modules/fileuploads/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/server/modules/fileuploads/index.ts b/packages/server/modules/fileuploads/index.ts index 5ca76b596..6c82cdaa2 100644 --- a/packages/server/modules/fileuploads/index.ts +++ b/packages/server/modules/fileuploads/index.ts @@ -5,7 +5,7 @@ import { parseMessagePayload } from '@/modules/fileuploads/services/resultListener' import { getFileInfoFactory } from '@/modules/fileuploads/repositories/fileUploads' -import { FileImportSubscriptions, publish } from '@/modules/shared/utils/subscriptions' +import { publish } from '@/modules/shared/utils/subscriptions' import { SpeckleModule } from '@/modules/shared/helpers/typeHelper' import { getStreamBranchByNameFactory } from '@/modules/core/repositories/branches' import { isFileUploadsEnabled } from '@/modules/shared/helpers/envHelper' @@ -24,7 +24,8 @@ export const init: SpeckleModule['init'] = async (app, isInitial) => { app.use(fileuploadRouterFactory()) if (isInitial) { - listenFor(FileImportSubscriptions.ProjectFileImportUpdated, async (msg) => { + // subscribe to database notifications + listenFor('file_import_update', async (msg) => { const parsedMessage = parseMessagePayload(msg.payload) if (!parsedMessage.streamId) return const projectDb = await getProjectDbClient({ projectId: parsedMessage.streamId }) From b5201fe8d5bdf16c37ac066a54bb9d82456d180f Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Thu, 6 Mar 2025 08:45:32 +0000 Subject: [PATCH 014/178] Restore incorrectly deleted db listener --- packages/server/modules/fileuploads/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/server/modules/fileuploads/index.ts b/packages/server/modules/fileuploads/index.ts index 6c82cdaa2..63a4f5ef6 100644 --- a/packages/server/modules/fileuploads/index.ts +++ b/packages/server/modules/fileuploads/index.ts @@ -2,6 +2,7 @@ import { moduleLogger } from '@/observability/logging' import { onFileImportProcessedFactory, + onFileProcessingFactory, parseMessagePayload } from '@/modules/fileuploads/services/resultListener' import { getFileInfoFactory } from '@/modules/fileuploads/repositories/fileUploads' @@ -36,5 +37,14 @@ export const init: SpeckleModule['init'] = async (app, isInitial) => { eventEmit: getEventBus().emit })(parsedMessage) }) + listenFor('file_import_started', async (msg) => { + const parsedMessage = parseMessagePayload(msg.payload) + if (!parsedMessage.streamId) return + const projectDb = await getProjectDbClient({ projectId: parsedMessage.streamId }) + await onFileProcessingFactory({ + getFileInfo: getFileInfoFactory({ db: projectDb }), + publish + })(parsedMessage) + }) } } From c261e39191223bb209042229f8de87a6caa3affa Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Thu, 6 Mar 2025 21:18:26 +0000 Subject: [PATCH 015/178] tidy --- .../server/modules/fileuploads/rest/router.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/server/modules/fileuploads/rest/router.ts b/packages/server/modules/fileuploads/rest/router.ts index 294c2fd4f..cf697742c 100644 --- a/packages/server/modules/fileuploads/rest/router.ts +++ b/packages/server/modules/fileuploads/rest/router.ts @@ -21,14 +21,12 @@ export const fileuploadRouterFactory = (): Router => { app.post( '/api/file/:fileType/:streamId/:branchName?', - async (req, res, next) => { - await authMiddlewareCreator( - streamWritePermissionsPipelineFactory({ - getRoles: getRolesFactory({ db }), - getStream: getStreamFactory({ db }) - }) - )(req, res, next) - }, + authMiddlewareCreator( + streamWritePermissionsPipelineFactory({ + getRoles: getRolesFactory({ db }), + getStream: getStreamFactory({ db }) + }) + ), async (req, res) => { const branchName = req.params.branchName || 'main' const streamId = req.params.streamId @@ -86,8 +84,8 @@ export const fileuploadRouterFactory = (): Router => { logger, onFinishAllFileUploads: async (uploadResults) => { await saveFileUploads({ - userId: req.context.userId!, - streamId: req.params.streamId, + userId, + streamId, branchName, uploadResults }) From 3c132f80985f1e3e7febc1c61ca7a9a071e911ad Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Thu, 6 Mar 2025 21:51:53 +0000 Subject: [PATCH 016/178] type the busboy event --- .../server/modules/blobstorage/rest/router.ts | 2 +- .../modules/blobstorage/services/streams.ts | 130 +++++++++--------- .../server/modules/fileuploads/rest/router.ts | 2 +- 3 files changed, 67 insertions(+), 67 deletions(-) diff --git a/packages/server/modules/blobstorage/rest/router.ts b/packages/server/modules/blobstorage/rest/router.ts index 66d1f02b1..38de9f56f 100644 --- a/packages/server/modules/blobstorage/rest/router.ts +++ b/packages/server/modules/blobstorage/rest/router.ts @@ -66,7 +66,7 @@ export const blobStorageRouterFactory = (): Router => { const busboy = createBusboy(req) const newFileStreamProcessor = await processNewFileStream({ - writeable: busboy, + busboy, streamId, userId, logger: req.log, diff --git a/packages/server/modules/blobstorage/services/streams.ts b/packages/server/modules/blobstorage/services/streams.ts index e63a07ea2..4c286e314 100644 --- a/packages/server/modules/blobstorage/services/streams.ts +++ b/packages/server/modules/blobstorage/services/streams.ts @@ -15,17 +15,17 @@ import { getObjectAttributesFactory, storeFileStreamFactory } from '@/modules/blobstorage/repositories/blobs' -import { ensureError, Nullable } from '@speckle/shared' +import { ensureError } from '@speckle/shared' import { getProjectObjectStorage } from '@/modules/multiregion/utils/blobStorageSelector' import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' import type { Logger } from '@/observability/logging' -import type { Writable } from 'stream' +import type { Readable, Writable } from 'stream' import { get } from 'lodash' -import { UploadResult } from '@/modules/blobstorage/domain/types' -import { ProcessingResult } from '@/modules/blobstorage/domain/types' +import type { UploadResult, ProcessingResult } from '@/modules/blobstorage/domain/types' +import type { Busboy } from 'busboy' type NewFileStreamProcessor = (params: { - writeable: Writable + busboy: Busboy streamId: string userId: string onFinishAllFileUploads: (results: Array) => Promise @@ -35,17 +35,10 @@ type NewFileStreamProcessor = (params: { export const processNewFileStreamFactory = (): NewFileStreamProcessor => { return async (params) => { - const { writeable, streamId, userId, onFinishAllFileUploads, onError } = params + const { busboy, streamId, userId, onFinishAllFileUploads, onError } = params let { logger } = params const uploadOperations: Record = {} - const finalizePromises: Promise<{ - uploadStatus?: number - uploadError?: Error | null | string - formKey: string - blobId: string - fileName: string - fileSize: Nullable - }>[] = [] + const finalizePromises: Promise[] = [] const [projectDb, projectStorage] = await Promise.all([ getProjectDbClient({ projectId: streamId }), @@ -77,58 +70,65 @@ export const processNewFileStreamFactory = (): NewFileStreamProcessor => { }) const deleteObject = deleteObjectFactory({ storage: projectStorage }) - writeable.on('file', (formKey, file, info) => { - const { filename: fileName } = info - const fileType = fileName?.split('.')?.pop()?.toLowerCase() - logger = logger.child({ fileName, fileType }) - const registerUploadResult = (processingPromise: Promise) => { - finalizePromises.push( - processingPromise.then((resultItem) => ({ ...resultItem, formKey })) - ) - } - - let blobId = crs({ length: 10 }) - let clientHash = null - if (formKey.includes('hash:')) { - clientHash = formKey.split(':')[1] - if (clientHash && clientHash !== '') { - // logger.debug(`I have a client hash (${clientHash})`) - blobId = clientHash + busboy.on( + 'file', + ( + formKey: string, + file: Readable & { truncated?: boolean }, + info: { filename: string; encoding: string; mimeType: string } + ) => { + const { filename: fileName } = info + const fileType = fileName?.split('.')?.pop()?.toLowerCase() + logger = logger.child({ fileName, fileType }) + const registerUploadResult = (processingPromise: Promise) => { + finalizePromises.push( + processingPromise.then((resultItem) => ({ ...resultItem, formKey })) + ) } + + let blobId = crs({ length: 10 }) + let clientHash = null + if (formKey.includes('hash:')) { + clientHash = formKey.split(':')[1] + if (clientHash && clientHash !== '') { + // logger.debug(`I have a client hash (${clientHash})`) + blobId = clientHash + } + } + + logger = logger.child({ blobId }) + + uploadOperations[blobId] = uploadFileStream( + { streamId, userId }, + { blobId, fileName, fileType, fileStream: file } + ) + + //this file level 'close' is fired when a single file upload finishes + //this way individual upload statuses can be updated, when done + file.on('close', async () => { + //this is handled by the file.on('limit', ...) event + if (file.truncated) return + await uploadOperations[blobId] + + registerUploadResult(markUploadSuccess(getObjectAttributes, streamId, blobId)) + }) + + file.on('limit', async () => { + await uploadOperations[blobId] + registerUploadResult( + markUploadOverFileSizeLimit(deleteObject, streamId, blobId) + ) + }) + + file.on('error', (err: unknown) => { + registerUploadResult( + markUploadError(deleteObject, streamId, blobId, get(err, 'message')) + ) + }) } + ) - logger = logger.child({ blobId }) - - uploadOperations[blobId] = uploadFileStream( - { streamId, userId }, - { blobId, fileName, fileType, fileStream: file } - ) - - //this file level 'close' is fired when a single file upload finishes - //this way individual upload statuses can be updated, when done - file.on('close', async () => { - //this is handled by the file.on('limit', ...) event - if (file.truncated) return - await uploadOperations[blobId] - - registerUploadResult(markUploadSuccess(getObjectAttributes, streamId, blobId)) - }) - - file.on('limit', async () => { - await uploadOperations[blobId] - registerUploadResult( - markUploadOverFileSizeLimit(deleteObject, streamId, blobId) - ) - }) - - file.on('error', (err: unknown) => { - registerUploadResult( - markUploadError(deleteObject, streamId, blobId, get(err, 'message')) - ) - }) - }) - - writeable.on('finish', async () => { + busboy.on('finish', async () => { // make sure all upload operations have been awaited, // otherwise the finish even can fire before all async operations finish //resulting in missing return values @@ -139,7 +139,7 @@ export const processNewFileStreamFactory = (): NewFileStreamProcessor => { return }) - writeable.on('error', async (err) => { + busboy.on('error', async (err) => { logger.info({ err }, 'Upload request error.') //delete all started uploads await Promise.all( @@ -157,6 +157,6 @@ export const processNewFileStreamFactory = (): NewFileStreamProcessor => { return }) - return writeable + return busboy } } diff --git a/packages/server/modules/fileuploads/rest/router.ts b/packages/server/modules/fileuploads/rest/router.ts index cf697742c..e133afee9 100644 --- a/packages/server/modules/fileuploads/rest/router.ts +++ b/packages/server/modules/fileuploads/rest/router.ts @@ -78,7 +78,7 @@ export const fileuploadRouterFactory = (): Router => { const busboy = createBusboy(req) const newFileStreamProcessor = await processNewFileStream({ - writeable: busboy, + busboy, streamId, userId, logger, From 16049b6a929e14a8f382a92428bcb3676e0a961c Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Mon, 10 Mar 2025 10:57:54 +0000 Subject: [PATCH 017/178] prefer pipeline over .pipe --- packages/server/modules/blobstorage/rest/router.ts | 5 +++-- packages/server/modules/fileuploads/rest/router.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/server/modules/blobstorage/rest/router.ts b/packages/server/modules/blobstorage/rest/router.ts index 38de9f56f..bace5b660 100644 --- a/packages/server/modules/blobstorage/rest/router.ts +++ b/packages/server/modules/blobstorage/rest/router.ts @@ -32,6 +32,7 @@ import { getStreamFactory } from '@/modules/core/repositories/streams' import { processNewFileStreamFactory } from '@/modules/blobstorage/services/streams' import { UserInputError } from '@/modules/core/errors/userinput' import { createBusboy } from '@/modules/blobstorage/rest/busboy' +import { pipeline } from 'node:stream' export const blobStorageRouterFactory = (): Router => { const createStreamWritePermissions = () => @@ -82,7 +83,7 @@ export const blobStorageRouterFactory = (): Router => { ) } }) - req.pipe(newFileStreamProcessor) + pipeline(req, newFileStreamProcessor) } ) @@ -142,7 +143,7 @@ export const blobStorageRouterFactory = (): Router => { 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename="${fileName}"` }) - fileStream.pipe(res) + pipeline(fileStream, res) } ) diff --git a/packages/server/modules/fileuploads/rest/router.ts b/packages/server/modules/fileuploads/rest/router.ts index e133afee9..379eed170 100644 --- a/packages/server/modules/fileuploads/rest/router.ts +++ b/packages/server/modules/fileuploads/rest/router.ts @@ -13,6 +13,7 @@ import { createBusboy } from '@/modules/blobstorage/rest/busboy' import { processNewFileStreamFactory } from '@/modules/blobstorage/services/streams' import { UnauthorizedError } from '@/modules/shared/errors' import { Nullable } from '@speckle/shared' +import { pipeline } from 'stream' export const fileuploadRouterFactory = (): Router => { const processNewFileStream = processNewFileStreamFactory() @@ -101,7 +102,7 @@ export const fileuploadRouterFactory = (): Router => { } }) - req.pipe(newFileStreamProcessor) + pipeline(req, newFileStreamProcessor) } ) From dd806b07308d563801a368b577a0edaa37b40422 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Tue, 11 Mar 2025 09:46:55 +0000 Subject: [PATCH 018/178] Revert "prefer pipeline over .pipe" This reverts commit 16049b6a929e14a8f382a92428bcb3676e0a961c. --- packages/server/modules/blobstorage/rest/router.ts | 5 ++--- packages/server/modules/fileuploads/rest/router.ts | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/server/modules/blobstorage/rest/router.ts b/packages/server/modules/blobstorage/rest/router.ts index bace5b660..38de9f56f 100644 --- a/packages/server/modules/blobstorage/rest/router.ts +++ b/packages/server/modules/blobstorage/rest/router.ts @@ -32,7 +32,6 @@ import { getStreamFactory } from '@/modules/core/repositories/streams' import { processNewFileStreamFactory } from '@/modules/blobstorage/services/streams' import { UserInputError } from '@/modules/core/errors/userinput' import { createBusboy } from '@/modules/blobstorage/rest/busboy' -import { pipeline } from 'node:stream' export const blobStorageRouterFactory = (): Router => { const createStreamWritePermissions = () => @@ -83,7 +82,7 @@ export const blobStorageRouterFactory = (): Router => { ) } }) - pipeline(req, newFileStreamProcessor) + req.pipe(newFileStreamProcessor) } ) @@ -143,7 +142,7 @@ export const blobStorageRouterFactory = (): Router => { 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename="${fileName}"` }) - pipeline(fileStream, res) + fileStream.pipe(res) } ) diff --git a/packages/server/modules/fileuploads/rest/router.ts b/packages/server/modules/fileuploads/rest/router.ts index 379eed170..e133afee9 100644 --- a/packages/server/modules/fileuploads/rest/router.ts +++ b/packages/server/modules/fileuploads/rest/router.ts @@ -13,7 +13,6 @@ import { createBusboy } from '@/modules/blobstorage/rest/busboy' import { processNewFileStreamFactory } from '@/modules/blobstorage/services/streams' import { UnauthorizedError } from '@/modules/shared/errors' import { Nullable } from '@speckle/shared' -import { pipeline } from 'stream' export const fileuploadRouterFactory = (): Router => { const processNewFileStream = processNewFileStreamFactory() @@ -102,7 +101,7 @@ export const fileuploadRouterFactory = (): Router => { } }) - pipeline(req, newFileStreamProcessor) + req.pipe(newFileStreamProcessor) } ) From ad9255cd395ab19f7b55c5ce7aa9e281f36d68a1 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Thu, 20 Mar 2025 09:38:50 +0000 Subject: [PATCH 019/178] fix liveness probe for node 22 --- .../speckle-server/templates/webhook_service/deployment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/helm/speckle-server/templates/webhook_service/deployment.yml b/utils/helm/speckle-server/templates/webhook_service/deployment.yml index b1e0e3b8e..7beb2fffd 100644 --- a/utils/helm/speckle-server/templates/webhook_service/deployment.yml +++ b/utils/helm/speckle-server/templates/webhook_service/deployment.yml @@ -35,7 +35,7 @@ spec: command: - /nodejs/bin/node - -e - - process.exit(Date.now() - require('fs').readFileSync('/tmp/last_successful_query', 'utf8') > 30 * 1000) + - "process.exit(Date.now() - require('fs').readFileSync('/tmp/last_successful_query', 'utf8') > 30 * 1000 ? 1 : 0)" resources: requests: From f90ffb7ecaa48861bf4ef59604a322816e9d48f0 Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Thu, 20 Mar 2025 10:25:41 +0000 Subject: [PATCH 020/178] fix broken merge from main --- packages/fileimport-service/Dockerfile | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/fileimport-service/Dockerfile b/packages/fileimport-service/Dockerfile index 89eb073e1..01964e30c 100644 --- a/packages/fileimport-service/Dockerfile +++ b/packages/fileimport-service/Dockerfile @@ -21,10 +21,7 @@ RUN apt-get update -y \ --no-install-recommends \ ca-certificates=20240203 \ curl=8.5.0-2ubuntu10.6 \ - && curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh \ - && chmod +x ./nodesource_setup.sh \ - && ./nodesource_setup.sh \ - && rm ./nodesource_setup.sh \ + gosu=1.17-1ubuntu0.24.04.2 \ && curl -fsSL https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini -o /usr/bin/tini \ && chmod +x /usr/bin/tini \ && curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh \ From cfb561c1c2ceaf0ab5962cb8b90cc05d7cb9ee9f Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Wed, 9 Apr 2025 15:09:18 +0100 Subject: [PATCH 021/178] add createFromObjects and createFromJSON --- .../src/operations/memoryDatabase.ts | 40 ++++++++++++++ .../src/operations/memoryDownloader.ts | 25 +++++++++ .../src/operations/objectLoader2.spec.ts | 55 +++++++++++++++++++ .../src/operations/objectLoader2.ts | 23 ++++++++ .../src/operations/serverDownloader.spec.ts | 8 +-- 5 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 packages/objectloader2/src/operations/memoryDatabase.ts create mode 100644 packages/objectloader2/src/operations/memoryDownloader.ts diff --git a/packages/objectloader2/src/operations/memoryDatabase.ts b/packages/objectloader2/src/operations/memoryDatabase.ts new file mode 100644 index 000000000..ef9d234f5 --- /dev/null +++ b/packages/objectloader2/src/operations/memoryDatabase.ts @@ -0,0 +1,40 @@ +import Queue from '../helpers/queue.js' +import { Base, Item } from '../types/types.js' +import { Cache } from './interfaces.js' + +export class MemoryDatabase implements Cache { + #items: Record + constructor(items: Record) { + this.#items = items + } + + getItem(params: { id: string }): Promise { + const item = this.#items[params.id] + if (item) { + return Promise.resolve({ baseId: params.id, base: item }) + } + throw new Error('Method not implemented.') + } + processItems(params: { + ids: string[] + foundItems: Queue + notFoundItems: Queue + }): Promise { + const { ids, foundItems, notFoundItems } = params + for (const id of ids) { + const item = this.#items[id] + if (item) { + foundItems.add({ baseId: id, base: item }) + } else { + notFoundItems.add(id) + } + } + return Promise.resolve() + } + add(): Promise { + return Promise.resolve() + } + disposeAsync(): Promise { + return Promise.resolve() + } +} diff --git a/packages/objectloader2/src/operations/memoryDownloader.ts b/packages/objectloader2/src/operations/memoryDownloader.ts new file mode 100644 index 000000000..23cbe08c7 --- /dev/null +++ b/packages/objectloader2/src/operations/memoryDownloader.ts @@ -0,0 +1,25 @@ +import { Base, Item } from '../types/types.js' +import { Downloader } from './interfaces.js' + +export class MemoryDownloader implements Downloader { + #items: Record + #rootId: string + constructor(rootId: string, items: Record) { + this.#rootId = rootId + this.#items = items + } + initializePool(): void {} + downloadSingle(): Promise { + const root = this.#items[this.#rootId] + if (root) { + return Promise.resolve({ baseId: this.#rootId, base: root }) + } + throw new Error('Method not implemented.') + } + disposeAsync(): Promise { + return Promise.resolve() + } + add(): void { + throw new Error('Method not implemented.') + } +} diff --git a/packages/objectloader2/src/operations/objectLoader2.spec.ts b/packages/objectloader2/src/operations/objectLoader2.spec.ts index 0dba1f752..c65c918bc 100644 --- a/packages/objectloader2/src/operations/objectLoader2.spec.ts +++ b/packages/objectloader2/src/operations/objectLoader2.spec.ts @@ -158,4 +158,59 @@ describe('objectloader2', () => { const x = await loader.getRootObject() expect(x).toBe(root) }) + + test('createFromJSON test', async () => { + const root = `{ + "list": [{ + "speckle_type": "reference", + "referencedId": "0e61e61edee00404ec6e0f9f594bce24", + "__closure": null + }], + "list2": [{ + "speckle_type": "reference", + "referencedId": "f70738e3e3e593ac11099a6ed6b71154", + "__closure": null + }], + "arr": null, + "detachedProp": null, + "detachedProp2": null, + "attachedProp": null, + "crazyProp": null, + "applicationId": "1", + "speckle_type": "Speckle.Core.Tests.Unit.Models.BaseTests+SampleObjectBase2", + "dynamicProp": 123, + "id": "efeadaca70a85ae6d3acfc93a8b380db", + "__closure": { + "0e61e61edee00404ec6e0f9f594bce24": 100, + "f70738e3e3e593ac11099a6ed6b71154": 100 + } +}` + + const list1 = `{ + "data": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0], + "applicationId": null, + "speckle_type": "Speckle.Core.Models.DataChunk", + "id": "0e61e61edee00404ec6e0f9f594bce24" +}` + + const list2 = `{ + "data": [1.0, 10.0], + "applicationId": null, + "speckle_type": "Speckle.Core.Models.DataChunk", + "id": "f70738e3e3e593ac11099a6ed6b71154" +}` + const rootObj = JSON.parse(root) as Base + const list1Obj = JSON.parse(list1) as Base + const list2Obj = JSON.parse(list2) as Base + + const loader = ObjectLoader2.createFromObjects([rootObj, list1Obj, list2Obj]) + const r = [] + for await (const x of loader.getObjectIterator()) { + r.push(x) + } + expect(r.length).toBe(3) + expect(r[0]).toBe(rootObj) + expect(r[1]).toBe(list1Obj) + expect(r[2]).toBe(list2Obj) + }) }) diff --git a/packages/objectloader2/src/operations/objectLoader2.ts b/packages/objectloader2/src/operations/objectLoader2.ts index 816ef6a2c..f16d4b056 100644 --- a/packages/objectloader2/src/operations/objectLoader2.ts +++ b/packages/objectloader2/src/operations/objectLoader2.ts @@ -5,6 +5,8 @@ import ServerDownloader from './serverDownloader.js' import { CustomLogger, Base, Item } from '../types/types.js' import { ObjectLoader2Options } from './options.js' import { DeferredBase } from '../helpers/deferredBase.js' +import { MemoryDownloader } from './memoryDownloader.js' +import { MemoryDatabase } from './memoryDatabase.js' export default class ObjectLoader2 { #objectId: string @@ -117,4 +119,25 @@ export default class ObjectLoader2 { } await processPromise } + + static createFromObjects(objects: Base[]): ObjectLoader2 { + const root = objects[0] + const records: Record = {} + objects.forEach((element) => { + records[element.id] = element + }) + const loader = new ObjectLoader2({ + serverUrl: 'dummy', + streamId: 'dummy', + objectId: root.id, + cache: new MemoryDatabase(records), + downloader: new MemoryDownloader(root.id, records) + }) + return loader + } + + static createFromJSON(json: string): ObjectLoader2 { + const jsonObj = JSON.parse(json) as Base[] + return this.createFromObjects(jsonObj) + } } diff --git a/packages/objectloader2/src/operations/serverDownloader.spec.ts b/packages/objectloader2/src/operations/serverDownloader.spec.ts index 1cbfb95fb..851a77989 100644 --- a/packages/objectloader2/src/operations/serverDownloader.spec.ts +++ b/packages/objectloader2/src/operations/serverDownloader.spec.ts @@ -11,7 +11,7 @@ describe('downloader', () => { const fetchMocker = createFetchMock(vi) const i: Item = { baseId: 'id', base: { id: 'id' } } fetchMocker.mockResponseOnce('id\t' + JSON.stringify(i.base) + '\n') - const results = new AsyncGeneratorQueue() + const results = new AsyncGeneratorQueue() const db = { async add(): Promise { return Promise.resolve() @@ -47,7 +47,7 @@ describe('downloader', () => { fetchMocker.mockResponseOnce( 'id1\t' + JSON.stringify(i1.base) + '\nid2\t' + JSON.stringify(i2.base) + '\n' ) - const results = new AsyncGeneratorQueue() + const results = new AsyncGeneratorQueue() const db = { async add(): Promise { return Promise.resolve() @@ -81,7 +81,7 @@ describe('downloader', () => { const fetchMocker = createFetchMock(vi) const i: Item = { baseId: 'id', base: { id: 'id', __closure: { childIds: 1 } } } fetchMocker.mockResponseOnce(JSON.stringify(i.base)) - const results = new AsyncGeneratorQueue() + const results = new AsyncGeneratorQueue() const db = { async add(): Promise { return Promise.resolve() @@ -108,7 +108,7 @@ describe('downloader', () => { (req) => req.headers.get('x-test') === 'asdf', JSON.stringify(i.base) ) - const results = new AsyncGeneratorQueue() + const results = new AsyncGeneratorQueue() const db = { async add(): Promise { return Promise.resolve() From 8f67e52e3894275eef482819cf9a41177e5a00bb Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Wed, 9 Apr 2025 15:50:24 +0100 Subject: [PATCH 022/178] deferred base usage correction --- packages/objectloader2/src/operations/objectLoader2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/objectloader2/src/operations/objectLoader2.ts b/packages/objectloader2/src/operations/objectLoader2.ts index f16d4b056..5b7f18530 100644 --- a/packages/objectloader2/src/operations/objectLoader2.ts +++ b/packages/objectloader2/src/operations/objectLoader2.ts @@ -77,7 +77,7 @@ export default class ObjectLoader2 { } const d = new DeferredBase(params.id) this.#buffer.push(d) - return d + return d.promise } async getTotalObjectCount() { From e0f17d421a2c300fe041fb4dcb943445fd3ff7ed Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Wed, 9 Apr 2025 16:44:59 +0100 Subject: [PATCH 023/178] make deferment manager and add tests for it and getObject --- .../src/helpers/defermentManager.spec.ts | 14 +++++++ .../src/helpers/defermentManager.ts | 25 ++++++++++++ .../src/operations/objectLoader2.spec.ts | 38 +++++++++++++++++++ .../src/operations/objectLoader2.ts | 22 +++-------- 4 files changed, 83 insertions(+), 16 deletions(-) create mode 100644 packages/objectloader2/src/helpers/defermentManager.spec.ts create mode 100644 packages/objectloader2/src/helpers/defermentManager.ts diff --git a/packages/objectloader2/src/helpers/defermentManager.spec.ts b/packages/objectloader2/src/helpers/defermentManager.spec.ts new file mode 100644 index 000000000..f8be3b345 --- /dev/null +++ b/packages/objectloader2/src/helpers/defermentManager.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'vitest' +import { DefermentManager } from './defermentManager.js' + +describe('deferments', () => { + test('defer one', async () => { + const deferments = new DefermentManager() + const x = deferments.defer({ id: 'id' }) + expect(x).toBeInstanceOf(Promise) + deferments.undefer({ baseId: 'id', base: { id: 'id' } }) + const b = await x + expect(b).toBeDefined() + expect(b.id).toBe('id') + }) +}) diff --git a/packages/objectloader2/src/helpers/defermentManager.ts b/packages/objectloader2/src/helpers/defermentManager.ts new file mode 100644 index 000000000..eb62fb9c5 --- /dev/null +++ b/packages/objectloader2/src/helpers/defermentManager.ts @@ -0,0 +1,25 @@ +import { DeferredBase } from './deferredBase.js' +import { Base, Item } from '../types/types.js' + +export class DefermentManager { + #deferments: DeferredBase[] = [] + + async defer(params: { id: string }): Promise { + const deferredBase = this.#deferments.find((x) => x.id === params.id) + if (deferredBase) { + return await deferredBase.promise + } + const d = new DeferredBase(params.id) + this.#deferments.push(d) + return d.promise + } + + undefer(item: Item): void { + const deferredIndex = this.#deferments.findIndex((x) => x.id === item.baseId) + if (deferredIndex !== -1) { + const deferredBase = this.#deferments[deferredIndex] + deferredBase.resolve(item.base) + this.#deferments.splice(deferredIndex, 1) + } + } +} diff --git a/packages/objectloader2/src/operations/objectLoader2.spec.ts b/packages/objectloader2/src/operations/objectLoader2.spec.ts index c65c918bc..9741caf3a 100644 --- a/packages/objectloader2/src/operations/objectLoader2.spec.ts +++ b/packages/objectloader2/src/operations/objectLoader2.spec.ts @@ -3,6 +3,8 @@ import ObjectLoader2 from './objectLoader2.js' import { Base, Item } from '../types/types.js' import { Cache, Downloader } from './interfaces.js' import Queue from '../helpers/queue.js' +import { MemoryDatabase } from './memoryDatabase.js' +import { MemoryDownloader } from './memoryDownloader.js' describe('objectloader2', () => { test('can get a root object from cache', async () => { @@ -136,6 +138,42 @@ describe('objectloader2', () => { expect(r[1]).toBe(child1Base) }) + test('can get root/child object from cache using iterator and getObject', async () => { + const child1Base = { id: 'child1Id' } + const child1 = { baseId: 'child1Id', base: child1Base } as unknown as Item + + const rootId = 'rootId' + const rootBase: Base = { id: 'rootId', __closure: { child1Id: 100 } } + const root = { + baseId: rootId, + base: rootBase + } as unknown as Item + + const records: Record = {} + records[root.baseId] = rootBase + records[child1.baseId] = child1Base + + const loader = new ObjectLoader2({ + serverUrl: 'a', + streamId: 'b', + objectId: root.baseId, + cache: new MemoryDatabase(records), + downloader: new MemoryDownloader(rootId, records) + }) + const r = [] + const obj = loader.getObject({ id: child1.baseId }) + for await (const x of loader.getObjectIterator()) { + r.push(x) + } + + expect(obj).toBeDefined() + expect(r.length).toBe(2) + expect(r[0]).toBe(rootBase) + expect(r[1]).toBe(child1Base) + const obj2 = await obj + expect(obj2).toBe(child1Base) + }) + test('add extra header', async () => { const root = { baseId: 'baseId' } as unknown as Item const cache = { diff --git a/packages/objectloader2/src/operations/objectLoader2.ts b/packages/objectloader2/src/operations/objectLoader2.ts index 5b7f18530..4ac3baee0 100644 --- a/packages/objectloader2/src/operations/objectLoader2.ts +++ b/packages/objectloader2/src/operations/objectLoader2.ts @@ -4,9 +4,9 @@ import IndexedDatabase from './indexedDatabase.js' import ServerDownloader from './serverDownloader.js' import { CustomLogger, Base, Item } from '../types/types.js' import { ObjectLoader2Options } from './options.js' -import { DeferredBase } from '../helpers/deferredBase.js' import { MemoryDownloader } from './memoryDownloader.js' import { MemoryDatabase } from './memoryDatabase.js' +import { DefermentManager } from '../helpers/defermentManager.js' export default class ObjectLoader2 { #objectId: string @@ -16,15 +16,16 @@ export default class ObjectLoader2 { #database: Cache #downloader: Downloader - #gathered: AsyncGeneratorQueue + #deferments: DefermentManager - #buffer: DeferredBase[] = [] + #gathered: AsyncGeneratorQueue constructor(options: ObjectLoader2Options) { this.#objectId = options.objectId this.#logger = options.logger || console.log this.#gathered = new AsyncGeneratorQueue() + this.#deferments = new DefermentManager() this.#database = options.cache || new IndexedDatabase({ @@ -71,13 +72,7 @@ export default class ObjectLoader2 { if (item) { return item.base } - const deferredBase = this.#buffer.find((x) => x.id === params.id) - if (deferredBase) { - return await deferredBase.promise - } - const d = new DeferredBase(params.id) - this.#buffer.push(d) - return d.promise + return await this.#deferments.defer({ id: params.id }) } async getTotalObjectCount() { @@ -105,12 +100,7 @@ export default class ObjectLoader2 { }) let count = 0 for await (const item of this.#gathered.consume()) { - const deferredIndex = this.#buffer.findIndex((x) => x.id === item.baseId) - if (deferredIndex !== -1) { - const deferredBase = this.#buffer[deferredIndex] - deferredBase.resolve(item.base) - this.#buffer.splice(deferredIndex, 1) - } + this.#deferments.undefer(item) yield item.base count++ if (count >= total) { From 870530055799d35c5abead9d83c39c38993fd0d4 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Thu, 10 Apr 2025 11:10:21 +0100 Subject: [PATCH 024/178] add traverser --- .../objectloader2/src/operations/traverser.ts | 102 ++++++++++++++++++ packages/objectloader2/src/types/types.ts | 6 ++ 2 files changed, 108 insertions(+) create mode 100644 packages/objectloader2/src/operations/traverser.ts diff --git a/packages/objectloader2/src/operations/traverser.ts b/packages/objectloader2/src/operations/traverser.ts new file mode 100644 index 000000000..bee0296eb --- /dev/null +++ b/packages/objectloader2/src/operations/traverser.ts @@ -0,0 +1,102 @@ +import { Base, DataChunk, isBase } from '../types/types.js' +import ObjectLoader2 from './objectLoader2.js' + +export type ProgressStage = 'download' | 'construction' +export type OnProgress = (e: { + stage: ProgressStage + current: number + total: number +}) => void + +export interface TraverserOptions { + excludeProps?: string[] +} + +export default class Traverser { + #loader: ObjectLoader2 + #options: TraverserOptions + + #totalChildrenCount = 0 + #traversedReferencesCount = 0 + + constructor(loader: ObjectLoader2, options?: TraverserOptions) { + this.#options = options || {} + this.#loader = loader + } + + async getAndConstructObject(onProgress: OnProgress) { + let firstObjectPromise: Promise | undefined = undefined + let first = true + for await (const obj of this.#loader.getObjectIterator()) { + if (first) { + firstObjectPromise = this.traverseBase(obj, onProgress) + first = false + } + } + + if (firstObjectPromise) { + await firstObjectPromise + } + } + + async traverseArray(obj: Array, onProgress: OnProgress): Promise { + const promises: Promise[] = [] + for (const arrayItem of obj) { + if (isBase(arrayItem)) { + promises.push(this.traverseBase(arrayItem, onProgress)) + } + } + await Promise.all(promises) + } + + async traverseBase(obj: Base, onProgress: OnProgress): Promise { + for (const ignoredProp of this.#options.excludeProps || []) { + delete (obj as never)[ignoredProp] + } + if (obj.__closure) { + const ids = Object.keys(obj.__closure) + const promises: Promise[] = [] + for (const id of ids) { + promises.push( + this.traverseBase(await this.#loader.getObject({ id }), onProgress) + ) + } + await Promise.all(promises) + } + if (obj.referenceId) { + await this.traverseBase( + await this.#loader.getObject({ id: obj.referenceId }), + onProgress + ) + } + // De-chunk + if (obj.speckle_type?.includes('DataChunk')) { + const chunk = obj as DataChunk + if (chunk.data) { + await this.traverseArray(chunk.data, onProgress) + } + } + + //other props + for (const prop in obj) { + if (prop === '__closure') continue + if (prop === 'referenceId') continue + if (prop === 'speckle_type') continue + const objProp = (obj as unknown as Record)[prop] + if (isBase(objProp)) { + await this.traverseBase(objProp, onProgress) + } + if (Array.isArray(objProp)) { + await this.traverseArray(objProp, onProgress) + } + } + onProgress({ + stage: 'construction', + current: + ++this.#traversedReferencesCount > this.#totalChildrenCount + ? this.#totalChildrenCount + : this.#traversedReferencesCount, + total: this.#totalChildrenCount + }) + } +} diff --git a/packages/objectloader2/src/types/types.ts b/packages/objectloader2/src/types/types.ts index 89b8ee390..af3c25191 100644 --- a/packages/objectloader2/src/types/types.ts +++ b/packages/objectloader2/src/types/types.ts @@ -12,9 +12,15 @@ export interface Item { export interface Base { id: string + speckle_type: string + referenceId?: string __closure?: Record } +export interface DataChunk extends Base { + data?: Base[] +} + export function isBase(maybeBase?: unknown): maybeBase is Base { return ( maybeBase !== null && From 7f2614cc005df21cd850f509346fac091cf7f4c1 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Thu, 10 Apr 2025 13:54:08 +0100 Subject: [PATCH 025/178] add snapshot testing --- .../defermentManager.spec.ts.snap | 8 ++ .../src/helpers/defermentManager.spec.ts | 5 +- .../indexedDatabase.spec.ts.snap | 52 ++++++++ .../__snapshots__/objectLoader2.spec.ts.snap | 126 ++++++++++++++++++ .../serverDownloader.spec.ts.snap | 58 ++++++++ .../src/operations/indexedDatabase.spec.ts | 28 ++-- .../src/operations/objectLoader2.spec.ts | 39 +++--- .../src/operations/serverDownloader.spec.ts | 27 ++-- 8 files changed, 292 insertions(+), 51 deletions(-) create mode 100644 packages/objectloader2/src/helpers/__snapshots__/defermentManager.spec.ts.snap create mode 100644 packages/objectloader2/src/operations/__snapshots__/indexedDatabase.spec.ts.snap create mode 100644 packages/objectloader2/src/operations/__snapshots__/objectLoader2.spec.ts.snap create mode 100644 packages/objectloader2/src/operations/__snapshots__/serverDownloader.spec.ts.snap diff --git a/packages/objectloader2/src/helpers/__snapshots__/defermentManager.spec.ts.snap b/packages/objectloader2/src/helpers/__snapshots__/defermentManager.spec.ts.snap new file mode 100644 index 000000000..3069712d9 --- /dev/null +++ b/packages/objectloader2/src/helpers/__snapshots__/defermentManager.spec.ts.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`deferments > defer one 1`] = ` +{ + "id": "id", + "speckle_type": "type", +} +`; diff --git a/packages/objectloader2/src/helpers/defermentManager.spec.ts b/packages/objectloader2/src/helpers/defermentManager.spec.ts index f8be3b345..2707bcd42 100644 --- a/packages/objectloader2/src/helpers/defermentManager.spec.ts +++ b/packages/objectloader2/src/helpers/defermentManager.spec.ts @@ -6,9 +6,8 @@ describe('deferments', () => { const deferments = new DefermentManager() const x = deferments.defer({ id: 'id' }) expect(x).toBeInstanceOf(Promise) - deferments.undefer({ baseId: 'id', base: { id: 'id' } }) + deferments.undefer({ baseId: 'id', base: { id: 'id', speckle_type: 'type' } }) const b = await x - expect(b).toBeDefined() - expect(b.id).toBe('id') + expect(b).toMatchSnapshot() }) }) diff --git a/packages/objectloader2/src/operations/__snapshots__/indexedDatabase.spec.ts.snap b/packages/objectloader2/src/operations/__snapshots__/indexedDatabase.spec.ts.snap new file mode 100644 index 000000000..42387a565 --- /dev/null +++ b/packages/objectloader2/src/operations/__snapshots__/indexedDatabase.spec.ts.snap @@ -0,0 +1,52 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`database cache > write single item to queue use getItem 1`] = ` +{ + "base": { + "id": "id", + "speckle_type": "type", + }, + "baseId": "id", +} +`; + +exports[`database cache > write two items to queue use getItem 1`] = ` +{ + "base": { + "id": "id", + "speckle_type": "type", + }, + "baseId": "id1", +} +`; + +exports[`database cache > write two items to queue use getItem 2`] = ` +{ + "base": { + "id": "id", + "speckle_type": "type", + }, + "baseId": "id2", +} +`; + +exports[`database cache > write two items to queue use processItems 1`] = ` +[ + { + "base": { + "id": "id", + "speckle_type": "type", + }, + "baseId": "id1", + }, + { + "base": { + "id": "id", + "speckle_type": "type", + }, + "baseId": "id2", + }, +] +`; + +exports[`database cache > write two items to queue use processItems 2`] = `[]`; diff --git a/packages/objectloader2/src/operations/__snapshots__/objectLoader2.spec.ts.snap b/packages/objectloader2/src/operations/__snapshots__/objectLoader2.spec.ts.snap new file mode 100644 index 000000000..6ef661dd9 --- /dev/null +++ b/packages/objectloader2/src/operations/__snapshots__/objectLoader2.spec.ts.snap @@ -0,0 +1,126 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`objectloader2 > add extra header 1`] = ` +{ + "baseId": "baseId", +} +`; + +exports[`objectloader2 > can get a root object from cache 1`] = ` +{ + "baseId": "baseId", +} +`; + +exports[`objectloader2 > can get a root object from downloader 1`] = ` +{ + "baseId": "baseId", +} +`; + +exports[`objectloader2 > can get root/child object from cache using iterator 1`] = ` +[ + { + "__closure": { + "child1Id": 100, + }, + "id": "rootId", + "speckle_type": "type", + }, + { + "id": "child1Id", + }, +] +`; + +exports[`objectloader2 > can get root/child object from cache using iterator and getObject 1`] = ` +[ + { + "__closure": { + "child1Id": 100, + }, + "id": "rootId", + "speckle_type": "type", + }, + { + "id": "child1Id", + "speckle_type": "type", + }, +] +`; + +exports[`objectloader2 > can get root/child object from cache using iterator and getObject 2`] = ` +{ + "id": "child1Id", + "speckle_type": "type", +} +`; + +exports[`objectloader2 > can get single object from cache using iterator 1`] = ` +[ + { + "id": "baseId", + "speckle_type": "type", + }, +] +`; + +exports[`objectloader2 > createFromJSON test 1`] = ` +[ + { + "__closure": { + "0e61e61edee00404ec6e0f9f594bce24": 100, + "f70738e3e3e593ac11099a6ed6b71154": 100, + }, + "applicationId": "1", + "arr": null, + "attachedProp": null, + "crazyProp": null, + "detachedProp": null, + "detachedProp2": null, + "dynamicProp": 123, + "id": "efeadaca70a85ae6d3acfc93a8b380db", + "list": [ + { + "__closure": null, + "referencedId": "0e61e61edee00404ec6e0f9f594bce24", + "speckle_type": "reference", + }, + ], + "list2": [ + { + "__closure": null, + "referencedId": "f70738e3e3e593ac11099a6ed6b71154", + "speckle_type": "reference", + }, + ], + "speckle_type": "Speckle.Core.Tests.Unit.Models.BaseTests+SampleObjectBase2", + }, + { + "applicationId": null, + "data": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + ], + "id": "0e61e61edee00404ec6e0f9f594bce24", + "speckle_type": "Speckle.Core.Models.DataChunk", + }, + { + "applicationId": null, + "data": [ + 1, + 10, + ], + "id": "f70738e3e3e593ac11099a6ed6b71154", + "speckle_type": "Speckle.Core.Models.DataChunk", + }, +] +`; diff --git a/packages/objectloader2/src/operations/__snapshots__/serverDownloader.spec.ts.snap b/packages/objectloader2/src/operations/__snapshots__/serverDownloader.spec.ts.snap new file mode 100644 index 000000000..438aa6f2f --- /dev/null +++ b/packages/objectloader2/src/operations/__snapshots__/serverDownloader.spec.ts.snap @@ -0,0 +1,58 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`downloader > add extra header 1`] = ` +{ + "base": { + "__closure": { + "childIds": 1, + }, + "id": "id", + "speckle_type": "type", + }, + "baseId": "id", +} +`; + +exports[`downloader > download batch of one 1`] = ` +[ + { + "base": { + "id": "id", + "speckle_type": "type", + }, + "baseId": "id", + }, +] +`; + +exports[`downloader > download batch of two 1`] = ` +[ + { + "base": { + "id": "id1", + "speckle_type": "type", + }, + "baseId": "id1", + }, + { + "base": { + "id": "id2", + "speckle_type": "type", + }, + "baseId": "id2", + }, +] +`; + +exports[`downloader > download single exists 1`] = ` +{ + "base": { + "__closure": { + "childIds": 1, + }, + "id": "id", + "speckle_type": "type", + }, + "baseId": "id", +} +`; diff --git a/packages/objectloader2/src/operations/indexedDatabase.spec.ts b/packages/objectloader2/src/operations/indexedDatabase.spec.ts index c7847e9e9..2b979933d 100644 --- a/packages/objectloader2/src/operations/indexedDatabase.spec.ts +++ b/packages/objectloader2/src/operations/indexedDatabase.spec.ts @@ -6,7 +6,7 @@ import BufferQueue from '../helpers/bufferQueue.js' describe('database cache', () => { test('write single item to queue use getItem', async () => { - const i: Item = { baseId: 'id', base: { id: 'id' } } + const i: Item = { baseId: 'id', base: { id: 'id', speckle_type: 'type' } } const database = new IndexedDatabase({ indexedDB: new IDBFactory(), keyRange: IDBKeyRange, @@ -16,13 +16,12 @@ describe('database cache', () => { await database.disposeAsync() const x = await database.getItem({ id: 'id' }) - expect(x).toBeDefined() - expect(JSON.stringify(x)).toBe(JSON.stringify(i)) + expect(x).toMatchSnapshot() }) test('write two items to queue use getItem', async () => { - const i1: Item = { baseId: 'id1', base: { id: 'id' } } - const i2: Item = { baseId: 'id2', base: { id: 'id' } } + const i1: Item = { baseId: 'id1', base: { id: 'id', speckle_type: 'type' } } + const i2: Item = { baseId: 'id2', base: { id: 'id', speckle_type: 'type' } } const database = new IndexedDatabase({ indexedDB: new IDBFactory(), keyRange: IDBKeyRange @@ -32,17 +31,15 @@ describe('database cache', () => { await database.disposeAsync() const x1 = await database.getItem({ id: i1.baseId }) - expect(x1).toBeDefined() - expect(JSON.stringify(x1)).toBe(JSON.stringify(i1)) + expect(x1).toMatchSnapshot() const x2 = await database.getItem({ id: i2.baseId }) - expect(x2).toBeDefined() - expect(JSON.stringify(x2)).toBe(JSON.stringify(i2)) + expect(x2).toMatchSnapshot() }) - test('write two items to queue use getItem', async () => { - const i1: Item = { baseId: 'id1', base: { id: 'id' } } - const i2: Item = { baseId: 'id2', base: { id: 'id' } } + test('write two items to queue use processItems', async () => { + const i1: Item = { baseId: 'id1', base: { id: 'id', speckle_type: 'type' } } + const i2: Item = { baseId: 'id2', base: { id: 'id', speckle_type: 'type' } } const database = new IndexedDatabase({ indexedDB: new IDBFactory(), keyRange: IDBKeyRange @@ -60,10 +57,7 @@ describe('database cache', () => { notFoundItems }) - expect(foundItems.values().length).toBe(2) - expect(JSON.stringify(foundItems.values()[0])).toBe(JSON.stringify(i1)) - expect(JSON.stringify(foundItems.values()[1])).toBe(JSON.stringify(i2)) - - expect(notFoundItems.values().length).toBe(0) + expect(foundItems.values()).toMatchSnapshot() + expect(notFoundItems.values()).toMatchSnapshot() }) }) diff --git a/packages/objectloader2/src/operations/objectLoader2.spec.ts b/packages/objectloader2/src/operations/objectLoader2.spec.ts index 9741caf3a..f877bd874 100644 --- a/packages/objectloader2/src/operations/objectLoader2.spec.ts +++ b/packages/objectloader2/src/operations/objectLoader2.spec.ts @@ -24,7 +24,7 @@ describe('objectloader2', () => { downloader }) const x = await loader.getRootObject() - expect(x).toBe(root) + expect(x).toMatchSnapshot() }) test('can get a root object from downloader', async () => { @@ -52,12 +52,12 @@ describe('objectloader2', () => { downloader }) const x = await loader.getRootObject() - expect(x).toBe(root) + expect(x).toMatchSnapshot() }) test('can get single object from cache using iterator', async () => { const rootId = 'baseId' - const rootBase: Base = { id: 'baseId' } + const rootBase: Base = { id: 'baseId', speckle_type: 'type' } const root = { baseId: rootId, base: rootBase } as unknown as Item const cache = { getItem(params: { id: string }): Promise { @@ -78,8 +78,7 @@ describe('objectloader2', () => { r.push(x) } - expect(r.length).toBe(1) - expect(r[0]).toBe(rootBase) + expect(r).toMatchSnapshot() }) test('can get root/child object from cache using iterator', async () => { @@ -87,7 +86,11 @@ describe('objectloader2', () => { const child1 = { baseId: 'child1Id', base: child1Base } as unknown as Item const rootId = 'rootId' - const rootBase: Base = { id: 'rootId', __closure: { child1Id: 100 } } + const rootBase: Base = { + id: 'rootId', + speckle_type: 'type', + __closure: { child1Id: 100 } + } const root = { baseId: rootId, base: rootBase @@ -132,18 +135,19 @@ describe('objectloader2', () => { for await (const x of loader.getObjectIterator()) { r.push(x) } - - expect(r.length).toBe(2) - expect(r[0]).toBe(rootBase) - expect(r[1]).toBe(child1Base) + expect(r).toMatchSnapshot() }) test('can get root/child object from cache using iterator and getObject', async () => { - const child1Base = { id: 'child1Id' } + const child1Base = { id: 'child1Id', speckle_type: 'type' } as Base const child1 = { baseId: 'child1Id', base: child1Base } as unknown as Item const rootId = 'rootId' - const rootBase: Base = { id: 'rootId', __closure: { child1Id: 100 } } + const rootBase: Base = { + id: 'rootId', + speckle_type: 'type', + __closure: { child1Id: 100 } + } const root = { baseId: rootId, base: rootBase @@ -167,11 +171,10 @@ describe('objectloader2', () => { } expect(obj).toBeDefined() - expect(r.length).toBe(2) - expect(r[0]).toBe(rootBase) - expect(r[1]).toBe(child1Base) + expect(r).toMatchSnapshot() const obj2 = await obj expect(obj2).toBe(child1Base) + expect(obj2).toMatchSnapshot() }) test('add extra header', async () => { @@ -195,6 +198,7 @@ describe('objectloader2', () => { }) const x = await loader.getRootObject() expect(x).toBe(root) + expect(x).toMatchSnapshot() }) test('createFromJSON test', async () => { @@ -246,9 +250,6 @@ describe('objectloader2', () => { for await (const x of loader.getObjectIterator()) { r.push(x) } - expect(r.length).toBe(3) - expect(r[0]).toBe(rootObj) - expect(r[1]).toBe(list1Obj) - expect(r[2]).toBe(list2Obj) + expect(r).toMatchSnapshot() }) }) diff --git a/packages/objectloader2/src/operations/serverDownloader.spec.ts b/packages/objectloader2/src/operations/serverDownloader.spec.ts index 851a77989..c10afa901 100644 --- a/packages/objectloader2/src/operations/serverDownloader.spec.ts +++ b/packages/objectloader2/src/operations/serverDownloader.spec.ts @@ -9,7 +9,7 @@ import ServerDownloader from './serverDownloader.js' describe('downloader', () => { test('download batch of one', async () => { const fetchMocker = createFetchMock(vi) - const i: Item = { baseId: 'id', base: { id: 'id' } } + const i: Item = { baseId: 'id', base: { id: 'id', speckle_type: 'type' } } fetchMocker.mockResponseOnce('id\t' + JSON.stringify(i.base) + '\n') const results = new AsyncGeneratorQueue() const db = { @@ -36,14 +36,13 @@ describe('downloader', () => { r.push(x) } - expect(r.length).toBe(1) - expect(JSON.stringify(r[0])).toBe(JSON.stringify(i)) + expect(r).toMatchSnapshot() }) test('download batch of two', async () => { const fetchMocker = createFetchMock(vi) - const i1: Item = { baseId: 'id1', base: { id: 'id1' } } - const i2: Item = { baseId: 'id2', base: { id: 'id2' } } + const i1: Item = { baseId: 'id1', base: { id: 'id1', speckle_type: 'type' } } + const i2: Item = { baseId: 'id2', base: { id: 'id2', speckle_type: 'type' } } fetchMocker.mockResponseOnce( 'id1\t' + JSON.stringify(i1.base) + '\nid2\t' + JSON.stringify(i2.base) + '\n' ) @@ -72,14 +71,15 @@ describe('downloader', () => { r.push(x) } - expect(r.length).toBe(2) - expect(JSON.stringify(r[0])).toBe(JSON.stringify(i1)) - expect(JSON.stringify(r[1])).toBe(JSON.stringify(i2)) + expect(r).toMatchSnapshot() }) test('download single exists', async () => { const fetchMocker = createFetchMock(vi) - const i: Item = { baseId: 'id', base: { id: 'id', __closure: { childIds: 1 } } } + const i: Item = { + baseId: 'id', + base: { id: 'id', speckle_type: 'type', __closure: { childIds: 1 } } + } fetchMocker.mockResponseOnce(JSON.stringify(i.base)) const results = new AsyncGeneratorQueue() const db = { @@ -98,12 +98,15 @@ describe('downloader', () => { fetch: fetchMocker }) const x = await downloader.downloadSingle() - expect(JSON.stringify(x)).toBe(JSON.stringify(i)) + expect(x).toMatchSnapshot() }) test('add extra header', async () => { const fetchMocker = createFetchMock(vi) - const i: Item = { baseId: 'id', base: { id: 'id', __closure: { childIds: 1 } } } + const i: Item = { + baseId: 'id', + base: { id: 'id', speckle_type: 'type', __closure: { childIds: 1 } } + } fetchMocker.mockResponseIf( (req) => req.headers.get('x-test') === 'asdf', JSON.stringify(i.base) @@ -128,6 +131,6 @@ describe('downloader', () => { fetch: fetchMocker }) const x = await downloader.downloadSingle() - expect(JSON.stringify(x)).toBe(JSON.stringify(i)) + expect(x).toMatchSnapshot() }) }) From 5f35d1fc9b4b4e9c2e17e93d7eff35b20a8f2cfc Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Thu, 10 Apr 2025 14:30:12 +0100 Subject: [PATCH 026/178] add traverse test --- .../__snapshots__/traverser.spec.ts.snap | 45 +++++++++ .../src/operations/traverser.spec.ts | 58 ++++++++++++ .../objectloader2/src/operations/traverser.ts | 92 ++++++++++--------- packages/objectloader2/src/types/types.ts | 31 ++++++- 4 files changed, 184 insertions(+), 42 deletions(-) create mode 100644 packages/objectloader2/src/operations/__snapshots__/traverser.spec.ts.snap create mode 100644 packages/objectloader2/src/operations/traverser.spec.ts diff --git a/packages/objectloader2/src/operations/__snapshots__/traverser.spec.ts.snap b/packages/objectloader2/src/operations/__snapshots__/traverser.spec.ts.snap new file mode 100644 index 000000000..e911fe867 --- /dev/null +++ b/packages/objectloader2/src/operations/__snapshots__/traverser.spec.ts.snap @@ -0,0 +1,45 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Traverser > root and two children with referenceId 1`] = ` +{ + "applicationId": "1", + "arr": null, + "attachedProp": null, + "crazyProp": null, + "detachedProp": null, + "detachedProp2": null, + "dynamicProp": 123, + "id": "efeadaca70a85ae6d3acfc93a8b380db", + "list": [ + { + "applicationId": null, + "data": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + ], + "id": "0e61e61edee00404ec6e0f9f594bce24", + "speckle_type": "Speckle.Core.Models.DataChunk", + }, + ], + "list2": [ + { + "applicationId": null, + "data": [ + 1, + 10, + ], + "id": "f70738e3e3e593ac11099a6ed6b71154", + "speckle_type": "Speckle.Core.Models.DataChunk", + }, + ], + "speckle_type": "Speckle.Core.Tests.Unit.Models.BaseTests+SampleObjectBase2", +} +`; diff --git a/packages/objectloader2/src/operations/traverser.spec.ts b/packages/objectloader2/src/operations/traverser.spec.ts new file mode 100644 index 000000000..ebce891cd --- /dev/null +++ b/packages/objectloader2/src/operations/traverser.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'vitest' +import { Base } from '../types/types.js' +import ObjectLoader2 from './objectLoader2.js' +import Traverser from './traverser.js' + +describe('Traverser', () => { + test('root and two children with referenceId', async () => { + const root = `{ + "list": [{ + "speckle_type": "reference", + "referencedId": "0e61e61edee00404ec6e0f9f594bce24", + "__closure": null + }], + "list2": [{ + "speckle_type": "reference", + "referencedId": "f70738e3e3e593ac11099a6ed6b71154", + "__closure": null + }], + "arr": null, + "detachedProp": null, + "detachedProp2": null, + "attachedProp": null, + "crazyProp": null, + "applicationId": "1", + "speckle_type": "Speckle.Core.Tests.Unit.Models.BaseTests+SampleObjectBase2", + "dynamicProp": 123, + "id": "efeadaca70a85ae6d3acfc93a8b380db", + "__closure": { + "0e61e61edee00404ec6e0f9f594bce24": 100, + "f70738e3e3e593ac11099a6ed6b71154": 100 + } +}` + + const list1 = `{ + "data": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0], + "applicationId": null, + "speckle_type": "Speckle.Core.Models.DataChunk", + "id": "0e61e61edee00404ec6e0f9f594bce24" +}` + + const list2 = `{ + "data": [1.0, 10.0], + "applicationId": null, + "speckle_type": "Speckle.Core.Models.DataChunk", + "id": "f70738e3e3e593ac11099a6ed6b71154" +}` + + const rootObj = JSON.parse(root) as Base + const list1Obj = JSON.parse(list1) as Base + const list2Obj = JSON.parse(list2) as Base + + const loader = ObjectLoader2.createFromObjects([rootObj, list1Obj, list2Obj]) + + const traverser = new Traverser(loader) + const r = await traverser.traverse() + expect(r).toMatchSnapshot() + }) +}) diff --git a/packages/objectloader2/src/operations/traverser.ts b/packages/objectloader2/src/operations/traverser.ts index bee0296eb..2c8304908 100644 --- a/packages/objectloader2/src/operations/traverser.ts +++ b/packages/objectloader2/src/operations/traverser.ts @@ -1,4 +1,4 @@ -import { Base, DataChunk, isBase } from '../types/types.js' +import { Base, DataChunk, isBase, isReference, isScalar } from '../types/types.js' import ObjectLoader2 from './objectLoader2.js' export type ProgressStage = 'download' | 'construction' @@ -24,38 +24,43 @@ export default class Traverser { this.#loader = loader } - async getAndConstructObject(onProgress: OnProgress) { - let firstObjectPromise: Promise | undefined = undefined - let first = true + async traverse(onProgress?: OnProgress): Promise { + let firstObjectPromise: Promise | undefined = undefined for await (const obj of this.#loader.getObjectIterator()) { - if (first) { + if (!firstObjectPromise) { firstObjectPromise = this.traverseBase(obj, onProgress) - first = false } } if (firstObjectPromise) { - await firstObjectPromise + return await firstObjectPromise + } else { + throw new Error('No objects found') } } - async traverseArray(obj: Array, onProgress: OnProgress): Promise { - const promises: Promise[] = [] - for (const arrayItem of obj) { - if (isBase(arrayItem)) { - promises.push(this.traverseBase(arrayItem, onProgress)) + async traverseArray(array: Array, onProgress?: OnProgress): Promise { + for (let i = 0; i < 10; i++) { + const prop = array[i] + if (isScalar(prop)) continue + if (isBase(prop)) { + array[i] = await this.traverseBase(prop, onProgress) + } else if (isReference(prop)) { + array[i] = await this.traverseBase( + await this.#loader.getObject({ id: prop.referencedId }), + onProgress + ) } } - await Promise.all(promises) } - async traverseBase(obj: Base, onProgress: OnProgress): Promise { + async traverseBase(base: Base, onProgress?: OnProgress): Promise { for (const ignoredProp of this.#options.excludeProps || []) { - delete (obj as never)[ignoredProp] + delete (base as never)[ignoredProp] } - if (obj.__closure) { - const ids = Object.keys(obj.__closure) - const promises: Promise[] = [] + if (base.__closure) { + const ids = Object.keys(base.__closure) + const promises: Promise[] = [] for (const id of ids) { promises.push( this.traverseBase(await this.#loader.getObject({ id }), onProgress) @@ -63,40 +68,45 @@ export default class Traverser { } await Promise.all(promises) } - if (obj.referenceId) { - await this.traverseBase( - await this.#loader.getObject({ id: obj.referenceId }), - onProgress - ) - } + delete (base as never)['__closure'] + // De-chunk - if (obj.speckle_type?.includes('DataChunk')) { - const chunk = obj as DataChunk + if (base.speckle_type?.includes('DataChunk')) { + const chunk = base as DataChunk if (chunk.data) { await this.traverseArray(chunk.data, onProgress) } } //other props - for (const prop in obj) { + for (const prop in base) { if (prop === '__closure') continue if (prop === 'referenceId') continue if (prop === 'speckle_type') continue - const objProp = (obj as unknown as Record)[prop] - if (isBase(objProp)) { - await this.traverseBase(objProp, onProgress) - } - if (Array.isArray(objProp)) { - await this.traverseArray(objProp, onProgress) + if (prop === 'data') continue + const baseProp = (base as unknown as Record)[prop] + if (isScalar(baseProp)) continue + if (isBase(baseProp)) { + await this.traverseBase(baseProp, onProgress) + } else if (isReference(baseProp)) { + await this.traverseBase( + await this.#loader.getObject({ id: baseProp.referencedId }), + onProgress + ) + } else if (Array.isArray(baseProp)) { + await this.traverseArray(baseProp, onProgress) } } - onProgress({ - stage: 'construction', - current: - ++this.#traversedReferencesCount > this.#totalChildrenCount - ? this.#totalChildrenCount - : this.#traversedReferencesCount, - total: this.#totalChildrenCount - }) + if (onProgress) { + onProgress({ + stage: 'construction', + current: + ++this.#traversedReferencesCount > this.#totalChildrenCount + ? this.#totalChildrenCount + : this.#traversedReferencesCount, + total: this.#totalChildrenCount + }) + } + return base } } diff --git a/packages/objectloader2/src/types/types.ts b/packages/objectloader2/src/types/types.ts index af3c25191..af53829c1 100644 --- a/packages/objectloader2/src/types/types.ts +++ b/packages/objectloader2/src/types/types.ts @@ -13,7 +13,12 @@ export interface Item { export interface Base { id: string speckle_type: string - referenceId?: string + __closure?: Record +} + +export interface Reference { + speckle_type: string + referencedId: string __closure?: Record } @@ -29,3 +34,27 @@ export function isBase(maybeBase?: unknown): maybeBase is Base { typeof maybeBase.id === 'string' ) } + +export function isReference(maybeRef?: unknown): maybeRef is Reference { + return ( + maybeRef !== null && + typeof maybeRef === 'object' && + 'referencedId' in maybeRef && + typeof maybeRef.referencedId === 'string' + ) +} + +export function isScalar( + value: unknown +): value is string | number | boolean | bigint | symbol | undefined { + const type = typeof value + return ( + value === null || + type === 'string' || + type === 'number' || + type === 'boolean' || + type === 'bigint' || + type === 'symbol' || + type === 'undefined' + ) +} From 7f0390c51d4d669ad6f82ba4d778d90a7e7f0226 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Thu, 10 Apr 2025 17:11:08 +0100 Subject: [PATCH 027/178] add camelcase ignore --- packages/objectloader2/eslint.config.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/objectloader2/eslint.config.mjs b/packages/objectloader2/eslint.config.mjs index 4b9e2fa16..105f47963 100644 --- a/packages/objectloader2/eslint.config.mjs +++ b/packages/objectloader2/eslint.config.mjs @@ -49,6 +49,7 @@ const configs = [ { files: ['**/*.spec.ts'], rules: { + camelcase: 'off', '@typescript-eslint/no-unused-expressions': 'off' } } From 7ac5b9afb079d50f6cab57fc7e6da5924ff576ad Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Mon, 14 Apr 2025 10:34:32 +0100 Subject: [PATCH 028/178] add tests for memory usage for cache and download --- packages/objectloader2/src/index.ts | 1 + .../__snapshots__/objectLoader2.spec.ts.snap | 27 ++++++++++- .../src/operations/memoryDatabase.ts | 10 ++-- .../src/operations/memoryDownloader.ts | 17 ++++++- .../src/operations/objectLoader2.spec.ts | 46 ++++++++++++++++++- .../src/operations/objectLoader2.ts | 4 +- .../objectloader2/src/operations/options.ts | 9 +++- 7 files changed, 101 insertions(+), 13 deletions(-) diff --git a/packages/objectloader2/src/index.ts b/packages/objectloader2/src/index.ts index 2517a22f0..4dbbac01b 100644 --- a/packages/objectloader2/src/index.ts +++ b/packages/objectloader2/src/index.ts @@ -1,3 +1,4 @@ import ObjectLoader2 from './operations/objectLoader2.js' export default ObjectLoader2 +export { MemoryDatabase } from './operations/memoryDatabase.js' diff --git a/packages/objectloader2/src/operations/__snapshots__/objectLoader2.spec.ts.snap b/packages/objectloader2/src/operations/__snapshots__/objectLoader2.spec.ts.snap index 6ef661dd9..bba7ad678 100644 --- a/packages/objectloader2/src/operations/__snapshots__/objectLoader2.spec.ts.snap +++ b/packages/objectloader2/src/operations/__snapshots__/objectLoader2.spec.ts.snap @@ -33,7 +33,7 @@ exports[`objectloader2 > can get root/child object from cache using iterator 1`] ] `; -exports[`objectloader2 > can get root/child object from cache using iterator and getObject 1`] = ` +exports[`objectloader2 > can get root/child object from memory cache using iterator and getObject 1`] = ` [ { "__closure": { @@ -49,7 +49,30 @@ exports[`objectloader2 > can get root/child object from cache using iterator and ] `; -exports[`objectloader2 > can get root/child object from cache using iterator and getObject 2`] = ` +exports[`objectloader2 > can get root/child object from memory cache using iterator and getObject 2`] = ` +{ + "id": "child1Id", + "speckle_type": "type", +} +`; + +exports[`objectloader2 > can get root/child object from memory downloader using iterator and getObject 1`] = ` +[ + { + "__closure": { + "child1Id": 100, + }, + "id": "rootId", + "speckle_type": "type", + }, + { + "id": "child1Id", + "speckle_type": "type", + }, +] +`; + +exports[`objectloader2 > can get root/child object from memory downloader using iterator and getObject 2`] = ` { "id": "child1Id", "speckle_type": "type", diff --git a/packages/objectloader2/src/operations/memoryDatabase.ts b/packages/objectloader2/src/operations/memoryDatabase.ts index ef9d234f5..680f44576 100644 --- a/packages/objectloader2/src/operations/memoryDatabase.ts +++ b/packages/objectloader2/src/operations/memoryDatabase.ts @@ -1,11 +1,12 @@ import Queue from '../helpers/queue.js' import { Base, Item } from '../types/types.js' import { Cache } from './interfaces.js' +import { MemoryDatabaseOptions } from './options.js' export class MemoryDatabase implements Cache { #items: Record - constructor(items: Record) { - this.#items = items + constructor(options?: MemoryDatabaseOptions) { + this.#items = options?.items || {} } getItem(params: { id: string }): Promise { @@ -13,7 +14,7 @@ export class MemoryDatabase implements Cache { if (item) { return Promise.resolve({ baseId: params.id, base: item }) } - throw new Error('Method not implemented.') + return Promise.resolve(undefined) } processItems(params: { ids: string[] @@ -31,7 +32,8 @@ export class MemoryDatabase implements Cache { } return Promise.resolve() } - add(): Promise { + add(item: Item): Promise { + this.#items[item.baseId] = item.base return Promise.resolve() } disposeAsync(): Promise { diff --git a/packages/objectloader2/src/operations/memoryDownloader.ts b/packages/objectloader2/src/operations/memoryDownloader.ts index 23cbe08c7..a58d92a9f 100644 --- a/packages/objectloader2/src/operations/memoryDownloader.ts +++ b/packages/objectloader2/src/operations/memoryDownloader.ts @@ -1,12 +1,20 @@ +import AsyncGeneratorQueue from '../helpers/asyncGeneratorQueue.js' import { Base, Item } from '../types/types.js' import { Downloader } from './interfaces.js' export class MemoryDownloader implements Downloader { #items: Record #rootId: string - constructor(rootId: string, items: Record) { + #results?: AsyncGeneratorQueue + + constructor( + rootId: string, + items: Record, + results?: AsyncGeneratorQueue + ) { this.#rootId = rootId this.#items = items + this.#results = results } initializePool(): void {} downloadSingle(): Promise { @@ -19,7 +27,12 @@ export class MemoryDownloader implements Downloader { disposeAsync(): Promise { return Promise.resolve() } - add(): void { + add(id: string): void { + const base = this.#items[id] + if (base) { + this.#results?.add({ baseId: id, base }) + return + } throw new Error('Method not implemented.') } } diff --git a/packages/objectloader2/src/operations/objectLoader2.spec.ts b/packages/objectloader2/src/operations/objectLoader2.spec.ts index f877bd874..ad592f076 100644 --- a/packages/objectloader2/src/operations/objectLoader2.spec.ts +++ b/packages/objectloader2/src/operations/objectLoader2.spec.ts @@ -5,6 +5,7 @@ import { Cache, Downloader } from './interfaces.js' import Queue from '../helpers/queue.js' import { MemoryDatabase } from './memoryDatabase.js' import { MemoryDownloader } from './memoryDownloader.js' +import AsyncGeneratorQueue from '../helpers/asyncGeneratorQueue.js' describe('objectloader2', () => { test('can get a root object from cache', async () => { @@ -138,7 +139,7 @@ describe('objectloader2', () => { expect(r).toMatchSnapshot() }) - test('can get root/child object from cache using iterator and getObject', async () => { + test('can get root/child object from memory cache using iterator and getObject', async () => { const child1Base = { id: 'child1Id', speckle_type: 'type' } as Base const child1 = { baseId: 'child1Id', base: child1Base } as unknown as Item @@ -161,7 +162,7 @@ describe('objectloader2', () => { serverUrl: 'a', streamId: 'b', objectId: root.baseId, - cache: new MemoryDatabase(records), + cache: new MemoryDatabase({ items: records }), downloader: new MemoryDownloader(rootId, records) }) const r = [] @@ -177,6 +178,47 @@ describe('objectloader2', () => { expect(obj2).toMatchSnapshot() }) + test('can get root/child object from memory downloader using iterator and getObject', async () => { + const child1Base = { id: 'child1Id', speckle_type: 'type' } as Base + const child1 = { baseId: 'child1Id', base: child1Base } as unknown as Item + + const rootId = 'rootId' + const rootBase: Base = { + id: 'rootId', + speckle_type: 'type', + __closure: { child1Id: 100 } + } + const root = { + baseId: rootId, + base: rootBase + } as unknown as Item + + const records: Record = {} + records[root.baseId] = rootBase + records[child1.baseId] = child1Base + + const results: AsyncGeneratorQueue = new AsyncGeneratorQueue() + const loader = new ObjectLoader2({ + serverUrl: 'a', + streamId: 'b', + objectId: root.baseId, + results, + cache: new MemoryDatabase(), + downloader: new MemoryDownloader(rootId, records, results) + }) + const r = [] + const obj = loader.getObject({ id: child1.baseId }) + for await (const x of loader.getObjectIterator()) { + r.push(x) + } + + expect(obj).toBeDefined() + expect(r).toMatchSnapshot() + const obj2 = await obj + expect(obj2).toBe(child1Base) + expect(obj2).toMatchSnapshot() + }) + test('add extra header', async () => { const root = { baseId: 'baseId' } as unknown as Item const cache = { diff --git a/packages/objectloader2/src/operations/objectLoader2.ts b/packages/objectloader2/src/operations/objectLoader2.ts index 4ac3baee0..4cd105b77 100644 --- a/packages/objectloader2/src/operations/objectLoader2.ts +++ b/packages/objectloader2/src/operations/objectLoader2.ts @@ -24,7 +24,7 @@ export default class ObjectLoader2 { this.#objectId = options.objectId this.#logger = options.logger || console.log - this.#gathered = new AsyncGeneratorQueue() + this.#gathered = options.results || new AsyncGeneratorQueue() this.#deferments = new DefermentManager() this.#database = options.cache || @@ -120,7 +120,7 @@ export default class ObjectLoader2 { serverUrl: 'dummy', streamId: 'dummy', objectId: root.id, - cache: new MemoryDatabase(records), + cache: new MemoryDatabase({ items: records }), downloader: new MemoryDownloader(root.id, records) }) return loader diff --git a/packages/objectloader2/src/operations/options.ts b/packages/objectloader2/src/operations/options.ts index 2691c8499..59587d7e3 100644 --- a/packages/objectloader2/src/operations/options.ts +++ b/packages/objectloader2/src/operations/options.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-function-type */ +import AsyncGeneratorQueue from '../helpers/asyncGeneratorQueue.js'; import Queue from '../helpers/queue.js' -import { CustomLogger, Fetcher, Item } from '../types/types.js' +import { Base, CustomLogger, Fetcher, Item } from '../types/types.js' import { Cache, Downloader } from './interfaces.js' export interface ObjectLoader2Options { @@ -12,6 +13,7 @@ export interface ObjectLoader2Options { token?: string logger?: CustomLogger headers?: Headers + results?: AsyncGeneratorQueue, cache?: Cache downloader?: Downloader } @@ -39,3 +41,8 @@ export interface BaseDownloadOptions { database: Cache results: Queue } + +export interface MemoryDatabaseOptions { + logger?: CustomLogger + items?: Record +} From bedb649407cdba1fe6eecb0f04cf637923bbc029 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Mon, 14 Apr 2025 10:34:55 +0100 Subject: [PATCH 029/178] fix options --- packages/objectloader2/src/operations/options.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/objectloader2/src/operations/options.ts b/packages/objectloader2/src/operations/options.ts index 59587d7e3..7bc7c6e81 100644 --- a/packages/objectloader2/src/operations/options.ts +++ b/packages/objectloader2/src/operations/options.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unsafe-function-type */ -import AsyncGeneratorQueue from '../helpers/asyncGeneratorQueue.js'; +import AsyncGeneratorQueue from '../helpers/asyncGeneratorQueue.js' import Queue from '../helpers/queue.js' import { Base, CustomLogger, Fetcher, Item } from '../types/types.js' import { Cache, Downloader } from './interfaces.js' @@ -13,7 +13,7 @@ export interface ObjectLoader2Options { token?: string logger?: CustomLogger headers?: Headers - results?: AsyncGeneratorQueue, + results?: AsyncGeneratorQueue cache?: Cache downloader?: Downloader } From 0027748eb6bdb6a210c94d9405cd6e17971f1596 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Wed, 23 Apr 2025 14:25:35 +0100 Subject: [PATCH 030/178] merge fixes --- packages/viewer-sandbox/src/Sandbox.ts | 10 ++++++++-- packages/viewer-sandbox/src/main.ts | 13 ++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/viewer-sandbox/src/Sandbox.ts b/packages/viewer-sandbox/src/Sandbox.ts index 85977d24c..59cc0c79a 100644 --- a/packages/viewer-sandbox/src/Sandbox.ts +++ b/packages/viewer-sandbox/src/Sandbox.ts @@ -57,7 +57,7 @@ import Bright from '../assets/hdri/Bright.png' import { Euler, Vector3, Box3, Color, LinearFilter } from 'three' import { GeometryType } from '@speckle/viewer' import { MeshBatch } from '@speckle/viewer' -import ObjectLoader2 from '@speckle/objectloader2' +import ObjectLoader2, { MemoryDatabase } from '@speckle/objectloader2' export default class Sandbox { private viewer: Viewer @@ -1344,7 +1344,13 @@ export default class Sandbox { options: { enableCaching: true } })*/ - const loader = new ObjectLoader2({ serverUrl, streamId, objectId, token }) + const loader = new ObjectLoader2({ + serverUrl, + streamId, + objectId, + token, + cache: new MemoryDatabase() + }) let count = 0 for await (const {} of loader.getObjectIterator()) { diff --git a/packages/viewer-sandbox/src/main.ts b/packages/viewer-sandbox/src/main.ts index 040fca3ca..a53d1d363 100644 --- a/packages/viewer-sandbox/src/main.ts +++ b/packages/viewer-sandbox/src/main.ts @@ -102,8 +102,8 @@ const createViewer = async (containerName: string, _stream: string) => { sandbox.makeDiffUI() sandbox.makeMeasurementsUI() - // await sandbox.objectLoaderOnly(_stream) - await sandbox.loadUrl(_stream) + await sandbox.objectLoaderOnly(_stream) + //await sandbox.loadUrl(_stream) // await sandbox.loadJSON(JSONSpeckleStream) } @@ -111,12 +111,11 @@ const getStream = () => { return ( // prettier-ignore // Revit sample house (good for bim-like stuff with many display meshes) - 'https://app.speckle.systems/streams/da9e320dad/commits/5388ef24b8' - // 'https://latest.speckle.systems/streams/c1faab5c62/commits/ab1a1ab2b6' - // 'https://app.speckle.systems/streams/da9e320dad/commits/5388ef24b8' - // 'https://latest.speckle.systems/streams/58b5648c4d/commits/60371ecb2d' + //'https://app.speckle.systems/streams/da9e320dad/commits/5388ef24b8' + 'https://latest.speckle.systems/streams/c1faab5c62/commits/ab1a1ab2b6' + // 'https://latest.speckle.systems/streams/58b5648c4d/commits/60371ecb2d' // 'Super' heavy revit shit - // 'https://app.speckle.systems/streams/e6f9156405/commits/0694d53bb5' + // 'https://app.speckle.systems/streams/e6f9156405/commits/0694d53bb5' // IFC building (good for a tree based structure) // 'https://latest.speckle.systems/streams/92b620fb17/commits/2ebd336223' // IFC story, a subtree of the above From a4290a05750385850f552e7cdbcaa9e55902f0ae Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Wed, 23 Apr 2025 14:26:25 +0100 Subject: [PATCH 031/178] don't use memory default --- packages/viewer-sandbox/src/Sandbox.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/viewer-sandbox/src/Sandbox.ts b/packages/viewer-sandbox/src/Sandbox.ts index 59cc0c79a..bb059dc9a 100644 --- a/packages/viewer-sandbox/src/Sandbox.ts +++ b/packages/viewer-sandbox/src/Sandbox.ts @@ -1348,8 +1348,7 @@ export default class Sandbox { serverUrl, streamId, objectId, - token, - cache: new MemoryDatabase() + token }) let count = 0 From 1402a16d29608e9d357f5bb81079b675878e49c7 Mon Sep 17 00:00:00 2001 From: Alexandru Popovici Date: Mon, 28 Apr 2025 11:22:02 +0300 Subject: [PATCH 032/178] RTE Transform Inconsistencies (#4469) * fix(viewer-lib): Figured out the source for the inconsistencies between the TAS node transformation and the shade transformation in regards to using pivot points for RTE. * feat(viewer): BatchObject now stores the pivot value. Added convenience setters for positions, rotation and scale in BatchObject * chore(viewer-lib): Updated the rest of the shaders with the scaling fix * chore(viewer-lib): Reset default stream --- packages/viewer-sandbox/src/Sandbox.ts | 37 +++++--------- .../src/modules/batching/BatchObject.ts | 51 ++++++++++++------- .../materials/shaders/speckle-basic-vert.ts | 18 ++++--- .../shaders/speckle-depth-normal-id-vert.ts | 20 +++++--- .../shaders/speckle-depth-normal-vert.ts | 18 ++++--- .../materials/shaders/speckle-depth-vert.ts | 18 ++++--- .../shaders/speckle-displace.vert.ts | 18 ++++--- .../materials/shaders/speckle-ghost-vert.ts | 20 +++++--- .../materials/shaders/speckle-normal-vert.ts | 20 +++++--- .../shaders/speckle-standard-colored-vert.ts | 26 ++++++---- .../shaders/speckle-standard-vert.ts | 24 +++++---- .../materials/shaders/speckle-text-vert.ts | 18 ++++--- .../shaders/speckle-viewport-vert.ts | 18 ++++--- .../objects/TopLevelAccelerationStructure.ts | 6 +-- 14 files changed, 191 insertions(+), 121 deletions(-) diff --git a/packages/viewer-sandbox/src/Sandbox.ts b/packages/viewer-sandbox/src/Sandbox.ts index 85977d24c..2ddb0813b 100644 --- a/packages/viewer-sandbox/src/Sandbox.ts +++ b/packages/viewer-sandbox/src/Sandbox.ts @@ -307,24 +307,26 @@ export default class Sandbox { objects.push(batchObject) } } + const unionBox: Box3 = new Box3() + objects.forEach((obj: BatchObject) => { + unionBox.union(obj.renderView.aabb || new Box3()) + }) + const origin = unionBox.getCenter(new Vector3()) + objects.forEach((obj: BatchObject) => { + obj.pivot = origin + }) const position = { value: { x: 0, y: 0, z: 0 } } const rotation = { value: { x: 0, y: 0, z: 0 } } const scale = { value: { x: 1, y: 1, z: 1 } } this.objectControls .addInput(position, 'value', { label: 'Position' }) .on('change', () => { - // const unionBox: Box3 = new Box3() - // objects.forEach((obj: BatchObject) => { - // unionBox.union(obj.renderView.aabb) - // }) - // const origin = unionBox.getCenter(new Vector3()) objects.forEach((obj: BatchObject) => { - obj.transformTRS(position.value) - // obj.position = new Vector3( - // position.value.x, - // position.value.y, - // position.value.z - // ) + obj.position = new Vector3( + position.value.x, + position.value.y, + position.value.z + ) }) this.viewer.requestRender() }) @@ -337,13 +339,7 @@ export default class Sandbox { z: { step: 0.1 } }) .on('change', () => { - // const unionBox: Box3 = new Box3() - // objects.forEach((obj: BatchObject) => { - // unionBox.union(obj.renderView.aabb) - // }) - // const origin = unionBox.getCenter(new Vector3()) objects.forEach((obj: BatchObject) => { - // obj.transformTRS(position.value, rotation.value, scale.value, origin) obj.euler = new Euler( rotation.value.x, rotation.value.y, @@ -362,13 +358,8 @@ export default class Sandbox { z: { step: 0.1 } }) .on('change', () => { - const unionBox: Box3 = new Box3() objects.forEach((obj: BatchObject) => { - unionBox.union(obj.renderView.aabb || new Box3()) - }) - const origin = unionBox.getCenter(new Vector3()) - objects.forEach((obj: BatchObject) => { - obj.transformTRS(position.value, rotation.value, scale.value, origin) + obj.scale = new Vector3(scale.value.x, scale.value.y, scale.value.z) }) this.viewer.requestRender() }) diff --git a/packages/viewer/src/modules/batching/BatchObject.ts b/packages/viewer/src/modules/batching/BatchObject.ts index a6da6ebd5..aeff9d74e 100644 --- a/packages/viewer/src/modules/batching/BatchObject.ts +++ b/packages/viewer/src/modules/batching/BatchObject.ts @@ -32,6 +32,7 @@ export class BatchObject { public pivot_Low: Vector3 = new Vector3() public translation: Vector3 = new Vector3() public scaleValue: Vector3 = new Vector3(1, 1, 1) + public pivotValue: Vector3 = new Vector3() protected static matBuff0: Matrix4 = new Matrix4() protected static matBuff1: Matrix4 = new Matrix4() @@ -68,31 +69,23 @@ export class BatchObject { return this._localOrigin } + public set pivot(value: Vector3 | null) { + if (!value) { + this.pivotValue.copy(this._localOrigin) + } else this.pivotValue.copy(value) + Geometry.DoubleToHighLowVector(this.pivotValue, this.pivot_Low, this.pivot_High) + } + public set position(value: Vector3) { - this.transformTRS( - new Vector3().subVectors(value, this._localOrigin), - this.eulerValue, - this.scaleValue, - new Vector3().addVectors(this.pivot_Low, this.pivot_High) - ) + this.transformTRS(value, this.eulerValue, this.scaleValue, this.pivotValue) } public set euler(euler: Euler) { - this.transformTRS( - this.translation, - euler, - this.scaleValue, - new Vector3().addVectors(this.pivot_Low, this.pivot_High) - ) + this.transformTRS(this.translation, euler, this.scaleValue, this.pivotValue) } public set scale(scale: Vector3) { - this.transformTRS( - this.translation, - this.eulerValue, - scale, - new Vector3().addVectors(this.pivot_Low, this.pivot_High) - ) + this.transformTRS(this.translation, this.eulerValue, scale, this.pivotValue) } public constructor(renderView: NodeRenderView, batchIndex: number) { @@ -102,6 +95,7 @@ export class BatchObject { this.transformInv = new Matrix4().identity() this._localOrigin = this._renderView.aabb.getCenter(new Vector3()) + this.pivotValue.copy(this._localOrigin) Geometry.DoubleToHighLowVector( new Vector3(this._localOrigin.x, this._localOrigin.y, this._localOrigin.z), this.pivot_Low, @@ -179,9 +173,27 @@ export class BatchObject { } this.transform.identity() + this.transform.multiply(T) this.transform.multiply(R) this.transform.multiply(S) - this.transform.premultiply(T) + + const mat = new Matrix4().multiplyMatrices( + new Matrix4().makeTranslation( + BatchObject.pivotBuff.x, + BatchObject.pivotBuff.y, + BatchObject.pivotBuff.z + ), + this.transform + ) + + mat.multiply( + new Matrix4().makeTranslation( + -BatchObject.pivotBuff.x, + -BatchObject.pivotBuff.y, + -BatchObject.pivotBuff.z + ) + ) + this.transform.copy(mat) this.transformInv.copy(this.transform) this.transformInv.invert() @@ -189,6 +201,7 @@ export class BatchObject { this.translation.copy(BatchObject.translationBuff) this.quaternion.setFromEuler(BatchObject.eulerBuff) this.scaleValue.copy(BatchObject.scaleBuff) + this.pivotValue.copy(BatchObject.pivotBuff) Geometry.DoubleToHighLowVector( BatchObject.pivotBuff, diff --git a/packages/viewer/src/modules/materials/shaders/speckle-basic-vert.ts b/packages/viewer/src/modules/materials/shaders/speckle-basic-vert.ts index b72e0bdbc..813f40346 100644 --- a/packages/viewer/src/modules/materials/shaders/speckle-basic-vert.ts +++ b/packages/viewer/src/modules/materials/shaders/speckle-basic-vert.ts @@ -101,7 +101,13 @@ export const speckleBasicVert = /* glsl */ ` return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); } - highp vec3 rotate_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 quat) + /** Another workaround for Apple's stupid compiler */ + vec4 safeMul(vec4 a, vec4 b) { + // Prevents constant folding and optimization + return (a + vec4(0.0)) * (b + vec4(1.0)) - a * vec4(1.0); + } + + highp vec3 rotate_scaled_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 scale, highp vec4 quat) { /** !!! WORKAROUND FOR Intel IrisXe CARDS !!! */ /** The code below will not produce correct results in intel IrisXE integrated GPUs. @@ -113,10 +119,10 @@ export const speckleBasicVert = /* glsl */ ` // return position.xyz + 2.0 * cross(quat.xyz, cross(quat.xyz, position.xyz) + quat.w * position.xyz); /** Subtracting the rotated vectors works. */ - return rotate_vertex_position(v0.xyz, quat) - rotate_vertex_position(v1.xyz, quat); + return rotate_vertex_position(safeMul(v0, scale).xyz, quat) - rotate_vertex_position(safeMul(v1, scale).xyz, quat) ; /** An alternate workaround is - * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. _ 1e-7)); + * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. + 1e-7)); return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); However I'm not such a fan of the (1. + 1e-7) part @@ -158,10 +164,10 @@ void main() { vec4 position_highT = vec4(position, 1.); const vec3 ZERO3 = vec3(0., 0., 0.); - vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); + highp vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); #ifdef TRANSFORM_STORAGE - vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); - rteLocalPosition.xyz = rotate_vertex_position_delta(rteLocalPosition, rtePivot, tQuaternion) * tScale.xyz + rtePivot.xyz + tTranslation.xyz; + highp vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); + rteLocalPosition.xyz = rotate_scaled_vertex_position_delta(rteLocalPosition, rtePivot, tScale, tQuaternion) + rtePivot.xyz + tTranslation.xyz; #endif #ifdef USE_INSTANCING vec4 instancePivot = computeRelativePosition(ZERO3, ZERO3, uViewer_low, uViewer_high); diff --git a/packages/viewer/src/modules/materials/shaders/speckle-depth-normal-id-vert.ts b/packages/viewer/src/modules/materials/shaders/speckle-depth-normal-id-vert.ts index 269ed2632..8a9750baf 100644 --- a/packages/viewer/src/modules/materials/shaders/speckle-depth-normal-id-vert.ts +++ b/packages/viewer/src/modules/materials/shaders/speckle-depth-normal-id-vert.ts @@ -90,7 +90,13 @@ varying vec2 vHighPrecisionZW; return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); } - highp vec3 rotate_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 quat) + /** Another workaround for Apple's stupid compiler */ + vec4 safeMul(vec4 a, vec4 b) { + // Prevents constant folding and optimization + return (a + vec4(0.0)) * (b + vec4(1.0)) - a * vec4(1.0); + } + + highp vec3 rotate_scaled_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 scale, highp vec4 quat) { /** !!! WORKAROUND FOR Intel IrisXe CARDS !!! */ /** The code below will not produce correct results in intel IrisXE integrated GPUs. @@ -102,10 +108,10 @@ varying vec2 vHighPrecisionZW; // return position.xyz + 2.0 * cross(quat.xyz, cross(quat.xyz, position.xyz) + quat.w * position.xyz); /** Subtracting the rotated vectors works. */ - return rotate_vertex_position(v0.xyz, quat) - rotate_vertex_position(v1.xyz, quat); + return rotate_vertex_position(safeMul(v0, scale).xyz, quat) - rotate_vertex_position(safeMul(v1, scale).xyz, quat) ; /** An alternate workaround is - * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. _ 1e-7)); + * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. + 1e-7)); return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); However I'm not such a fan of the (1. + 1e-7) part @@ -137,7 +143,7 @@ varying vec2 vHighPrecisionZW; #endif -/** Original glsl100 and glsl300 has functions. Good outputs but maybe a bit slow? */ +/** Original glsl100 and glsl300 hash functions. Good outputs but maybe a bit slow? */ /* #if __VERSION__ == 300 vec3 hashColor(uint id) { @@ -215,10 +221,10 @@ void main() { vec4 position_highT = vec4(position, 1.); const vec3 ZERO3 = vec3(0., 0., 0.); - vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); + highp vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); #ifdef TRANSFORM_STORAGE - vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); - rteLocalPosition.xyz = rotate_vertex_position_delta(rteLocalPosition, rtePivot, tQuaternion) * tScale.xyz + rtePivot.xyz + tTranslation.xyz; + highp vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); + rteLocalPosition.xyz = rotate_scaled_vertex_position_delta(rteLocalPosition, rtePivot, tScale, tQuaternion) + rtePivot.xyz + tTranslation.xyz; #endif #ifdef USE_INSTANCING vec4 instancePivot = computeRelativePosition(ZERO3, ZERO3, uViewer_low, uViewer_high); diff --git a/packages/viewer/src/modules/materials/shaders/speckle-depth-normal-vert.ts b/packages/viewer/src/modules/materials/shaders/speckle-depth-normal-vert.ts index 17d391146..8f5842499 100644 --- a/packages/viewer/src/modules/materials/shaders/speckle-depth-normal-vert.ts +++ b/packages/viewer/src/modules/materials/shaders/speckle-depth-normal-vert.ts @@ -85,7 +85,13 @@ varying vec2 vHighPrecisionZW; return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); } - highp vec3 rotate_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 quat) + /** Another workaround for Apple's stupid compiler */ + vec4 safeMul(vec4 a, vec4 b) { + // Prevents constant folding and optimization + return (a + vec4(0.0)) * (b + vec4(1.0)) - a * vec4(1.0); + } + + highp vec3 rotate_scaled_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 scale, highp vec4 quat) { /** !!! WORKAROUND FOR Intel IrisXe CARDS !!! */ /** The code below will not produce correct results in intel IrisXE integrated GPUs. @@ -97,10 +103,10 @@ varying vec2 vHighPrecisionZW; // return position.xyz + 2.0 * cross(quat.xyz, cross(quat.xyz, position.xyz) + quat.w * position.xyz); /** Subtracting the rotated vectors works. */ - return rotate_vertex_position(v0.xyz, quat) - rotate_vertex_position(v1.xyz, quat); + return rotate_vertex_position(safeMul(v0, scale).xyz, quat) - rotate_vertex_position(safeMul(v1, scale).xyz, quat) ; /** An alternate workaround is - * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. _ 1e-7)); + * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. + 1e-7)); return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); However I'm not such a fan of the (1. + 1e-7) part @@ -156,10 +162,10 @@ void main() { vec4 position_highT = vec4(position, 1.); const vec3 ZERO3 = vec3(0., 0., 0.); - vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); + highp vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); #ifdef TRANSFORM_STORAGE - vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); - rteLocalPosition.xyz = rotate_vertex_position_delta(rteLocalPosition, rtePivot, tQuaternion) * tScale.xyz + rtePivot.xyz + tTranslation.xyz; + highp vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); + rteLocalPosition.xyz = rotate_scaled_vertex_position_delta(rteLocalPosition, rtePivot, tScale, tQuaternion) + rtePivot.xyz + tTranslation.xyz; #endif #ifdef USE_INSTANCING vec4 instancePivot = computeRelativePosition(ZERO3, ZERO3, uViewer_low, uViewer_high); diff --git a/packages/viewer/src/modules/materials/shaders/speckle-depth-vert.ts b/packages/viewer/src/modules/materials/shaders/speckle-depth-vert.ts index af30d5fc4..5054f7ffc 100644 --- a/packages/viewer/src/modules/materials/shaders/speckle-depth-vert.ts +++ b/packages/viewer/src/modules/materials/shaders/speckle-depth-vert.ts @@ -84,7 +84,13 @@ varying vec2 vHighPrecisionZW; return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); } - highp vec3 rotate_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 quat) + /** Another workaround for Apple's stupid compiler */ + vec4 safeMul(vec4 a, vec4 b) { + // Prevents constant folding and optimization + return (a + vec4(0.0)) * (b + vec4(1.0)) - a * vec4(1.0); + } + + highp vec3 rotate_scaled_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 scale, highp vec4 quat) { /** !!! WORKAROUND FOR Intel IrisXe CARDS !!! */ /** The code below will not produce correct results in intel IrisXE integrated GPUs. @@ -96,10 +102,10 @@ varying vec2 vHighPrecisionZW; // return position.xyz + 2.0 * cross(quat.xyz, cross(quat.xyz, position.xyz) + quat.w * position.xyz); /** Subtracting the rotated vectors works. */ - return rotate_vertex_position(v0.xyz, quat) - rotate_vertex_position(v1.xyz, quat); + return rotate_vertex_position(safeMul(v0, scale).xyz, quat) - rotate_vertex_position(safeMul(v1, scale).xyz, quat) ; /** An alternate workaround is - * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. _ 1e-7)); + * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. + 1e-7)); return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); However I'm not such a fan of the (1. + 1e-7) part @@ -155,10 +161,10 @@ void main() { vec4 position_highT = vec4(position, 1.); const vec3 ZERO3 = vec3(0., 0., 0.); - vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); + highp vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); #ifdef TRANSFORM_STORAGE - vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); - rteLocalPosition.xyz = rotate_vertex_position_delta(rteLocalPosition, rtePivot, tQuaternion) * tScale.xyz + rtePivot.xyz + tTranslation.xyz; + highp vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); + rteLocalPosition.xyz = rotate_scaled_vertex_position_delta(rteLocalPosition, rtePivot, tScale, tQuaternion) + rtePivot.xyz + tTranslation.xyz; #endif #ifdef USE_INSTANCING vec4 instancePivot = computeRelativePosition(ZERO3, ZERO3, uViewer_low, uViewer_high); diff --git a/packages/viewer/src/modules/materials/shaders/speckle-displace.vert.ts b/packages/viewer/src/modules/materials/shaders/speckle-displace.vert.ts index ce6e0c1bc..7505dbd06 100644 --- a/packages/viewer/src/modules/materials/shaders/speckle-displace.vert.ts +++ b/packages/viewer/src/modules/materials/shaders/speckle-displace.vert.ts @@ -80,7 +80,13 @@ uniform float displacement; return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); } - highp vec3 rotate_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 quat) + /** Another workaround for Apple's stupid compiler */ + vec4 safeMul(vec4 a, vec4 b) { + // Prevents constant folding and optimization + return (a + vec4(0.0)) * (b + vec4(1.0)) - a * vec4(1.0); + } + + highp vec3 rotate_scaled_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 scale, highp vec4 quat) { /** !!! WORKAROUND FOR Intel IrisXe CARDS !!! */ /** The code below will not produce correct results in intel IrisXE integrated GPUs. @@ -92,10 +98,10 @@ uniform float displacement; // return position.xyz + 2.0 * cross(quat.xyz, cross(quat.xyz, position.xyz) + quat.w * position.xyz); /** Subtracting the rotated vectors works. */ - return rotate_vertex_position(v0.xyz, quat) - rotate_vertex_position(v1.xyz, quat); + return rotate_vertex_position(safeMul(v0, scale).xyz, quat) - rotate_vertex_position(safeMul(v1, scale).xyz, quat) ; /** An alternate workaround is - * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. _ 1e-7)); + * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. + 1e-7)); return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); However I'm not such a fan of the (1. + 1e-7) part @@ -152,10 +158,10 @@ void main() { vec4 position_highT = vec4(position, 1.); const vec3 ZERO3 = vec3(0., 0., 0.); - vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); + highp vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); #ifdef TRANSFORM_STORAGE - vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); - rteLocalPosition.xyz = rotate_vertex_position_delta(rteLocalPosition, rtePivot, tQuaternion) * tScale.xyz + rtePivot.xyz + tTranslation.xyz; + highp vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); + rteLocalPosition.xyz = rotate_scaled_vertex_position_delta(rteLocalPosition, rtePivot, tScale, tQuaternion) + rtePivot.xyz + tTranslation.xyz; #endif #ifdef USE_INSTANCING vec4 instancePivot = computeRelativePosition(ZERO3, ZERO3, uViewer_low, uViewer_high); diff --git a/packages/viewer/src/modules/materials/shaders/speckle-ghost-vert.ts b/packages/viewer/src/modules/materials/shaders/speckle-ghost-vert.ts index 5ccde2f10..94b077772 100644 --- a/packages/viewer/src/modules/materials/shaders/speckle-ghost-vert.ts +++ b/packages/viewer/src/modules/materials/shaders/speckle-ghost-vert.ts @@ -78,7 +78,13 @@ export const speckleGhostVert = /* glsl */ ` return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); } - highp vec3 rotate_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 quat) + /** Another workaround for Apple's stupid compiler */ + vec4 safeMul(vec4 a, vec4 b) { + // Prevents constant folding and optimization + return (a + vec4(0.0)) * (b + vec4(1.0)) - a * vec4(1.0); + } + + highp vec3 rotate_scaled_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 scale, highp vec4 quat) { /** !!! WORKAROUND FOR Intel IrisXe CARDS !!! */ /** The code below will not produce correct results in intel IrisXE integrated GPUs. @@ -90,14 +96,14 @@ export const speckleGhostVert = /* glsl */ ` // return position.xyz + 2.0 * cross(quat.xyz, cross(quat.xyz, position.xyz) + quat.w * position.xyz); /** Subtracting the rotated vectors works. */ - return rotate_vertex_position(v0.xyz, quat) - rotate_vertex_position(v1.xyz, quat); + return rotate_vertex_position(safeMul(v0, scale).xyz, quat) - rotate_vertex_position(safeMul(v1, scale).xyz, quat) ; /** An alternate workaround is - * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. _ 1e-7)); + * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. + 1e-7)); return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); However I'm not such a fan of the (1. + 1e-7) part - */ + */ } #endif @@ -149,10 +155,10 @@ void main() { vec4 position_highT = vec4(position, 1.); const vec3 ZERO3 = vec3(0., 0., 0.); - vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); + highp vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); #ifdef TRANSFORM_STORAGE - vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); - rteLocalPosition.xyz = rotate_vertex_position_delta(rteLocalPosition, rtePivot, tQuaternion) * tScale.xyz + rtePivot.xyz + tTranslation.xyz; + highp vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); + rteLocalPosition.xyz = rotate_scaled_vertex_position_delta(rteLocalPosition, rtePivot, tScale, tQuaternion) + rtePivot.xyz + tTranslation.xyz; #endif #ifdef USE_INSTANCING vec4 instancePivot = computeRelativePosition(ZERO3, ZERO3, uViewer_low, uViewer_high); diff --git a/packages/viewer/src/modules/materials/shaders/speckle-normal-vert.ts b/packages/viewer/src/modules/materials/shaders/speckle-normal-vert.ts index 60f488ce2..f82f00564 100644 --- a/packages/viewer/src/modules/materials/shaders/speckle-normal-vert.ts +++ b/packages/viewer/src/modules/materials/shaders/speckle-normal-vert.ts @@ -103,7 +103,13 @@ export const speckleNormalVert = /* glsl */ ` return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); } - highp vec3 rotate_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 quat) + /** Another workaround for Apple's stupid compiler */ + vec4 safeMul(vec4 a, vec4 b) { + // Prevents constant folding and optimization + return (a + vec4(0.0)) * (b + vec4(1.0)) - a * vec4(1.0); + } + + highp vec3 rotate_scaled_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 scale, highp vec4 quat) { /** !!! WORKAROUND FOR Intel IrisXe CARDS !!! */ /** The code below will not produce correct results in intel IrisXE integrated GPUs. @@ -115,14 +121,14 @@ export const speckleNormalVert = /* glsl */ ` // return position.xyz + 2.0 * cross(quat.xyz, cross(quat.xyz, position.xyz) + quat.w * position.xyz); /** Subtracting the rotated vectors works. */ - return rotate_vertex_position(v0.xyz, quat) - rotate_vertex_position(v1.xyz, quat); + return rotate_vertex_position(safeMul(v0, scale).xyz, quat) - rotate_vertex_position(safeMul(v1, scale).xyz, quat) ; /** An alternate workaround is - * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. _ 1e-7)); + * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. + 1e-7)); return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); However I'm not such a fan of the (1. + 1e-7) part - */ + */ } #endif @@ -149,10 +155,10 @@ void main() { vec4 position_highT = vec4(position, 1.); const vec3 ZERO3 = vec3(0., 0., 0.); - vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); + highp vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); #ifdef TRANSFORM_STORAGE - vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); - rteLocalPosition.xyz = rotate_vertex_position_delta(rteLocalPosition, rtePivot, tQuaternion) * tScale.xyz + rtePivot.xyz + tTranslation.xyz; + highp vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); + rteLocalPosition.xyz = rotate_scaled_vertex_position_delta(rteLocalPosition, rtePivot, tScale, tQuaternion) + rtePivot.xyz + tTranslation.xyz; #endif #ifdef USE_INSTANCING vec4 instancePivot = computeRelativePosition(ZERO3, ZERO3, uViewer_low, uViewer_high); diff --git a/packages/viewer/src/modules/materials/shaders/speckle-standard-colored-vert.ts b/packages/viewer/src/modules/materials/shaders/speckle-standard-colored-vert.ts index ef5aa3f9e..bb7d5c02c 100644 --- a/packages/viewer/src/modules/materials/shaders/speckle-standard-colored-vert.ts +++ b/packages/viewer/src/modules/materials/shaders/speckle-standard-colored-vert.ts @@ -92,7 +92,13 @@ varying vec3 vViewPosition; return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); } - highp vec3 rotate_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 quat) + /** Another workaround for Apple's stupid compiler */ + vec4 safeMul(vec4 a, vec4 b) { + // Prevents constant folding and optimization + return (a + vec4(0.0)) * (b + vec4(1.0)) - a * vec4(1.0); + } + + highp vec3 rotate_scaled_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 scale, highp vec4 quat) { /** !!! WORKAROUND FOR Intel IrisXe CARDS !!! */ /** The code below will not produce correct results in intel IrisXE integrated GPUs. @@ -104,14 +110,14 @@ varying vec3 vViewPosition; // return position.xyz + 2.0 * cross(quat.xyz, cross(quat.xyz, position.xyz) + quat.w * position.xyz); /** Subtracting the rotated vectors works. */ - return rotate_vertex_position(v0.xyz, quat) - rotate_vertex_position(v1.xyz, quat); + return rotate_vertex_position(safeMul(v0, scale).xyz, quat) - rotate_vertex_position(safeMul(v1, scale).xyz, quat) ; /** An alternate workaround is - * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. _ 1e-7)); + * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. + 1e-7)); return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); However I'm not such a fan of the (1. + 1e-7) part - */ + */ } #endif @@ -169,10 +175,10 @@ void main() { vec4 position_highT = vec4(position, 1.); const vec3 ZERO3 = vec3(0., 0., 0.); - vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); + highp vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); #ifdef TRANSFORM_STORAGE - vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); - rteLocalPosition.xyz = rotate_vertex_position_delta(rteLocalPosition, rtePivot, tQuaternion) * tScale.xyz + rtePivot.xyz + tTranslation.xyz; + highp vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); + rteLocalPosition.xyz = rotate_scaled_vertex_position_delta(rteLocalPosition, rtePivot, tScale, tQuaternion) + rtePivot.xyz + tTranslation.xyz; #endif #ifdef USE_INSTANCING vec4 instancePivot = computeRelativePosition(ZERO3, ZERO3, uViewer_low, uViewer_high); @@ -211,7 +217,7 @@ void main() { #if NUM_DIR_LIGHT_SHADOWS > 0 #pragma unroll_loop_start for ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) { - vec4 shadowPosition = vec4(transformed, 1.0); + highp vec4 shadowPosition = vec4(transformed, 1.0); mat4 shadowMatrix = directionalShadowMatrix[ i ]; #ifdef USE_RTE @@ -219,8 +225,8 @@ void main() { shadowMatrix = rteShadowMatrix; #endif #ifdef TRANSFORM_STORAGE - vec4 rtePivotShadow = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uShadowViewer_low, uShadowViewer_high); - shadowPosition.xyz = rotate_vertex_position_delta(shadowPosition, rtePivotShadow, tQuaternion) * tScale.xyz + rtePivotShadow.xyz + tTranslation.xyz; + highp vec4 rtePivotShadow = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uShadowViewer_low, uShadowViewer_high); + shadowPosition.xyz = rotate_scaled_vertex_position_delta(shadowPosition, rtePivotShadow, tScale, tQuaternion) + rtePivotShadow.xyz + tTranslation.xyz; #endif #ifdef USE_INSTANCING vec4 rtePivotShadow = computeRelativePosition(ZERO3, ZERO3, uShadowViewer_low, uShadowViewer_high); diff --git a/packages/viewer/src/modules/materials/shaders/speckle-standard-vert.ts b/packages/viewer/src/modules/materials/shaders/speckle-standard-vert.ts index 6e0481eeb..f8d718733 100644 --- a/packages/viewer/src/modules/materials/shaders/speckle-standard-vert.ts +++ b/packages/viewer/src/modules/materials/shaders/speckle-standard-vert.ts @@ -92,7 +92,13 @@ varying vec3 vViewPosition; return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); } - highp vec3 rotate_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 quat) + /** Another workaround for Apple's stupid compiler */ + vec4 safeMul(vec4 a, vec4 b) { + // Prevents constant folding and optimization + return (a + vec4(0.0)) * (b + vec4(1.0)) - a * vec4(1.0); + } + + highp vec3 rotate_scaled_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 scale, highp vec4 quat) { /** !!! WORKAROUND FOR Intel IrisXe CARDS !!! */ /** The code below will not produce correct results in intel IrisXE integrated GPUs. @@ -104,10 +110,10 @@ varying vec3 vViewPosition; // return position.xyz + 2.0 * cross(quat.xyz, cross(quat.xyz, position.xyz) + quat.w * position.xyz); /** Subtracting the rotated vectors works. */ - return rotate_vertex_position(v0.xyz, quat) - rotate_vertex_position(v1.xyz, quat); + return rotate_vertex_position(safeMul(v0, scale).xyz, quat) - rotate_vertex_position(safeMul(v1, scale).xyz, quat) ; /** An alternate workaround is - * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. _ 1e-7)); + * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. + 1e-7)); return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); However I'm not such a fan of the (1. + 1e-7) part @@ -167,10 +173,10 @@ void main() { vec4 position_highT = vec4(position, 1.); const vec3 ZERO3 = vec3(0., 0., 0.); - vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); + highp vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); #ifdef TRANSFORM_STORAGE - vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); - rteLocalPosition.xyz = rotate_vertex_position_delta(rteLocalPosition, rtePivot, tQuaternion) * tScale.xyz + rtePivot.xyz + tTranslation.xyz; + highp vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); + rteLocalPosition.xyz = rotate_scaled_vertex_position_delta(rteLocalPosition, rtePivot, tScale, tQuaternion) + rtePivot.xyz + tTranslation.xyz; #endif #ifdef USE_INSTANCING vec4 instancePivot = computeRelativePosition(ZERO3, ZERO3, uViewer_low, uViewer_high); @@ -208,7 +214,7 @@ void main() { #if NUM_DIR_LIGHT_SHADOWS > 0 #pragma unroll_loop_start for ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) { - vec4 shadowPosition = vec4(transformed, 1.0); + highp vec4 shadowPosition = vec4(transformed, 1.0); mat4 shadowMatrix = directionalShadowMatrix[ i ]; #ifdef USE_RTE @@ -216,8 +222,8 @@ void main() { shadowMatrix = rteShadowMatrix; #endif #ifdef TRANSFORM_STORAGE - vec4 rtePivotShadow = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uShadowViewer_low, uShadowViewer_high); - shadowPosition.xyz = rotate_vertex_position_delta(shadowPosition, rtePivotShadow, tQuaternion) * tScale.xyz + rtePivotShadow.xyz + tTranslation.xyz; + highp vec4 rtePivotShadow = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uShadowViewer_low, uShadowViewer_high); + shadowPosition.xyz = rotate_scaled_vertex_position_delta(shadowPosition, rtePivotShadow, tScale, tQuaternion) + rtePivotShadow.xyz + tTranslation.xyz; #endif #ifdef USE_INSTANCING vec4 rtePivotShadow = computeRelativePosition(ZERO3, ZERO3, uShadowViewer_low, uShadowViewer_high); diff --git a/packages/viewer/src/modules/materials/shaders/speckle-text-vert.ts b/packages/viewer/src/modules/materials/shaders/speckle-text-vert.ts index 29961bd65..c312686a5 100644 --- a/packages/viewer/src/modules/materials/shaders/speckle-text-vert.ts +++ b/packages/viewer/src/modules/materials/shaders/speckle-text-vert.ts @@ -101,7 +101,13 @@ export const speckleTextVert = /* glsl */ ` return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); } - highp vec3 rotate_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 quat) + /** Another workaround for Apple's stupid compiler */ + vec4 safeMul(vec4 a, vec4 b) { + // Prevents constant folding and optimization + return (a + vec4(0.0)) * (b + vec4(1.0)) - a * vec4(1.0); + } + + highp vec3 rotate_scaled_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 scale, highp vec4 quat) { /** !!! WORKAROUND FOR Intel IrisXe CARDS !!! */ /** The code below will not produce correct results in intel IrisXE integrated GPUs. @@ -113,10 +119,10 @@ export const speckleTextVert = /* glsl */ ` // return position.xyz + 2.0 * cross(quat.xyz, cross(quat.xyz, position.xyz) + quat.w * position.xyz); /** Subtracting the rotated vectors works. */ - return rotate_vertex_position(v0.xyz, quat) - rotate_vertex_position(v1.xyz, quat); + return rotate_vertex_position(safeMul(v0, scale).xyz, quat) - rotate_vertex_position(safeMul(v1, scale).xyz, quat) ; /** An alternate workaround is - * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. _ 1e-7)); + * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. + 1e-7)); return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); However I'm not such a fan of the (1. + 1e-7) part @@ -158,10 +164,10 @@ void main() { vec4 position_highT = vec4(position, 1.); const vec3 ZERO3 = vec3(0., 0., 0.); - vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); + highp vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); #ifdef TRANSFORM_STORAGE - vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); - rteLocalPosition.xyz = rotate_vertex_position_delta(rteLocalPosition, rtePivot, tQuaternion) * tScale.xyz + rtePivot.xyz + tTranslation.xyz; + highp vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); + rteLocalPosition.xyz = rotate_scaled_vertex_position_delta(rteLocalPosition, rtePivot, tScale, tQuaternion) + rtePivot.xyz + tTranslation.xyz; #endif #ifdef USE_INSTANCING vec4 instancePivot = computeRelativePosition(ZERO3, ZERO3, uViewer_low, uViewer_high); diff --git a/packages/viewer/src/modules/materials/shaders/speckle-viewport-vert.ts b/packages/viewer/src/modules/materials/shaders/speckle-viewport-vert.ts index 07d759ddc..151ddc8c5 100644 --- a/packages/viewer/src/modules/materials/shaders/speckle-viewport-vert.ts +++ b/packages/viewer/src/modules/materials/shaders/speckle-viewport-vert.ts @@ -104,7 +104,13 @@ varying vec3 vViewPosition; return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); } - highp vec3 rotate_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 quat) + /** Another workaround for Apple's stupid compiler */ + vec4 safeMul(vec4 a, vec4 b) { + // Prevents constant folding and optimization + return (a + vec4(0.0)) * (b + vec4(1.0)) - a * vec4(1.0); + } + + highp vec3 rotate_scaled_vertex_position_delta(highp vec4 v0, highp vec4 v1, highp vec4 scale, highp vec4 quat) { /** !!! WORKAROUND FOR Intel IrisXe CARDS !!! */ /** The code below will not produce correct results in intel IrisXE integrated GPUs. @@ -116,10 +122,10 @@ varying vec3 vViewPosition; // return position.xyz + 2.0 * cross(quat.xyz, cross(quat.xyz, position.xyz) + quat.w * position.xyz); /** Subtracting the rotated vectors works. */ - return rotate_vertex_position(v0.xyz, quat) - rotate_vertex_position(v1.xyz, quat); + return rotate_vertex_position(safeMul(v0, scale).xyz, quat) - rotate_vertex_position(safeMul(v1, scale).xyz, quat) ; /** An alternate workaround is - * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. _ 1e-7)); + * highp vec3 position = (v0.xyz * (1. + 1e-7)) - (v1.xyz * (1. + 1e-7)); return position + 2.0 * cross(quat.xyz, cross(quat.xyz, position) + quat.w * position); However I'm not such a fan of the (1. + 1e-7) part @@ -162,10 +168,10 @@ void main() { vec4 position_highT = vec4(position, 1.); const vec3 ZERO3 = vec3(0., 0., 0.); - vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); + highp vec4 rteLocalPosition = computeRelativePosition(position_lowT.xyz, position_highT.xyz, uViewer_low, uViewer_high); #ifdef TRANSFORM_STORAGE - vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); - rteLocalPosition.xyz = rotate_vertex_position_delta(rteLocalPosition, rtePivot, tQuaternion) * tScale.xyz + rtePivot.xyz + tTranslation.xyz; + highp vec4 rtePivot = computeRelativePosition(tPivotLow.xyz, tPivotHigh.xyz, uViewer_low, uViewer_high); + rteLocalPosition.xyz = rotate_scaled_vertex_position_delta(rteLocalPosition, rtePivot, tScale, tQuaternion) + rtePivot.xyz + tTranslation.xyz; #endif #ifdef USE_INSTANCING vec4 instancePivot = computeRelativePosition(ZERO3, ZERO3, uViewer_low, uViewer_high); diff --git a/packages/viewer/src/modules/objects/TopLevelAccelerationStructure.ts b/packages/viewer/src/modules/objects/TopLevelAccelerationStructure.ts index 76391b5dc..437167c6d 100644 --- a/packages/viewer/src/modules/objects/TopLevelAccelerationStructure.ts +++ b/packages/viewer/src/modules/objects/TopLevelAccelerationStructure.ts @@ -142,14 +142,14 @@ export class TopLevelAccelerationStructure { public refit() { const positions = this.accelerationStructure.geometry.attributes.position .array as number[] - const boxBuffer: Box3 = new Box3() + // const boxBuffer: Box3 = new Box3() for (let k = 0; k < this.batchObjects.length; k++) { const start = this.batchObjects[k].tasVertIndexStart - const basBox = - this.batchObjects[k].accelerationStructure.getBoundingBox(boxBuffer) + const basBox = this.batchObjects[k].aabb //accelerationStructure.getBoundingBox(boxBuffer) this.updateVertArray(basBox, start * 3, positions) } this.accelerationStructure.bvh.refit() + this.bvhHelper?.update() } /* Core Cast Functions */ From b7b62348cafe68411665c191352a2876f626c562 Mon Sep 17 00:00:00 2001 From: Mike Date: Mon, 28 Apr 2025 11:23:43 +0200 Subject: [PATCH 033/178] Fix: Workspace dashboard padding updates (#4617) --- packages/frontend-2/pages/workspaces/[slug]/index.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend-2/pages/workspaces/[slug]/index.vue b/packages/frontend-2/pages/workspaces/[slug]/index.vue index 6207a8291..ea0d79739 100644 --- a/packages/frontend-2/pages/workspaces/[slug]/index.vue +++ b/packages/frontend-2/pages/workspaces/[slug]/index.vue @@ -1,6 +1,6 @@
-
- - Add another user - -
+ + Add another user +
@@ -102,7 +92,7 @@ const { handleSubmit } = useForm({ fields: [ { ...emptyInviteProjectItem, - projectRole: Roles.Stream.Contributor + projectRole: Roles.Stream.Reviewer } ] } @@ -116,7 +106,6 @@ const { const isInWorkspace = computed(() => !!props.project.workspaceId) const isAdmin = computed(() => props.project.workspace?.role === Roles.Workspace.Admin) -const disableAddUserButton = computed(() => fields.value.length >= 10) const dialogButtons = computed((): LayoutDialogButton[] => [ { text: 'Cancel', @@ -140,6 +129,22 @@ const addInviteItem = () => { }) } +const addMultipleEmails = (emails: string[]) => { + const existingEmails = fields.value.map((field) => field.value.email?.toLowerCase()) + const newEmails = emails.filter( + (email) => !existingEmails.includes(email.toLowerCase()) + ) + + newEmails.forEach((email) => { + pushInvite({ + ...emptyInviteProjectItem, + project: { id: props.project.id, name: props.project.name }, + email, + projectRole: Roles.Stream.Reviewer + }) + }) +} + const onSubmit = handleSubmit(async () => { const invites = fields.value .filter((invite) => invite.value.email || invite.value.userId) diff --git a/packages/frontend-2/components/invite/dialog/project/Row.vue b/packages/frontend-2/components/invite/dialog/project/Row.vue index a519a4665..c5924934c 100644 --- a/packages/frontend-2/components/invite/dialog/project/Row.vue +++ b/packages/frontend-2/components/invite/dialog/project/Row.vue @@ -19,6 +19,7 @@ :show-label="showLabel" label="Email" :rules="[isEmailOrEmpty]" + @paste="handlePaste" /> @@ -121,6 +123,7 @@ import { graphql } from '~~/lib/common/generated/gql' import { useQuery } from '@vue/apollo-composable' import { Roles } from '@speckle/shared' import { isEmailOrUserId } from '~~/lib/invites/helpers/validation' +import { parsePastedEmails } from '~~/lib/invites/helpers/helpers' type SelectedUser = { id: string @@ -161,6 +164,7 @@ const props = defineProps<{ const emit = defineEmits<{ (e: 'update:modelValue', value: InviteProjectItem): void (e: 'remove'): void + (e: 'add-multiple-emails', emails: string[]): void }>() const isMounted = useMounted() @@ -288,6 +292,36 @@ const showSuggestions = () => { isMenuOpen.value = true } +const handlePaste = (event: ClipboardEvent) => { + const pastedText = event.clipboardData?.getData('text') + + if (pastedText && /[\s,;]/.test(pastedText)) { + event.preventDefault() + + const validEmails = parsePastedEmails(pastedText) + + if (validEmails.length > 0) { + input.value = validEmails[0] + + if (props.isInWorkspace && props.canInviteNewMembers) { + handleInput(validEmails[0]) + } else if (!props.isInWorkspace) { + email.value = validEmails[0] + } + + validEmails.shift() + + if (validEmails.length > 0) { + emit('add-multiple-emails', validEmails) + } + } + } +} + +onMounted(() => { + input.value = props.modelValue.email +}) + onClickOutside( menuEl, () => { diff --git a/packages/frontend-2/components/invite/dialog/shared/SelectUsers.vue b/packages/frontend-2/components/invite/dialog/shared/SelectUsers.vue index 2d8fca34c..cf8b59adf 100644 --- a/packages/frontend-2/components/invite/dialog/shared/SelectUsers.vue +++ b/packages/frontend-2/components/invite/dialog/shared/SelectUsers.vue @@ -19,28 +19,12 @@ isEmailOrEmpty, canHaveRole({ allowedDomains: props.allowedDomains, - workspaceRole: props.targetRole + workspaceRole: targetRole }) ]" - :help=" - item.value.matchesDomainPolicy === false - ? 'This email does not match the set domain policy, and can only be invited as a guest' - : undefined - " + @paste="handlePaste($event, index)" /> -
@@ -55,20 +39,9 @@
-
- - Add another user - -
+ + Add another user +
@@ -84,12 +57,12 @@ import type { import { emptyInviteWorkspaceItem } from '~~/lib/invites/helpers/constants' import { isEmailOrEmpty } from '~~/lib/common/helpers/validation' import { Roles, type WorkspaceRoles, type MaybeNullOrUndefined } from '@speckle/shared' -import { canHaveRole, matchesDomainPolicy } from '~/lib/invites/helpers/validation' +import { canHaveRole } from '~/lib/invites/helpers/validation' +import { parsePastedEmails } from '~/lib/invites/helpers/helpers' const props = defineProps<{ invites: InviteWorkspaceItem[] allowedDomains: MaybeNullOrUndefined - showWorkspaceRoles?: boolean targetRole?: WorkspaceRoles }>() @@ -104,13 +77,11 @@ const { remove: removeInvite } = useFieldArray('fields') -const disableAddUserButton = computed(() => fields.value.length >= 10) - const addInviteItem = () => { pushInvite({ ...emptyInviteWorkspaceItem, - workspaceRole: Roles.Workspace.Member, - projectRole: Roles.Stream.Contributor + workspaceRole: props.targetRole || Roles.Workspace.Guest, + projectRole: Roles.Stream.Reviewer }) } @@ -118,10 +89,29 @@ const removeInviteItem = (index: number) => { removeInvite(index) } -const getDisabledWorkspaceItems = (email: string): WorkspaceRoles[] => { - return !matchesDomainPolicy(email, props.allowedDomains) - ? [Roles.Workspace.Admin, Roles.Workspace.Member] - : [] +const handlePaste = (event: ClipboardEvent, index: number) => { + const pastedText = event.clipboardData?.getData('text') + + if (pastedText && /[\s,;]/.test(pastedText)) { + event.preventDefault() + const validEmails = parsePastedEmails(pastedText) + + if (validEmails.length > 0) { + fields.value[index].value.email = validEmails[0] + validEmails.shift() + + if (validEmails.length > 0) { + validEmails.forEach((email) => { + pushInvite({ + ...emptyInviteWorkspaceItem, + email, + workspaceRole: Roles.Workspace.Member, + projectRole: Roles.Stream.Reviewer + }) + }) + } + } + } } const submitForm = handleSubmit(() => { diff --git a/packages/frontend-2/lib/invites/helpers/helpers.ts b/packages/frontend-2/lib/invites/helpers/helpers.ts new file mode 100644 index 000000000..9262a49bb --- /dev/null +++ b/packages/frontend-2/lib/invites/helpers/helpers.ts @@ -0,0 +1,11 @@ +import { isValidEmail } from '~/lib/invites/helpers/validation' + +export const parsePastedEmails = (pastedText: string) => { + const emails = pastedText + .split(/[\s,;]+/) + .map((email) => email.trim()) + .filter((email) => email.length > 0) + .filter((email) => isValidEmail(email)) + + return emails +} From cf833a77196852dadf73e50fee93caefac3561a3 Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Tue, 29 Apr 2025 10:49:37 +0300 Subject: [PATCH 040/178] fix(server): project role updates after workspace role/seat changes (#4599) * fix(workspaces): workspace role sync * role changes fixed + validated * seat changes validated * fix tests --------- Co-authored-by: Charles Driesler --- packages/server/codegen.yml | 1 + .../modules/core/repositories/streams.ts | 4 +- .../modules/shared/helpers/envHelper.ts | 2 +- .../modules/workspaces/domain/operations.ts | 4 + .../workspaces/events/eventListener.ts | 255 +++- .../workspaces/graph/resolvers/workspaces.ts | 104 +- .../workspaces/repositories/workspaces.ts | 8 +- .../modules/workspaces/services/management.ts | 14 +- .../modules/workspaces/services/projects.ts | 2 +- .../workspaces/tests/helpers/creation.ts | 3 +- .../workspaces/tests/helpers/rolesGraphql.ts | 112 ++ .../tests/integration/roles.graph.spec.ts | 1257 +++++++++++------ .../tests/unit/events/eventListener.spec.ts | 136 +- .../tests/unit/services/management.spec.ts | 38 +- .../modules/workspacesCore/domain/events.ts | 3 +- .../server/test/graphql/generated/graphql.ts | 35 + utils/helm/speckle-server/values.schema.json | 2 +- utils/helm/speckle-server/values.yaml | 2 +- 18 files changed, 1328 insertions(+), 654 deletions(-) create mode 100644 packages/server/modules/workspaces/tests/helpers/rolesGraphql.ts diff --git a/packages/server/codegen.yml b/packages/server/codegen.yml index fcc3c7d89..11ac09d9d 100644 --- a/packages/server/codegen.yml +++ b/packages/server/codegen.yml @@ -118,6 +118,7 @@ generates: documents: - 'test/graphql/*.{js,ts}' - 'modules/**/tests/helpers/graphql.ts' + - 'modules/**/tests/helpers/*Graphql.ts' config: enumsAsConst: true scalars: diff --git a/packages/server/modules/core/repositories/streams.ts b/packages/server/modules/core/repositories/streams.ts index 7bbe9e6e9..02cadc776 100644 --- a/packages/server/modules/core/repositories/streams.ts +++ b/packages/server/modules/core/repositories/streams.ts @@ -1144,7 +1144,7 @@ export const grantStreamPermissionsFactory = .count() if (parseInt(countObj.count as string) === 1) throw new StreamAccessUpdateError( - 'Could not revoke permissions for last admin', + 'A project needs at least one project owner', { info: { streamId, userId } } @@ -1222,7 +1222,7 @@ export const revokeStreamPermissionsFactory = .count() if (parseInt(countObj.count as string) === 1) throw new StreamAccessUpdateError( - 'Could not revoke permissions for last admin', + 'A project needs at least one project owner', { info: { streamId, userId } } diff --git a/packages/server/modules/shared/helpers/envHelper.ts b/packages/server/modules/shared/helpers/envHelper.ts index 35ec3b211..a9c5942c9 100644 --- a/packages/server/modules/shared/helpers/envHelper.ts +++ b/packages/server/modules/shared/helpers/envHelper.ts @@ -374,7 +374,7 @@ export function isEmailEnabled() { } export function postgresMaxConnections() { - return getIntFromEnv('POSTGRES_MAX_CONNECTIONS_SERVER', '4') + return getIntFromEnv('POSTGRES_MAX_CONNECTIONS_SERVER', '8') } export function postgresConnectionAcquireTimeoutMillis() { diff --git a/packages/server/modules/workspaces/domain/operations.ts b/packages/server/modules/workspaces/domain/operations.ts index e20029883..bb3f51f1a 100644 --- a/packages/server/modules/workspaces/domain/operations.ts +++ b/packages/server/modules/workspaces/domain/operations.ts @@ -124,6 +124,10 @@ export type GetWorkspaceCollaboratorsArgs = { */ search?: string seatType?: WorkspaceSeatType + /** + * Optionally filter by user id + */ + excludeUserIds?: string[] } } diff --git a/packages/server/modules/workspaces/events/eventListener.ts b/packages/server/modules/workspaces/events/eventListener.ts index d04b85285..d94520d32 100644 --- a/packages/server/modules/workspaces/events/eventListener.ts +++ b/packages/server/modules/workspaces/events/eventListener.ts @@ -1,5 +1,4 @@ import { - deleteProjectRoleFactory, getStreamFactory, getStreamsCollaboratorCountsFactory, grantStreamPermissionsFactory, @@ -11,6 +10,7 @@ import { CountWorkspaceRoleWithOptionalProjectRole, GetDefaultRegion, GetWorkspace, + GetWorkspaceCollaborators, GetWorkspaceRoleForUser, GetWorkspaceRoleToDefaultProjectRoleMapping, GetWorkspaceSeatTypeToProjectRoleMapping, @@ -29,15 +29,18 @@ import { logger, moduleLogger } from '@/observability/logging' import { updateWorkspaceRoleFactory } from '@/modules/workspaces/services/management' import { EventPayload, getEventBus } from '@/modules/shared/services/eventBus' import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants' -import { Roles, throwUncoveredError, WorkspaceRoles } from '@speckle/shared' import { - DeleteProjectRole, - UpsertProjectRole -} from '@/modules/core/domain/projects/operations' + Roles, + StreamRoles, + throwUncoveredError, + WorkspaceRoles +} from '@speckle/shared' +import { UpsertProjectRole } from '@/modules/core/domain/projects/operations' import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' import { Knex } from 'knex' import { countWorkspaceRoleWithOptionalProjectRoleFactory, + getWorkspaceCollaboratorsFactory, getWorkspaceFactory, getWorkspaceRoleForUserFactory, getWorkspaceRolesFactory, @@ -86,7 +89,7 @@ import { GetWorkspaceWithPlan } from '@/modules/gatekeeper/domain/billing' import { getWorkspacePlanProductId } from '@/modules/gatekeeper/stripe' -import { Workspace } from '@/modules/workspacesCore/domain/types' +import { Workspace, WorkspaceSeatType } from '@/modules/workspacesCore/domain/types' import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations' import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regions' import { @@ -103,7 +106,10 @@ import { getWorkspaceRolesAndSeatsFactory, getWorkspaceUserSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat' -import { DeleteWorkspaceSeat } from '@/modules/gatekeeper/domain/operations' +import { + DeleteWorkspaceSeat, + GetWorkspaceUserSeat +} from '@/modules/gatekeeper/domain/operations' import { isStreamCollaboratorFactory, setStreamCollaboratorFactory, @@ -202,9 +208,9 @@ export const onInviteFinalizedFactory = role: workspaceRole, userId: targetUserId, workspaceId: project.workspaceId, - skipProjectRoleUpdatesFor: [project.id], preventRoleDowngrade: true, - updatedByUserId: invite.inviterId + updatedByUserId: invite.inviterId, + skipProjectRoleUpdatesFor: [project.id] }) // Automatically promote user to project owner if workspace admin @@ -256,29 +262,73 @@ export const onWorkspaceAuthorizedFactory = } export const onWorkspaceRoleDeletedFactory = - ({ - queryAllWorkspaceProjects, - deleteProjectRole, - deleteWorkspaceSeat - }: { + (deps: { queryAllWorkspaceProjects: QueryAllWorkspaceProjects - deleteProjectRole: DeleteProjectRole deleteWorkspaceSeat: DeleteWorkspaceSeat + getStreamsCollaboratorCounts: GetStreamsCollaboratorCounts + getWorkspaceCollaborators: GetWorkspaceCollaborators + setStreamCollaborator: SetStreamCollaborator }) => - async ({ userId, workspaceId }: { userId: string; workspaceId: string }) => { + async ({ + acl: { userId, workspaceId }, + updatedByUserId + }: { + acl: { userId: string; workspaceId: string } + updatedByUserId: string + }) => { + // Resolve a fallback admin + const [admin] = await deps.getWorkspaceCollaborators({ + workspaceId, + limit: 1, + filter: { + roles: [Roles.Workspace.Admin], + excludeUserIds: [userId] + } + }) + // Delete roles for all workspace projects - for await (const projectsPage of queryAllWorkspaceProjects({ - workspaceId + for await (const projectsPage of deps.queryAllWorkspaceProjects({ + workspaceId, + userId })) { + const projectsOldOwnerCounts = await deps.getStreamsCollaboratorCounts({ + streamIds: projectsPage.map((p) => p.id), + type: Roles.Stream.Owner + }) await Promise.all( - projectsPage.map(({ id: projectId }) => - deleteProjectRole({ projectId, userId }) - ) + projectsPage.map(async ({ id: projectId, role: originalProjectRole }) => { + // If downgraded from owner & last owner, transfer ownership to a workspace admin + const isNoLongerOwner = originalProjectRole === Roles.Stream.Owner + const wasLastOwner = + projectsOldOwnerCounts[projectId]?.[Roles.Stream.Owner] === 1 + if (isNoLongerOwner && wasLastOwner) { + await deps.setStreamCollaborator( + { + streamId: projectId, + userId: admin.id, + role: Roles.Stream.Owner, + setByUserId: updatedByUserId + }, + { trackProjectUpdate: false, skipAuthorization: true } + ) + } + + // Do actual role change for changed user + await deps.setStreamCollaborator( + { + streamId: projectId, + userId, + role: null, + setByUserId: updatedByUserId + }, + { trackProjectUpdate: false, skipAuthorization: true } + ) + }) ) } // Delete seat - await deleteWorkspaceSeat({ userId, workspaceId }) + await deps.deleteWorkspaceSeat({ userId, workspaceId }) } export const onWorkspaceSeatUpdatedFactory = @@ -288,6 +338,8 @@ export const onWorkspaceSeatUpdatedFactory = setStreamCollaborator: SetStreamCollaborator getWorkspaceWithPlan: GetWorkspaceWithPlan getWorkspaceRoleForUser: GetWorkspaceRoleForUser + getStreamsCollaboratorCounts: GetStreamsCollaboratorCounts + getWorkspaceCollaborators: GetWorkspaceCollaborators }) => async (params: EventPayload) => { const { seat, updatedByUserId } = params.payload @@ -310,11 +362,25 @@ export const onWorkspaceSeatUpdatedFactory = workspaceId }) + // Resolve a fallback admin + const [admin] = await deps.getWorkspaceCollaborators({ + workspaceId, + limit: 1, + filter: { + roles: [Roles.Workspace.Admin], + excludeUserIds: [userId] + } + }) + // Ensure project roles are valid on seat type switch for await (const projectsPage of deps.queryAllWorkspaceProjects({ workspaceId, userId })) { + const projectsOldOwnerCounts = await deps.getStreamsCollaboratorCounts({ + streamIds: projectsPage.map((p) => p.id), + type: Roles.Stream.Owner + }) await Promise.all( projectsPage.map(async ({ id: projectId, role: originalProjectRole }) => { const disallowedProjectRole = @@ -322,12 +388,32 @@ export const onWorkspaceSeatUpdatedFactory = !allowedProjectRoles[seatType].includes(originalProjectRole) if (!disallowedProjectRole) return - const newRole = defaultProjectRoles[seatType] + const nextUserRole = defaultProjectRoles[seatType] + + // If downgraded from owner & last owner, transfer ownership to a workspace admin + const isNoLongerOwner = + originalProjectRole === Roles.Stream.Owner && + nextUserRole !== Roles.Stream.Owner + const wasLastOwner = + projectsOldOwnerCounts[projectId]?.[Roles.Stream.Owner] === 1 + if (isNoLongerOwner && wasLastOwner) { + await deps.setStreamCollaborator( + { + streamId: projectId, + userId: admin.id, + role: Roles.Stream.Owner, + setByUserId: updatedByUserId + }, + { trackProjectUpdate: false, skipAuthorization: true } + ) + } + + // Do actual role change for changed user await deps.setStreamCollaborator( { streamId: projectId, userId, - role: newRole, + role: nextUserRole, setByUserId: updatedByUserId }, { trackProjectUpdate: false, skipAuthorization: true } @@ -342,13 +428,15 @@ export const onWorkspaceRoleUpdatedFactory = getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping queryAllWorkspaceProjects: QueryAllWorkspaceProjects setStreamCollaborator: SetStreamCollaborator + getWorkspaceUserSeat: GetWorkspaceUserSeat getStreamsCollaboratorCounts: GetStreamsCollaboratorCounts + getWorkspaceCollaborators: GetWorkspaceCollaborators getWorkspaceWithPlan: GetWorkspaceWithPlan }) => async ({ acl, - flags, - updatedByUserId + updatedByUserId, + flags }: { acl: { userId: string; role: WorkspaceRoles; workspaceId: string } flags?: { @@ -357,23 +445,31 @@ export const onWorkspaceRoleUpdatedFactory = updatedByUserId: string }) => { const { userId, role, workspaceId } = acl + const workspace = await deps.getWorkspaceWithPlan({ workspaceId }) if (!workspace) return - // New plans don't do automatic project role assignment - const isNewPlan = workspace.plan && isNewPlanType(workspace.plan.name) - if (isNewPlan) { - return - } - + // Until we kill old plan code, we need to do full project role assignment for them + const isOldPlan = !workspace.plan || !isNewPlanType(workspace.plan.name) const { default: defaultProjectRoles } = await deps.getWorkspaceRoleToDefaultProjectRoleMapping({ workspaceId }) - const nextUserRole = defaultProjectRoles[role] + const seatType = await deps.getWorkspaceUserSeat({ workspaceId, userId }) + if (!seatType) return - // Keep user's project roles in sync with their workspace role + // Resolve a fallback admin + const [admin] = await deps.getWorkspaceCollaborators({ + workspaceId, + limit: 1, + filter: { + roles: [Roles.Workspace.Admin], + excludeUserIds: [userId] + } + }) + + // Enforce project roles based on workspace role and seat type, if project role exists for await (const projectsPage of deps.queryAllWorkspaceProjects({ workspaceId, userId @@ -382,26 +478,64 @@ export const onWorkspaceRoleUpdatedFactory = streamIds: projectsPage.map((p) => p.id), type: Roles.Stream.Owner }) - await Promise.all( projectsPage.map(async ({ id: projectId, role: originalProjectRole }) => { - if (flags?.skipProjectRoleUpdatesFor.includes(projectId)) { + if (isOldPlan && flags?.skipProjectRoleUpdatesFor.includes(projectId)) { // Skip assignment (used during invite flow) // TODO: Can we refactor this special case away? return } - // If downgraded from owner & last owner, transfer ownership to admin causing the role update (updatedByUserId) + if (!originalProjectRole && !isOldPlan) { + return + } + + /** + * We cant really throw here, because by this point the workspace role has already + * been written to DB. So we must ensure the updates we make here are valid + */ + + let nextUserRole: StreamRoles | null + if (isOldPlan) { + nextUserRole = defaultProjectRoles[role] + } else { + switch (role) { + case Roles.Workspace.Admin: { + // Set workspace owner as project owner + nextUserRole = Roles.Stream.Owner + break + } + case Roles.Workspace.Guest: { + // If workspace guest is project owner + if (originalProjectRole !== Roles.Stream.Owner) { + return + } + + // If workspace guest has an editor seat + if (seatType.type !== WorkspaceSeatType.Editor) { + return + } + + // Demote to contributor + nextUserRole = Roles.Stream.Contributor + break + } + default: + return + } + } + + // If downgraded from owner & last owner, transfer ownership to a workspace admin const isNoLongerOwner = originalProjectRole === Roles.Stream.Owner && - (!nextUserRole || nextUserRole !== Roles.Stream.Owner) + nextUserRole !== Roles.Stream.Owner const wasLastOwner = projectsOldOwnerCounts[projectId]?.[Roles.Stream.Owner] === 1 if (isNoLongerOwner && wasLastOwner) { await deps.setStreamCollaborator( { streamId: projectId, - userId: updatedByUserId, + userId: admin.id, role: Roles.Stream.Owner, setByUserId: updatedByUserId }, @@ -409,7 +543,7 @@ export const onWorkspaceRoleUpdatedFactory = ) } - // Finally change target role + // Do actual role change for changed user await deps.setStreamCollaborator( { streamId: projectId, @@ -735,11 +869,28 @@ export const initializeEventListenersFactory = queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ getStreams }), - deleteProjectRole: deleteProjectRoleFactory({ db: trx }), - deleteWorkspaceSeat: deleteWorkspaceSeatFactory({ db: trx }) + deleteWorkspaceSeat: deleteWorkspaceSeatFactory({ db: trx }), + getStreamsCollaboratorCounts: getStreamsCollaboratorCountsFactory({ db }), + getWorkspaceCollaborators: getWorkspaceCollaboratorsFactory({ db }), + setStreamCollaborator: setStreamCollaboratorFactory({ + getUser: getUserFactory({ db }), + validateStreamAccess: validateStreamAccessFactory({ + authorizeResolver + }), + emitEvent: eventBus.emit, + grantStreamPermissions: grantStreamPermissionsFactory({ + db: trx + }), + isStreamCollaborator: isStreamCollaboratorFactory({ + getStream: getStreamFactory({ db }) + }), + revokeStreamPermissions: revokeStreamPermissionsFactory({ + db: trx + }) + }) }) - return await onWorkspaceRoleDeleted(payload.acl) + return await onWorkspaceRoleDeleted(payload) }, { db } ) @@ -748,11 +899,6 @@ export const initializeEventListenersFactory = await withTransaction( async ({ db: trx }) => { const onWorkspaceRoleUpdated = onWorkspaceRoleUpdatedFactory({ - getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }), - getWorkspaceRoleToDefaultProjectRoleMapping: - getWorkspaceRoleToDefaultProjectRoleMappingFactory({ - getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }) - }), queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ getStreams }), @@ -772,7 +918,14 @@ export const initializeEventListenersFactory = db: trx }) }), - getStreamsCollaboratorCounts: getStreamsCollaboratorCountsFactory({ db }) + getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db }), + getStreamsCollaboratorCounts: getStreamsCollaboratorCountsFactory({ db }), + getWorkspaceCollaborators: getWorkspaceCollaboratorsFactory({ db }), + getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }), + getWorkspaceRoleToDefaultProjectRoleMapping: + getWorkspaceRoleToDefaultProjectRoleMappingFactory({ + getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }) + }) }) return await onWorkspaceRoleUpdated(payload) }, @@ -803,7 +956,9 @@ export const initializeEventListenersFactory = getWorkspaceSeatTypeToProjectRoleMapping: getWorkspaceSeatTypeToProjectRoleMappingFactory({ getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }) - }) + }), + getStreamsCollaboratorCounts: getStreamsCollaboratorCountsFactory({ db }), + getWorkspaceCollaborators: getWorkspaceCollaboratorsFactory({ db }) }) return await onWorkspaceSeatUpdated(payload) diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 687085615..0aea2092b 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -148,7 +148,6 @@ import { updateStreamRoleAndNotifyFactory } from '@/modules/core/services/stream import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/users' import { getServerInfoFactory } from '@/modules/core/repositories/server' import { asOperation, commandFactory } from '@/modules/shared/command' -import { withTransaction } from '@/modules/shared/helpers/dbHelper' import { getRateLimitResult, isRateLimitBreached @@ -728,37 +727,35 @@ export = FF_WORKSPACES_MODULE_ENABLED }) if (!role) { - // this is currently not working with the command factory - // TODO: include the onWorkspaceRoleDeletedFactory listener service - await withOperationLogging( - async () => - await withTransaction( - async ({ db: trx }) => { - const deleteWorkspaceRole = deleteWorkspaceRoleFactory({ - deleteWorkspaceRole: repoDeleteWorkspaceRoleFactory({ db: trx }), - getWorkspaceRoles: getWorkspaceRolesFactory({ db: trx }), - emitWorkspaceEvent: getEventBus().emit - }) + await asOperation( + async ({ db, emit }) => { + const deleteWorkspaceRole = deleteWorkspaceRoleFactory({ + deleteWorkspaceRole: repoDeleteWorkspaceRoleFactory({ db }), + getWorkspaceRoles: getWorkspaceRolesFactory({ db }), + emitWorkspaceEvent: emit + }) - return await deleteWorkspaceRole({ workspaceId, userId }) - }, - { db } - ), + return await deleteWorkspaceRole({ + workspaceId, + userId, + deletedByUserId: context.userId! + }) + }, { logger, - operationName: 'deleteWorkspaceRole', - operationDescription: 'Delete workspace role' + name: 'deleteWorkspaceRole', + description: 'Delete workspace role', + transaction: true } ) } else { if (!isWorkspaceRole(role)) { throw new WorkspaceInvalidRoleError() } - const updateWorkspaceRole = commandFactory({ - db, - eventBus, - operationFactory: ({ trx, emit }) => - updateWorkspaceRoleFactory({ + + await asOperation( + async ({ db: trx, emit }) => { + const updateWorkspaceRole = updateWorkspaceRoleFactory({ upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db: trx }), getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db: trx }), findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ @@ -772,23 +769,25 @@ export = FF_WORKSPACES_MODULE_ENABLED eventEmit: emit }) }) - }) - await withOperationLogging( - async () => - await updateWorkspaceRole({ + + return await updateWorkspaceRole({ userId, workspaceId, role, updatedByUserId: context.userId! - }), + }) + }, { logger, - operationName: 'updateWorkspaceRole', - operationDescription: 'Update workspace role' + name: 'updateWorkspaceRole', + description: 'Update workspace role', + transaction: true } ) } + context.clearCache() + return await getWorkspaceFactory({ db })({ workspaceId: args.input.workspaceId, userId: context.userId @@ -943,31 +942,30 @@ export = FF_WORKSPACES_MODULE_ENABLED const logger = context.log.child({ workspaceId }) - // this is currently not working with the command factory - // TODO: include the onWorkspaceRoleDeletedFactory listener service - await withOperationLogging( - async () => - await withTransaction( - async ({ db: trx }) => { - const deleteWorkspaceRole = deleteWorkspaceRoleFactory({ - deleteWorkspaceRole: repoDeleteWorkspaceRoleFactory({ db: trx }), - getWorkspaceRoles: getWorkspaceRolesFactory({ db: trx }), - emitWorkspaceEvent: getEventBus().emit - }) + await asOperation( + async ({ db, emit }) => { + const deleteWorkspaceRole = deleteWorkspaceRoleFactory({ + deleteWorkspaceRole: repoDeleteWorkspaceRoleFactory({ db }), + getWorkspaceRoles: getWorkspaceRolesFactory({ db }), + emitWorkspaceEvent: emit + }) - return await deleteWorkspaceRole({ - workspaceId, - userId: context.userId! - }) - }, - { db } - ), + return await deleteWorkspaceRole({ + workspaceId, + userId: context.userId!, + deletedByUserId: context.userId! + }) + }, { logger, - operationName: 'leaveWorkspace', - operationDescription: 'Leave workspace' + name: 'leaveWorkspace', + description: 'Leave workspace', + transaction: true } ) + + context.clearCache() + return true }, updateCreationState: async (_parent, args, context) => { @@ -1394,7 +1392,7 @@ export = FF_WORKSPACES_MODULE_ENABLED projectId, streamId: projectId //legacy }) - return await withOperationLogging( + const ret = await withOperationLogging( async () => await updateStreamRoleAndNotify( args.input, @@ -1407,6 +1405,10 @@ export = FF_WORKSPACES_MODULE_ENABLED operationDescription: 'Update workspace project role' } ) + + context.clearCache() + + return ret }, moveToWorkspace: async (_parent, args, context) => { const { projectId, workspaceId } = args diff --git a/packages/server/modules/workspaces/repositories/workspaces.ts b/packages/server/modules/workspaces/repositories/workspaces.ts index a18b8a596..7e965a990 100644 --- a/packages/server/modules/workspaces/repositories/workspaces.ts +++ b/packages/server/modules/workspaces/repositories/workspaces.ts @@ -364,7 +364,7 @@ export const getWorkspaceCollaboratorsFactory = .where(DbWorkspaceAcl.col.workspaceId, workspaceId) .orderBy('workspaceRoleCreatedAt', 'desc') - const { search, roles, seatType } = filter || {} + const { search, roles, seatType, excludeUserIds } = filter || {} if (seatType) { query @@ -387,6 +387,12 @@ export const getWorkspaceCollaboratorsFactory = }) } + if (excludeUserIds?.length) { + query.andWhere((w) => { + w.whereNotIn(Users.col.id, excludeUserIds) + }) + } + if (cursor) { query.andWhere(DbWorkspaceAcl.col.createdAt, '<', cursor) } diff --git a/packages/server/modules/workspaces/services/management.ts b/packages/server/modules/workspaces/services/management.ts index d7d1700d6..baa4aeffe 100644 --- a/packages/server/modules/workspaces/services/management.ts +++ b/packages/server/modules/workspaces/services/management.ts @@ -349,6 +349,7 @@ export const deleteWorkspaceFactory = type WorkspaceRoleDeleteArgs = { userId: string workspaceId: string + deletedByUserId: string } export const deleteWorkspaceRoleFactory = @@ -363,7 +364,8 @@ export const deleteWorkspaceRoleFactory = }) => async ({ workspaceId, - userId + userId, + deletedByUserId }: WorkspaceRoleDeleteArgs): Promise => { // Protect against removing last admin const workspaceRoles = await getWorkspaceRoles({ workspaceId }) @@ -380,7 +382,7 @@ export const deleteWorkspaceRoleFactory = // Emit deleted role await emitWorkspaceEvent({ eventName: WorkspaceEvents.RoleDeleted, - payload: { acl: deletedRole } + payload: { acl: deletedRole, updatedByUserId: deletedByUserId } }) return deletedRole @@ -420,9 +422,9 @@ export const updateWorkspaceRoleFactory = workspaceId, userId, role: nextWorkspaceRole, - skipProjectRoleUpdatesFor, preventRoleDowngrade, - updatedByUserId + updatedByUserId, + skipProjectRoleUpdatesFor }): Promise => { const workspaceRoles = await getWorkspaceRoles({ workspaceId }) @@ -493,10 +495,10 @@ export const updateWorkspaceRoleFactory = workspaceId, role: nextWorkspaceRole }, + updatedByUserId, flags: { skipProjectRoleUpdatesFor: skipProjectRoleUpdatesFor ?? [] - }, - updatedByUserId + } } }) } diff --git a/packages/server/modules/workspaces/services/projects.ts b/packages/server/modules/workspaces/services/projects.ts index 25573eac8..aea08cc37 100644 --- a/packages/server/modules/workspaces/services/projects.ts +++ b/packages/server/modules/workspaces/services/projects.ts @@ -356,7 +356,7 @@ export const validateWorkspaceMemberProjectRoleFactory = // User's workspace role does not allow the requested project role throw new WorkspaceInvalidRoleError( isNewPlan - ? `User's workspace seat type '${seatType}' does not allow project role '${projectRole}'.` + ? `User's workspace seat type '${seatType}' and workspace role '${workspaceRole}' does not allow project role '${projectRole}'.` : `User's workspace role '${workspaceRole}' does not allow project role '${projectRole}'.` ) } diff --git a/packages/server/modules/workspaces/tests/helpers/creation.ts b/packages/server/modules/workspaces/tests/helpers/creation.ts index 2e43ad91d..347bc6261 100644 --- a/packages/server/modules/workspaces/tests/helpers/creation.ts +++ b/packages/server/modules/workspaces/tests/helpers/creation.ts @@ -339,7 +339,8 @@ export const unassignFromWorkspace = async ( await deleteWorkspaceRole({ userId: user.id, - workspaceId: workspace.id + workspaceId: workspace.id, + deletedByUserId: workspace.ownerId }) } diff --git a/packages/server/modules/workspaces/tests/helpers/rolesGraphql.ts b/packages/server/modules/workspaces/tests/helpers/rolesGraphql.ts new file mode 100644 index 000000000..e06546498 --- /dev/null +++ b/packages/server/modules/workspaces/tests/helpers/rolesGraphql.ts @@ -0,0 +1,112 @@ +import { basicWorkspaceFragment } from '@/modules/workspaces/tests/helpers/graphql' +import { ProjectImplicitRoleCheckFragment } from '@/test/graphql/generated/graphql' +import { MaybeNullOrUndefined, Roles } from '@speckle/shared' +import { gql } from 'graphql-tag' + +export const fullPermissionCheckResultFragment = gql(` + fragment FullPermissionCheckResult on PermissionCheckResult { + authorized + code + message + payload + } +`) + +export const projectImplicitRoleCheckFragment = gql` + fragment ProjectImplicitRoleCheck on Project { + id + role + permissions { + # general access check + canRead { + ...FullPermissionCheckResult + } + # implicit reviewer check + canReadSettings { + ...FullPermissionCheckResult + } + # implicit owner check + canReadWebhooks { + ...FullPermissionCheckResult + } + # implicit contributor check + canCreateModel { + ...FullPermissionCheckResult + } + } + } + + ${fullPermissionCheckResultFragment} +` + +export const getUserWorkspaceAccessQuery = gql` + query GetUserWorkspaceAccess($id: String!) { + workspace(id: $id) { + id + role + seatType + } + } +` + +export const getUserWorkspaceProjectsWithAccessChecksQuery = gql` + query GetUserWorkspaceProjectsWithAccessChecks( + $id: String! + $limit: Int + $cursor: String + $filter: WorkspaceProjectsFilter + ) { + workspace(id: $id) { + ...BasicWorkspace + role + seatType + projects(limit: $limit, cursor: $cursor, filter: $filter) { + items { + ...ProjectImplicitRoleCheck + } + cursor + totalCount + } + } + } + + ${basicWorkspaceFragment} + ${projectImplicitRoleCheckFragment} +` + +export const getUserProjectsWithAccessChecksQuery = gql` + query GetUserProjectsWithAccessChecks( + $limit: Int + $cursor: String + $filter: UserProjectsFilter + ) { + activeUser { + id + projects(limit: $limit, cursor: $cursor, filter: $filter) { + items { + ...ProjectImplicitRoleCheck + } + cursor + totalCount + } + } + } + ${projectImplicitRoleCheckFragment} +` + +export const projectImplicitRoleCheck = ( + project: MaybeNullOrUndefined +) => { + return { + hasAccess: !!project?.permissions?.canRead.authorized, + isReviewer: !!project?.permissions?.canReadSettings.authorized, + isContributor: !!project?.permissions?.canCreateModel.authorized, + isOwner: !!project?.permissions?.canReadWebhooks.authorized, + isExplicitOwner: project?.role === Roles.Stream.Owner, + isExplicitContributor: project?.role === Roles.Stream.Contributor, + isExplicitReviewer: project?.role === Roles.Stream.Reviewer, + hasExplicitRole: !!project?.role + } +} + +export type ProjectImplicitRoleCheck = ReturnType diff --git a/packages/server/modules/workspaces/tests/integration/roles.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/roles.graph.spec.ts index 0d7af2595..e929a6183 100644 --- a/packages/server/modules/workspaces/tests/integration/roles.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/roles.graph.spec.ts @@ -1,11 +1,21 @@ -import { db } from '@/db/knex' +import { Streams } from '@/modules/core/dbSchema' import { AllScopes } from '@/modules/core/helpers/mainConstants' -import { grantStreamPermissionsFactory } from '@/modules/core/repositories/streams' import { assignToWorkspace, BasicTestWorkspace, - createTestWorkspace + createTestWorkspace, + unassignFromWorkspace } from '@/modules/workspaces/tests/helpers/creation' +import { + ProjectImplicitRoleCheck, + projectImplicitRoleCheck +} from '@/modules/workspaces/tests/helpers/rolesGraphql' +import { WorkspaceSeatType } from '@/modules/workspacesCore/domain/types' +import { + WorkspaceAcl, + Workspaces, + WorkspaceSeats +} from '@/modules/workspacesCore/helpers/db' import { BasicTestUser, createAuthTokenForUser, @@ -13,10 +23,12 @@ import { } from '@/test/authHelper' import { ActiveUserLeaveWorkspaceDocument, + GetUserProjectsWithAccessChecksDocument, + GetUserWorkspaceAccessDocument, + GetUserWorkspaceProjectsWithAccessChecksDocument, GetWorkspaceDocument, - GetWorkspaceProjectsDocument, - GetWorkspaceTeamDocument, - UpdateWorkspaceRoleDocument + UpdateWorkspaceRoleDocument, + UpdateWorkspaceSeatTypeDocument } from '@/test/graphql/generated/graphql' import { createTestContext, @@ -24,15 +36,16 @@ import { TestApolloServer } from '@/test/graphqlHelper' import { beforeEachContext, truncateTables } from '@/test/hooks' -import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper' +import { + addToStream, + BasicTestStream, + createTestStream +} from '@/test/speckle-helpers/streamHelper' import { Roles } from '@speckle/shared' import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' -import { isUndefined } from 'lodash' -const grantStreamPermissions = grantStreamPermissionsFactory({ db }) - -describe('Workspaces Roles GQL', () => { +describe('Workspaces Roles/Seats GQL', () => { let apollo: TestApolloServer const serverAdminUser: BasicTestUser = { @@ -64,6 +77,75 @@ describe('Workspaces Roles GQL', () => { }) }) + const getWorkspaceProjects = async (params: { + user: BasicTestUser + workspace: BasicTestWorkspace + }) => { + const res = await apollo.execute( + GetUserWorkspaceProjectsWithAccessChecksDocument, + { + id: params.workspace.id + }, + { authUserId: params.user.id, assertNoErrors: true } + ) + + const projects = res.data?.workspace.projects.items || [] + expect(res.data?.workspace, 'Could not retrieve workspace for user').to.be.ok + + return { + projects, + workspace: res.data!.workspace, + checkProject: (project: BasicTestStream) => { + return projectImplicitRoleCheck(projects.find((p) => p.id === project.id)) + }, + checkAllProjects: (check: (project: ProjectImplicitRoleCheck) => boolean) => { + return projects.map(projectImplicitRoleCheck).every(check) + } + } + } + + const getUserProjects = async (params: { user: BasicTestUser }) => { + const res = await apollo.execute( + GetUserProjectsWithAccessChecksDocument, + { + filter: { + includeImplicitAccess: true + } + }, + { authUserId: params.user.id, assertNoErrors: true } + ) + + const projects = res.data?.activeUser?.projects.items || [] + + return { + projects, + checkProject: (project: BasicTestStream) => { + return projectImplicitRoleCheck(projects.find((p) => p.id === project.id)) + }, + checkAllProjects: (check: (project: ProjectImplicitRoleCheck) => boolean) => { + return projects.map(projectImplicitRoleCheck).every(check) + } + } + } + + const getUserWorkspace = async (params: { + user: BasicTestUser + workspace: BasicTestWorkspace + }) => { + const res = await apollo.execute( + GetUserWorkspaceAccessDocument, + { + id: params.workspace.id + }, + { authUserId: params.user.id } + ) + const workspace = res.data?.workspace + + return { + workspace + } + } + describe('single role changes in a workspace without projects', () => { const workspace: BasicTestWorkspace = { id: '', @@ -73,18 +155,27 @@ describe('Workspaces Roles GQL', () => { } before(async () => { - await createTestWorkspace(workspace, serverAdminUser) + await createTestWorkspace(workspace, serverAdminUser, { + addPlan: { + name: 'team', + status: 'valid' + } + }) }) describe('update workspace role', () => { after(async () => { - await apollo.execute(UpdateWorkspaceRoleDocument, { - input: { - userId: serverMemberUser.id, - workspaceId: workspace.id, - role: null - } - }) + await apollo.execute( + UpdateWorkspaceRoleDocument, + { + input: { + userId: serverMemberUser.id, + workspaceId: workspace.id, + role: null + } + }, + { assertNoErrors: true } + ) }) it('should create a role if none exists', async () => { @@ -150,13 +241,17 @@ describe('Workspaces Roles GQL', () => { describe('delete workspace role', () => { before(async () => { - await apollo.execute(UpdateWorkspaceRoleDocument, { - input: { - userId: serverMemberUser.id, - workspaceId: workspace.id, - role: Roles.Workspace.Member - } - }) + await apollo.execute( + UpdateWorkspaceRoleDocument, + { + input: { + userId: serverMemberUser.id, + workspaceId: workspace.id, + role: Roles.Workspace.Member + } + }, + { assertNoErrors: true } + ) }) it('should delete the specified role', async () => { @@ -188,7 +283,8 @@ describe('Workspaces Roles GQL', () => { }) }) - describe('single role changes in a workspace with projects', () => { + // TODO: Viewer vs Editor + describe('in a workspace with projects', () => { const workspace: BasicTestWorkspace = { id: '', ownerId: '', @@ -214,6 +310,18 @@ describe('Workspaces Roles GQL', () => { email: 'john-guest-speckle@example.org' } + const workspaceMemberViewerUser: BasicTestUser = { + id: '', + name: 'John "Member" Viewer Speckel', + email: 'john-member-speckle-viewer@example.org' + } + + const workspaceGuestViewerUser: BasicTestUser = { + id: '', + name: 'John "Middle Child" Viewer Speckle', + email: 'john-guest-speckle-viewer@example.org' + } + const workspaceProjectA: BasicTestStream = { id: '', ownerId: '', @@ -253,17 +361,53 @@ describe('Workspaces Roles GQL', () => { await createTestUsers([ workspaceAdminUser, workspaceMemberUser, - workspaceGuestUser + workspaceGuestUser, + workspaceMemberViewerUser, + workspaceGuestViewerUser ]) }) beforeEach(async () => { - await createTestWorkspace(workspace, serverAdminUser) + await createTestWorkspace(workspace, serverAdminUser, { + addPlan: { + name: 'team', + status: 'valid' + } + }) + await Promise.all([ - assignToWorkspace(workspace, workspaceAdminUser, Roles.Workspace.Admin), - assignToWorkspace(workspace, workspaceMemberUser, Roles.Workspace.Member), - assignToWorkspace(workspace, workspaceGuestUser, Roles.Workspace.Guest) + assignToWorkspace( + workspace, + workspaceAdminUser, + Roles.Workspace.Admin, + WorkspaceSeatType.Editor + ), + assignToWorkspace( + workspace, + workspaceMemberUser, + Roles.Workspace.Member, + WorkspaceSeatType.Editor + ), + assignToWorkspace( + workspace, + workspaceGuestUser, + Roles.Workspace.Guest, + WorkspaceSeatType.Editor + ), + assignToWorkspace( + workspace, + workspaceMemberViewerUser, + Roles.Workspace.Member, + WorkspaceSeatType.Viewer + ), + assignToWorkspace( + workspace, + workspaceGuestViewerUser, + Roles.Workspace.Guest, + WorkspaceSeatType.Viewer + ) ]) + for (const project of workspaceProjects) { project.workspaceId = workspace.id await createTestStream(project, serverAdminUser) @@ -272,478 +416,797 @@ describe('Workspaces Roles GQL', () => { /** * Initial workspace roles: * - * workspaceAdminUser Admin - * workspaceMemberUser Member - * workspaceGuestUser Guest + * workspaceAdminUser Admin (Editor) + * workspaceMemberUser Member (Editor) + * workspaceGuestUser Guest (Editor) + * workspaceMemberViewerUser Member (Viewer) + * workspaceGuestViewerUser Guest (Viewer) * - * Initial workspace project roles: + * Initial explicit workspace project roles: * - * | | Project A | Project B | Project C | Project D | - * |---------------------|-------------|-------------|-----------|-----------| - * | workspaceAdminUser | Owner | Owner | Owner | Owner | - * | workspaceMemberUser | Owner | Contributor | Reviewer | None | - * | workspaceGuestUser | Contributor | Reviewer | None | None | + * | | Project A | Project B | Project C | Project D | + * |---------------------------|-------------|-------------|-----------|-----------| + * | workspaceAdminUser | Owner | None | None | None | + * | workspaceMemberUser | Owner | Contributor | Reviewer | None | + * | workspaceGuestUser | Contributor | Reviewer | None | None | + * | workspaceMemberViewerUser | Reviewer | None | None | None | + * | workspaceGuestViewerUser | None | Reviewer | None | None | */ await Promise.all([ // A - grantStreamPermissions({ - streamId: workspaceProjectA.id, - userId: workspaceAdminUser.id, - role: Roles.Stream.Owner - }), - grantStreamPermissions({ - streamId: workspaceProjectA.id, - userId: workspaceMemberUser.id, - role: Roles.Stream.Owner - }), - grantStreamPermissions({ - streamId: workspaceProjectA.id, - userId: workspaceGuestUser.id, - role: Roles.Stream.Contributor - }), + addToStream(workspaceProjectA, workspaceAdminUser, Roles.Stream.Owner), + addToStream(workspaceProjectA, workspaceMemberUser, Roles.Stream.Owner), + addToStream(workspaceProjectA, workspaceGuestUser, Roles.Stream.Contributor), + addToStream( + workspaceProjectA, + workspaceMemberViewerUser, + Roles.Stream.Reviewer + ), // B - grantStreamPermissions({ - streamId: workspaceProjectB.id, - userId: workspaceAdminUser.id, - role: Roles.Stream.Owner - }), - grantStreamPermissions({ - streamId: workspaceProjectB.id, - userId: workspaceMemberUser.id, - role: Roles.Stream.Contributor - }), - grantStreamPermissions({ - streamId: workspaceProjectB.id, - userId: workspaceGuestUser.id, - role: Roles.Stream.Reviewer - }), + addToStream(workspaceProjectB, workspaceMemberUser, Roles.Stream.Contributor), + addToStream(workspaceProjectB, workspaceGuestUser, Roles.Stream.Reviewer), + addToStream(workspaceProjectB, workspaceGuestViewerUser, Roles.Stream.Reviewer), // C - grantStreamPermissions({ - streamId: workspaceProjectC.id, - userId: workspaceAdminUser.id, - role: Roles.Stream.Owner - }), - grantStreamPermissions({ - streamId: workspaceProjectC.id, - userId: workspaceMemberUser.id, - role: Roles.Stream.Reviewer - }), - // D - grantStreamPermissions({ - streamId: workspaceProjectD.id, - userId: workspaceAdminUser.id, - role: Roles.Stream.Owner - }) + addToStream(workspaceProjectC, workspaceMemberUser, Roles.Stream.Reviewer) ]) }) afterEach(async () => { - await truncateTables(['workspaces', 'streams']) + await truncateTables([ + Workspaces.name, + Streams.name, + WorkspaceAcl.name, + WorkspaceSeats.name + ]) }) - describe('when changing workspace admin', () => { - describe('to workspace member', () => { - beforeEach(async () => { - await apollo.execute(UpdateWorkspaceRoleDocument, { - input: { - userId: workspaceAdminUser.id, - workspaceId: workspace.id, - role: Roles.Workspace.Member - } - }) + const getProjects = async (params: { user: BasicTestUser }) => + getWorkspaceProjects({ user: params.user, workspace }) + + describe('retrieving projects', () => { + it('workspaceAdminUser is implicit owner of all of them and explicit owner in one', async () => { + const { projects, checkProject, checkAllProjects } = await getProjects({ + user: workspaceAdminUser }) - it('should grant default project role for all workspace projects', async () => { - const res = await apollo.execute(GetWorkspaceProjectsDocument, { - id: workspace.id - }) - - const projects = res.data?.workspace.projects.items - - expect(res).to.not.haveGraphQLErrors() - expect(projects).to.exist - expect( - projects?.every((project) => { - const team = project.team - const role = team.find((acl) => acl.id === workspaceAdminUser.id) - return role?.role === Roles.Stream.Reviewer - }) - ).to.be.true - }) + expect(projects.length).to.eq(4) + expect(checkAllProjects((p) => p.isOwner)).to.be.ok + expect(checkProject(workspaceProjectA).isExplicitOwner).to.be.ok + expect(checkProject(workspaceProjectB).hasExplicitRole).to.be.not.ok + expect(checkProject(workspaceProjectC).hasExplicitRole).to.be.not.ok + expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok }) - describe('to workspace guest', () => { - beforeEach(async () => { - await apollo.execute(UpdateWorkspaceRoleDocument, { - input: { - userId: workspaceAdminUser.id, - workspaceId: workspace.id, - role: Roles.Workspace.Guest - } - }) + it('workspaceMemberUser is implicit reviewer in all of them, and also has explicit roles in some', async () => { + const { projects, checkAllProjects, checkProject } = await getProjects({ + user: workspaceMemberUser }) - it('should drop all workspace project roles', async () => { - const res = await apollo.execute(GetWorkspaceProjectsDocument, { - id: workspace.id - }) + expect(projects.length).to.eq(4) + expect(checkAllProjects((p) => p.isReviewer)).to.be.ok + expect(checkProject(workspaceProjectA).isExplicitOwner).to.be.ok + expect(checkProject(workspaceProjectB).isExplicitContributor).to.be.ok + expect(checkProject(workspaceProjectC).isExplicitReviewer).to.be.ok + expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok + }) - const projects = res.data?.workspace.projects.items - - expect(res).to.not.haveGraphQLErrors() - expect(projects).to.exist - expect( - projects?.every((project) => { - const team = project.team - const role = team.find((acl) => acl.id === workspaceAdminUser.id) - return isUndefined(role) - }) - ).to.be.true + it('workspaceGuestUser only has explicit roles in 2 projects', async () => { + const { projects, checkProject } = await getProjects({ + user: workspaceGuestUser }) + + expect(projects.length).to.eq(2) + expect(checkProject(workspaceProjectA).isExplicitContributor).to.be.ok + expect(checkProject(workspaceProjectB).isExplicitReviewer).to.be.ok + expect(checkProject(workspaceProjectC).hasExplicitRole).to.be.not.ok + expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok + }) + + it('workspaceMemberViewerUser is only explicit reviewer in 1 project, and has implicit roles elsewhere', async () => { + const { projects, checkAllProjects, checkProject } = await getProjects({ + user: workspaceMemberViewerUser + }) + expect(projects.length).to.eq(4) + expect(checkAllProjects((p) => p.isReviewer)).to.be.ok + expect(checkProject(workspaceProjectA).isExplicitReviewer).to.be.ok + expect(checkProject(workspaceProjectB).hasExplicitRole).to.be.not.ok + expect(checkProject(workspaceProjectC).hasExplicitRole).to.be.not.ok + expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok + }) + + it('workspaceGuestViewerUser is only explicit reviewer in 1 project', async () => { + const { projects, checkProject } = await getProjects({ + user: workspaceGuestViewerUser + }) + + expect(projects.length).to.eq(1) + expect(checkProject(workspaceProjectB).isExplicitReviewer).to.be.ok + expect(checkProject(workspaceProjectA).hasExplicitRole).to.be.not.ok + expect(checkProject(workspaceProjectC).hasExplicitRole).to.be.not.ok + expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok }) }) - describe('when changing workspace member', () => { - describe('to workspace admin', () => { - beforeEach(async () => { - await apollo.execute(UpdateWorkspaceRoleDocument, { + describe('doing single seat type changes', () => { + it('cant change workspace admin to viewer', async () => { + const res = await apollo.execute(UpdateWorkspaceSeatTypeDocument, { + input: { + userId: workspaceAdminUser.id, + workspaceId: workspace.id, + seatType: WorkspaceSeatType.Viewer + } + }) + + expect(res).to.haveGraphQLErrors('cannot have a seat of type') + }) + + it('changing member editor to viewer, should downgrade all explicit roles to reviewer', async () => { + await apollo.execute( + UpdateWorkspaceSeatTypeDocument, + { input: { userId: workspaceMemberUser.id, workspaceId: workspace.id, - role: Roles.Workspace.Admin + seatType: WorkspaceSeatType.Viewer } - }) + }, + { assertNoErrors: true } + ) + + const { projects, checkProject } = await getProjects({ + user: workspaceMemberUser }) - it('should grant project owner role for all workspace projects', async () => { - const res = await apollo.execute(GetWorkspaceProjectsDocument, { - id: workspace.id - }) - - const projects = res.data?.workspace.projects.items - - expect(res).to.not.haveGraphQLErrors() - expect(projects).to.exist - expect( - projects?.every((project) => { - const team = project.team - const role = team.find((acl) => acl.id === workspaceMemberUser.id) - return role?.role === Roles.Stream.Owner - }) - ).to.be.true - }) + expect(projects.length).to.eq(4) + expect(checkProject(workspaceProjectA).isExplicitReviewer).to.be.ok + expect(checkProject(workspaceProjectB).isExplicitReviewer).to.be.ok + expect(checkProject(workspaceProjectC).isExplicitReviewer).to.be.ok + expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok }) - describe('to workspace guest', () => { - beforeEach(async () => { - await apollo.execute(UpdateWorkspaceRoleDocument, { + it('changing guest editor to viewer, should downgrade all explicit roles to reviewer', async () => { + await apollo.execute( + UpdateWorkspaceSeatTypeDocument, + { input: { - userId: workspaceMemberUser.id, + userId: workspaceGuestUser.id, workspaceId: workspace.id, - role: Roles.Workspace.Guest + seatType: WorkspaceSeatType.Viewer } - }) + }, + { assertNoErrors: true } + ) + + const { projects, checkProject } = await getProjects({ + user: workspaceGuestUser }) - it('should drop all workspace project roles', async () => { - const res = await apollo.execute(GetWorkspaceProjectsDocument, { - id: workspace.id - }) - - const projects = res.data?.workspace.projects.items - - expect(res).to.not.haveGraphQLErrors() - expect(projects).to.exist - expect( - projects?.every((project) => { - const team = project.team - const role = team.find((acl) => acl.id === workspaceMemberUser.id) - return isUndefined(role) - }) - ).to.be.true - }) + expect(projects.length).to.eq(2) + expect(checkProject(workspaceProjectA).isExplicitReviewer).to.be.ok + expect(checkProject(workspaceProjectB).isExplicitReviewer).to.be.ok }) }) - describe('when changing workspace guest', () => { - describe('to workspace admin', () => { - beforeEach(async () => { - await apollo.execute(UpdateWorkspaceRoleDocument, { - input: { - userId: workspaceGuestUser.id, - workspaceId: workspace.id, - role: Roles.Workspace.Admin - } + describe('doing single role changes', () => { + describe('when changing workspace admin', () => { + describe('to workspace member', () => { + beforeEach(async () => { + await apollo.execute( + UpdateWorkspaceRoleDocument, + { + input: { + userId: workspaceAdminUser.id, + workspaceId: workspace.id, + role: Roles.Workspace.Member + } + }, + { assertNoErrors: true } + ) + }) + + it('should still remain explicit owner and be implicit reviewer elsewhere', async () => { + const { projects, checkAllProjects, checkProject } = await getProjects({ + user: workspaceAdminUser + }) + + expect(projects.length).to.eq(4) + expect(checkProject(workspaceProjectA).isExplicitOwner).to.be.ok + expect(checkProject(workspaceProjectB).hasExplicitRole).to.be.not.ok + expect(checkProject(workspaceProjectC).hasExplicitRole).to.be.not.ok + expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok + expect(checkAllProjects((p) => p.isReviewer)).to.be.ok }) }) - it('should grant project owner role for all workspace projects', async () => { - const res = await apollo.execute(GetWorkspaceProjectsDocument, { - id: workspace.id + describe('to workspace guest', () => { + beforeEach(async () => { + await apollo.execute( + UpdateWorkspaceRoleDocument, + { + input: { + userId: workspaceAdminUser.id, + workspaceId: workspace.id, + role: Roles.Workspace.Guest + } + }, + { assertNoErrors: true } + ) }) - const projects = res.data?.workspace.projects.items - - expect(res).to.not.haveGraphQLErrors() - expect(projects).to.exist - expect( - projects?.every((project) => { - const team = project.team - const role = team.find((acl) => acl.id === workspaceGuestUser.id) - return role?.role === Roles.Stream.Owner + it('should only have 1 project access, and not owner, but contributor', async () => { + const { projects, checkProject } = await getProjects({ + user: workspaceAdminUser }) - ).to.be.true + + expect(projects.length).to.eq(1) + expect(checkProject(workspaceProjectA).isOwner).to.not.be.ok + expect(checkProject(workspaceProjectA).isExplicitContributor).to.be.ok + expect(checkProject(workspaceProjectB).hasExplicitRole).to.be.not.ok + expect(checkProject(workspaceProjectC).hasExplicitRole).to.be.not.ok + expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok + }) }) }) - describe('to workspace member', () => { - beforeEach(async () => { - await apollo.execute(UpdateWorkspaceRoleDocument, { - input: { - userId: workspaceGuestUser.id, - workspaceId: workspace.id, - role: Roles.Workspace.Member - } + describe('when changing workspace member', () => { + describe('to workspace admin', () => { + beforeEach(async () => { + await apollo.execute( + UpdateWorkspaceRoleDocument, + { + input: { + userId: workspaceMemberUser.id, + workspaceId: workspace.id, + role: Roles.Workspace.Admin + } + }, + { assertNoErrors: true } + ) + }) + + it('should get implicit owner role everywhere and explicit upgraded to owner', async () => { + const { projects, checkProject, checkAllProjects } = await getProjects({ + user: workspaceMemberUser + }) + + expect(projects.length).to.eq(4) + expect(checkAllProjects((p) => p.isOwner)).to.be.ok + expect(checkProject(workspaceProjectA).isExplicitOwner).to.be.ok + expect(checkProject(workspaceProjectB).isExplicitOwner).to.be.ok + expect(checkProject(workspaceProjectC).isExplicitOwner).to.be.ok + expect(checkProject(workspaceProjectD).hasExplicitRole).to.not.be.ok }) }) - it('should grant default project role for all workspace projects', async () => { - const res = await apollo.execute(GetWorkspaceProjectsDocument, { - id: workspace.id + describe('to workspace guest', () => { + beforeEach(async () => { + await apollo.execute( + UpdateWorkspaceRoleDocument, + { + input: { + userId: workspaceMemberUser.id, + workspaceId: workspace.id, + role: Roles.Workspace.Guest + } + }, + { assertNoErrors: true } + ) }) - const projects = res.data?.workspace.projects.items - - expect(res).to.not.haveGraphQLErrors() - expect(projects).to.exist - expect( - projects?.every((project) => { - const team = project.team - const role = team.find((acl) => acl.id === workspaceGuestUser.id) - // TODO: This is a workspace setting - return role?.role === Roles.Stream.Reviewer + it('no implicit access and all explicit downgraded to contributor or less', async () => { + const { projects, checkProject } = await getProjects({ + user: workspaceMemberUser }) - ).to.be.true + + expect(projects.length).to.eq(3) + expect(checkProject(workspaceProjectA).isExplicitContributor).to.be.ok + expect(checkProject(workspaceProjectB).isExplicitContributor).to.be.ok + expect(checkProject(workspaceProjectC).isExplicitReviewer).to.be.ok + expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok + }) + }) + }) + + describe('when changing workspace guest', () => { + describe('to workspace admin', () => { + beforeEach(async () => { + await apollo.execute( + UpdateWorkspaceRoleDocument, + { + input: { + userId: workspaceGuestUser.id, + workspaceId: workspace.id, + role: Roles.Workspace.Admin + } + }, + { assertNoErrors: true } + ) + }) + + it('should upgrade explicit role to owner, and have implicit owner everywhere', async () => { + const { projects, checkProject, checkAllProjects } = await getProjects({ + user: workspaceGuestUser + }) + + expect(projects.length).to.eq(4) + expect(checkAllProjects((p) => p.isOwner)).to.be.ok + expect(checkProject(workspaceProjectA).isExplicitOwner).to.be.ok + expect(checkProject(workspaceProjectB).isExplicitOwner).to.be.ok + expect(checkProject(workspaceProjectC).hasExplicitRole).to.not.be.ok + expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok + }) + }) + + describe('to workspace member', () => { + beforeEach(async () => { + await apollo.execute( + UpdateWorkspaceRoleDocument, + { + input: { + userId: workspaceGuestUser.id, + workspaceId: workspace.id, + role: Roles.Workspace.Member + } + }, + { assertNoErrors: true } + ) + }) + + it('should retain same explicit access and get full implicit acccess', async () => { + const { projects, checkProject, checkAllProjects } = await getProjects({ + user: workspaceGuestUser + }) + + expect(projects.length).to.eq(4) + expect(checkAllProjects((p) => p.isReviewer)).to.be.ok + expect(checkProject(workspaceProjectA).isExplicitContributor).to.be.ok + expect(checkProject(workspaceProjectB).isExplicitReviewer).to.be.ok + expect(checkProject(workspaceProjectC).hasExplicitRole).to.be.not.ok + expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok + }) + }) + }) + + describe('when changing workspace member viewer', () => { + describe('to workspace admin', () => { + beforeEach(async () => { + await apollo.execute( + UpdateWorkspaceRoleDocument, + { + input: { + userId: workspaceMemberViewerUser.id, + workspaceId: workspace.id, + role: Roles.Workspace.Admin + } + }, + { assertNoErrors: true } + ) + }) + + it('should get editor seat, implicit owner role everywhere and explicit upgraded to owner', async () => { + const { workspace, projects, checkProject, checkAllProjects } = + await getProjects({ + user: workspaceMemberViewerUser + }) + + expect(workspace.seatType).to.eq(WorkspaceSeatType.Editor) + expect(projects.length).to.eq(4) + expect(checkAllProjects((p) => p.isOwner)).to.be.ok + expect(checkProject(workspaceProjectA).isExplicitOwner).to.be.ok + expect(checkProject(workspaceProjectB).hasExplicitRole).to.not.be.ok + expect(checkProject(workspaceProjectC).hasExplicitRole).to.not.be.ok + expect(checkProject(workspaceProjectD).hasExplicitRole).to.not.be.ok + }) + }) + + describe('to workspace guest', () => { + beforeEach(async () => { + await apollo.execute( + UpdateWorkspaceRoleDocument, + { + input: { + userId: workspaceMemberViewerUser.id, + workspaceId: workspace.id, + role: Roles.Workspace.Guest + } + }, + { assertNoErrors: true } + ) + }) + + it('retain viewer seat, no implicit access and all explicit at reviewer or less', async () => { + const { projects, checkProject, workspace } = await getProjects({ + user: workspaceMemberViewerUser + }) + + expect(workspace.seatType).to.eq(WorkspaceSeatType.Viewer) + expect(projects.length).to.eq(1) + expect(checkProject(workspaceProjectA).isExplicitReviewer).to.be.ok + }) + }) + }) + + describe('when changing workspace guest viewer', () => { + describe('to workspace admin', () => { + beforeEach(async () => { + await apollo.execute( + UpdateWorkspaceRoleDocument, + { + input: { + userId: workspaceGuestViewerUser.id, + workspaceId: workspace.id, + role: Roles.Workspace.Admin + } + }, + { assertNoErrors: true } + ) + }) + + it('should upgrade seatType to editor, explicit role to owner, and have implicit owner everywhere', async () => { + const { workspace, projects, checkProject, checkAllProjects } = + await getProjects({ + user: workspaceGuestViewerUser + }) + + expect(workspace.seatType).to.eq(WorkspaceSeatType.Editor) + expect(projects.length).to.eq(4) + expect(checkAllProjects((p) => p.isOwner)).to.be.ok + expect(checkProject(workspaceProjectA).hasExplicitRole).to.not.be.ok + expect(checkProject(workspaceProjectB).isExplicitOwner).to.be.ok + expect(checkProject(workspaceProjectC).hasExplicitRole).to.not.be.ok + expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok + }) + }) + + describe('to workspace member', () => { + beforeEach(async () => { + await apollo.execute( + UpdateWorkspaceRoleDocument, + { + input: { + userId: workspaceGuestViewerUser.id, + workspaceId: workspace.id, + role: Roles.Workspace.Member + } + }, + { assertNoErrors: true } + ) + }) + + it('should retain viewer seat, same explicit access and get full implicit acccess', async () => { + const { workspace, projects, checkProject, checkAllProjects } = + await getProjects({ + user: workspaceGuestViewerUser + }) + + expect(workspace.seatType).to.eq(WorkspaceSeatType.Viewer) + expect(projects.length).to.eq(4) + expect(checkAllProjects((p) => p.isReviewer)).to.be.ok + expect(checkProject(workspaceProjectA).hasExplicitRole).to.be.not.ok + expect(checkProject(workspaceProjectB).isExplicitReviewer).to.be.ok + expect(checkProject(workspaceProjectC).hasExplicitRole).to.be.not.ok + expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok + }) }) }) }) }) - describe('composite role changes in a workspace with projects', () => { - let workspaceMemberApollo: TestApolloServer - - const workspace: BasicTestWorkspace = { + describe('doing composite role/seat changes', () => { + const testWorkspace: BasicTestWorkspace = { id: '', ownerId: '', - slug: cryptoRandomString({ length: 10 }), - name: 'Test Workspace w/ Projects' + slug: '', + name: 'Test Composite Role Change Workspace' } - const workspaceProject: BasicTestStream = { + const workspaceAdminUser: BasicTestUser = { id: '', - ownerId: '', - name: 'Test Project', - isPublic: true + name: 'Composite John "Owner" Specke', + email: 'composite-john-owner-speckle@example.org' + } + + const workspaceMemberUser: BasicTestUser = { + id: '', + name: 'Composite John "Member" Speckel', + email: 'composite-john-member-speckle@example.org' } before(async () => { - const token = await createAuthTokenForUser(serverMemberUser.id, AllScopes) - workspaceMemberApollo = await testApolloServer({ - context: await createTestContext({ - auth: true, - userId: serverMemberUser.id, - token, - role: serverMemberUser.role, - scopes: AllScopes - }) - }) + await createTestUsers([workspaceAdminUser, workspaceMemberUser]) }) beforeEach(async () => { - await createTestWorkspace(workspace, serverAdminUser) - workspaceProject.workspaceId = workspace.id - await createTestStream(workspaceProject, serverAdminUser) + await createTestWorkspace(testWorkspace, serverAdminUser, { + addPlan: { + name: 'team', + status: 'valid' + } + }) + + await assignToWorkspace( + testWorkspace, + workspaceAdminUser, + Roles.Workspace.Admin, + WorkspaceSeatType.Editor + ) + await assignToWorkspace( + testWorkspace, + workspaceMemberUser, + Roles.Workspace.Member, + WorkspaceSeatType.Editor + ) }) afterEach(async () => { - await truncateTables(['workspaces', 'streams']) + await truncateTables([ + Workspaces.name, + Streams.name, + WorkspaceAcl.name, + WorkspaceSeats.name + ]) }) - describe('when leaving the workspace as the last owner of a workspace project', () => { + it('downgrading admin->guest if last owner, sets new owner from workspace admins', async () => { // User Workspace Role Project Role - // serverAdminUser Admin Reviewer - // serverMemberUser Admin Owner + // serverAdminUser Admin None + // workspaceAdminUser Admin Owner // - // Action: `serverMemberUser` leaves workspace + // Action: `workspaceAdminUser` downgraded to workspace guest - beforeEach(async () => { - await assignToWorkspace(workspace, serverMemberUser, Roles.Workspace.Admin) - await grantStreamPermissions({ - streamId: workspaceProject.id, - userId: serverAdminUser.id, - role: Roles.Stream.Reviewer - }) + const project: BasicTestStream = { + id: '', + ownerId: '', + name: 'Test Composite Project', + isPublic: false, + workspaceId: testWorkspace.id + } + await createTestStream(project, workspaceAdminUser) + const apollo = await testApolloServer({ + authUserId: serverAdminUser.id }) - it('should throw and preserve all roles', async () => { - const res = await workspaceMemberApollo.execute( - ActiveUserLeaveWorkspaceDocument, - { id: workspace.id } - ) - - const { data: workspaceTeamData } = await apollo.execute( - GetWorkspaceTeamDocument, - { workspaceId: workspace.id } - ) - const { data: workspaceProjectsData } = await apollo.execute( - GetWorkspaceProjectsDocument, - { id: workspace.id } - ) - - const teamRoles = workspaceTeamData?.workspace.team.items - const projectRoles = workspaceProjectsData?.workspace.projects.items[0].team - - expect(res).to.haveGraphQLErrors('Could not revoke permissions for last admin') - expect(teamRoles).to.exist - expect(teamRoles?.some((role) => role.id === serverMemberUser.id)).to.be.true - expect(projectRoles).to.exist - expect(projectRoles?.some((role) => role.id === serverMemberUser.id)).to.be.true + const remove = await apollo.execute(UpdateWorkspaceRoleDocument, { + input: { + userId: workspaceAdminUser.id, + role: Roles.Workspace.Guest, + workspaceId: testWorkspace.id + } }) + expect(remove).to.not.haveGraphQLErrors() + + const { workspace, checkProject } = await getWorkspaceProjects({ + user: workspaceAdminUser, + workspace: testWorkspace + }) + expect(workspace?.role).to.eq(Roles.Workspace.Guest) + expect(checkProject(project).isExplicitContributor).to.be.ok + + const { checkProject: checkProjectForAdmin } = await getUserProjects({ + user: serverAdminUser + }) + expect(checkProjectForAdmin(project).isExplicitOwner).to.be.ok }) - describe('when removing a workspace member that is the last owner of a workspace project', () => { + it('downgrading member to viewer if last owner, sets new owner from workspace admins', async () => { // User Workspace Role Project Role - // serverAdminUser Admin Reviewer - // serverMemberUser Admin Owner + // workspaceAdminUser Admin None + // workspaceMemberUser Member Owner // - // Action: `serverAdminUser` removes `serverMemberUser` from the workspace + // Action: `workspaceAdminUser` downgraded to workspace guest - beforeEach(async () => { - await assignToWorkspace(workspace, serverMemberUser, Roles.Workspace.Admin) - await grantStreamPermissions({ - streamId: workspaceProject.id, - userId: serverAdminUser.id, - role: Roles.Stream.Reviewer - }) + // ensure serverAdmin is no longer admin, so there's only 1 - workspaceAdmin + await unassignFromWorkspace(testWorkspace, serverAdminUser) + + const project: BasicTestStream = { + id: '', + ownerId: '', + name: 'Test Composite Project', + isPublic: false, + workspaceId: testWorkspace.id + } + await createTestStream(project, workspaceMemberUser) + const apollo = await testApolloServer({ + authUserId: workspaceAdminUser.id }) - it('should throw and preserve all roles', async () => { - const res = await apollo.execute(UpdateWorkspaceRoleDocument, { - input: { - userId: serverMemberUser.id, - role: null, - workspaceId: workspace.id - } - }) - - const { data: workspaceTeamData } = await apollo.execute( - GetWorkspaceTeamDocument, - { workspaceId: workspace.id } - ) - const { data: workspaceProjectsData } = await apollo.execute( - GetWorkspaceProjectsDocument, - { id: workspace.id } - ) - - const teamRoles = workspaceTeamData?.workspace.team.items - const projectRoles = workspaceProjectsData?.workspace.projects.items[0].team - - expect(res).to.haveGraphQLErrors('Could not revoke permissions for last admin') - expect(teamRoles).to.exist - expect(teamRoles?.some((role) => role.id === serverMemberUser.id)).to.be.true - expect(projectRoles).to.exist - expect(projectRoles?.some((role) => role.id === serverMemberUser.id)).to.be.true + const downgrade = await apollo.execute(UpdateWorkspaceSeatTypeDocument, { + input: { + userId: workspaceMemberUser.id, + workspaceId: testWorkspace.id, + seatType: WorkspaceSeatType.Viewer + } }) + expect(downgrade).to.not.haveGraphQLErrors() + + const { workspace, checkProject } = await getWorkspaceProjects({ + user: workspaceMemberUser, + workspace: testWorkspace + }) + expect(workspace?.role).to.eq(Roles.Workspace.Member) + expect(workspace?.seatType).to.eq(WorkspaceSeatType.Viewer) + expect(checkProject(project).isExplicitReviewer).to.be.ok + + const { checkProject: checkProjectForAdmin } = await getUserProjects({ + user: workspaceAdminUser + }) + expect(checkProjectForAdmin(project).isExplicitOwner).to.be.ok }) - describe('when leaving a workspace without any project owner roles', () => { + it('leaving workspace as last owner of a workspace, sets new owner from workspace admins', async () => { // User Workspace Role Project Role - // serverAdminUser Admin Owner - // serverMemberUser Member Reviewer + // workspaceAdminUser Admin None + // workspaceMemberUser Member Owner // - // Action: `serverMemberUser` leaves workspace + // Action: `workspaceMemberUser` leaves workspace - beforeEach(async () => { - await assignToWorkspace(workspace, serverMemberUser, Roles.Workspace.Member) - await grantStreamPermissions({ - streamId: workspaceProject.id, - userId: serverMemberUser.id, - role: Roles.Stream.Reviewer - }) + // ensure serverAdmin is no longer admin, so there's only 1 - workspaceAdmin + await unassignFromWorkspace(testWorkspace, serverAdminUser) + + const project: BasicTestStream = { + id: '', + ownerId: '', + name: 'Test Leave Project', + isPublic: false, + workspaceId: testWorkspace.id + } + await createTestStream(project, workspaceMemberUser) + const apollo = await testApolloServer({ + authUserId: workspaceMemberUser.id }) - it('should remove all workspace and project roles for user', async () => { - const res = await workspaceMemberApollo.execute( - ActiveUserLeaveWorkspaceDocument, - { id: workspace.id } - ) - - const { data: workspaceTeamData } = await apollo.execute( - GetWorkspaceTeamDocument, - { workspaceId: workspace.id } - ) - const { data: workspaceProjectsData } = await apollo.execute( - GetWorkspaceProjectsDocument, - { id: workspace.id } - ) - - const teamRoles = workspaceTeamData?.workspace.team.items - const projectRoles = workspaceProjectsData?.workspace.projects.items[0].team - - expect(res).to.not.haveGraphQLErrors() - expect(teamRoles).to.exist - expect(teamRoles?.some((role) => role.id === serverMemberUser.id)).to.be.false - expect(projectRoles).to.exist - expect(projectRoles?.some((role) => role.id === serverMemberUser.id)).to.be - .false + const leave = await apollo.execute(ActiveUserLeaveWorkspaceDocument, { + id: testWorkspace.id }) + expect(leave).to.not.haveGraphQLErrors() + + const { workspace } = await getUserWorkspace({ + user: workspaceMemberUser, + workspace: testWorkspace + }) + expect(workspace?.role).to.not.be.ok + + const { checkProject } = await getUserProjects({ + user: workspaceMemberUser + }) + expect(checkProject(project).hasExplicitRole).to.be.not.ok + + const { checkProject: checkProjectForAdmin } = await getUserProjects({ + user: workspaceAdminUser + }) + expect(checkProjectForAdmin(project).isExplicitOwner).to.be.ok }) - describe('when removing a workspace member that has no workspace project owner roles', () => { + it('leaving workspace w/o owner roles works fine and removes all roles', async () => { // User Workspace Role Project Role - // serverAdminUser Admin Owner - // serverMemberUser Member Reviewer + // workspaceAdminUser Admin Owner + // workspaceMemberUser Member Reviewer // - // Action: `serverAdminUser` removes `serverMemberUser` from the workspace + // Action: `workspaceMemberUser` leaves workspace - beforeEach(async () => { - await assignToWorkspace(workspace, serverMemberUser, Roles.Workspace.Member) - await grantStreamPermissions({ - streamId: workspaceProject.id, - userId: serverMemberUser.id, - role: Roles.Stream.Reviewer - }) + const project: BasicTestStream = { + id: '', + ownerId: '', + name: 'Test Leave Project', + isPublic: false, + workspaceId: testWorkspace.id + } + await createTestStream(project, workspaceAdminUser) + await addToStream(project, workspaceMemberUser, Roles.Stream.Reviewer) + + const apollo = await testApolloServer({ + authUserId: workspaceMemberUser.id }) - it('should remove all workspace and project roles for removed member', async () => { - const res = await apollo.execute(UpdateWorkspaceRoleDocument, { - input: { - userId: serverMemberUser.id, - role: null, - workspaceId: workspace.id - } - }) - - const { data: workspaceTeamData } = await apollo.execute( - GetWorkspaceTeamDocument, - { workspaceId: workspace.id } - ) - const { data: workspaceProjectsData } = await apollo.execute( - GetWorkspaceProjectsDocument, - { id: workspace.id } - ) - - const teamRoles = workspaceTeamData?.workspace.team.items - const projectRoles = workspaceProjectsData?.workspace.projects.items[0].team - - expect(res).to.not.haveGraphQLErrors() - expect(teamRoles).to.exist - expect(teamRoles?.some((role) => role.id === serverMemberUser.id)).to.be.false - expect(projectRoles).to.exist - expect(projectRoles?.some((role) => role.id === serverMemberUser.id)).to.be - .false + const leave = await apollo.execute(ActiveUserLeaveWorkspaceDocument, { + id: testWorkspace.id }) + expect(leave).to.not.haveGraphQLErrors() + expect(leave.data?.workspaceMutations.leave).to.be.ok + + const { checkProject } = await getUserProjects({ + user: workspaceMemberUser + }) + expect(checkProject(project).hasExplicitRole).to.be.not.ok + + const { workspace } = await getUserWorkspace({ + user: workspaceMemberUser, + workspace: testWorkspace + }) + expect(workspace?.role).to.be.not.ok + }) + + it('removing a workspace member that is the last owner of a workspace project sets new owner from workspace admins', async () => { + // User Workspace Role Project Role + // workspaceAdminUser Admin None + // workspaceMemberUser Member Owner + // + // Action: `workspaceAdminUser` removes `workspaceMemberUser` from the workspace + + // ensure serverAdmin is no longer admin, so there's only 1 - workspaceAdmin + await unassignFromWorkspace(testWorkspace, serverAdminUser) + + const project: BasicTestStream = { + id: '', + ownerId: '', + name: 'Test Remove Project', + isPublic: false, + workspaceId: testWorkspace.id + } + await createTestStream(project, workspaceMemberUser) + const apollo = await testApolloServer({ + authUserId: workspaceAdminUser.id + }) + + const remove = await apollo.execute(UpdateWorkspaceRoleDocument, { + input: { + userId: workspaceMemberUser.id, + role: null, + workspaceId: testWorkspace.id + } + }) + expect(remove).to.not.haveGraphQLErrors() + expect(remove.data?.workspaceMutations.updateRole).to.be.ok + + const { checkProject } = await getUserProjects({ + user: workspaceMemberUser + }) + expect(checkProject(project).hasExplicitRole).to.be.not.ok + + const { workspace } = await getUserWorkspace({ + user: workspaceMemberUser, + workspace: testWorkspace + }) + expect(workspace?.role).to.be.not.ok + + const { checkProject: checkProjectForAdmin } = await getUserProjects({ + user: workspaceAdminUser + }) + expect(checkProjectForAdmin(project).isExplicitOwner).to.be.ok + }) + + it('removing a workspace member that is not the last owner of a workspace project works fine and removes all roles', async () => { + // User Workspace Role Project Role + // workspaceAdminUser Admin Owner + // workspaceMemberUser Member Reviewer + // + // Action: `workspaceAdminUser` removes `workspaceMemberUser` from the workspace + + const project: BasicTestStream = { + id: '', + ownerId: '', + name: 'Test Remove Project', + isPublic: false, + workspaceId: testWorkspace.id + } + await createTestStream(project, workspaceAdminUser) + await addToStream(project, workspaceMemberUser, Roles.Stream.Reviewer) + + const apollo = await testApolloServer({ + authUserId: workspaceAdminUser.id + }) + + const remove = await apollo.execute(UpdateWorkspaceRoleDocument, { + input: { + userId: workspaceMemberUser.id, + role: null, + workspaceId: testWorkspace.id + } + }) + expect(remove).to.not.haveGraphQLErrors() + expect(remove.data?.workspaceMutations.updateRole).to.be.ok + + const { checkProject } = await getUserProjects({ + user: workspaceMemberUser + }) + expect(checkProject(project).hasExplicitRole).to.be.not.ok + + const { workspace } = await getUserWorkspace({ + user: workspaceMemberUser, + workspace: testWorkspace + }) + expect(workspace?.role).to.be.not.ok }) }) }) diff --git a/packages/server/modules/workspaces/tests/unit/events/eventListener.spec.ts b/packages/server/modules/workspaces/tests/unit/events/eventListener.spec.ts index 7de734acb..f02655073 100644 --- a/packages/server/modules/workspaces/tests/unit/events/eventListener.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/events/eventListener.spec.ts @@ -1,13 +1,9 @@ import cryptoRandomString from 'crypto-random-string' import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types' -import { Roles, StreamRoles } from '@speckle/shared' +import { Roles } from '@speckle/shared' import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types' -import { - onProjectCreatedFactory, - onWorkspaceRoleUpdatedFactory -} from '@/modules/workspaces/events/eventListener' +import { onProjectCreatedFactory } from '@/modules/workspaces/events/eventListener' import { expect } from 'chai' -import { chunk } from 'lodash' import { GetWorkspaceRolesAndSeats } from '@/modules/gatekeeper/domain/billing' describe('Event handlers', () => { @@ -89,132 +85,4 @@ describe('Event handlers', () => { expect(projectRoles.length).to.equal(2) }) }) - describe('onWorkspaceRoleUpdatedFactory creates a function, that', () => { - it('assigns no project roles if the role mapping returns null', async () => { - let isDeleteCalled = false - const fakeProject = { id: 'test' } as StreamRecord - - await onWorkspaceRoleUpdatedFactory({ - getWorkspaceWithPlan: async () => - ({ - id: 'fake' - } as Workspace & { plan: null }), - getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({ - default: { - [Roles.Workspace.Admin]: Roles.Stream.Owner, - [Roles.Workspace.Member]: Roles.Stream.Contributor, - [Roles.Workspace.Guest]: null - }, - allowed: { - [Roles.Workspace.Admin]: [ - Roles.Stream.Owner, - Roles.Stream.Contributor, - Roles.Stream.Reviewer - ], - [Roles.Workspace.Member]: [ - Roles.Stream.Owner, - Roles.Stream.Contributor, - Roles.Stream.Reviewer - ], - [Roles.Workspace.Guest]: [Roles.Stream.Reviewer, Roles.Stream.Contributor] - } - }), - async *queryAllWorkspaceProjects() { - yield [fakeProject as StreamRecord] - }, - getStreamsCollaboratorCounts: async () => { - return {} - }, - setStreamCollaborator: async ({ role }) => { - if (!role) { - isDeleteCalled = true - } else { - expect.fail() - } - - return fakeProject - } - })({ - acl: { - role: Roles.Workspace.Guest, - userId: cryptoRandomString({ length: 10 }), - workspaceId: cryptoRandomString({ length: 10 }) - }, - updatedByUserId: cryptoRandomString({ length: 10 }) - }) - - expect(isDeleteCalled).to.be.true - }) - it('assigns the mapped projects roles to all queried project', async () => { - const projectIds = [ - cryptoRandomString({ length: 10 }), - cryptoRandomString({ length: 10 }), - cryptoRandomString({ length: 10 }), - cryptoRandomString({ length: 10 }) - ] - const userId = cryptoRandomString({ length: 10 }) - const projectRole = Roles.Stream.Reviewer - - const storedRoles: { userId: string; role: StreamRoles; projectId: string }[] = [] - let trackProjectUpdate: boolean | undefined = false - await onWorkspaceRoleUpdatedFactory({ - getWorkspaceWithPlan: async () => - ({ - id: 'fake' - } as Workspace & { plan: null }), - getWorkspaceRoleToDefaultProjectRoleMapping: async () => ({ - default: { - [Roles.Workspace.Admin]: Roles.Stream.Owner, - [Roles.Workspace.Member]: projectRole, - [Roles.Workspace.Guest]: null - }, - allowed: { - [Roles.Workspace.Admin]: [ - Roles.Stream.Owner, - Roles.Stream.Contributor, - Roles.Stream.Reviewer - ], - [Roles.Workspace.Member]: [ - Roles.Stream.Owner, - Roles.Stream.Contributor, - Roles.Stream.Reviewer - ], - [Roles.Workspace.Guest]: [Roles.Stream.Reviewer, Roles.Stream.Contributor] - } - }), - async *queryAllWorkspaceProjects() { - for (const projIds of chunk(projectIds, 3)) { - yield projIds.map((projId) => ({ id: projId } as unknown as StreamRecord)) - } - }, - getStreamsCollaboratorCounts: async () => { - return {} - }, - setStreamCollaborator: async (params, options) => { - if (!params.role) { - return expect.fail() - } else { - storedRoles.push({ - userId: params.userId, - role: params.role, - projectId: params.streamId - }) - trackProjectUpdate = trackProjectUpdate || options?.trackProjectUpdate - return {} as StreamRecord - } - } - })({ - acl: { - role: Roles.Workspace.Member, - userId, - workspaceId: cryptoRandomString({ length: 10 }) - }, - updatedByUserId: cryptoRandomString({ length: 10 }) - }) - expect(storedRoles).deep.equals( - projectIds.map((projectId) => ({ projectId, role: projectRole, userId })) - ) - expect(trackProjectUpdate).to.not.be.true - }) - }) }) diff --git a/packages/server/modules/workspaces/tests/unit/services/management.spec.ts b/packages/server/modules/workspaces/tests/unit/services/management.spec.ts index 543291bf3..b9d6d3ef2 100644 --- a/packages/server/modules/workspaces/tests/unit/services/management.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/management.spec.ts @@ -693,7 +693,11 @@ describe('Workspace role services', () => { workspaceRoles: [role] }) - const deletedRole = await deleteWorkspaceRole({ userId, workspaceId }) + const deletedRole = await deleteWorkspaceRole({ + userId, + workspaceId, + deletedByUserId: cryptoRandomString({ length: 10 }) + }) expect(context.workspaceRoles.length).to.equal(0) expect(deletedRole).to.deep.equal(role) @@ -713,11 +717,19 @@ describe('Workspace role services', () => { workspaceRoles: [role] }) - await deleteWorkspaceRole({ userId, workspaceId }) + const deletedByUserId = cryptoRandomString({ length: 10 }) + await deleteWorkspaceRole({ + userId, + workspaceId, + deletedByUserId + }) expect(context.eventData.isCalled).to.be.true expect(context.eventData.eventName).to.equal(WorkspaceEvents.RoleDeleted) - expect(context.eventData.payload).to.deep.equal({ acl: role }) + expect(context.eventData.payload).to.deep.equal({ + acl: role, + updatedByUserId: deletedByUserId + }) }) it('throws if attempting to delete the last admin from a workspace', async () => { const userId = cryptoRandomString({ length: 10 }) @@ -734,7 +746,13 @@ describe('Workspace role services', () => { workspaceRoles: [role] }) - await expectToThrow(() => deleteWorkspaceRole({ userId, workspaceId })) + await expectToThrow(() => + deleteWorkspaceRole({ + userId, + workspaceId, + deletedByUserId: cryptoRandomString({ length: 10 }) + }) + ) }) it('deletes workspace project roles', async () => { const userId = cryptoRandomString({ length: 10 }) @@ -757,7 +775,11 @@ describe('Workspace role services', () => { ] }) - await deleteWorkspaceRole({ userId, workspaceId }) + await deleteWorkspaceRole({ + userId, + workspaceId, + deletedByUserId: cryptoRandomString({ length: 10 }) + }) expect(context.workspaceProjectRoles.length).to.equal(0) }) @@ -807,13 +829,15 @@ describe('Workspace role services', () => { ...(context.eventData .payload as WorkspaceEventsPayloads[typeof WorkspaceEvents.RoleUpdated]) } - delete payload.flags expect(context.eventData.isCalled).to.be.true expect(context.eventData.eventName).to.equal(WorkspaceEvents.RoleUpdated) expect(payload).to.deep.equal({ acl: role, - updatedByUserId: workspaceOwnerId + updatedByUserId: workspaceOwnerId, + flags: { + skipProjectRoleUpdatesFor: [] + } }) }) it('throws if attempting to remove the last admin in a workspace', async () => { diff --git a/packages/server/modules/workspacesCore/domain/events.ts b/packages/server/modules/workspacesCore/domain/events.ts index 780bd9774..18cbadf49 100644 --- a/packages/server/modules/workspacesCore/domain/events.ts +++ b/packages/server/modules/workspacesCore/domain/events.ts @@ -30,11 +30,12 @@ type WorkspaceCreatedPayload = { type WorkspaceUpdatedPayload = { workspace: Workspace } type WorkspaceRoleDeletedPayload = { acl: Pick + updatedByUserId: string } type WorkspaceRoleUpdatedPayload = { acl: Pick - flags?: { skipProjectRoleUpdatesFor: string[] } updatedByUserId: string + flags?: { skipProjectRoleUpdatesFor: string[] } } type WorkspaceSeatUpdatedPayload = { seat: WorkspaceSeat diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 0a0d461e0..c58c68f04 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -5490,6 +5490,36 @@ export type GetProjectInvitableCollaboratorsQueryVariables = Exact<{ export type GetProjectInvitableCollaboratorsQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, name: string, invitableCollaborators: { __typename?: 'WorkspaceCollaboratorCollection', totalCount: number, items: Array<{ __typename?: 'WorkspaceCollaborator', id: string, user: { __typename?: 'LimitedUser', name: string } }> } } }; +export type FullPermissionCheckResultFragment = { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record | null }; + +export type ProjectImplicitRoleCheckFragment = { __typename?: 'Project', id: string, role?: string | null, permissions: { __typename?: 'ProjectPermissionChecks', canRead: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record | null }, canReadSettings: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record | null }, canReadWebhooks: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record | null }, canCreateModel: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record | null } } }; + +export type GetUserWorkspaceAccessQueryVariables = Exact<{ + id: Scalars['String']['input']; +}>; + + +export type GetUserWorkspaceAccessQuery = { __typename?: 'Query', workspace: { __typename?: 'Workspace', id: string, role?: string | null, seatType?: WorkspaceSeatType | null } }; + +export type GetUserWorkspaceProjectsWithAccessChecksQueryVariables = Exact<{ + id: Scalars['String']['input']; + limit?: InputMaybe; + cursor?: InputMaybe; + filter?: InputMaybe; +}>; + + +export type GetUserWorkspaceProjectsWithAccessChecksQuery = { __typename?: 'Query', workspace: { __typename?: 'Workspace', role?: string | null, seatType?: WorkspaceSeatType | null, id: string, name: string, slug: string, updatedAt: string, createdAt: string, readOnly: boolean, projects: { __typename?: 'ProjectCollection', cursor?: string | null, totalCount: number, items: Array<{ __typename?: 'Project', id: string, role?: string | null, permissions: { __typename?: 'ProjectPermissionChecks', canRead: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record | null }, canReadSettings: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record | null }, canReadWebhooks: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record | null }, canCreateModel: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record | null } } }> } } }; + +export type GetUserProjectsWithAccessChecksQueryVariables = Exact<{ + limit?: InputMaybe; + cursor?: InputMaybe; + filter?: InputMaybe; +}>; + + +export type GetUserProjectsWithAccessChecksQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', id: string, projects: { __typename?: 'UserProjectCollection', cursor?: string | null, totalCount: number, items: Array<{ __typename?: 'Project', id: string, role?: string | null, permissions: { __typename?: 'ProjectPermissionChecks', canRead: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record | null }, canReadSettings: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record | null }, canReadWebhooks: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record | null }, canCreateModel: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record | null } } }> } } | null }; + export type BasicStreamAccessRequestFieldsFragment = { __typename?: 'StreamAccessRequest', id: string, requesterId: string, streamId: string, createdAt: string, requester: { __typename?: 'LimitedUser', id: string, name: string } }; export type CreateStreamAccessRequestMutationVariables = Exact<{ @@ -6264,6 +6294,8 @@ export type MoveProjectToWorkspaceMutation = { __typename?: 'Mutation', workspac export const BasicWorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"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":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}}]} as unknown as DocumentNode; export const BasicPendingWorkspaceCollaboratorFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"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":"token"}}]}}]} as unknown as DocumentNode; export const WorkspaceProjectsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceProjects"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectCollection"}},"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":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]} as unknown as DocumentNode; +export const FullPermissionCheckResultFragmentDoc = {"kind":"Document","definitions":[{"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"}}]}}]} as unknown as DocumentNode; +export const ProjectImplicitRoleCheckFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectImplicitRoleCheck"},"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":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canRead"},"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":"canReadWebhooks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canCreateModel"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}}]}}]}},{"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"}}]}}]} as unknown as DocumentNode; export const BasicStreamAccessRequestFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamAccessRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requester"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"requesterId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; export const TestAutomateFunctionFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestAutomateFunction"},"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":"repo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"owner"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"releases"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"versionTag"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"inputSchema"}},{"kind":"Field","name":{"kind":"Name","value":"commitId"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"supportedSourceApps"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}}]}}]} as unknown as DocumentNode; export const TestAutomationFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestAutomation"},"typeCondition":{"kind":"NamedType","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":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"runs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"trigger"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"VersionCreatedTrigger"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"version"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"model"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"functionRuns"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"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":"elapsed"}},{"kind":"Field","name":{"kind":"Name","value":"results"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"currentRevision"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"triggerDefinitions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"VersionCreatedTriggerDefinition"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"model"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"functions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"parameters"}},{"kind":"Field","name":{"kind":"Name","value":"release"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"function"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"versionTag"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"inputSchema"}},{"kind":"Field","name":{"kind":"Name","value":"commitId"}}]}}]}}]}}]}}]} as unknown as DocumentNode; @@ -6332,6 +6364,9 @@ export const GetWorkspaceWithMembersByRoleDocument = {"kind":"Document","definit export const UpdateWorkspaceProjectRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkspaceProjectRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectUpdateRoleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projects"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicProjectFields"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"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":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const UpdateWorkspaceSeatTypeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkspaceSeatType"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceUpdateSeatTypeInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateSeatType"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"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":"team"},"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":"role"}},{"kind":"Field","name":{"kind":"Name","value":"seatType"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GetProjectInvitableCollaboratorsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectInvitableCollaborators"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"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":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"invitableCollaborators"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"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"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetUserWorkspaceAccessDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUserWorkspaceAccess"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"seatType"}}]}}]}}]} as unknown as DocumentNode; +export const GetUserWorkspaceProjectsWithAccessChecksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUserWorkspaceProjectsWithAccessChecks"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"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":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"seatType"}},{"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":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectImplicitRoleCheck"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"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":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"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":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectImplicitRoleCheck"},"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":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canRead"},"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":"canReadWebhooks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canCreateModel"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetUserProjectsWithAccessChecksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUserProjectsWithAccessChecks"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"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":"UserProjectsFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"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":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectImplicitRoleCheck"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"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":"FragmentDefinition","name":{"kind":"Name","value":"ProjectImplicitRoleCheck"},"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":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canRead"},"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":"canReadWebhooks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canCreateModel"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateStreamAccessRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateStreamAccessRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamAccessRequestCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamAccessRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requester"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"requesterId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; export const GetStreamAccessRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetStreamAccessRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamAccessRequest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamAccessRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requester"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"requesterId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; export const GetFullStreamAccessRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetFullStreamAccessRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamAccessRequest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"}},{"kind":"Field","name":{"kind":"Name","value":"stream"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamAccessRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requester"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"requesterId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; diff --git a/utils/helm/speckle-server/values.schema.json b/utils/helm/speckle-server/values.schema.json index 0891df75c..089e75eeb 100644 --- a/utils/helm/speckle-server/values.schema.json +++ b/utils/helm/speckle-server/values.schema.json @@ -244,7 +244,7 @@ "maxConnectionsServer": { "type": "number", "description": "The number of connections to the Postgres database to provide in the connection pool", - "default": 4 + "default": 8 }, "certificate": { "type": "string", diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml index ee18d279d..3a7c9a243 100644 --- a/utils/helm/speckle-server/values.yaml +++ b/utils/helm/speckle-server/values.yaml @@ -191,7 +191,7 @@ db: useCertificate: false ## @param db.maxConnectionsServer The number of connections to the Postgres database to provide in the connection pool ## - maxConnectionsServer: 4 + maxConnectionsServer: 8 ## @param db.certificate The x509 public certificate for SSL connections to the Postgres database. Use of this certificate requires db.useCertificate to be enabled and an appropriate value for db.PGSSLMODE provided. ## The value must be formatted as a multi-line string. We recommend using the pipe-symbol and taking care to ## indent all lines of the value correctly. From 41800d90f2659f61eb2a8524473e571998e4b058 Mon Sep 17 00:00:00 2001 From: Mike Date: Tue, 29 Apr 2025 09:51:01 +0200 Subject: [PATCH 041/178] Fix: Fix workspace sidebar actions for guest (#4623) --- packages/frontend-2/components/workspace/sidebar/Sidebar.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend-2/components/workspace/sidebar/Sidebar.vue b/packages/frontend-2/components/workspace/sidebar/Sidebar.vue index 0b2851d45..618fb09d2 100644 --- a/packages/frontend-2/components/workspace/sidebar/Sidebar.vue +++ b/packages/frontend-2/components/workspace/sidebar/Sidebar.vue @@ -57,7 +57,7 @@ const { result: workspaceResult } = useQuery(workspaceSidebarQuery, () => ({ const workspace = computed(() => workspaceResult.value?.workspaceBySlug) const isFreePlan = computed(() => workspace.value?.plan?.name === WorkspacePlans.Free) -const isWorkspaceGuest = computed(() => workspace.value?.slug === Roles.Workspace.Guest) +const isWorkspaceGuest = computed(() => workspace.value?.role === Roles.Workspace.Guest) const isWorkspaceAdmin = computed(() => workspace.value?.role === Roles.Workspace.Admin) const hasDomains = computed(() => workspace.value?.domains?.length) From d4e326e89ea962fdb9c777bd883ff3995069ddbe Mon Sep 17 00:00:00 2001 From: Iain Sproat <68657+iainsproat@users.noreply.github.com> Date: Tue, 29 Apr 2025 10:14:47 +0100 Subject: [PATCH 042/178] ci(codecov): upload coverage results for shared (#4431) * ci(codecov): upload coverage results for shared * set targets for each upload --- .circleci/config.yml | 5 ++++- codecov.yml | 35 +++++++++++++++++++++++++++++++++++ packages/shared/package.json | 1 + 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index bfde5a1b8..c18fbe92f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -786,9 +786,12 @@ jobs: - run: name: Run tests - command: yarn test:single-run + command: yarn test:ci working_directory: 'packages/shared' + - codecov/upload: + files: packages/shared/coverage/coverage-final.json + - run: name: Build command: yarn build diff --git a/codecov.yml b/codecov.yml index 9ebf3bec0..24abb6c9a 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,3 +2,38 @@ codecov: notify: notify_error: true require_ci_to_pass: false + +coverage: + status: + project: + default: + target: 90% #overall project/ repo coverage + server: + target: 70% + flags: + - server + shared: + target: 70% + flags: + - shared + patch: + default: + target: 90% #overall project/ repo coverage + server: + target: 90% + flags: + - server + shared: + target: 100% + flags: + - shared + +flags: + server: + paths: + - packages/server/coverage/lcov.info + carryforward: false + shared: + paths: + - packages/shared/coverage/coverage-final.json + carryforward: false diff --git a/packages/shared/package.json b/packages/shared/package.json index 1b0890c6d..771e78a35 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -21,6 +21,7 @@ "lint:ci": "yarn lint:tsc", "test": "vitest", "test:ui": "vitest --ui", + "test:ci": "vitest --run --coverage", "test:coverage": "vitest --coverage", "test:single-run": "vitest run" }, From 9586ca3a375d4fd6f416cb50d4e4d2374217fb04 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 29 Apr 2025 10:39:20 +0100 Subject: [PATCH 043/178] update time usage --- packages/objectloader2/src/operations/indexedDatabase.ts | 4 ++-- packages/objectloader2/src/test/e2e.spec.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/objectloader2/src/operations/indexedDatabase.ts b/packages/objectloader2/src/operations/indexedDatabase.ts index 2091e899a..f0900d053 100644 --- a/packages/objectloader2/src/operations/indexedDatabase.ts +++ b/packages/objectloader2/src/operations/indexedDatabase.ts @@ -1,7 +1,7 @@ import BatchingQueue from '../helpers/batchingQueue.js' import Queue from '../helpers/queue.js' import { CustomLogger, Item } from '../types/types.js' -import { isSafari, TIME_MS } from '@speckle/shared' +import { isSafari, TIME } from '@speckle/shared' import { BaseDatabaseOptions } from './options.js' import { Cache } from './interfaces.js' import { Dexie, DexieOptions, Table } from 'dexie' @@ -91,7 +91,7 @@ export default class IndexedDatabase implements Cache { this.#logger( 'pausing reads (# in write queue: ' + this.#writeQueue?.count() + ')' ) - await new Promise((resolve) => setTimeout(resolve, TIME_MS.second)) // Pause for 1 second, protects against out of memory + await new Promise((resolve) => setTimeout(resolve, TIME.second)) // Pause for 1 second, protects against out of memory continue } const batch = ids.slice(i, i + maxCacheReadSize) diff --git a/packages/objectloader2/src/test/e2e.spec.ts b/packages/objectloader2/src/test/e2e.spec.ts index d0ca2a9a1..cd735a95c 100644 --- a/packages/objectloader2/src/test/e2e.spec.ts +++ b/packages/objectloader2/src/test/e2e.spec.ts @@ -2,7 +2,7 @@ import { describe, test, expect } from 'vitest' import { IDBFactory, IDBKeyRange } from 'fake-indexeddb' import ObjectLoader2 from '../operations/objectLoader2.js' import { Base } from '../types/types.js' -import { TIME_MS } from '@speckle/shared' +import { TIME } from '@speckle/shared' describe('e2e', () => { test( @@ -36,6 +36,6 @@ describe('e2e', () => { expect(base2).toBeDefined() expect(base2.id).toBe('3841e3cbc45d52c47bc2f1b7b0ad4eb9') }, - 10 * TIME_MS.second + 10 * TIME.second ) }) From 1af902367038fda73a4374d7f0d6dd78aae9bf82 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 29 Apr 2025 11:04:40 +0100 Subject: [PATCH 044/178] remove unused item --- packages/viewer-sandbox/src/Sandbox.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/viewer-sandbox/src/Sandbox.ts b/packages/viewer-sandbox/src/Sandbox.ts index bb059dc9a..a89c4b6ca 100644 --- a/packages/viewer-sandbox/src/Sandbox.ts +++ b/packages/viewer-sandbox/src/Sandbox.ts @@ -57,7 +57,7 @@ import Bright from '../assets/hdri/Bright.png' import { Euler, Vector3, Box3, Color, LinearFilter } from 'three' import { GeometryType } from '@speckle/viewer' import { MeshBatch } from '@speckle/viewer' -import ObjectLoader2, { MemoryDatabase } from '@speckle/objectloader2' +import ObjectLoader2 from '@speckle/objectloader2' export default class Sandbox { private viewer: Viewer From 437ee6d20b38cf4dddcc05089b3da1b1e33b3e01 Mon Sep 17 00:00:00 2001 From: Alessandro Magionami Date: Tue, 29 Apr 2025 12:06:35 +0200 Subject: [PATCH 045/178] feat(core): add project field on invites (#4588) * feat(core): add project field on invites * chore(workspaces): add workspaceSlug on invite --- .../lib/common/generated/gql/graphql.ts | 4 ++++ .../workspacesCore/typedefs/projects.graphql | 4 ++++ .../modules/core/graph/generated/graphql.ts | 2 ++ .../graph/generated/graphql.ts | 1 + .../workspaces/graph/resolvers/projects.ts | 24 +++++++++++++++++++ .../server/test/graphql/generated/graphql.ts | 1 + 6 files changed, 36 insertions(+) diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index 66f0cc8aa..03e457bf9 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -1955,7 +1955,10 @@ export type PendingStreamCollaborator = { id: Scalars['String']['output']; inviteId: Scalars['String']['output']; invitedBy: LimitedUser; + project: Project; + /** @deprecated Use project instead */ projectId: Scalars['String']['output']; + /** @deprecated Use project instead */ projectName: Scalars['String']['output']; role: Scalars['String']['output']; /** @deprecated Use projectId instead */ @@ -8266,6 +8269,7 @@ export type PendingStreamCollaboratorFieldArgs = { id: {}, inviteId: {}, invitedBy: {}, + project: {}, projectId: {}, projectName: {}, role: {}, diff --git a/packages/server/assets/workspacesCore/typedefs/projects.graphql b/packages/server/assets/workspacesCore/typedefs/projects.graphql index 462d3e9e5..b07e4cced 100644 --- a/packages/server/assets/workspacesCore/typedefs/projects.graphql +++ b/packages/server/assets/workspacesCore/typedefs/projects.graphql @@ -16,3 +16,7 @@ extend type ProjectCollaborator { """ workspaceRole: String } + +extend type PendingStreamCollaborator { + workspaceSlug: String +} diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 798695c37..8118364ec 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -1991,6 +1991,7 @@ export type PendingStreamCollaborator = { token?: Maybe; /** Set only if user is registered */ user?: Maybe; + workspaceSlug?: Maybe; }; export type PendingWorkspaceCollaborator = { @@ -6609,6 +6610,7 @@ export type PendingStreamCollaboratorResolvers; token?: Resolver, ParentType, ContextType>; user?: Resolver, ParentType, ContextType>; + workspaceSlug?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index 489d738fd..ec01ba22f 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -1971,6 +1971,7 @@ export type PendingStreamCollaborator = { token?: Maybe; /** Set only if user is registered */ user?: Maybe; + workspaceSlug?: Maybe; }; export type PendingWorkspaceCollaborator = { diff --git a/packages/server/modules/workspaces/graph/resolvers/projects.ts b/packages/server/modules/workspaces/graph/resolvers/projects.ts index 0961e6f18..6b8809b82 100644 --- a/packages/server/modules/workspaces/graph/resolvers/projects.ts +++ b/packages/server/modules/workspaces/graph/resolvers/projects.ts @@ -1,8 +1,10 @@ import { db } from '@/db/knex' +import { StreamNotFoundError } from '@/modules/core/errors/stream' import { Resolvers } from '@/modules/core/graph/generated/graphql' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' import { getPaginatedItemsFactory } from '@/modules/shared/services/paginatedItems' import { WorkspaceTeamMember } from '@/modules/workspaces/domain/types' +import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' import { intersectProjectCollaboratorsAndWorkspaceCollaboratorsFactory } from '@/modules/workspaces/repositories/projects' import { countInvitableCollaboratorsByProjectIdFactory, @@ -68,6 +70,28 @@ export default FF_WORKSPACES_MODULE_ENABLED addedToWorkspaceTotalCount: async (parent) => { return parent.length } + }, + PendingStreamCollaborator: { + workspaceSlug: async (parent, _args, ctx) => { + const project = await ctx.loaders.streams.getStream.load(parent.streamId) + if (!project) { + throw new StreamNotFoundError(null, { + info: { projectId: parent.streamId } + }) + } + if (!project.workspaceId) { + return null + } + const workspace = await ctx.loaders.workspaces?.getWorkspace.load( + project.workspaceId + ) + if (!workspace) { + throw new WorkspaceNotFoundError(null, { + info: { workspaceId: project.workspaceId } + }) + } + return workspace.slug + } } } as Resolvers) : {} diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index c58c68f04..de376b329 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -1972,6 +1972,7 @@ export type PendingStreamCollaborator = { token?: Maybe; /** Set only if user is registered */ user?: Maybe; + workspaceSlug?: Maybe; }; export type PendingWorkspaceCollaborator = { From 796fd5350c65b5dbd1faa9e2ff4387b09f5310a3 Mon Sep 17 00:00:00 2001 From: Mike Date: Tue, 29 Apr 2025 15:25:13 +0200 Subject: [PATCH 046/178] Fix: Add lineclamp instead of truncate for threads (#4627) --- packages/frontend-2/components/viewer/comments/ListItem.vue | 2 +- packages/frontend-2/lib/common/generated/gql/graphql.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/frontend-2/components/viewer/comments/ListItem.vue b/packages/frontend-2/components/viewer/comments/ListItem.vue index c9980ec98..8e4ac85c4 100644 --- a/packages/frontend-2/components/viewer/comments/ListItem.vue +++ b/packages/frontend-2/components/viewer/comments/ListItem.vue @@ -21,7 +21,7 @@
{{ thread.rawText }}
diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index 03e457bf9..8961760c7 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -1955,10 +1955,7 @@ export type PendingStreamCollaborator = { id: Scalars['String']['output']; inviteId: Scalars['String']['output']; invitedBy: LimitedUser; - project: Project; - /** @deprecated Use project instead */ projectId: Scalars['String']['output']; - /** @deprecated Use project instead */ projectName: Scalars['String']['output']; role: Scalars['String']['output']; /** @deprecated Use projectId instead */ @@ -1971,6 +1968,7 @@ export type PendingStreamCollaborator = { token?: Maybe; /** Set only if user is registered */ user?: Maybe; + workspaceSlug?: Maybe; }; export type PendingWorkspaceCollaborator = { @@ -8269,7 +8267,6 @@ export type PendingStreamCollaboratorFieldArgs = { id: {}, inviteId: {}, invitedBy: {}, - project: {}, projectId: {}, projectName: {}, role: {}, @@ -8278,6 +8275,7 @@ export type PendingStreamCollaboratorFieldArgs = { title: {}, token: {}, user: {}, + workspaceSlug: {}, } export type PendingWorkspaceCollaboratorFieldArgs = { email: {}, From c24af051071d074cce8c8d5232ea1eebca2432fc Mon Sep 17 00:00:00 2001 From: Mike Date: Tue, 29 Apr 2025 15:51:56 +0200 Subject: [PATCH 047/178] Feat: Redirect to project on accept invite (#4628) --- .../header/nav/notifications/ProjectInvite.vue | 12 ++++++++++++ packages/frontend-2/lib/common/generated/gql/gql.ts | 6 +++--- .../frontend-2/lib/common/generated/gql/graphql.ts | 8 ++++---- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/frontend-2/components/header/nav/notifications/ProjectInvite.vue b/packages/frontend-2/components/header/nav/notifications/ProjectInvite.vue index 21c2af25e..2bd45f68a 100644 --- a/packages/frontend-2/components/header/nav/notifications/ProjectInvite.vue +++ b/packages/frontend-2/components/header/nav/notifications/ProjectInvite.vue @@ -17,6 +17,8 @@ import { graphql } from '~~/lib/common/generated/gql' import type { HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragment } from '~~/lib/common/generated/gql/graphql' import type { Optional } from '@speckle/shared' import { useProjectInviteManager } from '~/lib/projects/composables/invites' +import { useNavigation } from '~/lib/navigation/composables/navigation' +import { projectRoute } from '~/lib/common/helpers/route' graphql(` fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator { @@ -27,6 +29,7 @@ graphql(` projectId projectName token + workspaceSlug user { id } @@ -37,6 +40,7 @@ const props = defineProps<{ invite: HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragment }>() +const { mutateActiveWorkspaceSlug, mutateIsProjectsActive } = useNavigation() const { useInvite } = useProjectInviteManager() const loading = ref(false) @@ -52,6 +56,14 @@ const processInvite = async (accept: boolean, token: Optional) => { inviteId: props.invite.id }) + if (props.invite.workspaceSlug) { + mutateActiveWorkspaceSlug(props.invite.workspaceSlug) + } else { + mutateIsProjectsActive(true) + } + + navigateTo(projectRoute(props.invite.projectId)) + loading.value = false } diff --git a/packages/frontend-2/lib/common/generated/gql/gql.ts b/packages/frontend-2/lib/common/generated/gql/gql.ts index 3ab2d25cc..2d1e38c90 100644 --- a/packages/frontend-2/lib/common/generated/gql/gql.ts +++ b/packages/frontend-2/lib/common/generated/gql/gql.ts @@ -50,7 +50,7 @@ type Documents = { "\n fragment HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace on LimitedWorkspace {\n id\n slug\n name\n logo\n }\n": typeof types.HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspaceFragmentDoc, "\n fragment HeaderWorkspaceSwitcherHeaderWorkspace_Workspace on Workspace {\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n": typeof types.HeaderWorkspaceSwitcherHeaderWorkspace_WorkspaceFragmentDoc, "\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n": typeof types.HeaderNavShare_ProjectFragmentDoc, - "\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n": typeof types.HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragmentDoc, + "\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n workspaceSlug\n user {\n id\n }\n }\n": typeof types.HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragmentDoc, "\n fragment HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": typeof types.HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaboratorFragmentDoc, "\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n name\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n }\n": typeof types.InviteDialogWorkspace_WorkspaceFragmentDoc, "\n fragment InviteDialogProject_Project on Project {\n id\n name\n workspaceId\n workspace {\n id\n name\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n }\n }\n": typeof types.InviteDialogProject_ProjectFragmentDoc, @@ -466,7 +466,7 @@ const documents: Documents = { "\n fragment HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace on LimitedWorkspace {\n id\n slug\n name\n logo\n }\n": types.HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspaceFragmentDoc, "\n fragment HeaderWorkspaceSwitcherHeaderWorkspace_Workspace on Workspace {\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n": types.HeaderWorkspaceSwitcherHeaderWorkspace_WorkspaceFragmentDoc, "\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n": types.HeaderNavShare_ProjectFragmentDoc, - "\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n": types.HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragmentDoc, + "\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n workspaceSlug\n user {\n id\n }\n }\n": types.HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragmentDoc, "\n fragment HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": types.HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaboratorFragmentDoc, "\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n name\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n }\n": types.InviteDialogWorkspace_WorkspaceFragmentDoc, "\n fragment InviteDialogProject_Project on Project {\n id\n name\n workspaceId\n workspace {\n id\n name\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n }\n }\n": types.InviteDialogProject_ProjectFragmentDoc, @@ -1007,7 +1007,7 @@ export function graphql(source: "\n fragment HeaderNavShare_Project on Project /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n"): (typeof documents)["\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n"]; +export function graphql(source: "\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n workspaceSlug\n user {\n id\n }\n }\n"): (typeof documents)["\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n workspaceSlug\n user {\n id\n }\n }\n"]; /** * 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 8961760c7..970181b43 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -5207,7 +5207,7 @@ export type HeaderWorkspaceSwitcherHeaderWorkspace_WorkspaceFragment = { __typen export type HeaderNavShare_ProjectFragment = { __typename?: 'Project', id: string, visibility: SimpleProjectVisibility, role?: string | null }; -export type HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragment = { __typename?: 'PendingStreamCollaborator', id: string, projectId: string, projectName: string, token?: string | null, invitedBy: { __typename?: 'LimitedUser', id: string, name: string, avatar?: string | null }, user?: { __typename?: 'LimitedUser', id: string } | null }; +export type HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragment = { __typename?: 'PendingStreamCollaborator', id: string, projectId: string, projectName: string, token?: string | null, workspaceSlug?: string | null, invitedBy: { __typename?: 'LimitedUser', id: string, name: string, avatar?: string | null }, user?: { __typename?: 'LimitedUser', id: string } | null }; export type HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaboratorFragment = { __typename?: 'PendingWorkspaceCollaborator', id: string, workspaceId: string, workspaceName: string, token?: string | null, workspaceSlug: string, invitedBy: { __typename?: 'LimitedUser', id: string, name: string, avatar?: string | null }, user?: { __typename?: 'LimitedUser', id: string } | null }; @@ -5787,7 +5787,7 @@ export type NavigationWorkspaceListQuery = { __typename?: 'Query', activeUser?: export type NavigationProjectInvitesQueryVariables = Exact<{ [key: string]: never; }>; -export type NavigationProjectInvitesQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', id: string, projectInvites: Array<{ __typename?: 'PendingStreamCollaborator', id: string, projectId: string, projectName: string, token?: string | null, invitedBy: { __typename?: 'LimitedUser', id: string, name: string, avatar?: string | null }, user?: { __typename?: 'LimitedUser', id: string } | null }> } | null }; +export type NavigationProjectInvitesQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', id: string, projectInvites: Array<{ __typename?: 'PendingStreamCollaborator', id: string, projectId: string, projectName: string, token?: string | null, workspaceSlug?: string | null, invitedBy: { __typename?: 'LimitedUser', id: string, name: string, avatar?: string | null }, user?: { __typename?: 'LimitedUser', id: string } | null }> } | null }; export type NavigationWorkspaceInvitesQueryVariables = Exact<{ [key: string]: never; }>; @@ -7140,7 +7140,7 @@ export const FormSelectProjects_ProjectFragmentDoc = {"kind":"Document","definit export const ProjectsPageTeamDialogManagePermissions_ProjectFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectsPageTeamDialogManagePermissions_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":"role"}}]}}]} as unknown as DocumentNode; export const ProjectsModelPageEmbed_ProjectFragmentDoc = {"kind":"Document","definitions":[{"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":"FragmentSpread","name":{"kind":"Name","value":"ProjectsPageTeamDialogManagePermissions_Project"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectsPageTeamDialogManagePermissions_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":"role"}}]}}]} as unknown as DocumentNode; export const HeaderNavShare_ProjectFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"HeaderNavShare_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":"FragmentSpread","name":{"kind":"Name","value":"ProjectsModelPageEmbed_Project"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectsPageTeamDialogManagePermissions_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":"role"}}]}},{"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":"FragmentSpread","name":{"kind":"Name","value":"ProjectsPageTeamDialogManagePermissions_Project"}}]}}]} as unknown as DocumentNode; -export const HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"HeaderNavNotificationsProjectInvite_PendingStreamCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingStreamCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"projectName"}},{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedUserAvatar"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"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"}}]}}]} as unknown as DocumentNode; +export const HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"HeaderNavNotificationsProjectInvite_PendingStreamCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingStreamCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"projectName"}},{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceSlug"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedUserAvatar"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"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"}}]}}]} as unknown as DocumentNode; export const UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UseWorkspaceInviteManager_PendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceSlug"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaboratorFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"UseWorkspaceInviteManager_PendingWorkspaceCollaborator"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedUserAvatar"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"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":"FragmentDefinition","name":{"kind":"Name","value":"UseWorkspaceInviteManager_PendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceSlug"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const InviteDialogProject_ProjectFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"InviteDialogProject_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":"workspaceId"}},{"kind":"Field","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":"role"}},{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; @@ -7364,7 +7364,7 @@ export const UpdateRegionDocument = {"kind":"Document","definitions":[{"kind":"O export const SetActiveWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetActiveWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"slug"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"isProjectsActive"}},"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":"setActiveWorkspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"slug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"slug"}}},{"kind":"Argument","name":{"kind":"Name","value":"isProjectsActive"},"value":{"kind":"Variable","name":{"kind":"Name","value":"isProjectsActive"}}}]}]}}]}}]} as unknown as DocumentNode; export const NavigationActiveWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"NavigationActiveWorkspace"},"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":"UseNavigationActiveWorkspace_Workspace"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"HeaderWorkspaceSwitcherHeaderWorkspace_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":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"InviteDialogWorkspace_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":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"HeaderWorkspaceSwitcherActiveWorkspace_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"HeaderWorkspaceSwitcherHeaderWorkspace_Workspace"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"InviteDialogWorkspace_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UseNavigationActiveWorkspace_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"HeaderWorkspaceSwitcherActiveWorkspace_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]} as unknown as DocumentNode; export const NavigationWorkspaceListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"NavigationWorkspaceList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UserProjectsFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"UseNavigationWorkspaceList_User"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedWorkspace"}},"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":"FragmentDefinition","name":{"kind":"Name","value":"HeaderWorkspaceSwitcherWorkspaceList_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":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"creationState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"completed"}}]}},{"kind":"Field","name":{"kind":"Name","value":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"HeaderWorkspaceSwitcherWorkspaceList_User"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"expiredSsoSessions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"HeaderWorkspaceSwitcherWorkspaceList_Workspace"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UseNavigationWorkspaceList_User"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"HeaderWorkspaceSwitcherWorkspaceList_User"}},{"kind":"Field","name":{"kind":"Name","value":"projects"},"arguments":[{"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":"totalCount"}}]}}]}}]} as unknown as DocumentNode; -export const NavigationProjectInvitesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"NavigationProjectInvites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"projectInvites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"HeaderNavNotificationsProjectInvite_PendingStreamCollaborator"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedUserAvatar"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"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":"FragmentDefinition","name":{"kind":"Name","value":"HeaderNavNotificationsProjectInvite_PendingStreamCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingStreamCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"projectName"}},{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; +export const NavigationProjectInvitesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"NavigationProjectInvites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"projectInvites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"HeaderNavNotificationsProjectInvite_PendingStreamCollaborator"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedUserAvatar"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"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":"FragmentDefinition","name":{"kind":"Name","value":"HeaderNavNotificationsProjectInvite_PendingStreamCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingStreamCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"projectName"}},{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceSlug"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const NavigationWorkspaceInvitesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"NavigationWorkspaceInvites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceInvites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedUserAvatar"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"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":"FragmentDefinition","name":{"kind":"Name","value":"UseWorkspaceInviteManager_PendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceSlug"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"UseWorkspaceInviteManager_PendingWorkspaceCollaborator"}}]}}]} as unknown as DocumentNode; export const CreateModelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateModel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateModelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"modelMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageLatestItemsModelItem"}}]}}]}}]}},{"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":"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":"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":"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":"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","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":"ProjectPageModelsCardRenameDialog"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageModelsCardDeleteDialog"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageModelsActions"}},{"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"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectCreateInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageProject"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectDashboardItem"}}]}}]}}]}},{"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":"FragmentDefinition","name":{"kind":"Name","value":"LimitedUserAvatar"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"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":"FragmentDefinition","name":{"kind":"Name","value":"ProjectPageTeamInternals_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":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"seatType"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceRole"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectPageProjectHeader"},"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":"description"}},{"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":"role"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectsPageTeamDialogManagePermissions_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":"role"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectPageTeamDialog"},"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":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectsPageTeamDialogManagePermissions_Project"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceMoveProjectManager_ProjectBase"},"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","alias":{"kind":"Name","value":"modelCount"},"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":"FragmentDefinition","name":{"kind":"Name","value":"ProjectPageSettingsTab_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":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canReadWebhooks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}}]}}]}},{"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":"FragmentSpread","name":{"kind":"Name","value":"ProjectsPageTeamDialogManagePermissions_Project"}}]}},{"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":"FragmentSpread","name":{"kind":"Name","value":"ProjectsModelPageEmbed_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":"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":"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":"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":"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","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":"ProjectPageModelsCardRenameDialog"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageModelsCardDeleteDialog"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageModelsActions"}},{"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":"ProjectPageProject"},"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":"createdAt"}},{"kind":"Field","alias":{"kind":"Name","value":"modelCount"},"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","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":"workspace"},"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":"canReadSettings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canUpdate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}},{"kind":"Field","name":{"kind":"Name","value":"canMoveToWorkspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageTeamInternals_Project"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageProjectHeader"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageTeamDialog"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceMoveProjectManager_ProjectBase"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageSettingsTab_Project"}}]}},{"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":"Field","name":{"kind":"Name","value":"models"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"4"}}],"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":"4"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PendingFileUpload"}}]}}]}}]} as unknown as DocumentNode; From 7866f4304476c45bed5ac18f2f2cd2c67c16d818 Mon Sep 17 00:00:00 2001 From: Mike Date: Wed, 30 Apr 2025 12:58:36 +0200 Subject: [PATCH 048/178] Feat: Include book a demo step when creating an account (#4621) --- packages/frontend-2/components/Cal/PopUp.vue | 41 +++++++ packages/frontend-2/components/Cal/Widget.vue | 24 ++++ .../components/dashboard/Sidebar.vue | 9 ++ .../components/onboarding/questions/Form.vue | 17 ++- .../project/page/automations/EmptyState.vue | 2 +- .../lib/auth/composables/onboarding.ts | 1 - packages/frontend-2/lib/cal/cal.ts | 39 +++++++ .../frontend-2/lib/cal/helpers/constants.ts | 2 + packages/frontend-2/lib/cal/library/cal.ts | 53 +++++++++ packages/frontend-2/lib/cal/types/cal.ts | 13 +++ .../frontend-2/lib/common/helpers/route.ts | 2 +- .../middleware/004-onboarding.global.ts | 6 +- packages/frontend-2/pages/book-a-demo.vue | 106 ++++++++++++++++++ packages/frontend-2/pages/onboarding.vue | 14 ++- 14 files changed, 318 insertions(+), 11 deletions(-) create mode 100644 packages/frontend-2/components/Cal/PopUp.vue create mode 100644 packages/frontend-2/components/Cal/Widget.vue create mode 100644 packages/frontend-2/lib/cal/cal.ts create mode 100644 packages/frontend-2/lib/cal/helpers/constants.ts create mode 100644 packages/frontend-2/lib/cal/library/cal.ts create mode 100644 packages/frontend-2/lib/cal/types/cal.ts create mode 100644 packages/frontend-2/pages/book-a-demo.vue diff --git a/packages/frontend-2/components/Cal/PopUp.vue b/packages/frontend-2/components/Cal/PopUp.vue new file mode 100644 index 000000000..8f4866c70 --- /dev/null +++ b/packages/frontend-2/components/Cal/PopUp.vue @@ -0,0 +1,41 @@ + + + diff --git a/packages/frontend-2/components/Cal/Widget.vue b/packages/frontend-2/components/Cal/Widget.vue new file mode 100644 index 000000000..448bfcec3 --- /dev/null +++ b/packages/frontend-2/components/Cal/Widget.vue @@ -0,0 +1,24 @@ + + + diff --git a/packages/frontend-2/components/dashboard/Sidebar.vue b/packages/frontend-2/components/dashboard/Sidebar.vue index 417cfe3a2..61083b635 100644 --- a/packages/frontend-2/components/dashboard/Sidebar.vue +++ b/packages/frontend-2/components/dashboard/Sidebar.vue @@ -93,6 +93,14 @@ + + + + + + Skip @@ -28,12 +28,16 @@ import { useForm } from 'vee-validate' import type { OnboardingRole, OnboardingPlan, OnboardingSource } from '@speckle/shared' import { useProcessOnboarding } from '~~/lib/auth/composables/onboarding' -import { homeRoute, workspaceJoinRoute } from '~/lib/common/helpers/route' +import { homeRoute, bookDemoRoute } from '~/lib/common/helpers/route' import { useDiscoverableWorkspaces } from '~/lib/workspaces/composables/discoverableWorkspaces' +import { useBreakpoints } from '@vueuse/core' +import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind' const isOnboardingForced = useIsOnboardingForced() const isWorkspacesEnabled = useIsWorkspacesEnabled() const { hasDiscoverableWorkspaces } = useDiscoverableWorkspaces() +const breakpoints = useBreakpoints(TailwindBreakpoints) +const isMobile = breakpoints.smaller('sm') const { setUserOnboardingComplete, setMixpanelSegments } = useProcessOnboarding() @@ -54,10 +58,15 @@ const onSubmit = handleSubmit(async () => { plans: values.plan, source: values.source }) - if (isWorkspacesEnabled.value && hasDiscoverableWorkspaces.value) { - navigateTo(workspaceJoinRoute) + if (isWorkspacesEnabled.value && hasDiscoverableWorkspaces.value && !isMobile.value) { + navigateTo(bookDemoRoute) } else { navigateTo(homeRoute) } }) + +const onSkip = () => { + setUserOnboardingComplete() + navigateTo(!isMobile.value && isWorkspacesEnabled.value ? bookDemoRoute : homeRoute) +} diff --git a/packages/frontend-2/components/project/page/automations/EmptyState.vue b/packages/frontend-2/components/project/page/automations/EmptyState.vue index 2cd808320..8d1568bb1 100644 --- a/packages/frontend-2/components/project/page/automations/EmptyState.vue +++ b/packages/frontend-2/components/project/page/automations/EmptyState.vue @@ -124,7 +124,7 @@ const emptyStateItems = computed(() => { "Let's chat! Find out how Automate can be customised to support and improve virtually any of your custom workflows.", buttons: [ { - text: 'Book a demo', + text: 'Book an intro call', props: { to: 'https://calendar.app.google/kH2EzSSMQktJ6bTZ7', external: true diff --git a/packages/frontend-2/lib/auth/composables/onboarding.ts b/packages/frontend-2/lib/auth/composables/onboarding.ts index b88b7eb74..bbea81176 100644 --- a/packages/frontend-2/lib/auth/composables/onboarding.ts +++ b/packages/frontend-2/lib/auth/composables/onboarding.ts @@ -120,7 +120,6 @@ export const useProcessOnboarding = () => { } }) .catch(convertThrowIntoFetchResult) - goHome() } /** diff --git a/packages/frontend-2/lib/cal/cal.ts b/packages/frontend-2/lib/cal/cal.ts new file mode 100644 index 000000000..3c971ef09 --- /dev/null +++ b/packages/frontend-2/lib/cal/cal.ts @@ -0,0 +1,39 @@ +import type { CalApi, EmbedThemeConfig } from '~/lib/cal/types/cal' +import { initCal } from '~/lib/cal/library/cal' + +export function initCalWidget(options: { + namespace: string + calLink?: string + theme?: EmbedThemeConfig + mode?: 'inline' | 'element-click' + elementOrSelector?: string | HTMLElement +}): CalApi | null { + const { + namespace, + calLink, + theme = 'auto', + mode = 'inline', + elementOrSelector + } = options + + const Cal = initCal() + if (!Cal) return null + + Cal('init', namespace, { origin: 'https://cal.com' }) + + if (mode === 'inline' && elementOrSelector && calLink) { + Cal.ns[namespace]('inline', { + elementOrSelector, + calLink, + config: { layout: 'month_view', theme } + }) + } + + Cal.ns[namespace]('ui', { + hideEventTypeDetails: false, + layout: 'month_view', + theme + }) + + return Cal +} diff --git a/packages/frontend-2/lib/cal/helpers/constants.ts b/packages/frontend-2/lib/cal/helpers/constants.ts new file mode 100644 index 000000000..fbd4fb015 --- /dev/null +++ b/packages/frontend-2/lib/cal/helpers/constants.ts @@ -0,0 +1,2 @@ +export const calNamespace = '15min' +export const calLink = 'moritzhenschel/15min' diff --git a/packages/frontend-2/lib/cal/library/cal.ts b/packages/frontend-2/lib/cal/library/cal.ts new file mode 100644 index 000000000..e648c8fa4 --- /dev/null +++ b/packages/frontend-2/lib/cal/library/cal.ts @@ -0,0 +1,53 @@ +import type { CalApi } from '~/lib/cal/types/cal' + +declare global { + interface Window { + Cal: CalApi + } +} + +/** + * Initialize Cal.com, linting is diabled to not modify the code too much + * @returns Cal object + */ +export function initCal() { + const scriptUrl = 'https://app.cal.com/embed/embed.js' + + /* eslint-disable */ + ;(function (C, A, L) { + // @ts-ignore + const p = function (a, ar) { + a.q.push(ar) + } + const d = C.document + C.Cal = + C.Cal || + function () { + const cal = C.Cal + const ar = arguments + if (!cal.loaded) { + cal.ns = {} + cal.q = cal.q || [] + d.head.appendChild(d.createElement('script')).src = A + cal.loaded = true + } + if (ar[0] === L) { + const api = function () { + p(api, arguments) + } + const namespace = ar[1] + // @ts-ignore + api.q = api.q || [] + if (typeof namespace === 'string') { + cal.ns[namespace] = cal.ns[namespace] || api + p(cal.ns[namespace], ar) + p(cal, ['initNamespace', namespace]) + } else p(cal, ar) + return + } + p(cal, ar) + } + })(window, scriptUrl, 'init') + + return window.Cal +} diff --git a/packages/frontend-2/lib/cal/types/cal.ts b/packages/frontend-2/lib/cal/types/cal.ts new file mode 100644 index 000000000..4e0c998aa --- /dev/null +++ b/packages/frontend-2/lib/cal/types/cal.ts @@ -0,0 +1,13 @@ +export type EmbedThemeConfig = 'dark' | 'light' | 'auto' + +export interface CalApi { + (command: string, ...args: unknown[]): void + loaded: boolean + ns: Record + q: unknown[] +} + +export interface CalNamespace { + (action: string, options: Record): void + q: unknown[] +} diff --git a/packages/frontend-2/lib/common/helpers/route.ts b/packages/frontend-2/lib/common/helpers/route.ts index d10a431c8..3e813b62b 100644 --- a/packages/frontend-2/lib/common/helpers/route.ts +++ b/packages/frontend-2/lib/common/helpers/route.ts @@ -21,7 +21,7 @@ export const forumPageUrl = 'https://speckle.community/' export const defaultZapierWebhookUrl = 'https://hooks.zapier.com/hooks/catch/12120532/2m4okri/' export const guideBillingUrl = 'https://speckle.guide/workspaces/billing.html' - +export const bookDemoRoute = '/book-a-demo' export const onboardingRoute = '/onboarding' export const settingsUserRoutes = { diff --git a/packages/frontend-2/middleware/004-onboarding.global.ts b/packages/frontend-2/middleware/004-onboarding.global.ts index 3567e21c3..065dbbda7 100644 --- a/packages/frontend-2/middleware/004-onboarding.global.ts +++ b/packages/frontend-2/middleware/004-onboarding.global.ts @@ -13,7 +13,8 @@ import { workspaceCreateRoute, workspaceJoinRoute, projectsRoute, - workspaceRoute + workspaceRoute, + bookDemoRoute } from '~/lib/common/helpers/route' import { mainServerInfoDataQuery } from '~/lib/core/composables/server' import { activeUserQuery } from '~~/lib/auth/composables/activeUser' @@ -29,7 +30,8 @@ import { useNavigation } from '~/lib/navigation/composables/navigation' export default defineNuxtRouteMiddleware(async (to) => { const isAuthPage = to.path.startsWith('/authn/') const isSSOPath = to.path.includes('/sso/') - if (isAuthPage || isSSOPath) return + const isBookDemoPage = to.path === bookDemoRoute + if (isAuthPage || isSSOPath || isBookDemoPage) return const client = useApolloClientFromNuxt() const { diff --git a/packages/frontend-2/pages/book-a-demo.vue b/packages/frontend-2/pages/book-a-demo.vue new file mode 100644 index 000000000..249539fae --- /dev/null +++ b/packages/frontend-2/pages/book-a-demo.vue @@ -0,0 +1,106 @@ + + + diff --git a/packages/frontend-2/pages/onboarding.vue b/packages/frontend-2/pages/onboarding.vue index 9e6b36831..dad96f2f4 100644 --- a/packages/frontend-2/pages/onboarding.vue +++ b/packages/frontend-2/pages/onboarding.vue @@ -10,7 +10,7 @@ class="opacity-70 hover:opacity-100 p-1" size="sm" color="subtle" - @click="setUserOnboardingComplete()" + @click="onSkip" > Skip @@ -34,6 +34,9 @@ From 0b0d694bd0b3fe7adfe3441e5c9b978149f07f79 Mon Sep 17 00:00:00 2001 From: Alexandru Popovici Date: Wed, 30 Apr 2025 14:51:41 +0300 Subject: [PATCH 049/178] Alex/sandbox viewer load not objectloader only (#4630) * chore(sandbox): Viewer load not object loader only * chore(sandbox): Defautl stream --- packages/viewer-sandbox/src/main.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/viewer-sandbox/src/main.ts b/packages/viewer-sandbox/src/main.ts index 9f7c8227f..560535f7d 100644 --- a/packages/viewer-sandbox/src/main.ts +++ b/packages/viewer-sandbox/src/main.ts @@ -102,8 +102,8 @@ const createViewer = async (containerName: string, _stream: string) => { sandbox.makeDiffUI() sandbox.makeMeasurementsUI() - await sandbox.objectLoaderOnly(_stream) - //await sandbox.loadUrl(_stream) + // await sandbox.objectLoaderOnly(_stream) + await sandbox.loadUrl(_stream) // await sandbox.loadJSON(JSONSpeckleStream) } @@ -111,8 +111,8 @@ const getStream = () => { return ( // prettier-ignore // Revit sample house (good for bim-like stuff with many display meshes) - //'https://app.speckle.systems/streams/da9e320dad/commits/5388ef24b8' - 'https://latest.speckle.systems/streams/c1faab5c62/commits/ab1a1ab2b6' + 'https://app.speckle.systems/streams/da9e320dad/commits/5388ef24b8' + // 'https://latest.speckle.systems/streams/c1faab5c62/commits/ab1a1ab2b6' // 'https://latest.speckle.systems/streams/58b5648c4d/commits/60371ecb2d' // 'Super' heavy revit shit // 'https://app.speckle.systems/streams/e6f9156405/commits/0694d53bb5' From c060d6097d2fab8a9a3ef08dd7226acd24b3402d Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Wed, 30 Apr 2025 13:52:12 +0100 Subject: [PATCH 050/178] fix(workspaces): include name and id on discoverableworkspacecollaborator (#4629) * fix(workspaces): show more info for discoverable workspace members * chore(workspaces): something silly with gqlgen * Update FE * Fix FE --------- Co-authored-by: Mike Tasset --- .../frontend-2/components/workspace/Card.vue | 2 +- .../components/workspace/JoinPage.vue | 1 + .../workspace/discoverableWorkspaces/Card.vue | 27 +++---- .../discoverableWorkspaces/Modal.vue | 26 ++++++- .../lib/common/generated/gql/gql.ts | 24 +++--- .../lib/common/generated/gql/graphql.ts | 58 +++++--------- .../composables/discoverableWorkspaces.ts | 78 +++++++------------ .../lib/workspaces/graphql/queries.ts | 18 ++--- .../typedefs/workspaces.graphql | 10 +-- .../modules/core/graph/generated/graphql.ts | 55 +++++-------- .../graph/generated/graphql.ts | 21 ++--- .../server/test/graphql/generated/graphql.ts | 23 +++--- 12 files changed, 146 insertions(+), 197 deletions(-) diff --git a/packages/frontend-2/components/workspace/Card.vue b/packages/frontend-2/components/workspace/Card.vue index bf54b5671..cb3ffa957 100644 --- a/packages/frontend-2/components/workspace/Card.vue +++ b/packages/frontend-2/components/workspace/Card.vue @@ -7,7 +7,7 @@ }" @click="clickable && onClick" > -
+
diff --git a/packages/frontend-2/components/workspace/JoinPage.vue b/packages/frontend-2/components/workspace/JoinPage.vue index c0c9ff0d6..f0cbe8b01 100644 --- a/packages/frontend-2/components/workspace/JoinPage.vue +++ b/packages/frontend-2/components/workspace/JoinPage.vue @@ -28,6 +28,7 @@ v-for="workspace in discoverableWorkspacesAndJoinRequests" :key="`discoverable-${workspace.id}`" :workspace="workspace" + :request-status="workspace.requestStatus" location="workspace_join_page" />
diff --git a/packages/frontend-2/components/workspace/discoverableWorkspaces/Card.vue b/packages/frontend-2/components/workspace/discoverableWorkspaces/Card.vue index 193e96cc4..b0994f293 100644 --- a/packages/frontend-2/components/workspace/discoverableWorkspaces/Card.vue +++ b/packages/frontend-2/components/workspace/discoverableWorkspaces/Card.vue @@ -5,20 +5,18 @@
{{ workspace.description }}
-
{{ workspace.team?.totalCount }} members
+
diff --git a/packages/frontend-2/lib/common/generated/gql/gql.ts b/packages/frontend-2/lib/common/generated/gql/gql.ts index 2d1e38c90..b96977a44 100644 --- a/packages/frontend-2/lib/common/generated/gql/gql.ts +++ b/packages/frontend-2/lib/common/generated/gql/gql.ts @@ -357,8 +357,8 @@ type Documents = { "\n subscription OnViewerCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n id\n type\n comment {\n id\n parent {\n id\n }\n ...ViewerCommentThread\n }\n }\n }\n": typeof types.OnViewerCommentsUpdatedDocument, "\n fragment LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\n }\n }\n": typeof types.LinkableCommentFragmentDoc, "\n fragment ActiveWorkspace_Workspace on Workspace {\n id\n name\n logo\n role\n slug\n }\n": typeof types.ActiveWorkspace_WorkspaceFragmentDoc, - "\n fragment DiscoverableList_Discoverable on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n avatar\n }\n }\n }\n }\n": typeof types.DiscoverableList_DiscoverableFragmentDoc, - "\n fragment DiscoverableList_Requests on User {\n workspaceJoinRequests {\n items {\n id\n status\n workspace {\n id\n name\n logo\n slug\n team {\n totalCount\n items {\n avatar\n }\n }\n }\n }\n }\n }\n": typeof types.DiscoverableList_RequestsFragmentDoc, + "\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n id\n name\n avatar\n }\n }\n }\n": typeof types.DiscoverableWorkspace_LimitedWorkspaceFragmentDoc, + "\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n team {\n totalCount\n items {\n id\n name\n avatar\n }\n }\n }\n }\n": typeof types.WorkspaceJoinRequests_LimitedWorkspaceJoinRequestFragmentDoc, "\n fragment WorkspacePlanLimits_Workspace on Workspace {\n id\n slug\n plan {\n name\n }\n }\n": typeof types.WorkspacePlanLimits_WorkspaceFragmentDoc, "\n fragment UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n token\n workspaceId\n workspaceSlug\n user {\n id\n }\n }\n": typeof types.UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragmentDoc, "\n fragment WorkspacesPlan_Workspace on Workspace {\n id\n slug\n plan {\n status\n createdAt\n name\n paymentMethod\n usage {\n projectCount\n modelCount\n }\n }\n seats {\n editors {\n assigned\n available\n }\n viewers {\n assigned\n available\n }\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n currency\n }\n }\n": typeof types.WorkspacesPlan_WorkspaceFragmentDoc, @@ -393,8 +393,7 @@ type Documents = { "\n query WorkspaceSsoCheck($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...WorkspaceSsoStatus_Workspace\n }\n activeUser {\n ...WorkspaceSsoStatus_User\n }\n }\n": typeof types.WorkspaceSsoCheckDocument, "\n query WorkspaceWizard($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...WorkspaceWizard_Workspace\n }\n }\n": typeof types.WorkspaceWizardDocument, "\n query WorkspaceWizardRegion {\n serverInfo {\n ...WorkspaceWizardStepRegion_ServerInfo\n }\n }\n": typeof types.WorkspaceWizardRegionDocument, - "\n query DiscoverableWorkspaces {\n activeUser {\n id\n ...DiscoverableList_Discoverable\n }\n }\n": typeof types.DiscoverableWorkspacesDocument, - "\n query DiscoverableWorkspacesRequests {\n activeUser {\n id\n ...DiscoverableList_Requests\n }\n }\n": typeof types.DiscoverableWorkspacesRequestsDocument, + "\n query DiscoverableWorkspaces {\n activeUser {\n id\n discoverableWorkspaces {\n ...DiscoverableWorkspace_LimitedWorkspace\n }\n workspaceJoinRequests {\n items {\n ...WorkspaceJoinRequests_LimitedWorkspaceJoinRequest\n }\n }\n }\n }\n": typeof types.DiscoverableWorkspacesDocument, "\n query WorkspacePlan($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...WorkspacesPlan_Workspace\n }\n }\n": typeof types.WorkspacePlanDocument, "\n query activeWorkspace($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...ActiveWorkspace_Workspace\n }\n }\n": typeof types.ActiveWorkspaceDocument, "\n query WorkspaceLastAdminCheck($slug: String!) {\n workspaceBySlug(slug: $slug) {\n teamByRole {\n admins {\n totalCount\n }\n }\n }\n }\n": typeof types.WorkspaceLastAdminCheckDocument, @@ -773,8 +772,8 @@ const documents: Documents = { "\n subscription OnViewerCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n id\n type\n comment {\n id\n parent {\n id\n }\n ...ViewerCommentThread\n }\n }\n }\n": types.OnViewerCommentsUpdatedDocument, "\n fragment LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\n }\n }\n": types.LinkableCommentFragmentDoc, "\n fragment ActiveWorkspace_Workspace on Workspace {\n id\n name\n logo\n role\n slug\n }\n": types.ActiveWorkspace_WorkspaceFragmentDoc, - "\n fragment DiscoverableList_Discoverable on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n avatar\n }\n }\n }\n }\n": types.DiscoverableList_DiscoverableFragmentDoc, - "\n fragment DiscoverableList_Requests on User {\n workspaceJoinRequests {\n items {\n id\n status\n workspace {\n id\n name\n logo\n slug\n team {\n totalCount\n items {\n avatar\n }\n }\n }\n }\n }\n }\n": types.DiscoverableList_RequestsFragmentDoc, + "\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n id\n name\n avatar\n }\n }\n }\n": types.DiscoverableWorkspace_LimitedWorkspaceFragmentDoc, + "\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n team {\n totalCount\n items {\n id\n name\n avatar\n }\n }\n }\n }\n": types.WorkspaceJoinRequests_LimitedWorkspaceJoinRequestFragmentDoc, "\n fragment WorkspacePlanLimits_Workspace on Workspace {\n id\n slug\n plan {\n name\n }\n }\n": types.WorkspacePlanLimits_WorkspaceFragmentDoc, "\n fragment UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n token\n workspaceId\n workspaceSlug\n user {\n id\n }\n }\n": types.UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragmentDoc, "\n fragment WorkspacesPlan_Workspace on Workspace {\n id\n slug\n plan {\n status\n createdAt\n name\n paymentMethod\n usage {\n projectCount\n modelCount\n }\n }\n seats {\n editors {\n assigned\n available\n }\n viewers {\n assigned\n available\n }\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n currency\n }\n }\n": types.WorkspacesPlan_WorkspaceFragmentDoc, @@ -809,8 +808,7 @@ const documents: Documents = { "\n query WorkspaceSsoCheck($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...WorkspaceSsoStatus_Workspace\n }\n activeUser {\n ...WorkspaceSsoStatus_User\n }\n }\n": types.WorkspaceSsoCheckDocument, "\n query WorkspaceWizard($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...WorkspaceWizard_Workspace\n }\n }\n": types.WorkspaceWizardDocument, "\n query WorkspaceWizardRegion {\n serverInfo {\n ...WorkspaceWizardStepRegion_ServerInfo\n }\n }\n": types.WorkspaceWizardRegionDocument, - "\n query DiscoverableWorkspaces {\n activeUser {\n id\n ...DiscoverableList_Discoverable\n }\n }\n": types.DiscoverableWorkspacesDocument, - "\n query DiscoverableWorkspacesRequests {\n activeUser {\n id\n ...DiscoverableList_Requests\n }\n }\n": types.DiscoverableWorkspacesRequestsDocument, + "\n query DiscoverableWorkspaces {\n activeUser {\n id\n discoverableWorkspaces {\n ...DiscoverableWorkspace_LimitedWorkspace\n }\n workspaceJoinRequests {\n items {\n ...WorkspaceJoinRequests_LimitedWorkspaceJoinRequest\n }\n }\n }\n }\n": types.DiscoverableWorkspacesDocument, "\n query WorkspacePlan($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...WorkspacesPlan_Workspace\n }\n }\n": types.WorkspacePlanDocument, "\n query activeWorkspace($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...ActiveWorkspace_Workspace\n }\n }\n": types.ActiveWorkspaceDocument, "\n query WorkspaceLastAdminCheck($slug: String!) {\n workspaceBySlug(slug: $slug) {\n teamByRole {\n admins {\n totalCount\n }\n }\n }\n }\n": types.WorkspaceLastAdminCheckDocument, @@ -2235,11 +2233,11 @@ export function graphql(source: "\n fragment ActiveWorkspace_Workspace on Works /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n fragment DiscoverableList_Discoverable on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n avatar\n }\n }\n }\n }\n"): (typeof documents)["\n fragment DiscoverableList_Discoverable on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n avatar\n }\n }\n }\n }\n"]; +export function graphql(source: "\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n id\n name\n avatar\n }\n }\n }\n"): (typeof documents)["\n fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace {\n id\n name\n logo\n description\n slug\n team {\n totalCount\n items {\n id\n name\n avatar\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 fragment DiscoverableList_Requests on User {\n workspaceJoinRequests {\n items {\n id\n status\n workspace {\n id\n name\n logo\n slug\n team {\n totalCount\n items {\n avatar\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n fragment DiscoverableList_Requests on User {\n workspaceJoinRequests {\n items {\n id\n status\n workspace {\n id\n name\n logo\n slug\n team {\n totalCount\n items {\n avatar\n }\n }\n }\n }\n }\n }\n"]; +export function graphql(source: "\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n team {\n totalCount\n items {\n id\n name\n avatar\n }\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest {\n id\n status\n workspace {\n id\n name\n logo\n slug\n team {\n totalCount\n items {\n id\n name\n avatar\n }\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -2379,11 +2377,7 @@ export function graphql(source: "\n query WorkspaceWizardRegion {\n serverIn /** * 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 DiscoverableWorkspaces {\n activeUser {\n id\n ...DiscoverableList_Discoverable\n }\n }\n"): (typeof documents)["\n query DiscoverableWorkspaces {\n activeUser {\n id\n ...DiscoverableList_Discoverable\n }\n }\n"]; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql(source: "\n query DiscoverableWorkspacesRequests {\n activeUser {\n id\n ...DiscoverableList_Requests\n }\n }\n"): (typeof documents)["\n query DiscoverableWorkspacesRequests {\n activeUser {\n id\n ...DiscoverableList_Requests\n }\n }\n"]; +export function graphql(source: "\n query DiscoverableWorkspaces {\n activeUser {\n id\n discoverableWorkspaces {\n ...DiscoverableWorkspace_LimitedWorkspace\n }\n workspaceJoinRequests {\n items {\n ...WorkspaceJoinRequests_LimitedWorkspaceJoinRequest\n }\n }\n }\n }\n"): (typeof documents)["\n query DiscoverableWorkspaces {\n activeUser {\n id\n discoverableWorkspaces {\n ...DiscoverableWorkspace_LimitedWorkspace\n }\n workspaceJoinRequests {\n items {\n ...WorkspaceJoinRequests_LimitedWorkspaceJoinRequest\n }\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 970181b43..25cb514aa 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -990,18 +990,6 @@ export type DiscoverableStreamsSortingInput = { type: DiscoverableStreamsSortType; }; -export type DiscoverableWorkspaceCollaborator = { - __typename?: 'DiscoverableWorkspaceCollaborator'; - avatar?: Maybe; -}; - -export type DiscoverableWorkspaceCollaboratorCollection = { - __typename?: 'DiscoverableWorkspaceCollaboratorCollection'; - cursor?: Maybe; - items: Array; - totalCount: Scalars['Int']['output']; -}; - export type EditCommentInput = { commentId: Scalars['String']['input']; content: CommentContentInput; @@ -1206,6 +1194,13 @@ export type LimitedUserWorkspaceRoleArgs = { workspaceId?: InputMaybe; }; +export type LimitedUserCollection = { + __typename?: 'LimitedUserCollection'; + cursor?: Maybe; + items: Array; + totalCount: Scalars['Int']['output']; +}; + /** Workspace metadata visible to non-workspace members. */ export type LimitedWorkspace = { __typename?: 'LimitedWorkspace'; @@ -1220,7 +1215,7 @@ export type LimitedWorkspace = { /** Unique workspace short id. Used for navigation. */ slug: Scalars['String']['output']; /** Workspace members visible to people with verified email domain */ - team?: Maybe; + team?: Maybe; }; @@ -6723,9 +6718,9 @@ export type LinkableCommentFragment = { __typename?: 'Comment', id: string, view export type ActiveWorkspace_WorkspaceFragment = { __typename?: 'Workspace', id: string, name: string, logo?: string | null, role?: string | null, slug: string }; -export type DiscoverableList_DiscoverableFragment = { __typename?: 'User', discoverableWorkspaces: Array<{ __typename?: 'LimitedWorkspace', id: string, name: string, logo?: string | null, description?: string | null, slug: string, team?: { __typename?: 'DiscoverableWorkspaceCollaboratorCollection', totalCount: number, items: Array<{ __typename?: 'DiscoverableWorkspaceCollaborator', avatar?: string | null }> } | null }> }; +export type DiscoverableWorkspace_LimitedWorkspaceFragment = { __typename?: 'LimitedWorkspace', id: string, name: string, logo?: string | null, description?: string | null, slug: string, team?: { __typename?: 'LimitedUserCollection', totalCount: number, items: Array<{ __typename?: 'LimitedUser', id: string, name: string, avatar?: string | null }> } | null }; -export type DiscoverableList_RequestsFragment = { __typename?: 'User', workspaceJoinRequests?: { __typename?: 'LimitedWorkspaceJoinRequestCollection', items: Array<{ __typename?: 'LimitedWorkspaceJoinRequest', id: string, status: WorkspaceJoinRequestStatus, workspace: { __typename?: 'LimitedWorkspace', id: string, name: string, logo?: string | null, slug: string, team?: { __typename?: 'DiscoverableWorkspaceCollaboratorCollection', totalCount: number, items: Array<{ __typename?: 'DiscoverableWorkspaceCollaborator', avatar?: string | null }> } | null } }> } | null }; +export type WorkspaceJoinRequests_LimitedWorkspaceJoinRequestFragment = { __typename?: 'LimitedWorkspaceJoinRequest', id: string, status: WorkspaceJoinRequestStatus, workspace: { __typename?: 'LimitedWorkspace', id: string, name: string, logo?: string | null, slug: string, team?: { __typename?: 'LimitedUserCollection', totalCount: number, items: Array<{ __typename?: 'LimitedUser', id: string, name: string, avatar?: string | null }> } | null } }; export type WorkspacePlanLimits_WorkspaceFragment = { __typename?: 'Workspace', id: string, slug: string, plan?: { __typename?: 'WorkspacePlan', name: WorkspacePlans } | null }; @@ -6939,12 +6934,7 @@ export type WorkspaceWizardRegionQuery = { __typename?: 'Query', serverInfo: { _ export type DiscoverableWorkspacesQueryVariables = Exact<{ [key: string]: never; }>; -export type DiscoverableWorkspacesQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', id: string, discoverableWorkspaces: Array<{ __typename?: 'LimitedWorkspace', id: string, name: string, logo?: string | null, description?: string | null, slug: string, team?: { __typename?: 'DiscoverableWorkspaceCollaboratorCollection', totalCount: number, items: Array<{ __typename?: 'DiscoverableWorkspaceCollaborator', avatar?: string | null }> } | null }> } | null }; - -export type DiscoverableWorkspacesRequestsQueryVariables = Exact<{ [key: string]: never; }>; - - -export type DiscoverableWorkspacesRequestsQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', id: string, workspaceJoinRequests?: { __typename?: 'LimitedWorkspaceJoinRequestCollection', items: Array<{ __typename?: 'LimitedWorkspaceJoinRequest', id: string, status: WorkspaceJoinRequestStatus, workspace: { __typename?: 'LimitedWorkspace', id: string, name: string, logo?: string | null, slug: string, team?: { __typename?: 'DiscoverableWorkspaceCollaboratorCollection', totalCount: number, items: Array<{ __typename?: 'DiscoverableWorkspaceCollaborator', avatar?: string | null }> } | null } }> } | null } | null }; +export type DiscoverableWorkspacesQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', id: string, discoverableWorkspaces: Array<{ __typename?: 'LimitedWorkspace', id: string, name: string, logo?: string | null, description?: string | null, slug: string, team?: { __typename?: 'LimitedUserCollection', totalCount: number, items: Array<{ __typename?: 'LimitedUser', id: string, name: string, avatar?: string | null }> } | null }>, workspaceJoinRequests?: { __typename?: 'LimitedWorkspaceJoinRequestCollection', items: Array<{ __typename?: 'LimitedWorkspaceJoinRequest', id: string, status: WorkspaceJoinRequestStatus, workspace: { __typename?: 'LimitedWorkspace', id: string, name: string, logo?: string | null, slug: string, team?: { __typename?: 'LimitedUserCollection', totalCount: number, items: Array<{ __typename?: 'LimitedUser', id: string, name: string, avatar?: string | null }> } | null } }> } | null } | null }; export type WorkspacePlanQueryVariables = Exact<{ slug: Scalars['String']['input']; @@ -7268,8 +7258,8 @@ export const ViewerCommentThreadDataFragmentDoc = {"kind":"Document","definition export const ViewerCommentThreadFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewerCommentThread"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewerCommentsListItem"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewerCommentBubblesData"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewerCommentsReplyItem"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewerCommentThreadData"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedUserAvatar"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"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":"FragmentDefinition","name":{"kind":"Name","value":"ThreadCommentAttachment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"attachments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fileName"}},{"kind":"Field","name":{"kind":"Name","value":"fileType"}},{"kind":"Field","name":{"kind":"Name","value":"fileSize"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewerCommentsReplyItem"},"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":"archived"}},{"kind":"Field","name":{"kind":"Name","value":"rawText"}},{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"doc"}}]}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ThreadCommentAttachment"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FormUsersSelectItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"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":"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":"FragmentDefinition","name":{"kind":"Name","value":"ViewerCommentsListItem"},"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":"archived"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"viewedAt"}},{"kind":"Field","name":{"kind":"Name","value":"replies"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewerCommentsReplyItem"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"replyAuthors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"4"}}],"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":"FormUsersSelectItem"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"resources"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resourceId"}},{"kind":"Field","name":{"kind":"Name","value":"resourceType"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewerCommentBubblesData"},"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":"viewedAt"}},{"kind":"Field","name":{"kind":"Name","value":"viewerState"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewerCommentThreadData"},"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":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canArchive"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullPermissionCheckResult"}}]}}]}}]}}]} as unknown as DocumentNode; export const LinkableCommentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LinkableComment"},"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":"viewerResources"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"modelId"}},{"kind":"Field","name":{"kind":"Name","value":"versionId"}},{"kind":"Field","name":{"kind":"Name","value":"objectId"}}]}}]}}]} as unknown as DocumentNode; export const ActiveWorkspace_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ActiveWorkspace_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":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]} as unknown as DocumentNode; -export const DiscoverableList_DiscoverableFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"DiscoverableList_Discoverable"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"discoverableWorkspaces"},"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":"description"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]}}]}}]}}]} as unknown as DocumentNode; -export const DiscoverableList_RequestsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"DiscoverableList_Requests"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceJoinRequests"},"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":"status"}},{"kind":"Field","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":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const DiscoverableWorkspace_LimitedWorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"DiscoverableWorkspace_LimitedWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedWorkspace"}},"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":"description"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"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"}}]}}]}}]}}]} as unknown as DocumentNode; +export const WorkspaceJoinRequests_LimitedWorkspaceJoinRequestFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceJoinRequests_LimitedWorkspaceJoinRequest"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedWorkspaceJoinRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","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":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"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"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const WorkspacePlanLimits_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspacePlanLimits_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":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const WorkspacesPlan_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspacesPlan_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":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"paymentMethod"}},{"kind":"Field","name":{"kind":"Name","value":"usage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectCount"}},{"kind":"Field","name":{"kind":"Name","value":"modelCount"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"editors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"assigned"}},{"kind":"Field","name":{"kind":"Name","value":"available"}}]}},{"kind":"Field","name":{"kind":"Name","value":"viewers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"assigned"}},{"kind":"Field","name":{"kind":"Name","value":"available"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"billingInterval"}},{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}}]}}]} as unknown as DocumentNode; export const WorkspaceHasCustomDataResidency_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceHasCustomDataResidency_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":"defaultRegion"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; @@ -7512,8 +7502,7 @@ export const WorkspaceSsoByEmailDocument = {"kind":"Document","definitions":[{"k export const WorkspaceSsoCheckDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"WorkspaceSsoCheck"},"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":"WorkspaceSsoStatus_Workspace"}}]}},{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceSsoStatus_User"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceSsoStatus_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":"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","name":{"kind":"Name","value":"session"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"validUntil"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceSsoStatus_User"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"expiredSsoSessions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]}}]} as unknown as DocumentNode; export const WorkspaceWizardDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"WorkspaceWizard"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceWizard_Workspace"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceWizard_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"creationState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"state"}}]}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]} as unknown as DocumentNode; export const WorkspaceWizardRegionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"WorkspaceWizardRegion"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceWizardStepRegion_ServerInfo"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesRegionsSelect_ServerRegionItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerRegionItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceWizardStepRegion_ServerInfo"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerInfo"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"multiRegion"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"regions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesRegionsSelect_ServerRegionItem"}}]}}]}}]}}]} as unknown as DocumentNode; -export const DiscoverableWorkspacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"DiscoverableWorkspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"DiscoverableList_Discoverable"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"DiscoverableList_Discoverable"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"discoverableWorkspaces"},"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":"description"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]}}]}}]}}]} as unknown as DocumentNode; -export const DiscoverableWorkspacesRequestsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"DiscoverableWorkspacesRequests"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"DiscoverableList_Requests"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"DiscoverableList_Requests"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceJoinRequests"},"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":"status"}},{"kind":"Field","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":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const DiscoverableWorkspacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"DiscoverableWorkspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"discoverableWorkspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"DiscoverableWorkspace_LimitedWorkspace"}}]}},{"kind":"Field","name":{"kind":"Name","value":"workspaceJoinRequests"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceJoinRequests_LimitedWorkspaceJoinRequest"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"DiscoverableWorkspace_LimitedWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedWorkspace"}},"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":"description"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"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":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceJoinRequests_LimitedWorkspaceJoinRequest"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedWorkspaceJoinRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","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":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"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"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const WorkspacePlanDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"WorkspacePlan"},"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":"WorkspacesPlan_Workspace"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspacesPlan_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":"plan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"paymentMethod"}},{"kind":"Field","name":{"kind":"Name","value":"usage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectCount"}},{"kind":"Field","name":{"kind":"Name","value":"modelCount"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"seats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"editors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"assigned"}},{"kind":"Field","name":{"kind":"Name","value":"available"}}]}},{"kind":"Field","name":{"kind":"Name","value":"viewers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"assigned"}},{"kind":"Field","name":{"kind":"Name","value":"available"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"subscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"billingInterval"}},{"kind":"Field","name":{"kind":"Name","value":"currentBillingCycleEnd"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}}]}}]} as unknown as DocumentNode; export const ActiveWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"activeWorkspace"},"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":"ActiveWorkspace_Workspace"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ActiveWorkspace_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":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]} as unknown as DocumentNode; export const WorkspaceLastAdminCheckDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"WorkspaceLastAdminCheck"},"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":"Field","name":{"kind":"Name","value":"teamByRole"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"admins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]}}]}}]} as unknown as DocumentNode; @@ -7580,13 +7569,12 @@ export type AllObjectTypes = { CommitCollection: CommitCollection, CountOnlyCollection: CountOnlyCollection, CurrencyBasedPrices: CurrencyBasedPrices, - DiscoverableWorkspaceCollaborator: DiscoverableWorkspaceCollaborator, - DiscoverableWorkspaceCollaboratorCollection: DiscoverableWorkspaceCollaboratorCollection, FileUpload: FileUpload, GendoAIRender: GendoAiRender, GendoAIRenderCollection: GendoAiRenderCollection, LegacyCommentViewerData: LegacyCommentViewerData, LimitedUser: LimitedUser, + LimitedUserCollection: LimitedUserCollection, LimitedWorkspace: LimitedWorkspace, LimitedWorkspaceJoinRequest: LimitedWorkspaceJoinRequest, LimitedWorkspaceJoinRequestCollection: LimitedWorkspaceJoinRequestCollection, @@ -8037,14 +8025,6 @@ export type CurrencyBasedPricesFieldArgs = { gbp: {}, usd: {}, } -export type DiscoverableWorkspaceCollaboratorFieldArgs = { - avatar: {}, -} -export type DiscoverableWorkspaceCollaboratorCollectionFieldArgs = { - cursor: {}, - items: {}, - totalCount: {}, -} export type FileUploadFieldArgs = { branchName: {}, convertedCommitId: {}, @@ -8106,6 +8086,11 @@ export type LimitedUserFieldArgs = { workspaceDomainPolicyCompliant: LimitedUserWorkspaceDomainPolicyCompliantArgs, workspaceRole: LimitedUserWorkspaceRoleArgs, } +export type LimitedUserCollectionFieldArgs = { + cursor: {}, + items: {}, + totalCount: {}, +} export type LimitedWorkspaceFieldArgs = { description: {}, id: {}, @@ -9162,13 +9147,12 @@ export type AllObjectFieldArgTypes = { CommitCollection: CommitCollectionFieldArgs, CountOnlyCollection: CountOnlyCollectionFieldArgs, CurrencyBasedPrices: CurrencyBasedPricesFieldArgs, - DiscoverableWorkspaceCollaborator: DiscoverableWorkspaceCollaboratorFieldArgs, - DiscoverableWorkspaceCollaboratorCollection: DiscoverableWorkspaceCollaboratorCollectionFieldArgs, FileUpload: FileUploadFieldArgs, GendoAIRender: GendoAiRenderFieldArgs, GendoAIRenderCollection: GendoAiRenderCollectionFieldArgs, LegacyCommentViewerData: LegacyCommentViewerDataFieldArgs, LimitedUser: LimitedUserFieldArgs, + LimitedUserCollection: LimitedUserCollectionFieldArgs, LimitedWorkspace: LimitedWorkspaceFieldArgs, LimitedWorkspaceJoinRequest: LimitedWorkspaceJoinRequestFieldArgs, LimitedWorkspaceJoinRequestCollection: LimitedWorkspaceJoinRequestCollectionFieldArgs, diff --git a/packages/frontend-2/lib/workspaces/composables/discoverableWorkspaces.ts b/packages/frontend-2/lib/workspaces/composables/discoverableWorkspaces.ts index 2ef3f5088..c22303839 100644 --- a/packages/frontend-2/lib/workspaces/composables/discoverableWorkspaces.ts +++ b/packages/frontend-2/lib/workspaces/composables/discoverableWorkspaces.ts @@ -1,8 +1,5 @@ import { useQuery, useMutation, useApolloClient } from '@vue/apollo-composable' -import { - discoverableWorkspacesQuery, - discoverableWorkspacesRequestsQuery -} from '../graphql/queries' +import { discoverableWorkspacesQuery } from '~/lib/workspaces/graphql/queries' import { dismissDiscoverableWorkspaceMutation, requestToJoinWorkspaceMutation @@ -17,40 +14,38 @@ import { } from '~~/lib/common/helpers/graphql' graphql(` - fragment DiscoverableList_Discoverable on User { - discoverableWorkspaces { - id - name - logo - description - slug - team { - totalCount - items { - avatar - } + fragment DiscoverableWorkspace_LimitedWorkspace on LimitedWorkspace { + id + name + logo + description + slug + team { + totalCount + items { + id + name + avatar } } } `) graphql(` - fragment DiscoverableList_Requests on User { - workspaceJoinRequests { - items { - id - status - workspace { + fragment WorkspaceJoinRequests_LimitedWorkspaceJoinRequest on LimitedWorkspaceJoinRequest { + id + status + workspace { + id + name + logo + slug + team { + totalCount + items { id name - logo - slug - team { - totalCount - items { - avatar - } - } + avatar } } } @@ -60,18 +55,9 @@ graphql(` export const useDiscoverableWorkspaces = () => { const isWorkspacesEnabled = useIsWorkspacesEnabled() - const { result: discoverableResult, loading: discoverableLoading } = useQuery( - discoverableWorkspacesQuery, - undefined, - { enabled: isWorkspacesEnabled } - ) - const { result: requestsResult, loading: joinRequestsLoading } = useQuery( - discoverableWorkspacesRequestsQuery, - undefined, - { - enabled: isWorkspacesEnabled - } - ) + const { result, loading } = useQuery(discoverableWorkspacesQuery, undefined, { + enabled: isWorkspacesEnabled + }) const { mutate: requestToJoin } = useMutation(requestToJoinWorkspaceMutation) const { mutate: dismissWorkspace } = useMutation(dismissDiscoverableWorkspaceMutation) @@ -82,11 +68,11 @@ export const useDiscoverableWorkspaces = () => { const apollo = useApolloClient().client const discoverableWorkspaces = computed( - () => discoverableResult.value?.activeUser?.discoverableWorkspaces + () => result.value?.activeUser?.discoverableWorkspaces ) const workspaceJoinRequests = computed( - () => requestsResult.value?.activeUser?.workspaceJoinRequests + () => result.value?.activeUser?.workspaceJoinRequests ) const discoverableWorkspacesAndJoinRequests = computed(() => { @@ -237,10 +223,6 @@ export const useDiscoverableWorkspaces = () => { } } - const loading = computed(() => { - return discoverableLoading.value || joinRequestsLoading.value - }) - return { hasDiscoverableWorkspaces, hasDiscoverableJoinRequests, diff --git a/packages/frontend-2/lib/workspaces/graphql/queries.ts b/packages/frontend-2/lib/workspaces/graphql/queries.ts index fbb76eee3..731070cc4 100644 --- a/packages/frontend-2/lib/workspaces/graphql/queries.ts +++ b/packages/frontend-2/lib/workspaces/graphql/queries.ts @@ -121,16 +121,14 @@ export const discoverableWorkspacesQuery = graphql(` query DiscoverableWorkspaces { activeUser { id - ...DiscoverableList_Discoverable - } - } -`) - -export const discoverableWorkspacesRequestsQuery = graphql(` - query DiscoverableWorkspacesRequests { - activeUser { - id - ...DiscoverableList_Requests + discoverableWorkspaces { + ...DiscoverableWorkspace_LimitedWorkspace + } + workspaceJoinRequests { + items { + ...WorkspaceJoinRequests_LimitedWorkspaceJoinRequest + } + } } } `) diff --git a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql index a63fa8a9a..12c65c6e4 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql @@ -379,17 +379,13 @@ type LimitedWorkspace { """ Workspace members visible to people with verified email domain """ - team(cursor: String, limit: Int! = 25): DiscoverableWorkspaceCollaboratorCollection + team(cursor: String, limit: Int! = 25): LimitedUserCollection } -type DiscoverableWorkspaceCollaboratorCollection { +type LimitedUserCollection { totalCount: Int! cursor: String - items: [DiscoverableWorkspaceCollaborator!]! -} - -type DiscoverableWorkspaceCollaborator { - avatar: String + items: [LimitedUser!]! } type WorkspaceDomain { diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 8118364ec..24f892939 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -1013,18 +1013,6 @@ export type DiscoverableStreamsSortingInput = { type: DiscoverableStreamsSortType; }; -export type DiscoverableWorkspaceCollaborator = { - __typename?: 'DiscoverableWorkspaceCollaborator'; - avatar?: Maybe; -}; - -export type DiscoverableWorkspaceCollaboratorCollection = { - __typename?: 'DiscoverableWorkspaceCollaboratorCollection'; - cursor?: Maybe; - items: Array; - totalCount: Scalars['Int']['output']; -}; - export type EditCommentInput = { commentId: Scalars['String']['input']; content: CommentContentInput; @@ -1229,6 +1217,13 @@ export type LimitedUserWorkspaceRoleArgs = { workspaceId?: InputMaybe; }; +export type LimitedUserCollection = { + __typename?: 'LimitedUserCollection'; + cursor?: Maybe; + items: Array; + totalCount: Scalars['Int']['output']; +}; + /** Workspace metadata visible to non-workspace members. */ export type LimitedWorkspace = { __typename?: 'LimitedWorkspace'; @@ -1243,7 +1238,7 @@ export type LimitedWorkspace = { /** Unique workspace short id. Used for navigation. */ slug: Scalars['String']['output']; /** Workspace members visible to people with verified email domain */ - team?: Maybe; + team?: Maybe; }; @@ -5318,8 +5313,6 @@ export type ResolversTypes = { DenyWorkspaceJoinRequestInput: DenyWorkspaceJoinRequestInput; DiscoverableStreamsSortType: DiscoverableStreamsSortType; DiscoverableStreamsSortingInput: DiscoverableStreamsSortingInput; - DiscoverableWorkspaceCollaborator: ResolverTypeWrapper; - DiscoverableWorkspaceCollaboratorCollection: ResolverTypeWrapper; EditCommentInput: EditCommentInput; EmailVerificationRequestInput: EmailVerificationRequestInput; FileUpload: ResolverTypeWrapper; @@ -5334,7 +5327,8 @@ export type ResolversTypes = { JoinWorkspaceInput: JoinWorkspaceInput; LegacyCommentViewerData: ResolverTypeWrapper; LimitedUser: ResolverTypeWrapper; - LimitedWorkspace: ResolverTypeWrapper; + LimitedUserCollection: ResolverTypeWrapper & { items: Array }>; + LimitedWorkspace: ResolverTypeWrapper & { team?: Maybe }>; LimitedWorkspaceJoinRequest: ResolverTypeWrapper; LimitedWorkspaceJoinRequestCollection: ResolverTypeWrapper & { items: Array }>; MarkCommentViewedInput: MarkCommentViewedInput; @@ -5652,8 +5646,6 @@ export type ResolversParentTypes = { DeleteVersionsInput: DeleteVersionsInput; DenyWorkspaceJoinRequestInput: DenyWorkspaceJoinRequestInput; DiscoverableStreamsSortingInput: DiscoverableStreamsSortingInput; - DiscoverableWorkspaceCollaborator: DiscoverableWorkspaceCollaborator; - DiscoverableWorkspaceCollaboratorCollection: DiscoverableWorkspaceCollaboratorCollection; EditCommentInput: EditCommentInput; EmailVerificationRequestInput: EmailVerificationRequestInput; FileUpload: FileUploadGraphQLReturn; @@ -5668,7 +5660,8 @@ export type ResolversParentTypes = { JoinWorkspaceInput: JoinWorkspaceInput; LegacyCommentViewerData: LegacyCommentViewerData; LimitedUser: LimitedUserGraphQLReturn; - LimitedWorkspace: LimitedWorkspace; + LimitedUserCollection: Omit & { items: Array }; + LimitedWorkspace: Omit & { team?: Maybe }; LimitedWorkspaceJoinRequest: LimitedWorkspaceJoinRequestGraphQLReturn; LimitedWorkspaceJoinRequestCollection: Omit & { items: Array }; MarkCommentViewedInput: MarkCommentViewedInput; @@ -6327,18 +6320,6 @@ export interface DateTimeScalarConfig extends GraphQLScalarTypeConfig = { - avatar?: Resolver, ParentType, ContextType>; - __isTypeOf?: IsTypeOfResolverFn; -}; - -export type DiscoverableWorkspaceCollaboratorCollectionResolvers = { - cursor?: Resolver, ParentType, ContextType>; - items?: Resolver, ParentType, ContextType>; - totalCount?: Resolver; - __isTypeOf?: IsTypeOfResolverFn; -}; - export type FileUploadResolvers = { branchName?: Resolver; convertedCommitId?: Resolver, ParentType, ContextType>; @@ -6414,13 +6395,20 @@ export type LimitedUserResolvers; }; +export type LimitedUserCollectionResolvers = { + cursor?: Resolver, ParentType, ContextType>; + items?: Resolver, ParentType, ContextType>; + totalCount?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type LimitedWorkspaceResolvers = { description?: Resolver, ParentType, ContextType>; id?: Resolver; logo?: Resolver, ParentType, ContextType>; name?: Resolver; slug?: Resolver; - team?: Resolver, ParentType, ContextType, RequireFields>; + team?: Resolver, ParentType, ContextType, RequireFields>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -7721,14 +7709,13 @@ export type Resolvers = { CountOnlyCollection?: CountOnlyCollectionResolvers; CurrencyBasedPrices?: CurrencyBasedPricesResolvers; DateTime?: GraphQLScalarType; - DiscoverableWorkspaceCollaborator?: DiscoverableWorkspaceCollaboratorResolvers; - DiscoverableWorkspaceCollaboratorCollection?: DiscoverableWorkspaceCollaboratorCollectionResolvers; FileUpload?: FileUploadResolvers; GendoAIRender?: GendoAiRenderResolvers; GendoAIRenderCollection?: GendoAiRenderCollectionResolvers; JSONObject?: GraphQLScalarType; LegacyCommentViewerData?: LegacyCommentViewerDataResolvers; LimitedUser?: LimitedUserResolvers; + LimitedUserCollection?: LimitedUserCollectionResolvers; LimitedWorkspace?: LimitedWorkspaceResolvers; LimitedWorkspaceJoinRequest?: LimitedWorkspaceJoinRequestResolvers; LimitedWorkspaceJoinRequestCollection?: LimitedWorkspaceJoinRequestCollectionResolvers; diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index ec01ba22f..e776521e8 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -993,18 +993,6 @@ export type DiscoverableStreamsSortingInput = { type: DiscoverableStreamsSortType; }; -export type DiscoverableWorkspaceCollaborator = { - __typename?: 'DiscoverableWorkspaceCollaborator'; - avatar?: Maybe; -}; - -export type DiscoverableWorkspaceCollaboratorCollection = { - __typename?: 'DiscoverableWorkspaceCollaboratorCollection'; - cursor?: Maybe; - items: Array; - totalCount: Scalars['Int']['output']; -}; - export type EditCommentInput = { commentId: Scalars['String']['input']; content: CommentContentInput; @@ -1209,6 +1197,13 @@ export type LimitedUserWorkspaceRoleArgs = { workspaceId?: InputMaybe; }; +export type LimitedUserCollection = { + __typename?: 'LimitedUserCollection'; + cursor?: Maybe; + items: Array; + totalCount: Scalars['Int']['output']; +}; + /** Workspace metadata visible to non-workspace members. */ export type LimitedWorkspace = { __typename?: 'LimitedWorkspace'; @@ -1223,7 +1218,7 @@ export type LimitedWorkspace = { /** Unique workspace short id. Used for navigation. */ slug: Scalars['String']['output']; /** Workspace members visible to people with verified email domain */ - team?: Maybe; + team?: Maybe; }; diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index de376b329..96d5f80ea 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -994,18 +994,6 @@ export type DiscoverableStreamsSortingInput = { type: DiscoverableStreamsSortType; }; -export type DiscoverableWorkspaceCollaborator = { - __typename?: 'DiscoverableWorkspaceCollaborator'; - avatar?: Maybe; -}; - -export type DiscoverableWorkspaceCollaboratorCollection = { - __typename?: 'DiscoverableWorkspaceCollaboratorCollection'; - cursor?: Maybe; - items: Array; - totalCount: Scalars['Int']['output']; -}; - export type EditCommentInput = { commentId: Scalars['String']['input']; content: CommentContentInput; @@ -1210,6 +1198,13 @@ export type LimitedUserWorkspaceRoleArgs = { workspaceId?: InputMaybe; }; +export type LimitedUserCollection = { + __typename?: 'LimitedUserCollection'; + cursor?: Maybe; + items: Array; + totalCount: Scalars['Int']['output']; +}; + /** Workspace metadata visible to non-workspace members. */ export type LimitedWorkspace = { __typename?: 'LimitedWorkspace'; @@ -1224,7 +1219,7 @@ export type LimitedWorkspace = { /** Unique workspace short id. Used for navigation. */ slug: Scalars['String']['output']; /** Workspace members visible to people with verified email domain */ - team?: Maybe; + team?: Maybe; }; @@ -6208,7 +6203,7 @@ export type GetWorkspaceBySlugQuery = { __typename?: 'Query', workspaceBySlug: { export type GetActiveUserDiscoverableWorkspacesQueryVariables = Exact<{ [key: string]: never; }>; -export type GetActiveUserDiscoverableWorkspacesQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', discoverableWorkspaces: Array<{ __typename?: 'LimitedWorkspace', id: string, name: string, description?: string | null, team?: { __typename?: 'DiscoverableWorkspaceCollaboratorCollection', totalCount: number, cursor?: string | null, items: Array<{ __typename?: 'DiscoverableWorkspaceCollaborator', avatar?: string | null }> } | null }> } | null }; +export type GetActiveUserDiscoverableWorkspacesQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', discoverableWorkspaces: Array<{ __typename?: 'LimitedWorkspace', id: string, name: string, description?: string | null, team?: { __typename?: 'LimitedUserCollection', totalCount: number, cursor?: string | null, items: Array<{ __typename?: 'LimitedUser', avatar?: string | null }> } | null }> } | null }; export type UpdateWorkspaceMutationVariables = Exact<{ input: WorkspaceUpdateInput; From e3a09a932d9ba3b385160fa7d26ee532b8913cc4 Mon Sep 17 00:00:00 2001 From: Daniel Gak Anagrov Date: Wed, 30 Apr 2025 15:39:22 +0200 Subject: [PATCH 051/178] feat(package.json): added command to drop volumes (#4633) Co-authored-by: Daniel Gak Anagrov --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index b60687e2a..dade41a58 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dev:docker": "docker compose -f ./docker-compose-deps.yml", "dev:docker:up": "docker compose -f ./docker-compose-deps.yml up -d", "dev:docker:down": "docker compose -f ./docker-compose-deps.yml down", + "dev:docker:down:volumes": "docker compose -f ./docker-compose-deps.yml down --volumes", "dev:docker:restart": "yarn dev:docker:down && yarn dev:docker:up", "dev:kind:up": "ctlptl apply --filename ./.circleci/deployment/cluster-config.yaml", "dev:kind:down": "ctlptl delete -f ./.circleci/deployment/cluster-config.yaml", From c6dcf18bdb670afe91637fb0f5ba136f3c260d98 Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Wed, 30 Apr 2025 17:39:07 +0300 Subject: [PATCH 052/178] feat(server): workspace project invite auto-accept (for existing wp members) (#4622) * WIP ts * DI fix & tests moved to TS * auto-accept seems to work * CR comments --- .../events/streamInviteListeners.ts | 12 +- .../tests/activitySummary.spec.ts | 81 +++- .../server/modules/auth/tests/auth.spec.ts | 71 +++- .../tests/blobstorage.graph.spec.js | 193 --------- .../tests/blobstorage.graph.spec.ts | 244 +++++++++++ ...s.graph.spec.js => comments.graph.spec.ts} | 390 ++++++++++-------- .../modules/comments/tests/comments.spec.ts | 69 +++- .../modules/core/graph/resolvers/projects.ts | 73 +++- .../modules/core/graph/resolvers/streams.ts | 73 +++- .../modules/core/services/streams/access.ts | 5 +- .../modules/core/tests/branches.spec.ts | 69 +++- .../server/modules/core/tests/commits.spec.ts | 69 +++- ...treams.spec.js => favoriteStreams.spec.ts} | 222 ++++++---- .../{generic.spec.js => generic.spec.ts} | 198 +++++---- .../{objects.spec.js => objects.spec.ts} | 305 +++++++++----- .../core/tests/{rest.spec.js => rest.spec.ts} | 253 ++++++++---- .../server/modules/core/tests/streams.spec.ts | 72 +++- .../server/modules/core/tests/users.spec.ts | 66 ++- .../modules/core/tests/usersAdminList.spec.ts | 73 +++- .../tests/fileuploads.integration.spec.ts | 68 ++- .../fileuploads/tests/fileuploads.spec.ts | 68 ++- .../modules/serverinvites/domain/events.ts | 5 + .../modules/serverinvites/domain/types.ts | 6 + .../graph/resolvers/serverInvites.ts | 88 ++-- .../modules/serverinvites/helpers/core.ts | 4 + .../serverinvites/services/creation.ts | 23 +- .../serverinvites/services/operations.ts | 10 +- .../serverinvites/services/processing.ts | 6 +- .../serverinvites/tests/invites.spec.ts | 84 ++-- .../server/modules/stats/tests/stats.spec.ts | 65 ++- .../modules/webhooks/services/webhooks.ts | 1 + .../modules/webhooks/tests/cleanup.spec.ts | 68 ++- .../{webhooks.spec.js => webhooks.spec.ts} | 210 ++++++---- .../workspaces/graph/resolvers/workspaces.ts | 142 ++++--- .../modules/workspaces/services/invites.ts | 6 + .../workspaces/tests/helpers/creation.ts | 112 ++++- .../tests/integration/invites.graph.spec.ts | 71 +++- packages/server/scripts/streamObjects.js | 152 ------- packages/server/scripts/streamObjects.ts | 226 ++++++++++ .../test/speckle-helpers/inviteHelper.ts | 119 +++++- .../test/speckle-helpers/streamHelper.ts | 73 +++- 41 files changed, 2954 insertions(+), 1191 deletions(-) delete mode 100644 packages/server/modules/blobstorage/tests/blobstorage.graph.spec.js create mode 100644 packages/server/modules/blobstorage/tests/blobstorage.graph.spec.ts rename packages/server/modules/comments/tests/{comments.graph.spec.js => comments.graph.spec.ts} (79%) rename packages/server/modules/core/tests/{favoriteStreams.spec.js => favoriteStreams.spec.ts} (65%) rename packages/server/modules/core/tests/{generic.spec.js => generic.spec.ts} (59%) rename packages/server/modules/core/tests/{objects.spec.js => objects.spec.ts} (70%) rename packages/server/modules/core/tests/{rest.spec.js => rest.spec.ts} (71%) rename packages/server/modules/webhooks/tests/{webhooks.spec.js => webhooks.spec.ts} (74%) delete mode 100644 packages/server/scripts/streamObjects.js create mode 100644 packages/server/scripts/streamObjects.ts diff --git a/packages/server/modules/activitystream/events/streamInviteListeners.ts b/packages/server/modules/activitystream/events/streamInviteListeners.ts index f8317c15b..90ed453d0 100644 --- a/packages/server/modules/activitystream/events/streamInviteListeners.ts +++ b/packages/server/modules/activitystream/events/streamInviteListeners.ts @@ -49,7 +49,7 @@ const addStreamInviteAcceptedActivityFactory = getProjectInviteProject: GetProjectInviteProject }) => async (payload: EventPayload) => { - const { invite } = payload.payload + const { invite, trueFinalizerUserId } = payload.payload const project = await deps.getProjectInviteProject({ invite }) if (!project) return @@ -58,14 +58,18 @@ const addStreamInviteAcceptedActivityFactory = getResourceTypeRole(invite.resource, ProjectInviteResourceType) || Roles.Stream.Contributor + const differentFinalizer = trueFinalizerUserId !== userTarget.userId + await deps.saveActivity({ streamId: project.id, resourceType: ResourceTypes.Stream, resourceId: project.id, actionType: ActionTypes.Stream.InviteAccepted, - userId: userTarget.userId!, - info: { inviterUser: invite.inviterId, role }, - message: `User ${userTarget.userId!} has accepted an invitation to become a ${role}` + userId: trueFinalizerUserId, + info: { inviterUser: invite.inviterId, role, targetUserId: userTarget.userId! }, + message: differentFinalizer + ? `User ${trueFinalizerUserId} has auto-accepted ${userTarget.userId!} invitation to become a ${role}` + : `User ${userTarget.userId!} has accepted an invitation to become a ${role}` }) } diff --git a/packages/server/modules/activitystream/tests/activitySummary.spec.ts b/packages/server/modules/activitystream/tests/activitySummary.spec.ts index e5363d3a3..d415fb922 100644 --- a/packages/server/modules/activitystream/tests/activitySummary.spec.ts +++ b/packages/server/modules/activitystream/tests/activitySummary.spec.ts @@ -20,7 +20,8 @@ import { db } from '@/db/knex' import { createStreamFactory, deleteStreamFactory, - getStreamFactory + getStreamFactory, + grantStreamPermissionsFactory } from '@/modules/core/repositories/streams' import { createStreamReturnRecordFactory, @@ -29,8 +30,12 @@ import { import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/projectInviteManagement' import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' import { + deleteInvitesByTargetFactory, + deleteServerOnlyInvitesFactory, + findInviteFactory, findUserByTargetFactory, - insertInviteAndDeleteOldFactory + insertInviteAndDeleteOldFactory, + updateAllInviteTargetsFactory } from '@/modules/serverinvites/repositories/serverInvites' import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' @@ -38,6 +43,29 @@ import { getEventBus } from '@/modules/shared/services/eventBus' import { createBranchFactory } from '@/modules/core/repositories/branches' import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/users' import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' +import { + createUserEmailFactory, + ensureNoPrimaryEmailForUserFactory, + findEmailFactory +} from '@/modules/core/repositories/userEmails' +import { validateAndCreateUserEmailFactory } from '@/modules/core/services/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' const cleanup = async () => { await truncateTables([StreamActivity.name, Users.name]) @@ -53,6 +81,52 @@ const createActivitySummary = createActivitySummaryFactory({ getActivity: getActivityFactory({ db }), getUser }) + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -71,7 +145,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), diff --git a/packages/server/modules/auth/tests/auth.spec.ts b/packages/server/modules/auth/tests/auth.spec.ts index f8af76395..fc12fc899 100644 --- a/packages/server/modules/auth/tests/auth.spec.ts +++ b/packages/server/modules/auth/tests/auth.spec.ts @@ -12,7 +12,8 @@ import { findUserByTargetFactory, insertInviteAndDeleteOldFactory, deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory + updateAllInviteTargetsFactory, + deleteInvitesByTargetFactory } from '@/modules/serverinvites/repositories/serverInvites' import { db } from '@/db/knex' import { @@ -24,7 +25,8 @@ import { createAndSendInviteFactory } from '@/modules/serverinvites/services/cre import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' import { getStreamFactory, - createStreamFactory + createStreamFactory, + grantStreamPermissionsFactory } from '@/modules/core/repositories/streams' import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' import { getEventBus } from '@/modules/shared/services/eventBus' @@ -48,7 +50,10 @@ 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 { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' import { getServerInfoFactory, updateServerInfoFactory @@ -57,6 +62,15 @@ import { temporarilyEnableRateLimiter } from '@/modules/core/tests/ratelimiter.s import { passportAuthenticationCallbackFactory } from '@/modules/auth/services/passportService' import { testLogger as logger } from '@/observability/logging' import { Application } from 'express' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' const getServerInfo = getServerInfoFactory({ db }) const getUser = getUserFactory({ db }) @@ -64,6 +78,52 @@ const getUsers = getUsersFactory({ db }) const createInviteDirectly = createStreamInviteDirectly const findInvite = findInviteFactory({ db }) const getStream = getStreamFactory({ db }) + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -82,7 +142,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), @@ -205,7 +266,7 @@ describe('Auth @auth', () => { }@speckle.systems` const inviterUser = await getUserByEmail({ email: registeredUserEmail }) - const { token, inviteId } = await createInviteDirectly( + const { token, id: inviteId } = await createInviteDirectly( streamInvite ? { email: targetEmail, diff --git a/packages/server/modules/blobstorage/tests/blobstorage.graph.spec.js b/packages/server/modules/blobstorage/tests/blobstorage.graph.spec.js deleted file mode 100644 index dc5469f9b..000000000 --- a/packages/server/modules/blobstorage/tests/blobstorage.graph.spec.js +++ /dev/null @@ -1,193 +0,0 @@ -const { buildApolloServer } = require('@/app') -const { truncateTables } = require('@/test/hooks') -const { gql } = require('graphql-tag') -const { createBlobs } = require('@/modules/blobstorage/tests/helpers') -const { expect } = require('chai') -const { Users, Streams } = require('@/modules/core/dbSchema') -const { createAuthedTestContext, executeOperation } = require('@/test/graphqlHelper') -const { - getStreamFactory, - createStreamFactory -} = require('@/modules/core/repositories/streams') -const { db } = require('@/db/knex') -const { - legacyCreateStreamFactory, - createStreamReturnRecordFactory -} = require('@/modules/core/services/streams/management') -const { - inviteUsersToProjectFactory -} = require('@/modules/serverinvites/services/projectInviteManagement') -const { - createAndSendInviteFactory -} = require('@/modules/serverinvites/services/creation') -const { - findUserByTargetFactory, - insertInviteAndDeleteOldFactory, - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} = require('@/modules/serverinvites/repositories/serverInvites') -const { - collectAndValidateCoreTargetsFactory -} = require('@/modules/serverinvites/services/coreResourceCollection') -const { - buildCoreInviteEmailContentsFactory -} = require('@/modules/serverinvites/services/coreEmailContents') -const { getEventBus } = require('@/modules/shared/services/eventBus') -const { createBranchFactory } = require('@/modules/core/repositories/branches') -const { - getUsersFactory, - getUserFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory -} = require('@/modules/core/repositories/users') -const { - findEmailFactory, - createUserEmailFactory, - ensureNoPrimaryEmailForUserFactory -} = require('@/modules/core/repositories/userEmails') -const { - requestNewEmailVerificationFactory -} = require('@/modules/emails/services/verification/request') -const { - deleteOldAndInsertNewVerificationFactory -} = require('@/modules/emails/repositories') -const { renderEmail } = require('@/modules/emails/services/emailRendering') -const { sendEmail } = require('@/modules/emails/services/sending') -const { createUserFactory } = require('@/modules/core/services/users/management') -const { - validateAndCreateUserEmailFactory -} = require('@/modules/core/services/userEmails') -const { - finalizeInvitedServerRegistrationFactory -} = require('@/modules/serverinvites/services/processing') -const { getServerInfoFactory } = require('@/modules/core/repositories/server') - -const getServerInfo = getServerInfoFactory({ db }) -const getUser = getUserFactory({ db }) -const getUsers = getUsersFactory({ db }) -const getStream = getStreamFactory({ db }) -const createStream = legacyCreateStreamFactory({ - createStreamReturnRecord: createStreamReturnRecordFactory({ - inviteUsersToProject: inviteUsersToProjectFactory({ - createAndSendInvite: createAndSendInviteFactory({ - findUserByTarget: findUserByTargetFactory({ db }), - insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), - collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ - getStream - }), - buildInviteEmailContents: buildCoreInviteEmailContentsFactory({ - getStream - }), - emitEvent: ({ eventName, payload }) => - getEventBus().emit({ - eventName, - payload - }), - getUser, - getServerInfo - }), - getUsers - }), - createStream: createStreamFactory({ db }), - createBranch: createBranchFactory({ db }), - 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 -}) - -describe('Blobs graphql @blobstorage', () => { - /** @type {import('@/test/graphqlHelper').ServerAndContext} */ - let graphqlServer - - const user = { - name: 'Baron Von Blubba', - email: 'zebarron@bubble.bobble', - password: 'bubblesAreMyBlobs' - } - before(async () => { - await truncateTables(['blob_storage', Users.name, Streams.name]) - user.id = await createUser(user) - graphqlServer = { - apollo: await buildApolloServer(), - context: await createAuthedTestContext(user.id) - } - }) - - it('Stream has blob metadata for a single blob', async () => { - const query = gql` - query ($streamId: String!, $blobId: String!) { - stream(id: $streamId) { - id - blob(id: $blobId) { - id - fileName - uploadStatus - fileSize - fileHash - } - } - } - ` - const streamId = await createStream({ ownerId: user.id }) - const [blob] = await createBlobs({ streamId, number: 1 }) - - const result = await executeOperation(graphqlServer, query, { - streamId, - blobId: blob.id - }) - - const blobMetadata = result.data.stream.blob - expect(blobMetadata.id).to.equal(blob.id) - expect(blobMetadata.fileSize).to.equal(blob.fileSize) - expect(blobMetadata.fileHash).to.equal(blob.fileHash) - }) - - it('Blob metadata collection returns proper summary values', async () => { - const query = gql` - query ($streamId: String!) { - stream(id: $streamId) { - id - blobs { - totalCount - totalSize - } - } - } - ` - const streamId = await createStream({ ownerId: user.id }) - const number = 10 - const fileSize = 123 - await createBlobs({ streamId, number, fileSize }) - const result = await executeOperation(graphqlServer, query, { streamId }) - expect(result.data.stream.blobs.totalCount).to.equal(number) - expect(result.data.stream.blobs.totalSize).to.equal(number * fileSize) - }) -}) diff --git a/packages/server/modules/blobstorage/tests/blobstorage.graph.spec.ts b/packages/server/modules/blobstorage/tests/blobstorage.graph.spec.ts new file mode 100644 index 000000000..8e427a877 --- /dev/null +++ b/packages/server/modules/blobstorage/tests/blobstorage.graph.spec.ts @@ -0,0 +1,244 @@ +import { buildApolloServer } from '@/app' +import { truncateTables } from '@/test/hooks' +import gql from 'graphql-tag' +import { createBlobs } from '@/modules/blobstorage/tests/helpers' +import { expect } from 'chai' +import { Users, Streams } from '@/modules/core/dbSchema' +import { + createAuthedTestContext, + executeOperation, + ServerAndContext +} from '@/test/graphqlHelper' +import { + getStreamFactory, + createStreamFactory, + grantStreamPermissionsFactory +} from '@/modules/core/repositories/streams' +import { db } from '@/db/knex' +import { + legacyCreateStreamFactory, + createStreamReturnRecordFactory +} from '@/modules/core/services/streams/management' +import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/projectInviteManagement' +import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' +import { + findUserByTargetFactory, + insertInviteAndDeleteOldFactory, + deleteServerOnlyInvitesFactory, + updateAllInviteTargetsFactory, + findInviteFactory, + deleteInvitesByTargetFactory +} from '@/modules/serverinvites/repositories/serverInvites' +import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' +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 { + 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, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' +import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' + +const getServerInfo = getServerInfoFactory({ db }) +const getUser = getUserFactory({ db }) +const getUsers = getUsersFactory({ db }) +const getStream = getStreamFactory({ db }) + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + +const createStream = legacyCreateStreamFactory({ + createStreamReturnRecord: createStreamReturnRecordFactory({ + inviteUsersToProject: inviteUsersToProjectFactory({ + createAndSendInvite: createAndSendInviteFactory({ + findUserByTarget: findUserByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + buildInviteEmailContents: buildCoreInviteEmailContentsFactory({ + getStream + }), + emitEvent: ({ eventName, payload }) => + getEventBus().emit({ + eventName, + payload + }), + getUser, + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() + }), + getUsers + }), + createStream: createStreamFactory({ db }), + createBranch: createBranchFactory({ db }), + 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 +}) + +describe('Blobs graphql @blobstorage', () => { + let graphqlServer: ServerAndContext + + const user = { + name: 'Baron Von Blubba', + email: 'zebarron@bubble.bobble', + password: 'bubblesAreMyBlobs', + id: '' + } + + before(async () => { + await truncateTables(['blob_storage', Users.name, Streams.name]) + user.id = await createUser(user) + graphqlServer = { + apollo: await buildApolloServer(), + context: await createAuthedTestContext(user.id) + } + }) + + it('Stream has blob metadata for a single blob', async () => { + const query = gql` + query ($streamId: String!, $blobId: String!) { + stream(id: $streamId) { + id + blob(id: $blobId) { + id + fileName + uploadStatus + fileSize + fileHash + } + } + } + ` + const streamId = await createStream({ ownerId: user.id }) + const [blob] = await createBlobs({ streamId, number: 1 }) + + const result = await executeOperation(graphqlServer, query, { + streamId, + blobId: blob.id + }) + + const blobMetadata = result.data!.stream.blob + expect(blobMetadata.id).to.equal(blob.id) + expect(blobMetadata.fileSize).to.equal(blob.fileSize) + expect(blobMetadata.fileHash).to.equal(blob.fileHash) + }) + + it('Blob metadata collection returns proper summary values', async () => { + const query = gql` + query ($streamId: String!) { + stream(id: $streamId) { + id + blobs { + totalCount + totalSize + } + } + } + ` + const streamId = await createStream({ ownerId: user.id }) + const number = 10 + const fileSize = 123 + await createBlobs({ streamId, number, fileSize }) + const result = await executeOperation(graphqlServer, query, { streamId }) + expect(result.data!.stream.blobs.totalCount).to.equal(number) + expect(result.data!.stream.blobs.totalSize).to.equal(number * fileSize) + }) +}) diff --git a/packages/server/modules/comments/tests/comments.graph.spec.js b/packages/server/modules/comments/tests/comments.graph.spec.ts similarity index 79% rename from packages/server/modules/comments/tests/comments.graph.spec.js rename to packages/server/modules/comments/tests/comments.graph.spec.ts index 8960838fe..60cf702f8 100644 --- a/packages/server/modules/comments/tests/comments.graph.spec.js +++ b/packages/server/modules/comments/tests/comments.graph.spec.ts @@ -1,120 +1,117 @@ -const expect = require('chai').expect +import { expect } from 'chai' -const crs = require('crypto-random-string') -const { buildApolloServer } = require('@/app') -const { beforeEachContext } = require('@/test/hooks') -const { Roles } = require('@/modules/core/helpers/mainConstants') -const { gql } = require('graphql-tag') -const { - convertBasicStringToDocument -} = require('@/modules/core/services/richTextEditorService') -const { +import crs from 'crypto-random-string' +import { buildApolloServer } from '@/app' +import { beforeEachContext } from '@/test/hooks' +import { Roles } from '@/modules/core/helpers/mainConstants' +import gql from 'graphql-tag' +import { convertBasicStringToDocument } from '@/modules/core/services/richTextEditorService' +import { createTestContext, createAuthedTestContext, - executeOperation -} = require('@/test/graphqlHelper') -const { + executeOperation, + ServerAndContext, + ExecuteOperationResponse +} from '@/test/graphqlHelper' +import { streamResourceCheckFactory, createCommentFactory -} = require('@/modules/comments/services') -const { +} from '@/modules/comments/services' +import { checkStreamResourceAccessFactory, markCommentViewedFactory, insertCommentsFactory, insertCommentLinksFactory, deleteCommentFactory, getCommentsResourcesFactory -} = require('@/modules/comments/repositories/comments') -const { db } = require('@/db/knex') -const { - validateInputAttachmentsFactory -} = require('@/modules/comments/services/commentTextService') -const { getBlobsFactory } = require('@/modules/blobstorage/repositories') -const { +} from '@/modules/comments/repositories/comments' +import { db } from '@/db/knex' +import { validateInputAttachmentsFactory } from '@/modules/comments/services/commentTextService' +import { getBlobsFactory } from '@/modules/blobstorage/repositories' +import { createCommitByBranchIdFactory, createCommitByBranchNameFactory -} = require('@/modules/core/services/commit/management') -const { +} from '@/modules/core/services/commit/management' +import { createCommitFactory, insertStreamCommitsFactory, insertBranchCommitsFactory, getCommitsAndTheirBranchIdsFactory -} = require('@/modules/core/repositories/commits') -const { +} from '@/modules/core/repositories/commits' +import { getBranchByIdFactory, markCommitBranchUpdatedFactory, getStreamBranchByNameFactory, createBranchFactory -} = require('@/modules/core/repositories/branches') -const { +} from '@/modules/core/repositories/branches' +import { getStreamFactory, createStreamFactory, updateStreamFactory, grantStreamPermissionsFactory, markCommitStreamUpdatedFactory -} = require('@/modules/core/repositories/streams') -const { +} from '@/modules/core/repositories/streams' +import { getObjectFactory, storeSingleObjectIfNotFoundFactory, getStreamObjectsFactory -} = require('@/modules/core/repositories/objects') -const { +} from '@/modules/core/repositories/objects' +import { legacyCreateStreamFactory, createStreamReturnRecordFactory, legacyUpdateStreamFactory -} = require('@/modules/core/services/streams/management') -const { - inviteUsersToProjectFactory -} = require('@/modules/serverinvites/services/projectInviteManagement') -const { - createAndSendInviteFactory -} = require('@/modules/serverinvites/services/creation') -const { +} from '@/modules/core/services/streams/management' +import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/projectInviteManagement' +import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' +import { findUserByTargetFactory, insertInviteAndDeleteOldFactory, deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} = require('@/modules/serverinvites/repositories/serverInvites') -const { - collectAndValidateCoreTargetsFactory -} = require('@/modules/serverinvites/services/coreResourceCollection') -const { - buildCoreInviteEmailContentsFactory -} = require('@/modules/serverinvites/services/coreEmailContents') -const { getEventBus } = require('@/modules/shared/services/eventBus') -const { + updateAllInviteTargetsFactory, + findInviteFactory, + deleteInvitesByTargetFactory +} from '@/modules/serverinvites/repositories/serverInvites' +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 -} = require('@/modules/core/repositories/users') -const { +} from '@/modules/core/repositories/users' +import { findEmailFactory, ensureNoPrimaryEmailForUserFactory, createUserEmailFactory -} = require('@/modules/core/repositories/userEmails') -const { - requestNewEmailVerificationFactory -} = require('@/modules/emails/services/verification/request') -const { - deleteOldAndInsertNewVerificationFactory -} = require('@/modules/emails/repositories') -const { renderEmail } = require('@/modules/emails/services/emailRendering') -const { sendEmail } = require('@/modules/emails/services/sending') -const { createUserFactory } = require('@/modules/core/services/users/management') -const { - validateAndCreateUserEmailFactory -} = require('@/modules/core/services/userEmails') -const { - finalizeInvitedServerRegistrationFactory -} = require('@/modules/serverinvites/services/processing') -const { getServerInfoFactory } = require('@/modules/core/repositories/server') -const { createObjectFactory } = require('@/modules/core/services/objects/management') -const { +} 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, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' +import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { createObjectFactory } from '@/modules/core/services/objects/management' +import { getViewerResourcesFromLegacyIdentifiersFactory, getViewerResourcesForCommentsFactory -} = require('@/modules/core/services/commit/viewerResources') +} from '@/modules/core/services/commit/viewerResources' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' +import { SetNonNullable } from 'type-fest' const getServerInfo = getServerInfoFactory({ db }) const getUser = getUserFactory({ db }) @@ -168,6 +165,51 @@ const createCommitByBranchName = createCommitByBranchNameFactory({ }) const getStream = getStreamFactory({ db }) +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -186,7 +228,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), @@ -232,45 +275,54 @@ const createObject = createObjectFactory({ storeSingleObjectIfNotFoundFactory: storeSingleObjectIfNotFoundFactory({ db }) }) -function buildCommentInputFromString(textString) { +function buildCommentInputFromString(textString: string) { return convertBasicStringToDocument(textString) } -const testForbiddenResponse = (result) => { +const testForbiddenResponse = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result: ExecuteOperationResponse> +) => { expect(result.errors, 'This should have failed').to.exist - expect(result.errors.length).to.be.above(0) - expect(result.errors[0].extensions.code).to.match( + expect(result.errors!.length).to.be.above(0) + expect(result.errors![0].extensions!.code).to.match( /(STREAM_INVALID_ACCESS_ERROR|FORBIDDEN|UNAUTHORIZED_ACCESS_ERROR)/ ) } -const testResult = (shouldSucceed, result, successTests) => { +const testResult = ( + shouldSucceed: boolean, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result: ExecuteOperationResponse>, + successTests: ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result: SetNonNullable>, 'data'> + ) => void +) => { if (shouldSucceed) { expect(result.errors, 'This should not have failed').to.not.exist - successTests(result) + successTests( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result as SetNonNullable>, 'data'> + ) } else { testForbiddenResponse(result) } } -/** - * @typedef {{ - * apollo: import('@/test/graphqlHelper').ServerAndContext, - * resources: { - * streamId: string, - * objectId: string, - * commentId: string, - * testActorId: string - * }, - * shouldSucceed: boolean, - * streamId: string - * }} TestContext - */ +type TestContext = { + apollo: ServerAndContext + resources: { + streamId: string + objectId: string + commentId: string + testActorId: string + } + shouldSucceed: boolean + streamId: string +} -/** - * @param {TestContext} param0 - */ -const writeComment = async ({ apollo, resources, shouldSucceed }) => { +const writeComment = async ({ apollo, resources, shouldSucceed }: TestContext) => { const res = await executeOperation( apollo, gql` @@ -294,10 +346,11 @@ const writeComment = async ({ apollo, resources, shouldSucceed }) => { }) } -/** - * @param {TestContext} param0 - */ -const broadcastViewerActivity = async ({ apollo, resources, shouldSucceed }) => { +const broadcastViewerActivity = async ({ + apollo, + resources, + shouldSucceed +}: TestContext) => { const res = await executeOperation( apollo, gql` @@ -320,10 +373,11 @@ const broadcastViewerActivity = async ({ apollo, resources, shouldSucceed }) => }) } -/** - * @param {TestContext} param0 - */ -const broadcastCommentActivity = async ({ apollo, resources, shouldSucceed }) => { +const broadcastCommentActivity = async ({ + apollo, + resources, + shouldSucceed +}: TestContext) => { const res = await executeOperation( apollo, gql` @@ -346,10 +400,7 @@ const broadcastCommentActivity = async ({ apollo, resources, shouldSucceed }) => }) } -/** - * @param {TestContext} param0 - */ -const viewAComment = async ({ apollo, resources, shouldSucceed }) => { +const viewAComment = async ({ apollo, resources, shouldSucceed }: TestContext) => { const res = await executeOperation( apollo, gql` @@ -367,13 +418,10 @@ const viewAComment = async ({ apollo, resources, shouldSucceed }) => { }) } -/** - * @param {TestContext} param0 - */ -const archiveMyComment = async ({ apollo, resources, shouldSucceed }) => { +const archiveMyComment = async ({ apollo, resources, shouldSucceed }: TestContext) => { const context = apollo.context const { id: commentId } = await createComment({ - userId: context.userId, + userId: context!.userId!, input: { streamId: resources.streamId, text: buildCommentInputFromString('i wrote this myself'), @@ -399,10 +447,11 @@ const archiveMyComment = async ({ apollo, resources, shouldSucceed }) => { }) } -/** - * @param {TestContext} param0 - */ -const archiveOthersComment = async ({ apollo, resources, shouldSucceed }) => { +const archiveOthersComment = async ({ + apollo, + resources, + shouldSucceed +}: TestContext) => { const res = await executeOperation( apollo, gql` @@ -420,12 +469,9 @@ const archiveOthersComment = async ({ apollo, resources, shouldSucceed }) => { }) } -/** - * @param {TestContext} param0 - */ -const editMyComment = async ({ apollo, resources, shouldSucceed }) => { +const editMyComment = async ({ apollo, resources, shouldSucceed }: TestContext) => { const { id: commentId } = await createComment({ - userId: apollo.context.userId, + userId: apollo.context!.userId!, input: { streamId: resources.streamId, text: buildCommentInputFromString('i wrote this myself'), @@ -458,10 +504,7 @@ const editMyComment = async ({ apollo, resources, shouldSucceed }) => { }) } -/** - * @param {TestContext} param0 - */ -const editOthersComment = async ({ apollo, resources, shouldSucceed }) => { +const editOthersComment = async ({ apollo, resources, shouldSucceed }: TestContext) => { const res = await executeOperation( apollo, gql` @@ -485,10 +528,7 @@ const editOthersComment = async ({ apollo, resources, shouldSucceed }) => { }) } -/** - * @param {TestContext} param0 - */ -const replyToAComment = async ({ apollo, resources, shouldSucceed }) => { +const replyToAComment = async ({ apollo, resources, shouldSucceed }: TestContext) => { const res = await executeOperation( apollo, gql` @@ -514,10 +554,7 @@ const replyToAComment = async ({ apollo, resources, shouldSucceed }) => { }) } -/** - * @param {TestContext} param0 - */ -const queryComment = async ({ apollo, resources, shouldSucceed }) => { +const queryComment = async ({ apollo, resources, shouldSucceed }: TestContext) => { const res = await executeOperation( apollo, gql` @@ -547,10 +584,7 @@ const queryComment = async ({ apollo, resources, shouldSucceed }) => { }) } -/** - * @param {TestContext} param0 - */ -const queryComments = async ({ apollo, resources, shouldSucceed }) => { +const queryComments = async ({ apollo, resources, shouldSucceed }: TestContext) => { const object = { foo: 123, bar: crs({ length: 5 }) @@ -599,14 +633,17 @@ const queryComments = async ({ apollo, resources, shouldSucceed }) => { ) testResult(shouldSucceed, res, (res) => { expect(res.data.comments.totalCount).to.be.equal(numberOfComments) - expect(res.data.comments.items.map((i) => i.id)).to.be.equalInAnyOrder(commentIds) + expect( + res.data.comments.items.map((i: { id: string }) => i.id) + ).to.deep.equalInAnyOrder(commentIds) }) } -/** - * @param {TestContext} param0 - */ -const queryStreamCommentCount = async ({ apollo, resources, shouldSucceed }) => { +const queryStreamCommentCount = async ({ + apollo, + resources, + shouldSucceed +}: TestContext) => { await createComment({ userId: resources.testActorId, input: { @@ -635,10 +672,11 @@ const queryStreamCommentCount = async ({ apollo, resources, shouldSucceed }) => }) } -/** - * @param {TestContext} param0 - */ -const queryObjectCommentCount = async ({ apollo, resources, shouldSucceed }) => { +const queryObjectCommentCount = async ({ + apollo, + resources, + shouldSucceed +}: TestContext) => { const objectId = await createObject({ streamId: resources.streamId, object: { @@ -675,10 +713,11 @@ const queryObjectCommentCount = async ({ apollo, resources, shouldSucceed }) => }) } -/** - * @param {TestContext} param0 - */ -const queryCommitCommentCount = async ({ apollo, resources, shouldSucceed }) => { +const queryCommitCommentCount = async ({ + apollo, + resources, + shouldSucceed +}: TestContext) => { const objectId = await createObject({ streamId: resources.streamId, object: { @@ -722,14 +761,11 @@ const queryCommitCommentCount = async ({ apollo, resources, shouldSucceed }) => }) } -/** - * @param {TestContext} param0 - */ const queryCommitCollectionCommentCount = async ({ apollo, resources, shouldSucceed -}) => { +}: TestContext) => { const objectId = await createObject({ streamId: resources.streamId, object: { @@ -772,16 +808,13 @@ const queryCommitCollectionCommentCount = async ({ ) testResult(shouldSucceed, res, (res) => { res.data.otherUser.commits.items - .map((i) => i.commentCount) - .map((commentCount) => { + .map((i: { commentCount: number }) => i.commentCount) + .map((commentCount: number) => { expect(commentCount).to.be.greaterThanOrEqual(1) }) }) } -// eslint-disable-next-line no-unused-vars -const actions = ['queryCommitCommentCount', 'queryCommitCollectionCommentCount'] - describe('Graphql @comments', () => { // this user will be admin by default // it will be used to create all resources, that the other actors can @@ -789,59 +822,69 @@ describe('Graphql @comments', () => { const myTestActor = { name: 'Gergo Jedlicska', email: 'gergo@jedlicska.com', - password: 'sn3aky-1337-b1m' + password: 'sn3aky-1337-b1m', + id: '' } const chadTheEngineer = { name: 'Chad the Engineer', email: 'chad@engineering.acme', password: 'tryingNotToBeACadMonkey', - role: Roles.Server.User + role: Roles.Server.User, + id: '' } const archived = { name: 'The Balrog of Morgoth', email: 'durinsbane@moria.bridge', - role: Roles.Server.ArchivedUser + password: 'tryingNotToBeACadMonkey', + role: Roles.Server.ArchivedUser, + id: '' } const ownedStream = { name: 'stream owner', isPublic: false, - role: Roles.Stream.Owner + role: Roles.Stream.Owner, + id: '' } const contributorStream = { name: 'contributions are welcome', isPublic: false, - role: Roles.Stream.Contributor + role: Roles.Stream.Contributor, + id: '' } const reviewerStream = { name: 'no work, just talk', isPublic: false, - role: Roles.Stream.Reviewer + role: Roles.Stream.Reviewer, + id: '' } const noAccessStream = { name: 'aint nobody canna cross it', isPublic: false, - role: null + role: null, + id: '' } const publicStream = { name: 'come take a look', isPublic: true, - role: null + role: null, + id: '' } const publicStreamWithPublicComments = { name: 'the gossip protocol', isPublic: true, - role: null + role: null, + id: '' } - const testData = [ + const testData = [ { user: chadTheEngineer, streamData: [ @@ -1116,11 +1159,8 @@ describe('Graphql @comments', () => { }`, () => { userContext.streamData.forEach((streamContext) => { const stream = streamContext.stream - let resources - /** - * @type {import('@/test/graphqlHelper').ServerAndContext} - */ - let apollo + let resources: TestContext['resources'] + let apollo: ServerAndContext before(async () => { apollo = { diff --git a/packages/server/modules/comments/tests/comments.spec.ts b/packages/server/modules/comments/tests/comments.spec.ts index 8a2c3f954..db41f952a 100644 --- a/packages/server/modules/comments/tests/comments.spec.ts +++ b/packages/server/modules/comments/tests/comments.spec.ts @@ -55,7 +55,8 @@ import { getBlobsFactory } from '@/modules/blobstorage/repositories' import { getStreamFactory, createStreamFactory, - markCommitStreamUpdatedFactory + markCommitStreamUpdatedFactory, + grantStreamPermissionsFactory } from '@/modules/core/repositories/streams' import { createCommitByBranchIdFactory, @@ -88,7 +89,9 @@ import { findUserByTargetFactory, insertInviteAndDeleteOldFactory, deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory + updateAllInviteTargetsFactory, + findInviteFactory, + deleteInvitesByTargetFactory } from '@/modules/serverinvites/repositories/serverInvites' import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' @@ -111,7 +114,10 @@ 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 { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' import { getServerInfoFactory } from '@/modules/core/repositories/server' import { createObjectFactory } from '@/modules/core/services/objects/management' import type express from 'express' @@ -130,6 +136,15 @@ import { getViewerResourcesFromLegacyIdentifiersFactory } from '@/modules/core/services/commit/viewerResources' import { StreamRecord } from '@/modules/core/helpers/types' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' type LegacyCommentRecord = CommentRecord & { total_count: string @@ -223,6 +238,51 @@ const createCommitByBranchName = createCommitByBranchNameFactory({ getBranchById: getBranchByIdFactory({ db }) }) +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -241,7 +301,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), diff --git a/packages/server/modules/core/graph/resolvers/projects.ts b/packages/server/modules/core/graph/resolvers/projects.ts index e71fc24a7..c4880b738 100644 --- a/packages/server/modules/core/graph/resolvers/projects.ts +++ b/packages/server/modules/core/graph/resolvers/projects.ts @@ -77,8 +77,12 @@ import { } from '@/modules/multiregion/utils/dbSelector' import { deleteAllResourceInvitesFactory, + deleteInvitesByTargetFactory, + deleteServerOnlyInvitesFactory, + findInviteFactory, findUserByTargetFactory, - insertInviteAndDeleteOldFactory + insertInviteAndDeleteOldFactory, + updateAllInviteTargetsFactory } from '@/modules/serverinvites/repositories/serverInvites' import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' @@ -94,11 +98,75 @@ import { import { has } from 'lodash' import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper' import { withOperationLogging } from '@/observability/domain/businessLogging' +import { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + createUserEmailFactory, + ensureNoPrimaryEmailForUserFactory, + findEmailFactory +} from '@/modules/core/repositories/userEmails' +import { validateAndCreateUserEmailFactory } from '@/modules/core/services/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' const getServerInfo = getServerInfoFactory({ db }) const getUsers = getUsersFactory({ db }) const getUser = getUserFactory({ db }) const getStream = getStreamFactory({ db }) + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStreamReturnRecord = createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ createAndSendInvite: createAndSendInviteFactory({ @@ -116,7 +184,8 @@ const createStreamReturnRecord = createStreamReturnRecordFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), diff --git a/packages/server/modules/core/graph/resolvers/streams.ts b/packages/server/modules/core/graph/resolvers/streams.ts index 164d2bc77..4619cfed4 100644 --- a/packages/server/modules/core/graph/resolvers/streams.ts +++ b/packages/server/modules/core/graph/resolvers/streams.ts @@ -48,9 +48,13 @@ import { } from '@/modules/core/graph/generated/graphql' import { deleteAllResourceInvitesFactory, + deleteInvitesByTargetFactory, + deleteServerOnlyInvitesFactory, + findInviteFactory, findUserByTargetFactory, insertInviteAndDeleteOldFactory, - queryAllResourceInvitesFactory + queryAllResourceInvitesFactory, + updateAllInviteTargetsFactory } from '@/modules/serverinvites/repositories/serverInvites' import db from '@/db/knex' import { getInvitationTargetUsersFactory } from '@/modules/serverinvites/services/retrieval' @@ -75,6 +79,24 @@ import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/use import { getServerInfoFactory } from '@/modules/core/repositories/server' import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper' import { withOperationLogging } from '@/observability/domain/businessLogging' +import { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + createUserEmailFactory, + ensureNoPrimaryEmailForUserFactory, + findEmailFactory +} from '@/modules/core/repositories/userEmails' +import { validateAndCreateUserEmailFactory } from '@/modules/core/services/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' const getServerInfo = getServerInfoFactory({ db }) const getUsers = getUsersFactory({ db }) @@ -84,6 +106,52 @@ const getFavoriteStreamsCollection = getFavoriteStreamsCollectionFactory({ getFavoritedStreamsPage: getFavoritedStreamsPageFactory({ db }) }) const getStream = getStreamFactory({ db }) + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStreamReturnRecord = createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ createAndSendInvite: createAndSendInviteFactory({ @@ -101,7 +169,8 @@ const createStreamReturnRecord = createStreamReturnRecordFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), diff --git a/packages/server/modules/core/services/streams/access.ts b/packages/server/modules/core/services/streams/access.ts index 935458495..0459ac6ad 100644 --- a/packages/server/modules/core/services/streams/access.ts +++ b/packages/server/modules/core/services/streams/access.ts @@ -208,8 +208,9 @@ export const addOrUpdateStreamCollaboratorFactory = eventName: ServerInvitesEvents.Finalized, payload: { invite: fromInvite, - finalizerUserId: addedById, - accept: true + finalizerUserId: userId, + accept: true, + trueFinalizerUserId: addedById } }) } else { diff --git a/packages/server/modules/core/tests/branches.spec.ts b/packages/server/modules/core/tests/branches.spec.ts index 09c16817e..01e7bd0ee 100644 --- a/packages/server/modules/core/tests/branches.spec.ts +++ b/packages/server/modules/core/tests/branches.spec.ts @@ -27,7 +27,8 @@ import { getStreamFactory, createStreamFactory, markBranchStreamUpdatedFactory, - markCommitStreamUpdatedFactory + markCommitStreamUpdatedFactory, + grantStreamPermissionsFactory } from '@/modules/core/repositories/streams' import { createCommitByBranchIdFactory, @@ -52,7 +53,9 @@ import { findUserByTargetFactory, insertInviteAndDeleteOldFactory, deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory + updateAllInviteTargetsFactory, + findInviteFactory, + deleteInvitesByTargetFactory } from '@/modules/serverinvites/repositories/serverInvites' import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' @@ -75,12 +78,24 @@ 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 { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' import { getServerInfoFactory } from '@/modules/core/repositories/server' import { getPaginatedStreamBranchesFactory } from '@/modules/core/services/branch/retrieval' import { createObjectFactory } from '@/modules/core/services/objects/management' import { ensureError } from '@speckle/shared' import { ModelEvents } from '@/modules/core/domain/branches/events' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' const db = knex const Commits = () => knex('commits') @@ -125,6 +140,51 @@ const createCommitByBranchName = createCommitByBranchNameFactory({ getBranchById: getBranchByIdFactory({ db }) }) +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -143,7 +203,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), diff --git a/packages/server/modules/core/tests/commits.spec.ts b/packages/server/modules/core/tests/commits.spec.ts index 3bd275480..6b43f358b 100644 --- a/packages/server/modules/core/tests/commits.spec.ts +++ b/packages/server/modules/core/tests/commits.spec.ts @@ -37,7 +37,8 @@ import { getStreamFactory, getCommitStreamFactory, createStreamFactory, - markCommitStreamUpdatedFactory + markCommitStreamUpdatedFactory, + grantStreamPermissionsFactory } from '@/modules/core/repositories/streams' import { getObjectFactory, @@ -53,7 +54,9 @@ import { findUserByTargetFactory, insertInviteAndDeleteOldFactory, deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory + updateAllInviteTargetsFactory, + findInviteFactory, + deleteInvitesByTargetFactory } from '@/modules/serverinvites/repositories/serverInvites' import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' @@ -76,7 +79,10 @@ 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 { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' import { getServerInfoFactory } from '@/modules/core/repositories/server' import { getBranchCommitsTotalCountByNameFactory, @@ -85,6 +91,15 @@ import { import { createObjectFactory } from '@/modules/core/services/objects/management' import { ensureError } from '@speckle/shared' import { VersionEvents } from '@/modules/core/domain/commits/events' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' const getServerInfo = getServerInfoFactory({ db }) const getUser = getUserFactory({ db }) @@ -139,6 +154,51 @@ const updateCommitAndNotify = updateCommitAndNotifyFactory({ }) const getStreamCommitCount = getStreamCommitCountFactory({ db }) +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -157,7 +217,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), diff --git a/packages/server/modules/core/tests/favoriteStreams.spec.js b/packages/server/modules/core/tests/favoriteStreams.spec.ts similarity index 65% rename from packages/server/modules/core/tests/favoriteStreams.spec.js rename to packages/server/modules/core/tests/favoriteStreams.spec.ts index dc60442be..f3e282692 100644 --- a/packages/server/modules/core/tests/favoriteStreams.spec.js +++ b/packages/server/modules/core/tests/favoriteStreams.spec.ts @@ -1,78 +1,124 @@ /* instanbul ignore file */ -const expect = require('chai').expect +import { expect } from 'chai' -const { buildApolloServer } = require('@/app') -const { StreamFavorites, Streams, Users } = require('@/modules/core/dbSchema') -const { truncateTables } = require('@/test/hooks') -const { gql } = require('graphql-tag') -const { sleep } = require('@/test/helpers') -const { +import { buildApolloServer } from '@/app' +import { StreamFavorites, Streams, Users } from '@/modules/core/dbSchema' +import { truncateTables } from '@/test/hooks' +import gql from 'graphql-tag' +import { sleep } from '@/test/helpers' +import { createAuthedTestContext, createTestContext, - executeOperation -} = require('@/test/graphqlHelper') -const { + executeOperation, + ServerAndContext +} from '@/test/graphqlHelper' +import { getStreamFactory, - createStreamFactory -} = require('@/modules/core/repositories/streams') -const { db } = require('@/db/knex') -const { + createStreamFactory, + grantStreamPermissionsFactory +} from '@/modules/core/repositories/streams' +import { db } from '@/db/knex' +import { legacyCreateStreamFactory, createStreamReturnRecordFactory -} = require('@/modules/core/services/streams/management') -const { - inviteUsersToProjectFactory -} = require('@/modules/serverinvites/services/projectInviteManagement') -const { - createAndSendInviteFactory -} = require('@/modules/serverinvites/services/creation') -const { +} from '@/modules/core/services/streams/management' +import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/projectInviteManagement' +import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' +import { findUserByTargetFactory, insertInviteAndDeleteOldFactory, deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} = require('@/modules/serverinvites/repositories/serverInvites') -const { - collectAndValidateCoreTargetsFactory -} = require('@/modules/serverinvites/services/coreResourceCollection') -const { - buildCoreInviteEmailContentsFactory -} = require('@/modules/serverinvites/services/coreEmailContents') -const { getEventBus } = require('@/modules/shared/services/eventBus') -const { createBranchFactory } = require('@/modules/core/repositories/branches') -const { + updateAllInviteTargetsFactory, + findInviteFactory, + deleteInvitesByTargetFactory +} from '@/modules/serverinvites/repositories/serverInvites' +import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' +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 -} = require('@/modules/core/repositories/users') -const { +} from '@/modules/core/repositories/users' +import { findEmailFactory, createUserEmailFactory, ensureNoPrimaryEmailForUserFactory -} = require('@/modules/core/repositories/userEmails') -const { - requestNewEmailVerificationFactory -} = require('@/modules/emails/services/verification/request') -const { - deleteOldAndInsertNewVerificationFactory -} = require('@/modules/emails/repositories') -const { renderEmail } = require('@/modules/emails/services/emailRendering') -const { sendEmail } = require('@/modules/emails/services/sending') -const { createUserFactory } = require('@/modules/core/services/users/management') -const { - validateAndCreateUserEmailFactory -} = require('@/modules/core/services/userEmails') -const { - finalizeInvitedServerRegistrationFactory -} = require('@/modules/serverinvites/services/processing') -const { getServerInfoFactory } = require('@/modules/core/repositories/server') +} 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, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' +import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' const getServerInfo = getServerInfoFactory({ db }) const getUser = getUserFactory({ db }) const getUsers = getUsersFactory({ db }) const getStream = getStreamFactory({ db }) + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -91,7 +137,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), @@ -205,29 +252,35 @@ const totalOwnedStreamsFavoritesNew = gql` describe('Favorite streams', () => { const myPubStream = { name: 'My Stream 1', - isPublic: false + isPublic: false, + id: '' } const myStream = { name: 'My Stream 2', - isPublic: true + isPublic: true, + id: '' } const notMyStream = { name: 'Not My Stream 1', - isPublic: false + isPublic: false, + id: '' } const notMyPubStream = { name: 'Not My Stream 2', - isPublic: true + isPublic: true, + id: '' } const me = { name: 'Itsa Me', email: 'me@example.org', - password: 'sn3aky-1337-b1m' + password: 'sn3aky-1337-b1m', + id: '' } const otherGuy = { name: 'Some Other DUde', email: 'otherguy@example.org', - password: 'sn3aky-1337-b1m' + password: 'sn3aky-1337-b1m', + id: '' } before(async function () { @@ -259,9 +312,9 @@ describe('Favorite streams', () => { describe('when authenticated', () => { /** @type {import('@/test/graphqlHelper').ServerAndContext} */ - let apollo + let apollo: ServerAndContext - const favoriteStream = async (sid, favorited) => + const favoriteStream = async (sid: string, favorited: boolean) => await executeOperation(apollo, favoriteMutationGql, { sid, favorited }) before(async () => { @@ -274,7 +327,7 @@ describe('Favorite streams', () => { await StreamFavorites.knex().truncate() }) - const accessibleStreamIds = [ + const accessibleStreamIds = [ [() => myPubStream.id, 'owned and public'], [() => myStream.id, 'owned and not public'], [() => notMyPubStream.id, 'not owned, but public'] @@ -292,7 +345,7 @@ describe('Favorite streams', () => { expect(result.errors).to.not.be.ok expect(result.data?.streamFavorite?.favoritedDate).to.be.a('date') expect(result.data?.streamFavorite?.favoritedDate.getTime()).to.satisfy( - (t) => t > beforeTime && t < afterTime + (t: number) => t > beforeTime && t < afterTime ) expect(result.data?.streamFavorite?.id).to.equal(streamId) expect(result.data?.streamFavorite?.favoritesCount).to.equal(1) @@ -302,19 +355,19 @@ describe('Favorite streams', () => { it("can't be favorited if not owned and not public", async () => { const result = await favoriteStream(notMyStream.id, true) - expect(result.data.streamFavorite).to.not.be.ok + expect(result.data!.streamFavorite).to.not.be.ok expect(result.errors).to.have.lengthOf(1) - expect(result.errors.at(0).message).to.contain("doesn't have access") + expect(result.errors!.at(0)!.message).to.contain("doesn't have access") }) describe('and favorited', () => { const favoritedStream = { name: 'Favorited Stream', - isPublic: true + isPublic: true, + id: '' } - /** @type {{favoritedDate: Date, favoritesCount: number, id: string}} */ - let favoritingResults + let favoritingResults: { favoritedDate: Date; favoritesCount: number; id: string } before(async () => { favoritedStream.id = await createStream({ ...favoritedStream, ownerId: me.id }) @@ -345,12 +398,12 @@ describe('Favorite streams', () => { describe('and being queried', () => { const favoritableStreams = [ - { name: 'Random 1', isPublic: true }, - { name: 'Random 2', isPublic: true }, - { name: 'Random 2', isPublic: true } + { name: 'Random 1', isPublic: true, id: '' }, + { name: 'Random 2', isPublic: true, id: '' }, + { name: 'Random 2', isPublic: true, id: '' } ] - const getFavorites = async (cursor, limit = 10) => + const getFavorites = async (cursor: string | null, limit = 10) => await executeOperation(apollo, favoriteStreamsQueryGql, { cursor, limit }) const favoritedStreamIds = () => favoritableStreams.map((s) => s.id) @@ -380,7 +433,7 @@ describe('Favorite streams', () => { ) expect(data).to.be.ok - expect(data.otherUser?.favoriteStreams).to.not.be.ok + expect(data!.otherUser?.favoriteStreams).to.not.be.ok expect((errors || []).map((e) => e.message).join()).to.match( /cannot view another user's favorite streams/i ) @@ -394,22 +447,24 @@ describe('Favorite streams', () => { expect(results.data?.activeUser?.favoriteStreams?.items).to.have.lengthOf( ids.length ) - expect(results.data.activeUser.favoriteStreams.totalCount).to.equal(ids.length) - expect(results.data.activeUser.favoriteStreams.cursor).to.be.a('string') + expect(results.data!.activeUser.favoriteStreams.totalCount).to.equal(ids.length) + expect(results.data!.activeUser.favoriteStreams.cursor).to.be.a('string') }) it('are paginated correctly', async () => { let nextCursor = null - let returnedStreamIds = [] + let returnedStreamIds: string[] = [] - const getPaginatedAndAssert = async (nextCursor) => { + const getPaginatedAndAssert = async (nextCursor: string | null) => { const results = await getFavorites(nextCursor, 1) expect(results.errors).to.not.be.ok expect(results.data?.activeUser?.favoriteStreams).to.be.ok return { - cursor: results.data.activeUser.favoriteStreams.cursor, - sids: results.data.activeUser.favoriteStreams.items.map((i) => i.id) + cursor: results.data!.activeUser.favoriteStreams.cursor, + sids: results.data!.activeUser.favoriteStreams.items.map( + (i: { id: string }) => i.id + ) } } @@ -456,8 +511,7 @@ describe('Favorite streams', () => { }) describe('when not authenticated', () => { - /** @type {import('@/test/graphqlHelper').ServerAndContext} */ - let apollo + let apollo: ServerAndContext before(async () => { apollo = { @@ -472,15 +526,15 @@ describe('Favorite streams', () => { favorited: true }) - expect(result.data.streamFavorite).to.not.be.ok + expect(result.data!.streamFavorite).to.not.be.ok expect(result.errors).to.have.lengthOf(1) - expect(result.errors.at(0).message).to.contain('Must provide an auth token') + expect(result.errors!.at(0)!.message).to.contain('Must provide an auth token') }) it("can't be retrieved", async () => { const result = await executeOperation(apollo, favoriteStreamsQueryGql) - expect(result.data.activeUser).to.be.null + expect(result.data!.activeUser).to.be.null expect(result.errors).to.not.be.ok }) }) diff --git a/packages/server/modules/core/tests/generic.spec.js b/packages/server/modules/core/tests/generic.spec.ts similarity index 59% rename from packages/server/modules/core/tests/generic.spec.js rename to packages/server/modules/core/tests/generic.spec.ts index 8802c2d4a..02fba6e32 100644 --- a/packages/server/modules/core/tests/generic.spec.js +++ b/packages/server/modules/core/tests/generic.spec.ts @@ -1,71 +1,115 @@ /* istanbul ignore file */ -const expect = require('chai').expect +import { expect } from 'chai' -const { beforeEachContext } = require('@/test/hooks') +import { beforeEachContext } from '@/test/hooks' -const { validateScopes, authorizeResolver } = require('@/modules/shared') -const { buildContext } = require('@/modules/shared/middleware') -const { Roles, Scopes } = require('@speckle/shared') -const { throwForNotHavingServerRole } = require('@/modules/shared/authz') -const { ForbiddenError } = require('@/modules/shared/errors') -const { +import { validateScopes, authorizeResolver } from '@/modules/shared' +import { buildContext } from '@/modules/shared/middleware' +import { AvailableRoles, Roles, Scopes, ServerRoles } from '@speckle/shared' +import { throwForNotHavingServerRole } from '@/modules/shared/authz' +import { ForbiddenError } from '@/modules/shared/errors' +import { getStreamFactory, - createStreamFactory -} = require('@/modules/core/repositories/streams') -const { db } = require('@/db/knex') -const { + createStreamFactory, + grantStreamPermissionsFactory +} from '@/modules/core/repositories/streams' +import { db } from '@/db/knex' +import { legacyCreateStreamFactory, createStreamReturnRecordFactory -} = require('@/modules/core/services/streams/management') -const { - inviteUsersToProjectFactory -} = require('@/modules/serverinvites/services/projectInviteManagement') -const { - createAndSendInviteFactory -} = require('@/modules/serverinvites/services/creation') -const { +} from '@/modules/core/services/streams/management' +import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/projectInviteManagement' +import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' +import { findUserByTargetFactory, insertInviteAndDeleteOldFactory, deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} = require('@/modules/serverinvites/repositories/serverInvites') -const { - collectAndValidateCoreTargetsFactory -} = require('@/modules/serverinvites/services/coreResourceCollection') -const { - buildCoreInviteEmailContentsFactory -} = require('@/modules/serverinvites/services/coreEmailContents') -const { getEventBus } = require('@/modules/shared/services/eventBus') -const { createBranchFactory } = require('@/modules/core/repositories/branches') -const { + updateAllInviteTargetsFactory, + findInviteFactory, + deleteInvitesByTargetFactory +} from '@/modules/serverinvites/repositories/serverInvites' +import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' +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 -} = require('@/modules/core/repositories/users') -const { +} from '@/modules/core/repositories/users' +import { findEmailFactory, createUserEmailFactory, ensureNoPrimaryEmailForUserFactory -} = require('@/modules/core/repositories/userEmails') -const { - requestNewEmailVerificationFactory -} = require('@/modules/emails/services/verification/request') -const { - deleteOldAndInsertNewVerificationFactory -} = require('@/modules/emails/repositories') -const { renderEmail } = require('@/modules/emails/services/emailRendering') -const { sendEmail } = require('@/modules/emails/services/sending') -const { createUserFactory } = require('@/modules/core/services/users/management') -const { - validateAndCreateUserEmailFactory -} = require('@/modules/core/services/userEmails') -const { - finalizeInvitedServerRegistrationFactory -} = require('@/modules/serverinvites/services/processing') -const { getServerInfoFactory } = require('@/modules/core/repositories/server') -const { mockAdminOverride } = require('@/test/mocks/global') +} 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, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' +import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { mockAdminOverride } from '@/test/mocks/global' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { Request } from 'express' + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) const getServerInfo = getServerInfoFactory({ db }) const getUser = getUserFactory({ db }) @@ -89,7 +133,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), @@ -134,7 +179,7 @@ describe('Generic AuthN & AuthZ controller tests', () => { }) it('Validate scopes', async () => { - await validateScopes() + await validateScopes(undefined, undefined as unknown as string) .then(() => { throw new Error('This should have been rejected') }) @@ -156,14 +201,17 @@ describe('Generic AuthN & AuthZ controller tests', () => { await validateScopes(['a', 'b'], 'b') // should pass }) - ;[ - ['BS header', { req: { headers: { authorization: 'Bearer BS' } } }], - ['Null header', { req: { headers: { authorization: null } } }], - ['Undefined header', { req: { headers: { authorization: undefined } } }], + ;([ + ['BS header', { req: { headers: { authorization: 'Bearer BS' } } as Request }], + [ + 'Null header', + { req: { headers: { authorization: null as string | null } } as Request } + ], + ['Undefined header', { req: { headers: { authorization: undefined } } as Request }], ['BS token', { token: 'Bearer BS' }], ['Null token', { token: null }], ['Undefined token', { token: undefined }] - ].map(([caseName, contextInput]) => + ]).map(([caseName, contextInput]) => it(`Should create proper context ${caseName}`, async () => { const res = await buildContext(contextInput) expect(res.auth).to.equal(false) @@ -182,7 +230,10 @@ describe('Generic AuthN & AuthZ controller tests', () => { expect('You do not have the required server role').to.equal(err.message) ) - await throwForNotHavingServerRole({ auth: true, role: 'HACZOR' }, '133TCR3w') + await throwForNotHavingServerRole( + { auth: true, role: 'HACZOR' as ServerRoles }, + '133TCR3w' as ServerRoles + ) .then(() => { throw new Error('This should have been rejected') }) @@ -192,7 +243,7 @@ describe('Generic AuthN & AuthZ controller tests', () => { await throwForNotHavingServerRole( { auth: true, role: Roles.Server.Admin }, - '133TCR3w' + '133TCR3w' as ServerRoles ) .then(() => { throw new Error('This should have been rejected') @@ -209,14 +260,19 @@ describe('Generic AuthN & AuthZ controller tests', () => { }) it('Resolver Authorization Should fail nicely when roles & resources are wanky', async () => { - await authorizeResolver(null, 'foo', 'bar') + await authorizeResolver(null, 'foo', 'bar' as AvailableRoles, null) .then(() => { throw new Error('This should have been rejected') }) .catch((err) => expect('Unknown role: bar').to.equal(err.message)) // this caught me out, but streams:read is not a valid role for now - await authorizeResolver('foo', 'bar', Scopes.Streams.Read) + await authorizeResolver( + 'foo', + 'bar' as AvailableRoles, + Scopes.Streams.Read as AvailableRoles, + null + ) .then(() => { throw new Error('This should have been rejected') }) @@ -226,21 +282,25 @@ describe('Generic AuthN & AuthZ controller tests', () => { describe('Authorize resolver ', () => { const myStream = { name: 'My Stream 2', - isPublic: true + isPublic: true, + id: '' } const notMyStream = { name: 'Not My Stream 1', - isPublic: false + isPublic: false, + id: '' } const serverOwner = { name: 'Itsa Me', email: 'me@example.org', - password: 'sn3aky-1337-b1m' + password: 'sn3aky-1337-b1m', + id: '' } const otherGuy = { name: 'Some Other DUde', email: 'otherguy@example.org', - password: 'sn3aky-1337-b1m' + password: 'sn3aky-1337-b1m', + id: '' } before(async function () { @@ -290,7 +350,7 @@ describe('Generic AuthN & AuthZ controller tests', () => { Roles.Stream.Contributor, null ) - throw 'This should have thrown' + throw new Error('This should have thrown') } catch (e) { expect(e instanceof ForbiddenError) } @@ -315,7 +375,7 @@ describe('Generic AuthN & AuthZ controller tests', () => { Roles.Stream.Contributor, null ) - throw 'This should have thrown' + throw new Error('This should have thrown') } catch (e) { expect(e instanceof ForbiddenError) } @@ -331,7 +391,7 @@ describe('Generic AuthN & AuthZ controller tests', () => { Roles.Stream.Contributor, null ) - throw 'This should have thrown' + throw new Error('This should have thrown') } catch (e) { expect(e instanceof ForbiddenError) } diff --git a/packages/server/modules/core/tests/objects.spec.js b/packages/server/modules/core/tests/objects.spec.ts similarity index 70% rename from packages/server/modules/core/tests/objects.spec.js rename to packages/server/modules/core/tests/objects.spec.ts index 02546a52b..2cb8e55ba 100644 --- a/packages/server/modules/core/tests/objects.spec.js +++ b/packages/server/modules/core/tests/objects.spec.ts @@ -1,75 +1,65 @@ /* istanbul ignore file */ /* eslint-disable camelcase */ -const expect = require('chai').expect -const assert = require('assert') -const { cloneDeep, times, random, padStart } = require('lodash') +import { expect } from 'chai' +import assert from 'assert' +import { cloneDeep, times, random, padStart } from 'lodash' -const { beforeEachContext } = require('@/test/hooks') -const { getAnIdForThisOnePlease } = require('@/test/helpers') +import { beforeEachContext } from '@/test/hooks' +import { getAnIdForThisOnePlease } from '@/test/helpers' -const { +import { getStreamFactory, - createStreamFactory -} = require('@/modules/core/repositories/streams') -const { db } = require('@/db/knex') -const { + createStreamFactory, + grantStreamPermissionsFactory +} from '@/modules/core/repositories/streams' +import { db } from '@/db/knex' +import { legacyCreateStreamFactory, createStreamReturnRecordFactory -} = require('@/modules/core/services/streams/management') -const { - inviteUsersToProjectFactory -} = require('@/modules/serverinvites/services/projectInviteManagement') -const { - createAndSendInviteFactory -} = require('@/modules/serverinvites/services/creation') -const { +} from '@/modules/core/services/streams/management' +import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/projectInviteManagement' +import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' +import { findUserByTargetFactory, insertInviteAndDeleteOldFactory, deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} = require('@/modules/serverinvites/repositories/serverInvites') -const { - collectAndValidateCoreTargetsFactory -} = require('@/modules/serverinvites/services/coreResourceCollection') -const { - buildCoreInviteEmailContentsFactory -} = require('@/modules/serverinvites/services/coreEmailContents') -const { getEventBus } = require('@/modules/shared/services/eventBus') -const { createBranchFactory } = require('@/modules/core/repositories/branches') -const { + updateAllInviteTargetsFactory, + findInviteFactory, + deleteInvitesByTargetFactory +} from '@/modules/serverinvites/repositories/serverInvites' +import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' +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 -} = require('@/modules/core/repositories/users') -const { +} from '@/modules/core/repositories/users' +import { findEmailFactory, createUserEmailFactory, ensureNoPrimaryEmailForUserFactory -} = require('@/modules/core/repositories/userEmails') -const { - requestNewEmailVerificationFactory -} = require('@/modules/emails/services/verification/request') -const { - deleteOldAndInsertNewVerificationFactory -} = require('@/modules/emails/repositories') -const { renderEmail } = require('@/modules/emails/services/emailRendering') -const { sendEmail } = require('@/modules/emails/services/sending') -const { createUserFactory } = require('@/modules/core/services/users/management') -const { - validateAndCreateUserEmailFactory -} = require('@/modules/core/services/userEmails') -const { - finalizeInvitedServerRegistrationFactory -} = require('@/modules/serverinvites/services/processing') -const { getServerInfoFactory } = require('@/modules/core/repositories/server') -const { +} 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, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' +import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { createObjectFactory, createObjectsBatchedAndNoClosuresFactory, createObjectsFactory -} = require('@/modules/core/services/objects/management') -const { +} from '@/modules/core/services/objects/management' +import { storeSingleObjectIfNotFoundFactory, storeObjectsIfNotFoundFactory, getFormattedObjectFactory, @@ -77,7 +67,17 @@ const { getObjectChildrenFactory, getObjectChildrenQueryFactory, getStreamObjectsFactory -} = require('@/modules/core/repositories/objects') +} from '@/modules/core/repositories/objects' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' +import { ObjectRecord } from '@/modules/core/helpers/types' const sampleCommit = JSON.parse(`{ "Objects": [ @@ -106,6 +106,52 @@ const getServerInfo = getServerInfoFactory({ db }) const getUser = getUserFactory({ db }) const getUsers = getUsersFactory({ db }) const getStream = getStreamFactory({ db }) + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -124,7 +170,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), @@ -180,12 +227,14 @@ describe('Objects @core-objects', () => { const userOne = { name: 'Dimitrie Stefanescu', email: 'didimitrie43@example.org', - password: 'sn3aky-1337-b1m' + password: 'sn3aky-1337-b1m', + id: '' } const stream = { name: 'Test Streams', - description: 'Whatever goes in here usually...' + description: 'Whatever goes in here usually...', + id: '' } before(async () => { @@ -202,8 +251,8 @@ describe('Objects @core-objects', () => { const objCount_1 = 10 const objCount_2 = 1000 - const objs = [] - const objs2 = [] + const objs: Array & { id?: string }> = [] + const objs2: Array & { id?: string }> = [] it(`Should create ${objCount_1} objects`, async () => { for (let i = 0; i < objCount_1; i++) { @@ -258,7 +307,7 @@ describe('Objects @core-objects', () => { ]).reduce((obj, [key, value]) => { obj[key] = value return obj - }, {}) + }, {} as Record) } const id = await createObject({ streamId: stream.id, object: obj }) expect(id).to.be.ok @@ -272,20 +321,20 @@ describe('Objects @core-objects', () => { it('Should get more objects', async () => { const myObjs = await getObjects( stream.id, - objs.map((o) => o.id) + objs.map((o) => o.id!) ) expect(myObjs).to.have.lengthOf(objs.length) const match1 = myObjs.find((o) => o.id === objs[0].id) expect(match1).to.not.be.null - expect(match1.id).to.equal(objs[0].id) + expect(match1!.id).to.equal(objs[0].id) const match2 = myObjs.find((o) => o.id === objs[2].id) expect(match2).to.not.be.null - expect(match2.id).to.equal(objs[2].id) + expect(match2!.id).to.equal(objs[2].id) }) - let parentObjectId + let parentObjectId: string it('Should get object children', async () => { const objs_1 = createManyObjects(100, 'noise__') @@ -368,7 +417,7 @@ describe('Objects @core-objects', () => { { field: 'test.value', operator: '<', value: 24 }, { verb: 'OR', field: 'test.value', operator: '=', value: 42 } ], - orderBy: { field: 'test.value', direction: 'asc' } + orderBy: { field: 'test.value' as keyof ObjectRecord, direction: 'asc' } }) const test2 = await getObjectChildrenQuery({ @@ -381,7 +430,7 @@ describe('Objects @core-objects', () => { { field: 'test.value', operator: '<', value: 24 }, { verb: 'OR', field: 'test.value', operator: '=', value: 42 } ], - orderBy: { field: 'test.value', direction: 'asc' }, + orderBy: { field: 'test.value' as keyof ObjectRecord, direction: 'asc' }, cursor: test.cursor }) @@ -403,14 +452,19 @@ describe('Objects @core-objects', () => { expect(test.totalCount).to.equal(23) expect(test2.totalCount).to.equal(23) - expect(test.objects[0].data.test.value).to.be.below(test.objects[1].data.test.value) - expect(test2.objects[0].data.test.value).to.be.below( - test2.objects[1].data.test.value - ) + const testObjects = test.objects as unknown as Array<{ + data: { test: { value: number } } + }> + const test2Objects = test2.objects as unknown as Array<{ + data: { test: { value: number } } + }> + + expect(testObjects[0].data.test.value).to.be.below(testObjects[1].data.test.value) + expect(test2Objects[0].data.test.value).to.be.below(test2Objects[1].data.test.value) // continuity - expect(test.objects[test.objects.length - 1].data.test.value + 1).to.equal( - test2.objects[0].data.test.value + expect(testObjects[testObjects.length - 1].data.test.value + 1).to.equal( + test2Objects[0].data.test.value ) }) @@ -424,7 +478,7 @@ describe('Objects @core-objects', () => { { field: 'similar', operator: '>=', value: 0 }, { field: 'similar', operator: '<', value: 100 } ], - orderBy: { field: 'similar', direction: 'asc' }, + orderBy: { field: 'similar' as keyof ObjectRecord, direction: 'asc' }, limit: 5 }) @@ -436,7 +490,7 @@ describe('Objects @core-objects', () => { { field: 'similar', operator: '>=', value: 0 }, { field: 'similar', operator: '<', value: 100 } ], - orderBy: { field: 'similar', direction: 'asc' }, + orderBy: { field: 'similar' as keyof ObjectRecord, direction: 'asc' }, cursor: test3.cursor, limit: 5 }) @@ -453,17 +507,24 @@ describe('Objects @core-objects', () => { expect(test3.totalCount).to.equal(100) expect(test4.totalCount).to.equal(100) - expect(test3.objects[0].data.similar).to.be.below(test3.objects[1].data.similar) // 0, 1, 1, 1, ... - expect(test4.objects[0].data.similar).to.be.below(test4.objects[3].data.similar) + const test3Objects = test3.objects as unknown as Array<{ + data: { similar: number } + }> + const test4Objects = test4.objects as unknown as Array<{ + data: { similar: number } + }> + + expect(test3Objects[0].data.similar).to.be.below(test3Objects[1].data.similar) // 0, 1, 1, 1, ... + expect(test4Objects[0].data.similar).to.be.below(test4Objects[3].data.similar) // continuity (in reverse) - expect(test3.objects[test3.objects.length - 1].data.similar).to.equal( - test3.objects[test3.objects.length - 2].data.similar + 1 + expect(test3Objects[test3Objects.length - 1].data.similar).to.equal( + test3Objects[test3Objects.length - 2].data.similar + 1 ) - expect(test3.objects[test3.objects.length - 1].data.similar).to.equal( - test4.objects[0].data.similar + expect(test3Objects[test3Objects.length - 1].data.similar).to.equal( + test4Objects[0].data.similar ) - expect(test4.objects[1].data.similar).to.equal(test4.objects[2].data.similar - 1) + expect(test4Objects[1].data.similar).to.equal(test4Objects[2].data.similar - 1) }) it('should query object children with no results ', async () => { @@ -474,7 +535,7 @@ describe('Objects @core-objects', () => { { field: 'test.value', operator: '>=', value: 10 }, { field: 'test.value', operator: '<', value: 9 } ], - orderBy: { field: 'test.value', direction: 'desc' } + orderBy: { field: 'test.value' as keyof ObjectRecord, direction: 'desc' } }) expect(test.totalCount).to.equal(0) @@ -494,7 +555,7 @@ describe('Objects @core-objects', () => { }, { field: 'test.value', operator: '<', value: 9 } ], - orderBy: { field: 'test.value', direction: 'desc' } + orderBy: { field: 'test.value' as keyof ObjectRecord, direction: 'desc' } }) assert.fail('sql injections are bad for health') } catch { @@ -509,7 +570,7 @@ describe('Objects @core-objects', () => { limit: 5, select: ['test.value', 'nest.duck'], query: [{ field: 'test.value', operator: '<', value: 10 }], - orderBy: { field: 'nest.duck', direction: 'desc' } + orderBy: { field: 'nest.duck' as keyof ObjectRecord, direction: 'desc' } }) const test2 = await getObjectChildrenQuery({ @@ -518,12 +579,18 @@ describe('Objects @core-objects', () => { limit: 5, select: ['test.value', 'nest.duck'], query: [{ field: 'test.value', operator: '<', value: 10 }], - orderBy: { field: 'nest.duck', direction: 'desc' }, + orderBy: { field: 'nest.duck' as keyof ObjectRecord, direction: 'desc' }, cursor: test.cursor }) - expect(test.objects[0].data.nest.duck).to.equal(true) - expect(test2.objects[test2.objects.length - 1].data.nest.duck).to.equal(false) // last duck should be false + const testObjects = test.objects as unknown as Array<{ + data: { test: { value: number }; nest: { duck: boolean } } + }> + const test2Objects = test2.objects as unknown as Array<{ + data: { test: { value: number }; nest: { duck: boolean } } + }> + expect(testObjects[0].data.nest.duck).to.equal(true) + expect(test2Objects[test2Objects.length - 1].data.nest.duck).to.equal(false) // last duck should be false }) it('should query children and sort them by a string value ', async () => { @@ -534,7 +601,7 @@ describe('Objects @core-objects', () => { objectId: parentObjectId, limit: 5, query: [{ field: 'test.value', operator: '<', value: limVal }], - orderBy: { field: 'name', direction: 'asc' } + orderBy: { field: 'name' as keyof ObjectRecord, direction: 'asc' } }) const test2 = await getObjectChildrenQuery({ @@ -542,18 +609,25 @@ describe('Objects @core-objects', () => { objectId: parentObjectId, limit: 5, query: [{ field: 'test.value', operator: '<', value: limVal }], - orderBy: { field: 'name', direction: 'asc' }, + orderBy: { field: 'name' as keyof ObjectRecord, direction: 'asc' }, cursor: test.cursor }) expect(test.objects.length).to.equal(5) expect(test.cursor).to.be.a('string') - expect(test.objects[0].data.name).to.equal('mr. 0') - expect(test.objects[1].data.name).to.equal('mr. 1') - expect(test.objects[2].data.name).to.equal('mr. 10') // remember kids, this is a lexicographical sort - expect(test.objects[4].data.name).to.equal('mr. 12') - expect(test2.objects[0].data.name).to.equal('mr. 13') + const testObjects = test.objects as unknown as Array<{ + data: { name: string; test: { value: number } } + }> + const test2Objects = test2.objects as unknown as Array<{ + data: { name: string; test: { value: number } } + }> + + expect(testObjects[0].data.name).to.equal('mr. 0') + expect(testObjects[1].data.name).to.equal('mr. 1') + expect(testObjects[2].data.name).to.equal('mr. 10') // remember kids, this is a lexicographical sort + expect(testObjects[4].data.name).to.equal('mr. 12') + expect(test2Objects[0].data.name).to.equal('mr. 13') }) it('should query children and sort them by id by default ', async () => { @@ -588,41 +662,53 @@ describe('Objects @core-objects', () => { streamId: stream.id, objectId: parentObjectId, limit: 2, - orderBy: { field: 'test.value', direction: 'desc' } + orderBy: { field: 'test.value' as keyof ObjectRecord, direction: 'desc' } }) const test2 = await getObjectChildrenQuery({ streamId: stream.id, objectId: parentObjectId, limit: 2, - orderBy: { field: 'test.value', direction: 'desc' }, + orderBy: { field: 'test.value' as keyof ObjectRecord, direction: 'desc' }, cursor: test.cursor }) - expect(test.objects[1].data.test.value).to.equal( - test2.objects[0].data.test.value + 1 - ) // continuity check + const testObjects = test.objects as unknown as Array<{ + data: { test: { value: number } } + }> + const test2Objects = test2.objects as unknown as Array<{ + data: { test: { value: number } } + }> + + expect(testObjects[1].data.test.value).to.equal(test2Objects[0].data.test.value + 1) // continuity check const test3 = await getObjectChildrenQuery({ streamId: stream.id, objectId: parentObjectId, limit: 50, - orderBy: { field: 'nest.duck', direction: 'desc' } + orderBy: { field: 'nest.duck' as keyof ObjectRecord, direction: 'desc' } }) const test4 = await getObjectChildrenQuery({ streamId: stream.id, objectId: parentObjectId, limit: 50, - orderBy: { field: 'nest.duck', direction: 'desc' }, + orderBy: { field: 'nest.duck' as keyof ObjectRecord, direction: 'desc' }, cursor: test3.cursor }) - expect(test3.objects[49].data.nest.duck).to.equal(true) - expect(test4.objects[0].data.nest.duck).to.equal(false) + const test3Objects = test3.objects as unknown as Array<{ + data: { nest: { duck: boolean } } + }> + const test4Objects = test4.objects as unknown as Array<{ + data: { nest: { duck: boolean } } + }> + + expect(test3Objects[49].data.nest.duck).to.equal(true) + expect(test4Objects[0].data.nest.duck).to.equal(false) }) - let commitId + let commitId: string it('should batch create objects', async () => { const objs = createManyObjects(3333, 'perlin merlin magic') commitId = objs[0].id @@ -641,6 +727,7 @@ describe('Objects @core-objects', () => { it('should stream objects back', (done) => { let tcount = 0 + // eslint-disable-next-line @typescript-eslint/no-floating-promises getObjectChildrenStream({ streamId: stream.id, objectId: commitId }).then( (stream) => { stream.on('data', () => tcount++) @@ -656,7 +743,7 @@ describe('Objects @core-objects', () => { this.timeout(5000) const objs = createManyObjects(5000, 'perlin merlin magic') - function shuffleArray(array) { + function shuffleArray(array: Array) { for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)) ;[array[i], array[j]] = [array[j], array[i]] @@ -686,13 +773,18 @@ describe('Objects @core-objects', () => { }) }) -function createManyObjects(num, noise) { +function createManyObjects(num: number, noise: string | number) { num = num || 10000 noise = noise || Math.random() * 100 const objs = [] - const base = { name: 'base bastard 2', noise, __closure: {} } + const base = { + name: 'base bastard 2', + noise, + __closure: {} as Record, + id: '' + } objs.push(base) let k = 0 @@ -706,7 +798,8 @@ function createManyObjects(num, noise) { objArr: [{ a: i }, { b: i * i }, { c: true }], noise, sortValueA: i, - sortValueB: i * 0.42 * i + sortValueB: i * 0.42 * i, + id: '' } if (i % 3 === 0) k++ diff --git a/packages/server/modules/core/tests/rest.spec.js b/packages/server/modules/core/tests/rest.spec.ts similarity index 71% rename from packages/server/modules/core/tests/rest.spec.js rename to packages/server/modules/core/tests/rest.spec.ts index 051540bd4..f427bd523 100644 --- a/packages/server/modules/core/tests/rest.spec.js +++ b/packages/server/modules/core/tests/rest.spec.ts @@ -1,84 +1,130 @@ /* istanbul ignore file */ -const expect = require('chai').expect -const request = require('supertest') +import { expect } from 'chai' +import request from 'supertest' -const assert = require('assert') -const crypto = require('crypto') +import assert from 'assert' +import crypto from 'crypto' -const { beforeEachContext } = require('@/test/hooks') -const { createManyObjects } = require('@/test/helpers') +import { beforeEachContext } from '@/test/hooks' +import { createManyObjects } from '@/test/helpers' -const { Scopes } = require('@speckle/shared') -const { +import { Scopes } from '@speckle/shared' +import { getStreamFactory, - createStreamFactory -} = require('@/modules/core/repositories/streams') -const { db } = require('@/db/knex') -const { + createStreamFactory, + grantStreamPermissionsFactory +} from '@/modules/core/repositories/streams' +import { db } from '@/db/knex' +import { legacyCreateStreamFactory, createStreamReturnRecordFactory -} = require('@/modules/core/services/streams/management') -const { - inviteUsersToProjectFactory -} = require('@/modules/serverinvites/services/projectInviteManagement') -const { - createAndSendInviteFactory -} = require('@/modules/serverinvites/services/creation') -const { +} from '@/modules/core/services/streams/management' +import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/projectInviteManagement' +import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' +import { findUserByTargetFactory, insertInviteAndDeleteOldFactory, deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} = require('@/modules/serverinvites/repositories/serverInvites') -const { - collectAndValidateCoreTargetsFactory -} = require('@/modules/serverinvites/services/coreResourceCollection') -const { - buildCoreInviteEmailContentsFactory -} = require('@/modules/serverinvites/services/coreEmailContents') -const { getEventBus } = require('@/modules/shared/services/eventBus') -const { createBranchFactory } = require('@/modules/core/repositories/branches') -const { + updateAllInviteTargetsFactory, + findInviteFactory, + deleteInvitesByTargetFactory +} from '@/modules/serverinvites/repositories/serverInvites' +import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' +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 -} = require('@/modules/core/repositories/users') -const { +} from '@/modules/core/repositories/users' +import { findEmailFactory, createUserEmailFactory, ensureNoPrimaryEmailForUserFactory -} = require('@/modules/core/repositories/userEmails') -const { - requestNewEmailVerificationFactory -} = require('@/modules/emails/services/verification/request') -const { - deleteOldAndInsertNewVerificationFactory -} = require('@/modules/emails/repositories') -const { renderEmail } = require('@/modules/emails/services/emailRendering') -const { sendEmail } = require('@/modules/emails/services/sending') -const { createUserFactory } = require('@/modules/core/services/users/management') -const { - validateAndCreateUserEmailFactory -} = require('@/modules/core/services/userEmails') -const { - finalizeInvitedServerRegistrationFactory -} = require('@/modules/serverinvites/services/processing') -const { createPersonalAccessTokenFactory } = require('@/modules/core/services/tokens') -const { +} 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, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' +import { createPersonalAccessTokenFactory } from '@/modules/core/services/tokens' +import { storeTokenScopesFactory, storeApiTokenFactory, storeTokenResourceAccessDefinitionsFactory, storePersonalApiTokenFactory -} = require('@/modules/core/repositories/tokens') -const { getServerInfoFactory } = require('@/modules/core/repositories/server') -const cryptoRandomString = require('crypto-random-string') +} from '@/modules/core/repositories/tokens' +import { getServerInfoFactory } from '@/modules/core/repositories/server' +import cryptoRandomString from 'crypto-random-string' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' +import type Express from 'express' const getServerInfo = getServerInfoFactory({ db }) const getUser = getUserFactory({ db }) const getUsers = getUsersFactory({ db }) const getStream = getStreamFactory({ db }) + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -97,7 +143,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), @@ -147,22 +194,33 @@ describe('Upload/Download Routes @api-rest', () => { const userA = { name: 'd1', email: 'd.1@speckle.systems', - password: 'wowwow8charsplease' + password: 'wowwow8charsplease', + id: '', + token: '' } const userB = { name: 'd2', email: 'd.2@speckle.systems', - password: 'wowwow8charsplease' + password: 'wowwow8charsplease', + id: '', + token: '' } const testStream = { name: 'Test Stream 01', - description: 'wonderful test stream' + description: 'wonderful test stream', + id: '', + ownerId: '' } - const privateTestStream = { name: 'Private Test Stream', isPublic: false } + const privateTestStream = { + name: 'Private Test Stream', + isPublic: false, + id: '', + ownerId: '' + } - let app + let app: Express.Express before(async () => { ;({ app } = await beforeEachContext()) @@ -277,7 +335,12 @@ describe('Upload/Download Routes @api-rest', () => { .post(`/objects/${testStream.id}`) .set('Authorization', userA.token) .set('Content-type', 'application/json') - .attach(Buffer.from(JSON.stringify(objBatches[0]), 'utf8')) + .attach( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Buffer.from(JSON.stringify(objBatches[0]), 'utf8') as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + undefined as any + ) expect(res).to.have.status(400) expect(res.text).to.equal( 'Failed to parse request headers and body content as valid multipart/form-data.' @@ -289,7 +352,8 @@ describe('Upload/Download Routes @api-rest', () => { .post(`/objects/${testStream.id}`) .set('Authorization', userA.token) .set('Content-type', 'multipart/form-data') - .attach(JSON.stringify(objBatches[0], 'utf8')) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .attach(JSON.stringify(objBatches[0]) as any, undefined as any) expect(res).to.have.status(400) expect(res.text).to.equal( 'Failed to parse request headers and body content as valid multipart/form-data.' @@ -341,7 +405,8 @@ describe('Upload/Download Routes @api-rest', () => { it('Should not allow upload with invalid body (not contained within array)', async () => { //creating a single valid object const objectToPost = { - name: 'yet again cannot believe i have to create this' + name: 'yet again cannot believe i have to create this', + id: '' } const objectId = crypto .createHash('md5') @@ -384,7 +449,7 @@ describe('Upload/Download Routes @api-rest', () => { // expect(res.text).contains('Object too large') // }) - let parentId + let parentId: string const numObjs = 5000 const objBatches = [ createManyObjects(numObjs), @@ -412,19 +477,22 @@ describe('Upload/Download Routes @api-rest', () => { }) it('Should properly download an object, with all its children, into a application/json response', (done) => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises new Promise((resolve) => setTimeout(resolve, 1500)) // avoids race condition .then(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises request(app) .get(`/objects/${testStream.id}/${parentId}`) .set('Authorization', userA.token) .buffer() .parse((res, cb) => { - res.data = '' - res.on('data', (chunk) => { - res.data += chunk.toString() + const resTyped = res as typeof res & { data: string } + resTyped.data = '' + resTyped.on('data', (chunk) => { + resTyped.data += chunk.toString() }) - res.on('end', () => { - cb(null, res.data) + resTyped.on('end', () => { + cb(null, resTyped.data) }) }) .end((err, res) => { @@ -442,24 +510,27 @@ describe('Upload/Download Routes @api-rest', () => { }) it('Should properly download an object, with all its children, into a text/plain response', (done) => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises request(app) .get(`/objects/${testStream.id}/${parentId}`) .set('Authorization', userA.token) .set('Accept', 'text/plain') .buffer() .parse((res, cb) => { - res.data = '' - res.on('data', (chunk) => { - res.data += chunk.toString() + const resTyped = res as typeof res & { data: string } + + resTyped.data = '' + resTyped.on('data', (chunk) => { + resTyped.data += chunk.toString() }) - res.on('end', () => { - cb(null, res.data) + resTyped.on('end', () => { + cb(null, resTyped.data) }) }) .end((err, res) => { if (err) done(err) try { - const o = res.body.split('\n').filter((l) => l !== '') + const o = res.body.split('\n').filter((l: string) => l !== '') expect(o.length).to.equal(numObjs + 1) expect(res).to.be.text done() @@ -474,6 +545,7 @@ describe('Upload/Download Routes @api-rest', () => { for (let i = 0; i < objBatches[0].length; i++) { objectIds.push(objBatches[0][i].id) } + // eslint-disable-next-line @typescript-eslint/no-floating-promises request(app) .post(`/api/getobjects/${testStream.id}`) .set('Authorization', userA.token) @@ -481,18 +553,20 @@ describe('Upload/Download Routes @api-rest', () => { .send({ objects: JSON.stringify(objectIds) }) .buffer() .parse((res, cb) => { - res.data = '' - res.on('data', (chunk) => { - res.data += chunk.toString() + const resTyped = res as typeof res & { data: string } + + resTyped.data = '' + resTyped.on('data', (chunk) => { + resTyped.data += chunk.toString() }) - res.on('end', () => { - cb(null, res.data) + resTyped.on('end', () => { + cb(null, resTyped.data) }) }) .end((err, res) => { if (err) done(err) try { - const o = res.body.split('\n').filter((l) => l !== '') + const o = res.body.split('\n').filter((l: string) => l !== '') expect(o.length).to.equal(objectIds.length) expect(res).to.be.text done() @@ -530,7 +604,7 @@ describe('Upload/Download Routes @api-rest', () => { for (let i = 0; i < objBatches[0].length; i++) { objectIds.push(objBatches[0][i].id) } - const fakeIds = [] + const fakeIds: string[] = [] for (let i = 0; i < 100; i++) { const fakeId = crypto .createHash('md5') @@ -540,18 +614,21 @@ describe('Upload/Download Routes @api-rest', () => { objectIds.push(fakeId) } + // eslint-disable-next-line @typescript-eslint/no-floating-promises request(app) .post(`/api/diff/${testStream.id}`) .set('Authorization', userA.token) .send({ objects: JSON.stringify(objectIds) }) .buffer() .parse((res, cb) => { - res.data = '' - res.on('data', (chunk) => { - res.data += chunk.toString() + const resTyped = res as typeof res & { data: string } + + resTyped.data = '' + resTyped.on('data', (chunk) => { + resTyped.data += chunk.toString() }) - res.on('end', () => { - cb(null, res.data) + resTyped.on('end', () => { + cb(null, resTyped.data) }) }) .end((err, res) => { @@ -590,7 +667,7 @@ describe('Upload/Download Routes @api-rest', () => { }) describe('Express @core-rest', () => { - let app + let app: Express.Express before(async () => { ;({ app } = await beforeEachContext()) }) diff --git a/packages/server/modules/core/tests/streams.spec.ts b/packages/server/modules/core/tests/streams.spec.ts index 2faa2bd59..b482495fb 100644 --- a/packages/server/modules/core/tests/streams.spec.ts +++ b/packages/server/modules/core/tests/streams.spec.ts @@ -73,8 +73,12 @@ import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/pr import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' import { deleteAllResourceInvitesFactory, + deleteInvitesByTargetFactory, + deleteServerOnlyInvitesFactory, + findInviteFactory, findUserByTargetFactory, - insertInviteAndDeleteOldFactory + insertInviteAndDeleteOldFactory, + updateAllInviteTargetsFactory } from '@/modules/serverinvites/repositories/serverInvites' import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' @@ -94,6 +98,24 @@ import { import { changeUserRoleFactory } from '@/modules/core/services/users/management' import { getServerInfoFactory } from '@/modules/core/repositories/server' import { createObjectFactory } from '@/modules/core/services/objects/management' +import { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + createUserEmailFactory, + ensureNoPrimaryEmailForUserFactory, + findEmailFactory +} from '@/modules/core/repositories/userEmails' +import { validateAndCreateUserEmailFactory } from '@/modules/core/services/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' const getServerInfo = getServerInfoFactory({ db }) const getUser = getUserFactory({ db }) @@ -129,6 +151,51 @@ const createCommitByBranchName = createCommitByBranchNameFactory({ getBranchById: getBranchByIdFactory({ db }) }) +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -147,7 +214,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), diff --git a/packages/server/modules/core/tests/users.spec.ts b/packages/server/modules/core/tests/users.spec.ts index f2395bc8d..22f0e92d5 100644 --- a/packages/server/modules/core/tests/users.spec.ts +++ b/packages/server/modules/core/tests/users.spec.ts @@ -54,7 +54,9 @@ import { insertInviteAndDeleteOldFactory, deleteServerOnlyInvitesFactory, updateAllInviteTargetsFactory, - deleteAllUserInvitesFactory + deleteAllUserInvitesFactory, + findInviteFactory, + deleteInvitesByTargetFactory } from '@/modules/serverinvites/repositories/serverInvites' import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' @@ -95,7 +97,10 @@ import { changeUserRoleFactory } from '@/modules/core/services/users/management' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' +import { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' import { dbLogger } from '@/observability/logging' import { storeApiTokenFactory, @@ -114,6 +119,15 @@ import { getServerInfoFactory } from '@/modules/core/repositories/server' import { getPaginatedBranchCommitsItemsByNameFactory } from '@/modules/core/services/commit/retrieval' import { getPaginatedStreamBranchesFactory } from '@/modules/core/services/branch/retrieval' import { createObjectFactory } from '@/modules/core/services/objects/management' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' const getServerInfo = getServerInfoFactory({ db }) const getUser = legacyGetUserFactory({ db }) @@ -141,6 +155,51 @@ const createCommitByBranchName = createCommitByBranchNameFactory({ getBranchById: getBranchByIdFactory({ db }) }) +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -159,7 +218,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser: getUserFactory({ db }), - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), diff --git a/packages/server/modules/core/tests/usersAdminList.spec.ts b/packages/server/modules/core/tests/usersAdminList.spec.ts index bbcfde3d4..5b7376970 100644 --- a/packages/server/modules/core/tests/usersAdminList.spec.ts +++ b/packages/server/modules/core/tests/usersAdminList.spec.ts @@ -11,7 +11,8 @@ import { wait } from '@speckle/shared' import { createAuthedTestContext, ServerAndContext } from '@/test/graphqlHelper' import { createStreamFactory, - getStreamFactory + getStreamFactory, + grantStreamPermissionsFactory } from '@/modules/core/repositories/streams' import { db } from '@/db/knex' import { @@ -21,7 +22,9 @@ import { import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/projectInviteManagement' import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' import { + deleteInvitesByTargetFactory, deleteServerOnlyInvitesFactory, + findInviteFactory, findUserByTargetFactory, insertInviteAndDeleteOldFactory, updateAllInviteTargetsFactory @@ -48,8 +51,20 @@ 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 } from '@/modules/serverinvites/services/processing' +import { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' // To ensure that the invites are created in the correct order, we need to wait a bit between each creation const WAIT_TIMEOUT = 5 @@ -58,6 +73,52 @@ const getServerInfo = getServerInfoFactory({ db }) const getUser = getUserFactory({ db }) const getUsers = getUsersFactory({ db }) const getStream = getStreamFactory({ db }) + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -76,7 +137,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), @@ -264,7 +326,10 @@ describe('[Admin users list]', () => { userId }, ownerId - ) + ).then((invite) => ({ + inviteId: invite.id, + token: invite.token + })) ) } diff --git a/packages/server/modules/fileuploads/tests/fileuploads.integration.spec.ts b/packages/server/modules/fileuploads/tests/fileuploads.integration.spec.ts index 81b4338e1..bbe020c3d 100644 --- a/packages/server/modules/fileuploads/tests/fileuploads.integration.spec.ts +++ b/packages/server/modules/fileuploads/tests/fileuploads.integration.spec.ts @@ -11,7 +11,8 @@ import cryptoRandomString from 'crypto-random-string' import { noErrors } from '@/test/helpers' import { createStreamFactory, - getStreamFactory + getStreamFactory, + grantStreamPermissionsFactory } from '@/modules/core/repositories/streams' import { db } from '@/db/knex' import { @@ -21,7 +22,9 @@ import { import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/projectInviteManagement' import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' import { + deleteInvitesByTargetFactory, deleteServerOnlyInvitesFactory, + findInviteFactory, findUserByTargetFactory, insertInviteAndDeleteOldFactory, updateAllInviteTargetsFactory @@ -47,7 +50,10 @@ import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repos import { renderEmail } from '@/modules/emails/services/emailRendering' import { createUserFactory } from '@/modules/core/services/users/management' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' +import { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' import { sendEmail } from '@/modules/emails/services/sending' import { createTokenFactory } from '@/modules/core/services/tokens' import { @@ -57,11 +63,66 @@ import { } from '@/modules/core/repositories/tokens' import { getServerInfoFactory } from '@/modules/core/repositories/server' import { TIME_MS } from '@speckle/shared' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' const getServerInfo = getServerInfoFactory({ db }) const getUser = getUserFactory({ db }) const getUsers = getUsersFactory({ db }) const getStream = getStreamFactory({ db }) + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -80,7 +141,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), diff --git a/packages/server/modules/fileuploads/tests/fileuploads.spec.ts b/packages/server/modules/fileuploads/tests/fileuploads.spec.ts index 45257347a..da4d3ee28 100644 --- a/packages/server/modules/fileuploads/tests/fileuploads.spec.ts +++ b/packages/server/modules/fileuploads/tests/fileuploads.spec.ts @@ -1,7 +1,8 @@ import cryptoRandomString from 'crypto-random-string' import { createStreamFactory, - getStreamFactory + getStreamFactory, + grantStreamPermissionsFactory } from '@/modules/core/repositories/streams' import { db } from '@/db/knex' import { @@ -11,7 +12,9 @@ import { import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/projectInviteManagement' import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' import { + deleteInvitesByTargetFactory, deleteServerOnlyInvitesFactory, + findInviteFactory, findUserByTargetFactory, insertInviteAndDeleteOldFactory, updateAllInviteTargetsFactory @@ -40,7 +43,10 @@ import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repos import { renderEmail } from '@/modules/emails/services/emailRendering' import { createUserFactory } from '@/modules/core/services/users/management' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' +import { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' import { sendEmail } from '@/modules/emails/services/sending' import { getServerInfoFactory } from '@/modules/core/repositories/server' import { manageFileImportExpiryFactory } from '@/modules/fileuploads/services/tasks' @@ -59,11 +65,66 @@ import { sleep } from '@/test/helpers' import { expect } from 'chai' import { FileUploadConvertedStatus } from '@/modules/fileuploads/helpers/types' import { TIME } from '@speckle/shared' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' const getServerInfo = getServerInfoFactory({ db }) const getUser = getUserFactory({ db }) const getUsers = getUsersFactory({ db }) const getStream = getStreamFactory({ db }) + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -82,7 +143,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), diff --git a/packages/server/modules/serverinvites/domain/events.ts b/packages/server/modules/serverinvites/domain/events.ts index ec1bb1df9..9c54962ad 100644 --- a/packages/server/modules/serverinvites/domain/events.ts +++ b/packages/server/modules/serverinvites/domain/events.ts @@ -18,6 +18,11 @@ export type ServerInvitesEventsPayloads = { invite: ServerInviteRecord finalizerUserId: string accept: boolean + /** + * finalizerUserId will always be the invite target. This field will be the actual person triggering the action, + * which in auto-accept flows will be the initial inviter. Use this for reporting. + */ + trueFinalizerUserId: string } [ServerInvitesEvents.Canceled]: { invite: ServerInviteRecord diff --git a/packages/server/modules/serverinvites/domain/types.ts b/packages/server/modules/serverinvites/domain/types.ts index ba7b0d074..6016cf019 100644 --- a/packages/server/modules/serverinvites/domain/types.ts +++ b/packages/server/modules/serverinvites/domain/types.ts @@ -35,6 +35,12 @@ export type PrimaryInviteResourceTarget< * If invite also has secondary resource targets, you can specify the expected roles here */ secondaryResourceRoles?: Partial + + /** + * Whether the invite should be auto accepted or not. If this is true, no invite is actually created or email sent, + * and the accept process is done automatically without user involvement. + */ + autoAccept?: boolean } export type ServerInviteResourceTarget = InviteResourceTarget< diff --git a/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts b/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts index 4f69ab782..41ab38a66 100644 --- a/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts +++ b/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts @@ -110,6 +110,35 @@ const buildCollectAndValidateResourceTargets = () => getStream }) +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaborator + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification + }), + collectAndValidateResourceTargets: buildCollectAndValidateResourceTargets(), + getUser, + getServerInfo + }) + const buildCreateAndSendServerOrProjectInvite = () => createAndSendInviteFactory({ findUserByTarget: findUserByTargetFactory({ db }), @@ -124,7 +153,8 @@ const buildCreateAndSendServerOrProjectInvite = () => payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }) export = { @@ -375,33 +405,7 @@ export = { streamId: projectId //legacy }) const useProjectInvite = useProjectInviteAndNotifyFactory({ - finalizeInvite: finalizeResourceInviteFactory({ - findInvite: findInviteFactory({ db }), - validateInvite: validateProjectInviteBeforeFinalizationFactory({ - getProject: getStream - }), - processInvite: processFinalizedProjectInviteFactory({ - getProject: getStream, - addProjectRole: addOrUpdateStreamCollaborator - }), - deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), - insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), - emitEvent: (...args) => getEventBus().emit(...args), - findEmail: findEmailFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail: findEmailFactory({ db }), - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - collectAndValidateResourceTargets: buildCollectAndValidateResourceTargets(), - getUser, - getServerInfo - }) + finalizeInvite: buildFinalizeProjectInvite() }) await withOperationLogging( @@ -617,33 +621,7 @@ export = { async use(_parent, args, ctx) { const logger = ctx.log const useProjectInvite = useProjectInviteAndNotifyFactory({ - finalizeInvite: finalizeResourceInviteFactory({ - findInvite: findInviteFactory({ db }), - validateInvite: validateProjectInviteBeforeFinalizationFactory({ - getProject: getStream - }), - processInvite: processFinalizedProjectInviteFactory({ - getProject: getStream, - addProjectRole: addOrUpdateStreamCollaborator - }), - deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), - insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), - emitEvent: (...args) => getEventBus().emit(...args), - findEmail: findEmailFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail: findEmailFactory({ db }), - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - collectAndValidateResourceTargets: buildCollectAndValidateResourceTargets(), - getUser, - getServerInfo - }) + finalizeInvite: buildFinalizeProjectInvite() }) await withOperationLogging( diff --git a/packages/server/modules/serverinvites/helpers/core.ts b/packages/server/modules/serverinvites/helpers/core.ts index 19ee4c9f2..737bd379a 100644 --- a/packages/server/modules/serverinvites/helpers/core.ts +++ b/packages/server/modules/serverinvites/helpers/core.ts @@ -69,6 +69,10 @@ export const isProjectResourceTarget = ( ): target is ProjectInviteResourceTarget => target.resourceType === ProjectInviteResourceType +export const isPrimaryResourceTarget = ( + target: InviteResourceTarget +): target is PrimaryInviteResourceTarget => 'primary' in target && !!target.primary + export interface ResourceTargetTypeRoleTypeMap { [ServerInviteResourceType]: ServerRoles [ProjectInviteResourceType]: StreamRoles diff --git a/packages/server/modules/serverinvites/services/creation.ts b/packages/server/modules/serverinvites/services/creation.ts index b6a809dba..fb8a7b9e4 100644 --- a/packages/server/modules/serverinvites/services/creation.ts +++ b/packages/server/modules/serverinvites/services/creation.ts @@ -19,6 +19,7 @@ import { BuildInviteEmailContents, CollectAndValidateResourceTargets, CreateAndSendInvite, + FinalizeInvite, ResendInviteEmail } from '@/modules/serverinvites/services/operations' import { renderEmail } from '@/modules/emails/services/emailRendering' @@ -91,7 +92,8 @@ export const createAndSendInviteFactory = buildInviteEmailContents, emitEvent, getUser, - getServerInfo + getServerInfo, + finalizeInvite }: { findUserByTarget: FindUserByTarget insertInviteAndDeleteOld: InsertInviteAndDeleteOld @@ -100,6 +102,7 @@ export const createAndSendInviteFactory = emitEvent: EventBusEmit getUser: GetUser getServerInfo: GetServerInfo + finalizeInvite: FinalizeInvite }): CreateAndSendInvite => async (params, inviterResourceAccessLimits?) => { const sendInviteEmail = sendInviteEmailFactory({ buildInviteEmailContents }) @@ -165,6 +168,19 @@ export const createAndSendInviteFactory = targetUser ? [targetUser.email, buildUserTarget(targetUser.id)!] : [] ) + const autoAccept = finalPrimaryResource.autoAccept + if (autoAccept && targetUser?.id) { + await finalizeInvite({ + finalizerUserId: targetUser.id, + finalizerResourceAccessLimits: inviterResourceAccessLimits, + accept: true, + token: invite.token, + resourceType: finalPrimaryResource.resourceType, + trueFinalizerId: inviterId + }) + return + } + // generate and send email await sendInviteEmail({ invite: finalInvite, @@ -180,11 +196,6 @@ export const createAndSendInviteFactory = invite: finalInvite } }) - - return { - inviteId: invite.id, - token: invite.token - } } /** diff --git a/packages/server/modules/serverinvites/services/operations.ts b/packages/server/modules/serverinvites/services/operations.ts index d042d18a4..c539e4cc0 100644 --- a/packages/server/modules/serverinvites/services/operations.ts +++ b/packages/server/modules/serverinvites/services/operations.ts @@ -21,7 +21,7 @@ export type InviteResult = { export type CreateAndSendInvite = ( params: CreateInviteParams, inviterResourceAccessLimits: MaybeNullOrUndefined -) => Promise +) => Promise export type FinalizeInvite = (params: { finalizerUserId: string @@ -35,6 +35,11 @@ export type FinalizeInvite = (params: { * If the invite is accepted, the email will be attached to the user account as well in a verified state. */ allowAttachingNewEmail?: boolean + /** + * Allow someone else besides the target user to finalize the invite. Used in auto-accept flows. The finalizerUserId + * must be the target of the invite, but this different one will be used in reporting/activityStream actions + */ + trueFinalizerId?: string }) => Promise export type ResendInviteEmail = (params: { @@ -80,6 +85,9 @@ export enum InviteFinalizationAction { */ export type ValidateResourceInviteBeforeFinalization = (params: { invite: ServerInviteRecord + /** + * Not necessarily the invite target, can also be the inviter in case of auto-accept + */ finalizerUserId: string finalizerResourceAccessLimits: MaybeNullOrUndefined action: InviteFinalizationAction diff --git a/packages/server/modules/serverinvites/services/processing.ts b/packages/server/modules/serverinvites/services/processing.ts index 6d2887cce..cc669092b 100644 --- a/packages/server/modules/serverinvites/services/processing.ts +++ b/packages/server/modules/serverinvites/services/processing.ts @@ -202,7 +202,8 @@ export const finalizeResourceInviteFactory = token, resourceType, finalizerResourceAccessLimits, - allowAttachingNewEmail + allowAttachingNewEmail, + trueFinalizerId } = params const finalizerUserTarget = buildUserTarget(finalizerUserId) @@ -318,7 +319,8 @@ export const finalizeResourceInviteFactory = payload: { invite, accept, - finalizerUserId + finalizerUserId, + trueFinalizerUserId: trueFinalizerId || finalizerUserId } }) } diff --git a/packages/server/modules/serverinvites/tests/invites.spec.ts b/packages/server/modules/serverinvites/tests/invites.spec.ts index e9fb9ddaf..ea9b2e0a4 100644 --- a/packages/server/modules/serverinvites/tests/invites.spec.ts +++ b/packages/server/modules/serverinvites/tests/invites.spec.ts @@ -490,14 +490,12 @@ describe('[Stream & Server Invites]', () => { }) // Creating some invites - await Promise.all( - invites.map((i) => - createInviteDirectly(i, me.id).then((o) => { - i.inviteId = o.inviteId - i.token = o.token - }) - ) - ) + for (const invite of invites) { + await createInviteDirectly(invite, me.id).then((o) => { + invite.inviteId = o.id + invite.token = o.token + }) + } }) it('they can resend pre-existing invites irregardless of type', async () => { @@ -568,14 +566,12 @@ describe('[Stream & Server Invites]', () => { } ] - await Promise.all( - deletableInvites.map((i) => - createInviteDirectly(i, me.id).then((o) => { - i.inviteId = o.inviteId - i.token = o.token - }) - ) - ) + for (const deletableInvite of deletableInvites) { + await createInviteDirectly(deletableInvite, me.id).then((o) => { + deletableInvite.inviteId = o.id + deletableInvite.token = o.token + }) + } // Delete all invites for (const invite of deletableInvites) { @@ -695,7 +691,7 @@ describe('[Stream & Server Invites]', () => { // Create an invite before each test so that we can mutate them // in each test as needed await createInviteDirectly(inviteFromOtherGuy, otherGuy.id).then((o) => { - inviteFromOtherGuy.inviteId = o.inviteId + inviteFromOtherGuy.inviteId = o.id inviteFromOtherGuy.token = o.token }) }) @@ -804,23 +800,21 @@ describe('[Stream & Server Invites]', () => { ]) // Create a couple of static invites that shouldn't be mutated in tests - await Promise.all([ - createInviteDirectly(myInvite, me.id).then((o) => { - myInvite.inviteId = o.inviteId - myInvite.token = o.token - }), - createInviteDirectly(otherGuysInvite, otherGuy.id).then((o) => { - otherGuysInvite.inviteId = o.inviteId - otherGuysInvite.token = o.token - }) - ]) + await createInviteDirectly(myInvite, me.id).then((o) => { + myInvite.inviteId = o.id + myInvite.token = o.token + }) + await createInviteDirectly(otherGuysInvite, otherGuy.id).then((o) => { + otherGuysInvite.inviteId = o.id + otherGuysInvite.token = o.token + }) }) beforeEach(async () => { // Create an invite before each test so that we can mutate them // in each test as needed await createInviteDirectly(dynamicInvite, me.id).then((o) => { - dynamicInvite.inviteId = o.inviteId + dynamicInvite.inviteId = o.id dynamicInvite.token = o.token }) }) @@ -895,24 +889,22 @@ describe('[Stream & Server Invites]', () => { await createTestUser(ownInvitesGuy) // Invite him to a few streams - await Promise.all([ - createInviteDirectly( - { - stream: myPrivateStream, - // SPecifically w/ email - email: ownInvitesGuy.email - }, - me.id - ), - createInviteDirectly( - { - // Specifically w/ id - userId: ownInvitesGuy.id, - stream: otherGuysStream - }, - otherGuy.id - ) - ]) + await createInviteDirectly( + { + stream: myPrivateStream, + // SPecifically w/ email + email: ownInvitesGuy.email + }, + me.id + ) + await createInviteDirectly( + { + // Specifically w/ id + userId: ownInvitesGuy.id, + stream: otherGuysStream + }, + otherGuy.id + ) // Build authenticated apollo instance apollo = await testApolloServer({ authUserId: ownInvitesGuy.id }) diff --git a/packages/server/modules/stats/tests/stats.spec.ts b/packages/server/modules/stats/tests/stats.spec.ts index dce68352c..e8aa12d0a 100644 --- a/packages/server/modules/stats/tests/stats.spec.ts +++ b/packages/server/modules/stats/tests/stats.spec.ts @@ -27,6 +27,7 @@ import { import { createStreamFactory, getStreamFactory, + grantStreamPermissionsFactory, markCommitStreamUpdatedFactory } from '@/modules/core/repositories/streams' import { @@ -40,7 +41,9 @@ import { import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/projectInviteManagement' import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' import { + deleteInvitesByTargetFactory, deleteServerOnlyInvitesFactory, + findInviteFactory, findUserByTargetFactory, insertInviteAndDeleteOldFactory, updateAllInviteTargetsFactory @@ -66,7 +69,10 @@ 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 { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' import { createPersonalAccessTokenFactory } from '@/modules/core/services/tokens' import { storeApiTokenFactory, @@ -76,9 +82,19 @@ import { } from '@/modules/core/repositories/tokens' import { getServerInfoFactory } from '@/modules/core/repositories/server' import { createObjectsFactory } from '@/modules/core/services/objects/management' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' const getServerInfo = getServerInfoFactory({ db }) const getUsers = getUsersFactory({ db }) +const getUser = getUserFactory({ db }) const markCommitStreamUpdated = markCommitStreamUpdatedFactory({ db }) const getObject = getObjectFactory({ db }) const createCommitByBranchId = createCommitByBranchIdFactory({ @@ -99,6 +115,50 @@ const createCommitByBranchName = createCommitByBranchNameFactory({ }) const getStream = getStreamFactory({ db }) +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -117,7 +177,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser: getUserFactory({ db }), - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), diff --git a/packages/server/modules/webhooks/services/webhooks.ts b/packages/server/modules/webhooks/services/webhooks.ts index 50ade93b5..b9dd30552 100644 --- a/packages/server/modules/webhooks/services/webhooks.ts +++ b/packages/server/modules/webhooks/services/webhooks.ts @@ -124,6 +124,7 @@ export const dispatchStreamEventFactory = stream?: StreamWithOptionalRole userId?: string | null user?: Partial | null + test?: string } }) => { const payload: typeof eventPayload & { diff --git a/packages/server/modules/webhooks/tests/cleanup.spec.ts b/packages/server/modules/webhooks/tests/cleanup.spec.ts index 9598d1972..3fe486d96 100644 --- a/packages/server/modules/webhooks/tests/cleanup.spec.ts +++ b/packages/server/modules/webhooks/tests/cleanup.spec.ts @@ -7,7 +7,8 @@ import { createBranchFactory } from '@/modules/core/repositories/branches' import { getServerInfoFactory } from '@/modules/core/repositories/server' import { createStreamFactory, - getStreamFactory + getStreamFactory, + grantStreamPermissionsFactory } from '@/modules/core/repositories/streams' import { createUserEmailFactory, @@ -32,7 +33,9 @@ import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' import { + deleteInvitesByTargetFactory, deleteServerOnlyInvitesFactory, + findInviteFactory, findUserByTargetFactory, insertInviteAndDeleteOldFactory, updateAllInviteTargetsFactory @@ -40,13 +43,25 @@ import { import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' -import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' +import { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/projectInviteManagement' import { getEventBus } from '@/modules/shared/services/eventBus' import { truncateTables } from '@/test/hooks' import { expect } from 'chai' import crs from 'crypto-random-string' import { cleanOrphanedWebhookConfigsFactory } from '@/modules/webhooks/repositories/cleanup' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' const WEBHOOKS_CONFIG_TABLE = 'webhooks_config' const WEBHOOKS_EVENTS_TABLE = 'webhooks_events' @@ -59,6 +74,52 @@ const getServerInfo = getServerInfoFactory({ db }) const getUsers = getUsersFactory({ db }) const getUser = getUserFactory({ db }) const getStream = getStreamFactory({ db }) + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -77,7 +138,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), diff --git a/packages/server/modules/webhooks/tests/webhooks.spec.js b/packages/server/modules/webhooks/tests/webhooks.spec.ts similarity index 74% rename from packages/server/modules/webhooks/tests/webhooks.spec.js rename to packages/server/modules/webhooks/tests/webhooks.spec.ts index 99d6e83c7..e1028e7af 100644 --- a/packages/server/modules/webhooks/tests/webhooks.spec.js +++ b/packages/server/modules/webhooks/tests/webhooks.spec.ts @@ -1,11 +1,11 @@ /* istanbul ignore file */ -const expect = require('chai').expect -const assert = require('assert') +import { expect } from 'chai' +import assert from 'assert' -const { beforeEachContext, initializeTestServer } = require('@/test/hooks') -const { noErrors } = require('@/test/helpers') -const { Scopes, Roles } = require('@speckle/shared') -const { +import { beforeEachContext, initializeTestServer } from '@/test/hooks' +import { noErrors } from '@/test/helpers' +import { Scopes, Roles, ensureError } from '@speckle/shared' +import { createWebhookConfigFactory, countWebhooksByStreamIdFactory, getWebhookByIdFactory, @@ -14,78 +14,77 @@ const { getStreamWebhooksFactory, createWebhookEventFactory, getLastWebhookEventsFactory -} = require('@/modules/webhooks/repositories/webhooks') -const { db } = require('@/db/knex') -const { +} from '@/modules/webhooks/repositories/webhooks' +import { db } from '@/db/knex' +import { createWebhookFactory, updateWebhookFactory, deleteWebhookFactory, dispatchStreamEventFactory -} = require('@/modules/webhooks/services/webhooks') -const { +} from '@/modules/webhooks/services/webhooks' +import { getStreamFactory, createStreamFactory, grantStreamPermissionsFactory -} = require('@/modules/core/repositories/streams') -const { +} from '@/modules/core/repositories/streams' +import { legacyCreateStreamFactory, createStreamReturnRecordFactory -} = require('@/modules/core/services/streams/management') -const { - inviteUsersToProjectFactory -} = require('@/modules/serverinvites/services/projectInviteManagement') -const { - createAndSendInviteFactory -} = require('@/modules/serverinvites/services/creation') -const { +} from '@/modules/core/services/streams/management' +import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/projectInviteManagement' +import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' +import { findUserByTargetFactory, insertInviteAndDeleteOldFactory, deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} = require('@/modules/serverinvites/repositories/serverInvites') -const { - collectAndValidateCoreTargetsFactory -} = require('@/modules/serverinvites/services/coreResourceCollection') -const { - buildCoreInviteEmailContentsFactory -} = require('@/modules/serverinvites/services/coreEmailContents') -const { getEventBus } = require('@/modules/shared/services/eventBus') -const { createBranchFactory } = require('@/modules/core/repositories/branches') -const { + updateAllInviteTargetsFactory, + findInviteFactory, + deleteInvitesByTargetFactory +} from '@/modules/serverinvites/repositories/serverInvites' +import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' +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 -} = require('@/modules/core/repositories/users') -const { +} from '@/modules/core/repositories/users' +import { findEmailFactory, createUserEmailFactory, ensureNoPrimaryEmailForUserFactory -} = require('@/modules/core/repositories/userEmails') -const { - requestNewEmailVerificationFactory -} = require('@/modules/emails/services/verification/request') -const { - deleteOldAndInsertNewVerificationFactory -} = require('@/modules/emails/repositories') -const { renderEmail } = require('@/modules/emails/services/emailRendering') -const { sendEmail } = require('@/modules/emails/services/sending') -const { createUserFactory } = require('@/modules/core/services/users/management') -const { - validateAndCreateUserEmailFactory -} = require('@/modules/core/services/userEmails') -const { - finalizeInvitedServerRegistrationFactory -} = require('@/modules/serverinvites/services/processing') -const { createPersonalAccessTokenFactory } = require('@/modules/core/services/tokens') -const { +} 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, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' +import { createPersonalAccessTokenFactory } from '@/modules/core/services/tokens' +import { storeApiTokenFactory, storeTokenScopesFactory, storeTokenResourceAccessDefinitionsFactory, storePersonalApiTokenFactory -} = require('@/modules/core/repositories/tokens') -const { getServerInfoFactory } = require('@/modules/core/repositories/server') +} from '@/modules/core/repositories/tokens' +import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' +import { omit } from 'lodash' const getServerInfo = getServerInfoFactory({ db }) const getUser = getUserFactory({ db }) @@ -95,6 +94,52 @@ const updateWebhook = updateWebhookFactory({ updateWebhookConfig: updateWebhookConfigFactory({ db }) }) const getStreamWebhooks = getStreamWebhooksFactory({ db }) + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -113,7 +158,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), @@ -161,27 +207,32 @@ const createPersonalAccessToken = createPersonalAccessTokenFactory({ describe('Webhooks @webhooks', () => { const getWebhook = getWebhookByIdFactory({ db }) - let sendRequest + let sendRequest: Awaited>['sendRequest'] const userOne = { name: 'User', email: 'user@example.org', - password: 'jdsadjsadasfdsa' + password: 'jdsadjsadasfdsa', + id: '', + token: '' } const streamOne = { name: 'streamOne', description: 'stream', - isPublic: true + isPublic: true, + ownerId: '', + id: '' } const webhookOne = { - streamId: null, // filled in `before` + streamId: '', // filled in `before` url: 'http://localhost:42/non-existent', description: 'test wh', secret: 'secret', enabled: true, - triggers: ['commit_create', 'commit_update'] + triggers: ['commit_create', 'commit_update'], + id: '' } before(async () => { @@ -209,7 +260,7 @@ describe('Webhooks @webhooks', () => { const webhook = await getWebhook({ id: webhookOne.id }) expect(webhook).to.not.be.null expect(webhook).to.have.property('url') - expect(webhook.url).to.equal(webhookOne.url) + expect(webhook!.url).to.equal(webhookOne.url) }) it('Should update a webhook', async () => { @@ -223,7 +274,7 @@ describe('Webhooks @webhooks', () => { const webhook = await getWebhook({ id: webhookId }) expect(webhook).to.not.be.null expect(webhook).to.have.property('url') - expect(webhook.url).to.equal(newUrl) + expect(webhook!.url).to.equal(newUrl) }) it('Should delete a webhook', async () => { @@ -240,7 +291,8 @@ describe('Webhooks @webhooks', () => { description: 'test wh', secret: 'secret', enabled: true, - triggers: ['commit_create', 'commit_update'] + triggers: ['commit_create', 'commit_update'], + id: '' } webhook.id = await createWebhookFactory({ createWebhookConfig: createWebhookConfigFactory({ db }), @@ -307,7 +359,6 @@ describe('Webhooks @webhooks', () => { countWebhooksByStreamId: countWebhooksByStreamIdFactory({ db }) })(webhook) await dispatchStreamEventFactory({ - db, getServerInfo, getStream, createWebhookEvent: createWebhookEventFactory({ db }), @@ -328,22 +379,27 @@ describe('Webhooks @webhooks', () => { const userTwo = { name: 'User2', email: 'user2@example.org', - password: 'jdsadjsadasfdsa' + password: 'jdsadjsadasfdsa', + id: '', + token: '' } const webhookTwo = { - streamId: null, + streamId: '', url: 'http://localhost:42/non-existent-two', description: 'test wh no 2', secret: 'secret', enabled: true, - triggers: ['commit_create', 'commit_update'] + triggers: ['commit_create', 'commit_update'], + id: '' } const streamTwo = { name: 'streamTwo', description: 'stream', - isPublic: true + isPublic: true, + ownerId: '', + id: '' } before(async () => { @@ -373,7 +429,7 @@ describe('Webhooks @webhooks', () => { const res = await sendRequest(userTwo.token, { query: 'mutation createWebhook($webhook: WebhookCreateInput!) { webhookCreate( webhook: $webhook ) }', - variables: { webhook: webhookTwo } + variables: { webhook: omit(webhookTwo, ['id']) } }) expect(noErrors(res)) expect(res.body.data.webhookCreate).to.not.be.null @@ -382,7 +438,6 @@ describe('Webhooks @webhooks', () => { it('Should get stream webhooks and the previous events', async () => { await dispatchStreamEventFactory({ - db, getServerInfo, getStream, getStreamWebhooks: getStreamWebhooksFactory({ db }), @@ -420,9 +475,9 @@ describe('Webhooks @webhooks', () => { }) const webhook = await getWebhook({ id: webhookTwo.id }) expect(noErrors(res)) - expect(res.body.data.webhookUpdate).to.equal(webhook.id) - expect(webhook.description).to.equal('updated webhook') - expect(webhook.enabled).to.equal(false) + expect(res.body.data.webhookUpdate).to.equal(webhook!.id) + expect(webhook!.description).to.equal('updated webhook') + expect(webhook!.enabled).to.equal(false) }) it('Should *not* update or delete a webhook if the stream id and webhook id do not match', async () => { @@ -461,7 +516,8 @@ describe('Webhooks @webhooks', () => { description: 'test wh', secret: 'secret', enabled: true, - triggers: ['commit_create', 'commit_update'] + triggers: ['commit_create', 'commit_update'], + id: '' } webhook.id = await createWebhookFactory({ createWebhookConfig: createWebhookConfigFactory({ db }), @@ -475,11 +531,11 @@ describe('Webhooks @webhooks', () => { }) it('Should *not* create a webhook if user is not a stream owner', async () => { - delete webhookTwo.id + webhookTwo.id = '' const res = await sendRequest(userOne.token, { query: 'mutation createWebhook($webhook: WebhookCreateInput!) { webhookCreate( webhook: $webhook ) }', - variables: { webhook: webhookTwo } + variables: { webhook: omit(webhookTwo, ['id']) } }) expect(res.body.errors).to.exist expect(res.body.errors[0].extensions.code).to.equal('FORBIDDEN') @@ -525,7 +581,7 @@ describe('Webhooks @webhooks', () => { countWebhooksByStreamId: countWebhooksByStreamIdFactory({ db }) })(webhook) } catch (err) { - if (err.toString().indexOf('Maximum') > -1) return + if (ensureError(err).toString().indexOf('Maximum') > -1) return } assert.fail('Configured more webhooks than the limit') diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 0aea2092b..9267c59a8 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -217,6 +217,10 @@ import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper' import { withOperationLogging } from '@/observability/domain/businessLogging' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' import { WorkspaceInvitesLimit } from '@/modules/workspaces/domain/constants' const eventBus = getEventBus() @@ -255,6 +259,90 @@ const buildCollectAndValidateResourceTargets = () => }) }) +const buildFinalizeWorkspaceInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ + db, + filterQuery: workspaceInviteValidityFilter + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: ({ eventName, payload }) => + getEventBus().emit({ + eventName, + payload + }), + validateInvite: validateWorkspaceInviteBeforeFinalizationFactory({ + getWorkspace: getWorkspaceFactory({ db }) + }), + processInvite: processFinalizedWorkspaceInviteFactory({ + getWorkspace: getWorkspaceFactory({ db }), + updateWorkspaceRole: updateWorkspaceRoleFactory({ + getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }), + findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }), + getWorkspaceRoles: getWorkspaceRolesFactory({ db }), + upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }), + emitWorkspaceEvent: getEventBus().emit, + ensureValidWorkspaceRoleSeat: ensureValidWorkspaceRoleSeatFactory({ + createWorkspaceSeat: createWorkspaceSeatFactory({ db }), + getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db }), + eventEmit: getEventBus().emit + }) + }) + }), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification + }), + collectAndValidateResourceTargets: buildCollectAndValidateResourceTargets(), + getUser, + getServerInfo + }) + +const validateStreamAccess = validateStreamAccessFactory({ authorizeResolver }) +const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess, + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit +}) + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaborator + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification + }), + collectAndValidateResourceTargets: buildCollectAndValidateResourceTargets(), + getUser, + getServerInfo + }) + const buildCreateAndSendServerOrProjectInvite = () => createAndSendInviteFactory({ findUserByTarget: findUserByTargetFactory({ db }), @@ -269,7 +357,8 @@ const buildCreateAndSendServerOrProjectInvite = () => payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }) const buildCreateAndSendWorkspaceInvite = () => @@ -287,9 +376,9 @@ const buildCreateAndSendWorkspaceInvite = () => payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeWorkspaceInvite() }) -const validateStreamAccess = validateStreamAccessFactory({ authorizeResolver }) const isStreamCollaborator = isStreamCollaboratorFactory({ getStream }) @@ -1226,52 +1315,7 @@ export = FF_WORKSPACES_MODULE_ENABLED use: async (_parent, args, ctx) => { const logger = ctx.log - const finalizeInvite = finalizeResourceInviteFactory({ - findInvite: findInviteFactory({ - db, - filterQuery: workspaceInviteValidityFilter - }), - deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), - insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), - emitEvent: ({ eventName, payload }) => - getEventBus().emit({ - eventName, - payload - }), - validateInvite: validateWorkspaceInviteBeforeFinalizationFactory({ - getWorkspace: getWorkspaceFactory({ db }) - }), - processInvite: processFinalizedWorkspaceInviteFactory({ - getWorkspace: getWorkspaceFactory({ db }), - updateWorkspaceRole: updateWorkspaceRoleFactory({ - getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }), - findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }), - getWorkspaceRoles: getWorkspaceRolesFactory({ db }), - upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }), - emitWorkspaceEvent: getEventBus().emit, - ensureValidWorkspaceRoleSeat: ensureValidWorkspaceRoleSeatFactory({ - createWorkspaceSeat: createWorkspaceSeatFactory({ db }), - getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db }), - eventEmit: getEventBus().emit - }) - }) - }), - findEmail: findEmailFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail: findEmailFactory({ db }), - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - collectAndValidateResourceTargets: buildCollectAndValidateResourceTargets(), - getUser, - getServerInfo - }) - + const finalizeInvite = buildFinalizeWorkspaceInvite() await withOperationLogging( async () => await finalizeInvite({ diff --git a/packages/server/modules/workspaces/services/invites.ts b/packages/server/modules/workspaces/services/invites.ts index 0073e849c..e5804cd8f 100644 --- a/packages/server/modules/workspaces/services/invites.ts +++ b/packages/server/modules/workspaces/services/invites.ts @@ -30,6 +30,7 @@ import { } from '@/modules/serverinvites/errors' import { buildUserTarget, + isPrimaryResourceTarget, isProjectResourceTarget, resolveInviteTargetTitle, resolveTarget @@ -225,6 +226,11 @@ export const collectAndValidateWorkspaceTargetsFactory = userId: targetUser.id, projectRole }) + + // If project target is primary and user target is already a workspace member, mark invite as auto-acceptable + if (isPrimaryResourceTarget(projectTarget) && workspaceRole) { + projectTarget.autoAccept = true + } } // Do further validation only if we're actually planning to invite to a workspace diff --git a/packages/server/modules/workspaces/tests/helpers/creation.ts b/packages/server/modules/workspaces/tests/helpers/creation.ts index 347bc6261..14dd78ac9 100644 --- a/packages/server/modules/workspaces/tests/helpers/creation.ts +++ b/packages/server/modules/workspaces/tests/helpers/creation.ts @@ -1,11 +1,18 @@ import { db } from '@/db/knex' import { + createUserEmailFactory, + ensureNoPrimaryEmailForUserFactory, + findEmailFactory, findEmailsByUserIdFactory, findVerifiedEmailsByUserIdFactory } from '@/modules/core/repositories/userEmails' import { + deleteInvitesByTargetFactory, + deleteServerOnlyInvitesFactory, + findInviteFactory, findUserByTargetFactory, - insertInviteAndDeleteOldFactory + insertInviteAndDeleteOldFactory, + updateAllInviteTargetsFactory } from '@/modules/serverinvites/repositories/serverInvites' import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' import { getEventBus } from '@/modules/shared/services/eventBus' @@ -19,12 +26,15 @@ import { getWorkspaceDomainsFactory, storeWorkspaceDomainFactory, getWorkspaceBySlugFactory, - getWorkspaceRoleForUserFactory + getWorkspaceRoleForUserFactory, + workspaceInviteValidityFilter } from '@/modules/workspaces/repositories/workspaces' import { buildWorkspaceInviteEmailContentsFactory, collectAndValidateWorkspaceTargetsFactory, - createWorkspaceInviteFactory + createWorkspaceInviteFactory, + processFinalizedWorkspaceInviteFactory, + validateWorkspaceInviteBeforeFinalizationFactory } from '@/modules/workspaces/services/invites' import { createWorkspaceFactory, @@ -92,6 +102,16 @@ import { getWorkspaceSeatTypeToProjectRoleMappingFactory, validateWorkspaceMemberProjectRoleFactory } from '@/modules/workspaces/services/projects' +import { captureCreatedInvite } from '@/test/speckle-helpers/inviteHelper' +import { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' +import { validateAndCreateUserEmailFactory } from '@/modules/core/services/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' const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() @@ -378,10 +398,9 @@ export const createWorkspaceInviteDirectly = async ( const getServerInfo = getServerInfoFactory({ db }) const getStream = getStreamFactory({ db }) const getUser = getUserFactory({ db }) - const createAndSendInvite = createAndSendInviteFactory({ - findUserByTarget: findUserByTargetFactory({ db }), - insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), - collectAndValidateResourceTargets: collectAndValidateWorkspaceTargetsFactory({ + + const buildCollectAndValidateResourceTargets = () => + collectAndValidateWorkspaceTargetsFactory({ getStream, getWorkspace: getWorkspaceFactory({ db }), getWorkspaceDomains: getWorkspaceDomainsFactory({ db }), @@ -400,7 +419,68 @@ export const createWorkspaceInviteDirectly = async ( getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }) }) }) - }), + }) + + const buildFinalizeWorkspaceInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ + db, + filterQuery: workspaceInviteValidityFilter + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: ({ eventName, payload }) => + getEventBus().emit({ + eventName, + payload + }), + validateInvite: validateWorkspaceInviteBeforeFinalizationFactory({ + getWorkspace: getWorkspaceFactory({ db }) + }), + processInvite: processFinalizedWorkspaceInviteFactory({ + getWorkspace: getWorkspaceFactory({ db }), + updateWorkspaceRole: updateWorkspaceRoleFactory({ + getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }), + findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }), + getWorkspaceRoles: getWorkspaceRolesFactory({ db }), + upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }), + emitWorkspaceEvent: getEventBus().emit, + ensureValidWorkspaceRoleSeat: ensureValidWorkspaceRoleSeatFactory({ + createWorkspaceSeat: createWorkspaceSeatFactory({ db }), + getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db }), + eventEmit: getEventBus().emit + }) + }) + }), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: buildCollectAndValidateResourceTargets(), + getUser, + getServerInfo + }) + + const createAndSendInvite = createAndSendInviteFactory({ + findUserByTarget: findUserByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + collectAndValidateResourceTargets: buildCollectAndValidateResourceTargets(), buildInviteEmailContents: buildWorkspaceInviteEmailContentsFactory({ getStream, getWorkspace: getWorkspaceFactory({ db }) @@ -411,18 +491,22 @@ export const createWorkspaceInviteDirectly = async ( payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeWorkspaceInvite() }) const createInvite = createWorkspaceInviteFactory({ createAndSendInvite }) - return await createInvite({ - ...args, - inviterId, - inviterResourceAccessRules: null - }) + return await captureCreatedInvite( + async () => + await createInvite({ + ...args, + inviterId, + inviterResourceAccessRules: null + }) + ) } export const createTestOidcProvider = async ( diff --git a/packages/server/modules/workspaces/tests/integration/invites.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/invites.graph.spec.ts index 44f924d1b..bd9a98245 100644 --- a/packages/server/modules/workspaces/tests/integration/invites.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/invites.graph.spec.ts @@ -559,7 +559,9 @@ describe('Workspaces Invites GQL', () => { Roles.Stream.Owner, me.id ) + }) + beforeEach(async () => { // Remove all project access from workspaceMemberWithNoProjectAccess await Promise.all([ leaveStream( @@ -630,6 +632,38 @@ describe('Workspaces Invites GQL', () => { expect(res.data?.projectMutations.invites.createForWorkspace.id).to.not.be.ok }) + it('can invite to workspace project as admin, even if target doesnt belong to workspace', async () => { + const sendEmailInvocations = EmailSendingServiceMock.hijackFunction( + 'sendEmail', + async () => true + ) + + const res = await gqlHelpers.createWorkspaceProjectInvite({ + projectId: myProjectInviteTargetWorkspaceProject.id, + inputs: [ + { + userId: otherGuy.id, + role: Roles.Stream.Reviewer + } + ] + }) + + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.projectMutations.invites.createForWorkspace.id).to.be.ok + + // no auto-accept, since target is not a workspace member + expect(sendEmailInvocations.args).to.have.lengthOf(1) + const emailParams = sendEmailInvocations.args[0][0] + await validateInviteExistanceFromEmail(emailParams) + + await gqlHelpers.validateResourceAccess({ + shouldHaveAccess: false, + userId: otherGuy.id, + workspaceId: myProjectInviteTargetWorkspace.id, + streamId: myProjectInviteTargetWorkspaceProject.id + }) + }) + it('can invite to workspace project even if not workspace admin, if target already belongs to workspace', async () => { const res = await gqlHelpers.createWorkspaceProjectInvite( { @@ -652,6 +686,38 @@ describe('Workspaces Invites GQL', () => { expect(res.data?.projectMutations.invites.createForWorkspace.id).to.be.ok }) + it('invite auto-accepted if both users already belong to the workspace', async () => { + const sendEmailInvocations = EmailSendingServiceMock.hijackFunction( + 'sendEmail', + async () => true + ) + + const res = await gqlHelpers.createWorkspaceProjectInvite({ + projectId: myProjectInviteTargetWorkspaceProject.id, + inputs: [ + { + userId: workspaceMemberWithNoProjectAccess.id, + role: Roles.Stream.Reviewer + } + ] + }) + + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.projectMutations.invites.createForWorkspace.id).to.be.ok + + // No invite email should be sent out, due to auto-accept + expect(sendEmailInvocations.length()).to.eq(0) + + // Should have project role + await gqlHelpers.validateResourceAccess({ + shouldHaveAccess: true, + userId: workspaceMemberWithNoProjectAccess.id, + workspaceId: myProjectInviteTargetWorkspace.id, + streamId: myProjectInviteTargetWorkspaceProject.id, + expectedProjectRole: Roles.Stream.Reviewer + }) + }) + it("can't invite a workspace guest to be a workspace project owner", async () => { const res = await gqlHelpers.createWorkspaceProjectInvite({ projectId: myProjectInviteTargetWorkspaceProject.id, @@ -1097,7 +1163,7 @@ describe('Workspaces Invites GQL', () => { }, me.id ) - expect(brokenInvite.inviteId).to.be.ok + expect(brokenInvite.id).to.be.ok // Db query directly, cause this isn't a supported use case await Workspaces.knex() @@ -1569,8 +1635,7 @@ describe('Workspaces Invites GQL', () => { expect(res).to.not.haveGraphQLErrors() expect(res.data?.workspaceMutations.invites.use).to.be.ok - expect(await findInviteFactory({ db })({ inviteId: invite.inviteId })).to.be.not - .ok + expect(await findInviteFactory({ db })({ inviteId: invite.id })).to.be.not.ok await gqlHelpers.validateResourceAccess({ shouldHaveAccess: true, diff --git a/packages/server/scripts/streamObjects.js b/packages/server/scripts/streamObjects.js deleted file mode 100644 index dd6f48b8e..000000000 --- a/packages/server/scripts/streamObjects.js +++ /dev/null @@ -1,152 +0,0 @@ -require('../bootstrap') -const { createManyObjects } = require('@/test/helpers') -const { fetch } = require('undici') -const { init } = require(`@/app`) -const request = require('supertest') -const { exit } = require('yargs') -const { logger } = require('@/observability/logging') -const { Scopes } = require('@speckle/shared') -const { - getStreamFactory, - createStreamFactory -} = require('@/modules/core/repositories/streams') -const { db } = require('@/db/knex') -const { - legacyCreateStreamFactory, - createStreamReturnRecordFactory -} = require('@/modules/core/services/streams/management') -const { - inviteUsersToProjectFactory -} = require('@/modules/serverinvites/services/projectInviteManagement') -const { - createAndSendInviteFactory -} = require('@/modules/serverinvites/services/creation') -const { - findUserByTargetFactory, - insertInviteAndDeleteOldFactory -} = require('@/modules/serverinvites/repositories/serverInvites') -const { - collectAndValidateCoreTargetsFactory -} = require('@/modules/serverinvites/services/coreResourceCollection') -const { - buildCoreInviteEmailContentsFactory -} = require('@/modules/serverinvites/services/coreEmailContents') -const { getEventBus } = require('@/modules/shared/services/eventBus') -const { createBranchFactory } = require('@/modules/core/repositories/branches') -const { - getUsersFactory, - getUserFactory, - legacyGetUserByEmailFactory -} = require('@/modules/core/repositories/users') -const { createPersonalAccessTokenFactory } = require('@/modules/core/services/tokens') -const { - storeApiTokenFactory, - storeTokenScopesFactory, - storeTokenResourceAccessDefinitionsFactory, - storePersonalApiTokenFactory -} = require('@/modules/core/repositories/tokens') -const { getServerInfoFactory } = require('@/modules/core/repositories/server') - -const getServerInfo = getServerInfoFactory({ db }) -const getUsers = getUsersFactory({ db }) -const getUser = getUserFactory({ db }) -const getStream = getStreamFactory({ db }) -const createStream = legacyCreateStreamFactory({ - createStreamReturnRecord: createStreamReturnRecordFactory({ - inviteUsersToProject: inviteUsersToProjectFactory({ - createAndSendInvite: createAndSendInviteFactory({ - findUserByTarget: findUserByTargetFactory({ db }), - insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), - collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ - getStream - }), - buildInviteEmailContents: buildCoreInviteEmailContentsFactory({ - getStream - }), - emitEvent: ({ eventName, payload }) => - getEventBus().emit({ - eventName, - payload - }), - getUser, - getServerInfo - }), - getUsers - }), - createStream: createStreamFactory({ db }), - createBranch: createBranchFactory({ db }), - emitEvent: getEventBus().emit - }) -}) -const getUserByEmail = legacyGetUserByEmailFactory({ db }) -const createPersonalAccessToken = createPersonalAccessTokenFactory({ - storeApiToken: storeApiTokenFactory({ db }), - storeTokenScopes: storeTokenScopesFactory({ db }), - storeTokenResourceAccessDefinitions: storeTokenResourceAccessDefinitionsFactory({ - db - }), - storePersonalApiToken: storePersonalApiTokenFactory({ db }) -}) - -const main = async () => { - const testStream = { - name: 'Test Stream 01', - description: 'wonderful test stream' - } - - // const userA = { - // name: 'd1', - // email: 'd.1@speckle.systems', - // password: 'wowwow8charsplease' - // } - // userA.id = await createUser(userA) - - const userA = await getUserByEmail({ - email: 'd.1@speckle.systems' - }) - userA.token = `Bearer ${await createPersonalAccessToken( - userA.id, - 'test token user A', - [ - Scopes.Streams.Read, - Scopes.Streams.Write, - Scopes.Users.Read, - Scopes.Users.Email, - Scopes.Tokens.Write, - Scopes.Tokens.Read, - Scopes.Profile.Read, - Scopes.Profile.Email - ] - )}` - - testStream.id = await createStream({ ...testStream, ownerId: userA.id }) - - const { app } = await init() - - const numObjs = 5000 - const objBatch = createManyObjects(numObjs) - - const uploadRes = await request(app) - .post(`/objects/${testStream.id}`) - .set('Authorization', userA.token) - .set('Content-type', 'multipart/form-data') - .attach('batch1', Buffer.from(JSON.stringify(objBatch), 'utf8')) - - logger.info(uploadRes.status) - const objectIds = objBatch.map((obj) => obj.id) - - const res = await fetch(`http://127.0.0.1:3000/api/getobjects/${testStream.id}`, { - method: 'POST', - headers: { - Authorization: userA.token, - 'Content-Type': 'application/json', - Accept: 'text/plain' - }, - body: JSON.stringify({ objects: JSON.stringify(objectIds) }) - }) - const data = await res.body.getReader().read() - logger.info(data) - exit(0) -} - -main().then(logger.info('created')).catch(logger.error('failed')) diff --git a/packages/server/scripts/streamObjects.ts b/packages/server/scripts/streamObjects.ts new file mode 100644 index 000000000..ca15761a1 --- /dev/null +++ b/packages/server/scripts/streamObjects.ts @@ -0,0 +1,226 @@ +// eslint-disable-next-line no-restricted-imports +import '../bootstrap' +import { createManyObjects } from '@/test/helpers' +import { fetch } from 'undici' +import { init } from '@/app' +import request from 'supertest' +import { logger } from '@/observability/logging' +import { Scopes } from '@speckle/shared' +import { + getStreamFactory, + createStreamFactory, + grantStreamPermissionsFactory +} from '@/modules/core/repositories/streams' +import { db } from '@/db/knex' +import { + legacyCreateStreamFactory, + createStreamReturnRecordFactory +} from '@/modules/core/services/streams/management' +import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/projectInviteManagement' +import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' +import { + deleteInvitesByTargetFactory, + deleteServerOnlyInvitesFactory, + findInviteFactory, + findUserByTargetFactory, + insertInviteAndDeleteOldFactory, + updateAllInviteTargetsFactory +} from '@/modules/serverinvites/repositories/serverInvites' +import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' +import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' +import { getEventBus } from '@/modules/shared/services/eventBus' +import { createBranchFactory } from '@/modules/core/repositories/branches' +import { + getUsersFactory, + getUserFactory, + legacyGetUserByEmailFactory +} from '@/modules/core/repositories/users' +import { createPersonalAccessTokenFactory } from '@/modules/core/services/tokens' +import { + storeApiTokenFactory, + storeTokenScopesFactory, + storeTokenResourceAccessDefinitionsFactory, + storePersonalApiTokenFactory +} from '@/modules/core/repositories/tokens' +import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' +import { + createUserEmailFactory, + ensureNoPrimaryEmailForUserFactory, + findEmailFactory +} from '@/modules/core/repositories/userEmails' +import { validateAndCreateUserEmailFactory } from '@/modules/core/services/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' + +const getServerInfo = getServerInfoFactory({ db }) +const getUsers = getUsersFactory({ db }) +const getUser = getUserFactory({ db }) +const getStream = getStreamFactory({ db }) + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + +const createStream = legacyCreateStreamFactory({ + createStreamReturnRecord: createStreamReturnRecordFactory({ + inviteUsersToProject: inviteUsersToProjectFactory({ + createAndSendInvite: createAndSendInviteFactory({ + findUserByTarget: findUserByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + buildInviteEmailContents: buildCoreInviteEmailContentsFactory({ + getStream + }), + emitEvent: ({ eventName, payload }) => + getEventBus().emit({ + eventName, + payload + }), + getUser, + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() + }), + getUsers + }), + createStream: createStreamFactory({ db }), + createBranch: createBranchFactory({ db }), + emitEvent: getEventBus().emit + }) +}) +const getUserByEmail = legacyGetUserByEmailFactory({ db }) +const createPersonalAccessToken = createPersonalAccessTokenFactory({ + storeApiToken: storeApiTokenFactory({ db }), + storeTokenScopes: storeTokenScopesFactory({ db }), + storeTokenResourceAccessDefinitions: storeTokenResourceAccessDefinitionsFactory({ + db + }), + storePersonalApiToken: storePersonalApiTokenFactory({ db }) +}) + +const main = async () => { + const testStream = { + name: 'Test Stream 01', + description: 'wonderful test stream', + id: '' + } + + // const userA = { + // name: 'd1', + // email: 'd.1@speckle.systems', + // password: 'wowwow8charsplease' + // } + // userA.id = await createUser(userA) + + const userA = { + ...(await getUserByEmail({ + email: 'd.1@speckle.systems' + }))!, + token: '' + } + + userA.token = `Bearer ${await createPersonalAccessToken( + userA.id, + 'test token user A', + [ + Scopes.Streams.Read, + Scopes.Streams.Write, + Scopes.Users.Read, + Scopes.Users.Email, + Scopes.Tokens.Write, + Scopes.Tokens.Read, + Scopes.Profile.Read, + Scopes.Profile.Email + ] + )}` + + testStream.id = await createStream({ ...testStream, ownerId: userA.id }) + + const { app } = await init() + + const numObjs = 5000 + const objBatch = createManyObjects(numObjs) + + const uploadRes = await request(app) + .post(`/objects/${testStream.id}`) + .set('Authorization', userA.token) + .set('Content-type', 'multipart/form-data') + .attach('batch1', Buffer.from(JSON.stringify(objBatch), 'utf8')) + + logger.info(uploadRes.status) + const objectIds = objBatch.map((obj) => obj.id) + + const res = await fetch(`http://127.0.0.1:3000/api/getobjects/${testStream.id}`, { + method: 'POST', + headers: { + Authorization: userA.token, + 'Content-Type': 'application/json', + Accept: 'text/plain' + }, + body: JSON.stringify({ objects: JSON.stringify(objectIds) }) + }) + const data = await res.body!.getReader().read() + logger.info(data) + process.exit(0) +} + +main() + .then(() => logger.info('created')) + .catch((err) => logger.error('failed', err)) diff --git a/packages/server/test/speckle-helpers/inviteHelper.ts b/packages/server/test/speckle-helpers/inviteHelper.ts index f618daf49..07115600a 100644 --- a/packages/server/test/speckle-helpers/inviteHelper.ts +++ b/packages/server/test/speckle-helpers/inviteHelper.ts @@ -1,11 +1,14 @@ import { MaybeAsync, Roles, StreamRoles } from '@speckle/shared' import { buildUserTarget } from '@/modules/serverinvites/helpers/core' -import { InviteResult } from '@/modules/serverinvites/services/operations' import { + deleteInvitesByTargetFactory, + deleteServerOnlyInvitesFactory, findInviteByTokenFactory, + findInviteFactory, findUserByTargetFactory, - insertInviteAndDeleteOldFactory + insertInviteAndDeleteOldFactory, + updateAllInviteTargetsFactory } from '@/modules/serverinvites/repositories/serverInvites' import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' import { BasicTestUser } from '@/test/authHelper' @@ -17,21 +20,93 @@ import { ProjectInviteResourceType, ServerInviteResourceType } from '@/modules/serverinvites/domain/constants' -import { SendEmailParams } from '@/modules/emails/services/sending' +import { sendEmail, SendEmailParams } from '@/modules/emails/services/sending' import { db } from '@/db/knex' import { expect } from 'chai' import { PrimaryInviteResourceTarget, + ServerInviteRecord, ServerInviteResourceTarget } from '@/modules/serverinvites/domain/types' import { EmailSendingServiceMock } from '@/test/mocks/global' -import { getStreamFactory } from '@/modules/core/repositories/streams' +import { + getStreamFactory, + grantStreamPermissionsFactory +} from '@/modules/core/repositories/streams' import { getUserFactory } from '@/modules/core/repositories/users' import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' +import { + addOrUpdateStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { authorizeResolver } from '@/modules/shared' +import { + createUserEmailFactory, + ensureNoPrimaryEmailForUserFactory, + findEmailFactory +} from '@/modules/core/repositories/userEmails' +import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' +import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' +import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' +import { renderEmail } from '@/modules/emails/services/emailRendering' const getServerInfo = getServerInfoFactory({ db }) const getUser = getUserFactory({ db }) const getStream = getStreamFactory({ db }) + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createAndSendInvite = createAndSendInviteFactory({ findUserByTarget: findUserByTargetFactory({ db }), insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), @@ -47,7 +122,8 @@ const createAndSendInvite = createAndSendInviteFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }) export const createServerInviteDirectly = async ( @@ -89,7 +165,7 @@ export const createStreamInviteDirectly = async ( role?: StreamRoles }, creatorId: string -): Promise => { +): Promise => { const userId = invite.userId || invite.user?.id || null const email = invite.email || null if (!userId && !email) throw new Error('Either user/userId or email must be set') @@ -100,19 +176,24 @@ export const createStreamInviteDirectly = async ( const target = email || buildUserTarget(userId!) if (!target) throw new Error('Cannot create invite without a target') - return await createAndSendInvite( - { - target, - inviterId: creatorId, - message: invite.message, - primaryResourceTarget: { - resourceType: streamId ? ProjectInviteResourceType : ServerInviteResourceType, - resourceId: streamId || '', - role: streamId ? streamRole : Roles.Server.User, - primary: true - } - }, - null + return await captureCreatedInvite( + async () => + await createAndSendInvite( + { + target, + inviterId: creatorId, + message: invite.message, + primaryResourceTarget: { + resourceType: streamId + ? ProjectInviteResourceType + : ServerInviteResourceType, + resourceId: streamId || '', + role: streamId ? streamRole : Roles.Server.User, + primary: true + } + }, + null + ) ) } diff --git a/packages/server/test/speckle-helpers/streamHelper.ts b/packages/server/test/speckle-helpers/streamHelper.ts index b42d87e90..2af82afe4 100644 --- a/packages/server/test/speckle-helpers/streamHelper.ts +++ b/packages/server/test/speckle-helpers/streamHelper.ts @@ -10,6 +10,11 @@ import { grantStreamPermissionsFactory, revokeStreamPermissionsFactory } from '@/modules/core/repositories/streams' +import { + createUserEmailFactory, + ensureNoPrimaryEmailForUserFactory, + findEmailFactory +} from '@/modules/core/repositories/userEmails' import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/users' import { addOrUpdateStreamCollaboratorFactory, @@ -21,13 +26,30 @@ import { createStreamReturnRecordFactory, legacyCreateStreamFactory } from '@/modules/core/services/streams/management' +import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' +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 { + deleteInvitesByTargetFactory, + deleteServerOnlyInvitesFactory, + findInviteFactory, findUserByTargetFactory, - insertInviteAndDeleteOldFactory + insertInviteAndDeleteOldFactory, + updateAllInviteTargetsFactory } from '@/modules/serverinvites/repositories/serverInvites' import { buildCoreInviteEmailContentsFactory } from '@/modules/serverinvites/services/coreEmailContents' +import { + processFinalizedProjectInviteFactory, + validateProjectInviteBeforeFinalizationFactory +} from '@/modules/serverinvites/services/coreFinalization' import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/services/coreResourceCollection' import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation' +import { + finalizeInvitedServerRegistrationFactory, + finalizeResourceInviteFactory +} from '@/modules/serverinvites/services/processing' import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/projectInviteManagement' import { authorizeResolver } from '@/modules/shared' import { Nullable } from '@/modules/shared/helpers/typeHelper' @@ -44,6 +66,52 @@ const getServerInfo = getServerInfoFactory({ db }) const getUsers = getUsersFactory({ db }) const getUser = getUserFactory({ db }) const getStream = getStreamFactory({ db }) + +const buildFinalizeProjectInvite = () => + finalizeResourceInviteFactory({ + findInvite: findInviteFactory({ db }), + validateInvite: validateProjectInviteBeforeFinalizationFactory({ + getProject: getStream + }), + processInvite: processFinalizedProjectInviteFactory({ + getProject: getStream, + addProjectRole: addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess: validateStreamAccessFactory({ authorizeResolver }), + getUser, + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + emitEvent: getEventBus().emit + }) + }), + deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }), + insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db }), + emitEvent: (...args) => getEventBus().emit(...args), + findEmail: findEmailFactory({ db }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ + db + }), + renderEmail, + sendEmail + }) + }), + collectAndValidateResourceTargets: collectAndValidateCoreTargetsFactory({ + getStream + }), + getUser, + getServerInfo + }) + const createStream = legacyCreateStreamFactory({ createStreamReturnRecord: createStreamReturnRecordFactory({ inviteUsersToProject: inviteUsersToProjectFactory({ @@ -62,7 +130,8 @@ const createStream = legacyCreateStreamFactory({ payload }), getUser, - getServerInfo + getServerInfo, + finalizeInvite: buildFinalizeProjectInvite() }), getUsers }), From 76b84e206855196487a918889e9e070e54457875 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Wed, 30 Apr 2025 16:42:57 +0100 Subject: [PATCH 053/178] fix(workspaces): wrapper type on limited workspace team (#4635) --- .../workspace/discoverableWorkspaces/Card.vue | 8 +-- .../lib/common/generated/gql/gql.ts | 12 ++-- .../lib/common/generated/gql/graphql.ts | 52 ++++++++++------- .../composables/discoverableWorkspaces.ts | 16 ++++-- .../typedefs/workspaces.graphql | 10 +++- packages/server/codegen.yml | 1 + .../modules/core/graph/generated/graphql.ts | 57 ++++++++++++------- .../graph/generated/graphql.ts | 21 ++++--- .../workspaces/graph/resolvers/workspaces.ts | 5 ++ .../integration/workspaces.graph.spec.ts | 4 +- .../workspacesCore/helpers/graphTypes.ts | 1 + .../server/test/graphql/generated/graphql.ts | 25 ++++---- packages/server/test/graphql/workspaces.ts | 4 +- 13 files changed, 132 insertions(+), 84 deletions(-) diff --git a/packages/frontend-2/components/workspace/discoverableWorkspaces/Card.vue b/packages/frontend-2/components/workspace/discoverableWorkspaces/Card.vue index b0994f293..8eb467973 100644 --- a/packages/frontend-2/components/workspace/discoverableWorkspaces/Card.vue +++ b/packages/frontend-2/components/workspace/discoverableWorkspaces/Card.vue @@ -5,11 +5,7 @@
{{ workspace.description }}
- +