Merge branch 'main' of github.com:specklesystems/speckle-server into gergo/web-1119-define-workspaces-dataschema

This commit is contained in:
Gergő Jedlicska
2024-06-25 13:26:37 +02:00
108 changed files with 3923 additions and 4676 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ version: 2.1
orbs:
snyk: snyk/snyk@2.0.3
codecov: codecov/codecov@4.0.0
codecov: codecov/codecov@4.1.0
workflows:
test-build:
+3 -2
View File
@@ -39,8 +39,9 @@ What is Speckle? Check our [![YouTube Video Views](https://img.shields.io/youtub
Give Speckle a try in no time by:
- [![speckle](https://img.shields.io/badge/https://-app.speckle.systems-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](https://app.speckle.systems) ⇒ creating an account
- [![create a droplet](https://img.shields.io/badge/Create%20a%20Droplet-0069ff?style=flat-square&logo=digitalocean&logoColor=white)](https://marketplace.digitalocean.com/apps/speckle-server?refcode=947a2b5d7dc1) ⇒ deploying an instance in 1 click
- [![app.speckle.systems](https://img.shields.io/badge/https://-app.speckle.systems-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](https://app.speckle.systems) ⇒ Create an account at app.speckle.systems
- [![Deploy on your own infrastructure with docker compose](https://img.shields.io/badge/https://-speckle.guide-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](<[https://](https://speckle.guide/dev/server-manualsetup.html)>) ⇒ Deploy on your own infrastructure with Docker Compose
- [![Deploy on your own infrastructure with docker compose](https://img.shields.io/badge/https://-speckle.guide-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](<[https://](https://speckle.guide/dev/server-setup-k8s.html)>) ⇒ Deploy on your own infrastructure with Kubernetes
## Resources
+12 -15
View File
@@ -28,8 +28,8 @@
"dev:kind:helm:ci": "tilt ci --file ./.circleci/deployment/Tiltfile.helm --context kind-speckle-server --timeout 10m",
"dev": "yarn workspaces foreach -pivW -j unlimited run dev",
"dev:no-server": "yarn workspaces foreach --exclude @speckle/server -pivW -j unlimited run dev",
"dev:minimal": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend,@speckle/shared}' run dev",
"gqlgen": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend,@speckle/frontend-2}' run gqlgen",
"dev:minimal": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend-2}' run dev",
"gqlgen": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend,@speckle/frontend-2,@speckle/dui3}' run gqlgen",
"dev:server": "yarn workspace @speckle/server dev",
"dev:frontend": "yarn workspace @speckle/frontend dev",
"dev:frontend-2": "yarn workspace @speckle/frontend-2 dev",
@@ -65,35 +65,32 @@
"zx": "^8.1.2"
},
"resolutions": {
"@babel/traverse": ">=7.23.2",
"@aws-sdk/client-sts/fast-xml-parser": ">=4.2.5",
"@aws-sdk/client-s3/fast-xml-parser": ">=4.2.5",
"@bull-board/express/express": ">=4.19.2",
"@datadog/datadog-ci/ws": "^7.5.10",
"@microsoft/api-extractor/semver": "^7.5.4",
"@rushstack/node-core-library/semver": "^7.5.4",
"@typescript-eslint/eslint-plugin": "^7.12.0",
"@typescript-eslint/parser": "^7.12.0",
"typescript-eslint": "^7.12.0",
"@types/react": "file:./packages/frontend-2/type-augmentations/stubs/types__react",
"axios": ">=1.6.0",
"core-js": "3.22.4",
"core-js-compat/semver": "^7.5.4",
"eslint": "^9.4.0",
"eslint-config-prettier": "^9.1.0",
"express": ">=4.19.2",
"fast-xml-parser": ">=4.2.5",
"graphql": "^15.3.0",
"levelup/bl": ">=1.2.3",
"levelup/semver": ">=5.7.2",
"mocha/serialize-javascript": ">=6.0.2",
"prettier": "^2.8.7",
"serialize-javascript": ">=6.0.2",
"puppeteer-core/ws": "^8.17.1",
"request/tough-cookie": ">=4.1.3",
"rollup-plugin-terser/serialize-javascript": ">=6.0.2",
"simple-update-notifier/semver": "^7.5.4",
"tough-cookie": ">=4.1.3",
"tslib": "^2.3.1",
"typescript": "^5.2.2",
"undici": "^5.28.4",
"wait-on": ">=7.2.0",
"word-wrap": "npm:@aashutoshrathi/word-wrap@^1.2.4",
"xml2js": ">=0.5.0",
"puppeteer-core/ws": "^8.17.1",
"@datadog/datadog-ci/ws": "^7.5.10"
"typescript-eslint": "^7.12.0",
"wait-on": ">=7.2.0"
},
"config": {
"commitizen": {
@@ -18,10 +18,6 @@
</div>
<PromoBannersWrapper v-if="promoBanners.length" :banners="promoBanners" />
<div v-if="showErrorTest" class="w-full">
<FormButton @click="testError">Test error</FormButton>
</div>
<div
v-if="!showEmptyState"
class="flex flex-col space-y-2 md:flex-row md:items-center mb-8 pt-4"
@@ -134,7 +130,6 @@ const promoBanners = ref<PromoBanner[]>([
}
])
const route = useRoute()
const { activeUser, isGuest } = useActiveUser()
const { triggerNotification } = useGlobalToast()
const areQueriesLoading = useQueryLoading()
@@ -160,8 +155,6 @@ const { onResult: onUserProjectsUpdate } = useSubscription(
onUserProjectsUpdateSubscription
)
const showErrorTest = computed(() => route.query.showErrorButton === '1')
const projects = computed(() => projectsPanelResult.value?.activeUser?.projects)
const showEmptyState = computed(() => {
const isFiltering =
@@ -340,8 +333,4 @@ const clearSearch = () => {
selectedRoles.value = []
updateSearchImmediately()
}
const testError = () => {
throw new Error('what duhh hell')
}
</script>
@@ -66,7 +66,7 @@ import { ChevronLeftIcon } from '@heroicons/vue/24/solid'
import { VisualDiffMode } from '@speckle/viewer'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import { uniqBy, debounce } from 'lodash-es'
import type { SpeckleObject } from '~~/lib/common/helpers/sceneExplorer'
import type { SpeckleObject } from '~~/lib/viewer/helpers/sceneExplorer'
import { useMixpanel } from '~~/lib/core/composables/mp'
defineEmits<{
@@ -53,8 +53,7 @@
class="bg-foundation rounded-lg"
>
<ViewerExplorerTreeItem
:item-id="(rootNode.data?.id as string)"
:tree-item="markRaw(rootNode)"
:tree-item="rootNode"
:sub-header="'Model Version'"
:debug="false"
:expand-level="expandLevel"
@@ -75,14 +74,17 @@ import {
CodeBracketIcon
} from '@heroicons/vue/24/solid'
import { ViewerEvent } from '@speckle/viewer'
import type { ExplorerNode } from '~~/lib/common/helpers/sceneExplorer'
import type {
ExplorerNode,
TreeItemComponentModel
} from '~~/lib/viewer/helpers/sceneExplorer'
import {
useInjectedViewer,
useInjectedViewerLoadedResources,
useInjectedViewerState
} from '~~/lib/viewer/composables/setup'
import { markRaw } from 'vue'
import { useViewerEventListener } from '~~/lib/viewer/composables/viewer'
import { sortBy, flatten } from 'lodash-es'
defineEmits(['close'])
@@ -121,13 +123,21 @@ const rootNodes = computed(() => {
if (!worldTree.value) return []
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
expandLevel.value = -1
const nodes = []
const rootNodes = worldTree.value._root.children as ExplorerNode[]
const results: Record<number, ExplorerNode[]> = {}
const unmatchedNodes: ExplorerNode[] = []
for (const node of rootNodes) {
const objectId = ((node.model as Record<string, unknown>).id as string)
.split('/')
.reverse()[0] as string
const resourceItem = resourceItems.value.find((res) => res.objectId === objectId)
const resourceItemIdx = resourceItems.value.findIndex(
(res) => res.objectId === objectId
)
const resourceItem =
resourceItemIdx !== -1 ? resourceItems.value[resourceItemIdx] : null
const raw = node.model?.raw as Record<string, unknown>
if (resourceItem?.modelId) {
// Model resource
@@ -140,9 +150,24 @@ const rootNodes = computed(() => {
raw.name = 'Object'
raw.type = 'Single Object'
}
nodes.push(node.model as ExplorerNode)
const res = node.model as ExplorerNode
if (resourceItem) {
;(results[resourceItemIdx] = results[resourceItemIdx] || []).push(res)
} else {
unmatchedNodes.push(res)
}
}
return nodes
const nodes = [
...flatten(sortBy(Object.entries(results), (i) => i[0]).map((i) => i[1])),
...unmatchedNodes
]
return nodes.map(
(n): TreeItemComponentModel => ({
rawNode: markRaw(n)
})
)
})
</script>
@@ -70,7 +70,7 @@
refreshColorsIfSetOrActiveFilterIsNumeric()
"
>
{{ filter.key }}
{{ getPropertyName(filter.key) }}
</button>
</div>
<div v-if="itemCount < relevantFiltersSearched.length" class="mb-2">
@@ -82,11 +82,11 @@
</div>
<div v-if="activeFilter">
<ViewerExplorerStringFilter
v-if="activeFilter.type === 'string'"
v-if="stringActiveFilter"
:filter="stringActiveFilter"
/>
<ViewerExplorerNumericFilter
v-if="activeFilter.type === 'number'"
v-if="numericActiveFilter"
:filter="numericActiveFilter"
/>
</div>
@@ -95,13 +95,13 @@
<script setup lang="ts">
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/vue/24/solid'
import { ArrowPathIcon } from '@heroicons/vue/24/outline'
import type {
PropertyInfo,
StringPropertyInfo,
NumericPropertyInfo
} from '@speckle/viewer'
import type { PropertyInfo } from '@speckle/viewer'
import { useFilterUtilities } from '~~/lib/viewer/composables/ui'
import { useMixpanel } from '~~/lib/core/composables/mp'
import {
isNumericPropertyInfo,
isStringPropertyInfo
} from '~/lib/viewer/helpers/sceneExplorer'
const {
setPropertyFilter,
@@ -111,6 +111,8 @@ const {
filters: { propertyFilter }
} = useFilterUtilities()
const revitPropertyRegex = /^parameters\./
const showAllFilters = ref(false)
const props = defineProps<{
@@ -142,7 +144,7 @@ const relevantFilters = computed(() => {
return false
}
// handle revit params: the actual one single value we're interested is in paramters.HOST_BLA BLA_.value, the rest are not needed
if (f.key.startsWith('parameters')) {
if (isRevitProperty(f.key)) {
if (f.key.endsWith('.value')) return true
else return false
}
@@ -150,9 +152,8 @@ const relevantFilters = computed(() => {
})
})
const speckleTypeFilter = computed(
() =>
relevantFilters.value.find((f) => f.key === 'speckle_type') as StringPropertyInfo
const speckleTypeFilter = computed(() =>
relevantFilters.value.find((f) => f.key === 'speckle_type')
)
const activeFilter = computed(
() => propertyFilter.filter.value || speckleTypeFilter.value
@@ -169,18 +170,26 @@ watch(activeFilter, (newVal) => {
})
})
// Using these as casting activeFilter as XXX in the prop causes some syntax highliting bug to show. Apologies :)
const stringActiveFilter = computed(() => activeFilter.value as StringPropertyInfo)
const numericActiveFilter = computed(() => activeFilter.value as NumericPropertyInfo)
const stringActiveFilter = computed(() =>
isStringPropertyInfo(activeFilter.value) ? activeFilter.value : undefined
)
const numericActiveFilter = computed(() =>
isNumericPropertyInfo(activeFilter.value) ? activeFilter.value : undefined
)
const searchString = ref<string | undefined>(undefined)
const relevantFiltersSearched = computed(() => {
if (!searchString.value) return relevantFilters.value
const searchLower = searchString.value.toLowerCase()
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
itemCount.value = 30 // nasty, but yolo - reset max limit on search change
return relevantFilters.value.filter((f) =>
f.key.toLowerCase().includes((searchString.value as string).toLowerCase())
)
return relevantFilters.value.filter((f) => {
const userFriendlyName = getPropertyName(f.key).toLowerCase()
return (
f.key.toLowerCase().includes(searchLower) ||
userFriendlyName.includes(searchLower)
)
})
})
const itemCount = ref(30)
@@ -190,28 +199,7 @@ const relevantFiltersLimited = computed(() => {
.sort((a, b) => a.key.length - b.key.length)
})
// Too lazy to follow up in here for now, as i think we need a bit of a better strategy in connectors first :/
const title = computed(() => {
const currentFilterKey = activeFilter.value?.key
if (!currentFilterKey) return 'Loading'
if (currentFilterKey === 'level.name') return 'Level Name'
if (currentFilterKey === 'speckle_type') return 'Object Type'
// Handle revit names :/
if (
currentFilterKey.startsWith('parameters.') &&
currentFilterKey.endsWith('.value')
) {
return (
props.filters.find(
(f) => f.key === currentFilterKey.replace('.value', '.name')
) as StringPropertyInfo
).valueGroups[0].value
}
return currentFilterKey
})
const title = computed(() => getPropertyName(activeFilter.value?.key ?? ''))
const colors = computed(() => !!propertyFilter.isApplied.value)
@@ -229,7 +217,7 @@ const toggleColors = () => {
// Handles a rather complicated ux flow: user sets a numeric filter which only makes sense with colors on. we set the force colors flag in that scenario, so we can revert it if user selects a non-numeric filter afterwards.
let forcedColors = false
const refreshColorsIfSetOrActiveFilterIsNumeric = () => {
if (activeFilter.value.type === 'number' && !colors.value) {
if (!!numericActiveFilter.value && !colors.value) {
forcedColors = true
applyPropertyFilter()
return
@@ -246,4 +234,27 @@ const refreshColorsIfSetOrActiveFilterIsNumeric = () => {
// removePropertyFilter()
applyPropertyFilter()
}
const isRevitProperty = (key: string): boolean => {
return revitPropertyRegex.test(key)
}
const getPropertyName = (key: string): string => {
if (!key) return 'Loading'
if (key === 'level.name') return 'Level Name'
if (key === 'speckle_type') return 'Object Type'
if (isRevitProperty(key) && key.endsWith('.value')) {
const correspondingProperty = props.filters.find(
(f) => f.key === key.replace('.value', '.name')
)
if (correspondingProperty && isStringPropertyInfo(correspondingProperty)) {
return correspondingProperty.valueGroups[0]?.value || key
}
}
// Return the key as is for non-Revit properties
return key
}
</script>
@@ -2,10 +2,11 @@
<template>
<div>
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events -->
<div
<button
:class="`flex group pl-1 justify-between items-center w-full max-w-full overflow-hidden select-none space-x-2 rounded border-l-4 hover:bg-primary-muted hover:shadow-md text-foreground cursor-pointer ${
isSelected ? 'border-primary bg-primary-muted' : 'border-transparent'
}`"
:title="item.value"
@click="setSelection()"
>
<div class="flex gap-1 items-center flex-shrink truncate text-xs sm:text-sm">
@@ -15,7 +16,7 @@
:style="`background-color: #${color};`"
></span>
<span class="truncate">
{{ item.value.split('.').reverse()[0] || item.value || 'No Name' }}
{{ item.value || 'No Name' }}
</span>
<div class="flex">
<span
@@ -57,7 +58,7 @@
<FunnelIcon v-else class="h-3 w-3" />
<!-- </button> -->
</div>
</div>
</button>
<!-- Debugging info -->
<!-- <div v-if="true" class="text-xs text-foreground-2">
selected: {{ isSelected }}; isHidden {{ isHidden }}; isIsolated: {{ isIsolated }}
@@ -95,10 +95,13 @@
<!-- If we have array collections -->
<div v-if="isMultipleCollection">
<!-- mul col items -->
<div v-for="collection in arrayCollections" :key="collection?.raw?.name">
<div
v-for="(item, idx) in arrayCollections"
:key="item?.rawNode.raw?.name || idx"
>
<TreeItem
:item-id="(collection.raw?.id as string)"
:tree-item="collection"
:item-id="(item.rawNode.raw?.id as string)"
:tree-item="item"
:depth="depth + 1"
:expand-level="props.expandLevel"
:manual-expand-level="manualExpandLevel"
@@ -111,9 +114,12 @@
<!-- If we have a single model collection -->
<div v-if="isSingleCollection">
<!-- single col items -->
<div v-for="item in singleCollectionItemsPaginated" :key="item.raw?.id">
<div
v-for="(item, idx) in singleCollectionItemsPaginated"
:key="item.rawNode.raw?.id || idx"
>
<TreeItem
:item-id="(item.raw?.id as string)"
:item-id="(item.rawNode.raw?.id as string)"
:tree-item="item"
:depth="depth + 1"
:expand-level="props.expandLevel"
@@ -143,8 +149,9 @@ import { FunnelIcon as FunnelIconOutline } from '@heroicons/vue/24/outline'
import type {
ExplorerNode,
SpeckleObject,
SpeckleReference
} from '~~/lib/common/helpers/sceneExplorer'
SpeckleReference,
TreeItemComponentModel
} from '~~/lib/viewer/helpers/sceneExplorer'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import {
getHeaderAndSubheaderForSpeckleObject,
@@ -159,8 +166,8 @@ import {
const props = withDefaults(
defineProps<{
treeItem: ExplorerNode
parent?: ExplorerNode
treeItem: TreeItemComponentModel
parent?: TreeItemComponentModel
depth?: number
debug?: boolean
expandLevel: number
@@ -186,9 +193,9 @@ const { hideObjects, showObjects, isolateObjects, unIsolateObjects } =
useFilterUtilities()
const { highlightObjects, unhighlightObjects } = useHighlightedObjectsUtilities()
const isAtomic = computed(() => props.treeItem.atomic === true)
const speckleData = props.treeItem?.raw as SpeckleObject
const rawSpeckleData = props.treeItem?.raw as SpeckleObject
const isAtomic = computed(() => props.treeItem.rawNode.atomic === true)
const rawSpeckleData = computed(() => props.treeItem?.rawNode.raw as SpeckleObject)
const speckleData = rawSpeckleData
function getNestedModelHeader(name: string): string {
const parts = name.split('/')
@@ -196,7 +203,9 @@ function getNestedModelHeader(name: string): string {
}
const headerAndSubheader = computed(() => {
const { header, subheader } = getHeaderAndSubheaderForSpeckleObject(rawSpeckleData)
const { header, subheader } = getHeaderAndSubheaderForSpeckleObject(
rawSpeckleData.value
)
return {
header: getNestedModelHeader(header),
subheader
@@ -204,32 +213,45 @@ const headerAndSubheader = computed(() => {
})
const childrenLength = computed(() => {
if (rawSpeckleData.elements && Array.isArray(rawSpeckleData.elements))
return rawSpeckleData.elements.length
if (rawSpeckleData.children && Array.isArray(rawSpeckleData.children))
return rawSpeckleData.children.length
if (rawSpeckleData.value.elements && Array.isArray(rawSpeckleData.value.elements))
return rawSpeckleData.value.elements.length
if (rawSpeckleData.value.children && Array.isArray(rawSpeckleData.value.children))
return rawSpeckleData.value.children.length
return 0
})
const isSingleCollection = computed(() => {
return (
isNonEmptyObjectArray(speckleData.children) ||
isNonEmptyObjectArray(speckleData.elements)
isNonEmptyObjectArray(speckleData.value.children) ||
isNonEmptyObjectArray(speckleData.value.elements)
)
})
const singleCollectionItems = computed(() => {
const treeItems = props.treeItem.children.filter((child) => !!child.raw?.id) // filter out random tree children (no id means they're not actual objects)
const treeItems = props.treeItem.rawNode.children.filter(
(child) => !!child.raw?.id && isAllowedType(child)
// filter out random tree children (no id means they're not actual objects)
)
// Handle the case of a wall, roof or other atomic objects that have nested children
if (isNonEmptyObjectArray(speckleData.elements) && isAtomic.value) {
if (isNonEmptyObjectArray(speckleData.value.elements) && isAtomic.value) {
// We need to filter out children that are not direct descendants of `elements`
// Note: this is a current assumption convention.
const ids = (speckleData.elements as SpeckleReference[]).map(
const ids = (speckleData.value.elements as SpeckleReference[]).map(
(obj) => obj.referencedId
)
return treeItems.filter((item) => ids.includes(item.raw?.id as string))
return treeItems
.filter((item) => ids.includes(item.raw?.id as string))
.map(
(i): TreeItemComponentModel => ({
rawNode: i
})
)
}
return treeItems
return treeItems.map(
(i): TreeItemComponentModel => ({
rawNode: i
})
)
})
const itemCount = ref(10)
@@ -242,17 +264,17 @@ const singleCollectionItemsPaginated = computed(() => {
// object { @boat: [obj, obj, obj], @harbour: [obj, obj, obj], etc. }
// @boat and @harbour would ideally be model collections, but, alas, connectors don't have that yet.
const arrayCollections = computed(() => {
const arr = [] as ExplorerNode[]
const arr = [] as TreeItemComponentModel[]
for (const k of Object.keys(rawSpeckleData)) {
if (k === 'children' || k === 'elements' || k.includes('displayValue')) continue
const val = rawSpeckleData[k] as SpeckleReference[]
const val = rawSpeckleData.value[k] as SpeckleReference[]
if (!isNonEmptyObjectArray(val)) continue
const ids = val.map((ref) => ref.referencedId) // NOTE: we're assuming all collections have refs inside; might revisit/to think re edge cases
const actualRawRefs = props.treeItem.children.filter((node) =>
ids.includes(node.raw?.id as string)
const actualRawRefs = props.treeItem.rawNode.children.filter(
(node) => ids.includes(node.raw?.id as string) && isAllowedType(node)
)
if (actualRawRefs.length === 0) continue // bypasses chunks: if the actual object is not part of the tree item's children, it means it's a sublimated type (ie, a chunk). the assumption we're making is that any list of actual atomic objects is not chunked.
@@ -267,7 +289,9 @@ const arrayCollections = computed(() => {
children: actualRawRefs,
expanded: false
}
arr.push(modelCollectionItem)
arr.push({
rawNode: modelCollectionItem
})
}
return arr
@@ -283,6 +307,9 @@ const isNonEmptyObjectArray = (x: unknown) => isNonEmptyArray(x) && isObject(x[0
const isObject = (x: unknown) =>
typeof x === 'object' && !Array.isArray(x) && x !== null
const isAllowedType = (node: ExplorerNode) =>
!['Objects.Other.DisplayStyle'].includes(node.raw?.speckle_type || '')
const unfold = ref(false)
// NOTE: not happy with how unfolding and collapsing panned out :(
@@ -320,7 +347,7 @@ const manualUnfoldToggle = () => {
}
const isSelected = computed(() => {
return !!objects.value.find((o) => o.id === speckleData.id)
return !!objects.value.find((o) => o.id === speckleData.value.id)
})
const setSelection = (e: MouseEvent) => {
@@ -329,19 +356,19 @@ const setSelection = (e: MouseEvent) => {
return
}
if (isSelected.value && e.shiftKey) {
removeFromSelection(rawSpeckleData)
removeFromSelection(rawSpeckleData.value)
return
}
if (!e.shiftKey) clearSelection()
addToSelection(rawSpeckleData)
addToSelection(rawSpeckleData.value)
}
const highlightObject = () => {
highlightObjects(getTargetObjectIds(rawSpeckleData))
highlightObjects(getTargetObjectIds(rawSpeckleData.value))
}
const unhighlightObject = () => {
unhighlightObjects(getTargetObjectIds(rawSpeckleData))
unhighlightObjects(getTargetObjectIds(rawSpeckleData.value))
}
const hiddenObjects = computed(() => filteringState.value?.hiddenObjects)
@@ -349,7 +376,7 @@ const isolatedObjects = computed(() => filteringState.value?.isolatedObjects)
const isHidden = computed(() => {
if (!hiddenObjects.value) return false
const ids = getTargetObjectIds(rawSpeckleData)
const ids = getTargetObjectIds(rawSpeckleData.value)
return containsAll(ids, hiddenObjects.value)
})
@@ -360,12 +387,12 @@ const stateHasIsolatedObjectsInGeneral = computed(() => {
const isIsolated = computed(() => {
if (!isolatedObjects.value) return false
const ids = getTargetObjectIds(rawSpeckleData)
const ids = getTargetObjectIds(rawSpeckleData.value)
return containsAll(ids, isolatedObjects.value)
})
const hideOrShowObject = () => {
const ids = getTargetObjectIds(rawSpeckleData)
const ids = getTargetObjectIds(rawSpeckleData.value)
if (!isHidden.value) {
hideObjects(ids)
return
@@ -375,7 +402,7 @@ const hideOrShowObject = () => {
}
const isolateOrUnisolateObject = () => {
const ids = getTargetObjectIds(rawSpeckleData)
const ids = getTargetObjectIds(rawSpeckleData.value)
if (!isIsolated.value) {
isolateObjects(ids)
return
@@ -131,7 +131,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { ChevronDownIcon } from '@heroicons/vue/24/solid'
import { ClipboardDocumentIcon } from '@heroicons/vue/24/outline'
import type { SpeckleObject } from '~~/lib/common/helpers/sceneExplorer'
import type { SpeckleObject } from '~~/lib/viewer/helpers/sceneExplorer'
import { getHeaderAndSubheaderForSpeckleObject } from '~~/lib/object-sidebar/helpers'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import { useHighlightedObjectsUtilities } from '~/lib/viewer/composables/ui'
@@ -375,62 +375,6 @@ export type AutomationCollection = {
totalCount: Scalars['Int']['output'];
};
export type AutomationCreateInput = {
automationId: Scalars['String']['input'];
automationName: Scalars['String']['input'];
automationRevisionId: Scalars['String']['input'];
modelId: Scalars['String']['input'];
projectId: Scalars['String']['input'];
webhookId?: InputMaybe<Scalars['String']['input']>;
};
export type AutomationFunctionRun = {
__typename?: 'AutomationFunctionRun';
contextView?: Maybe<Scalars['String']['output']>;
elapsed: Scalars['Float']['output'];
functionId: Scalars['String']['output'];
functionLogo?: Maybe<Scalars['String']['output']>;
functionName: Scalars['String']['output'];
id: Scalars['ID']['output'];
resultVersions: Array<Version>;
/**
* NOTE: this is the schema for the results field below!
* Current schema: {
* version: "1.0.0",
* values: {
* objectResults: Record<str, {
* category: string
* level: ObjectResultLevel
* objectIds: string[]
* message: str | null
* metadata: Records<str, unknown> | null
* visualoverrides: Records<str, unknown> | null
* }[]>
* blobIds?: string[]
* }
* }
*/
results?: Maybe<Scalars['JSONObject']['output']>;
status: AutomationRunStatus;
statusMessage?: Maybe<Scalars['String']['output']>;
};
export type AutomationMutations = {
__typename?: 'AutomationMutations';
create: Scalars['Boolean']['output'];
functionRunStatusReport: Scalars['Boolean']['output'];
};
export type AutomationMutationsCreateArgs = {
input: AutomationCreateInput;
};
export type AutomationMutationsFunctionRunStatusReportArgs = {
input: AutomationRunStatusUpdateInput;
};
export type AutomationRevision = {
__typename?: 'AutomationRevision';
functions: Array<AutomationRevisionFunction>;
@@ -454,44 +398,8 @@ export type AutomationRevisionFunction = {
export type AutomationRevisionTriggerDefinition = VersionCreatedTriggerDefinition;
export type AutomationRun = {
__typename?: 'AutomationRun';
automationId: Scalars['String']['output'];
automationName: Scalars['String']['output'];
createdAt: Scalars['DateTime']['output'];
functionRuns: Array<AutomationFunctionRun>;
id: Scalars['ID']['output'];
/** Resolved from all function run statuses */
status: AutomationRunStatus;
updatedAt: Scalars['DateTime']['output'];
versionId: Scalars['String']['output'];
};
export enum AutomationRunStatus {
Failed = 'FAILED',
Initializing = 'INITIALIZING',
Running = 'RUNNING',
Succeeded = 'SUCCEEDED'
}
export type AutomationRunStatusUpdateInput = {
automationId: Scalars['String']['input'];
automationRevisionId: Scalars['String']['input'];
automationRunId: Scalars['String']['input'];
functionRuns: Array<FunctionRunStatusInput>;
versionId: Scalars['String']['input'];
};
export type AutomationRunTrigger = VersionCreatedTrigger;
export type AutomationsStatus = {
__typename?: 'AutomationsStatus';
automationRuns: Array<AutomationRun>;
id: Scalars['ID']['output'];
status: AutomationRunStatus;
statusMessage?: Maybe<Scalars['String']['output']>;
};
export type AvatarUser = {
__typename?: 'AvatarUser';
avatar?: Maybe<Scalars['String']['output']>;
@@ -936,27 +844,6 @@ export type FileUpload = {
userId: Scalars['String']['output'];
};
export type FunctionRunStatusInput = {
contextView?: InputMaybe<Scalars['String']['input']>;
elapsed: Scalars['Float']['input'];
functionId: Scalars['String']['input'];
functionLogo?: InputMaybe<Scalars['String']['input']>;
functionName: Scalars['String']['input'];
resultVersionIds: Array<Scalars['String']['input']>;
/**
* Current schema: {
* version: "1.0.0",
* values: {
* speckleObjects: Record<ObjectId, {level: string; statusMessage: string}[]>
* blobIds?: string[]
* }
* }
*/
results?: InputMaybe<Scalars['JSONObject']['input']>;
status: AutomationRunStatus;
statusMessage?: InputMaybe<Scalars['String']['input']>;
};
export type GendoAiRender = {
__typename?: 'GendoAIRender';
camera?: Maybe<Scalars['JSONObject']['output']>;
@@ -1082,7 +969,6 @@ export type LimitedUserTimelineArgs = {
export type Model = {
__typename?: 'Model';
author: LimitedUser;
automationStatus?: Maybe<AutomationsStatus>;
automationsStatus?: Maybe<TriggeredAutomationsStatus>;
/** Return a model tree of children */
childrenTree: Array<ModelsTreeItem>;
@@ -1215,7 +1101,6 @@ export type Mutation = {
appUpdate: Scalars['Boolean']['output'];
automateFunctionRunStatusReport: Scalars['Boolean']['output'];
automateMutations: AutomateMutations;
automationMutations: AutomationMutations;
branchCreate: Scalars['String']['output'];
branchDelete: Scalars['Boolean']['output'];
branchUpdate: Scalars['Boolean']['output'];
@@ -1901,14 +1786,6 @@ export type ProjectAutomationUpdateInput = {
name?: InputMaybe<Scalars['String']['input']>;
};
export type ProjectAutomationsStatusUpdatedMessage = {
__typename?: 'ProjectAutomationsStatusUpdatedMessage';
model: Model;
project: Project;
status: AutomationsStatus;
version: Version;
};
export type ProjectAutomationsUpdatedMessage = {
__typename?: 'ProjectAutomationsUpdatedMessage';
automation?: Maybe<Automation>;
@@ -2875,7 +2752,6 @@ export type Subscription = {
commitDeleted?: Maybe<Scalars['JSONObject']['output']>;
/** Subscribe to commit updated event. */
commitUpdated?: Maybe<Scalars['JSONObject']['output']>;
projectAutomationsStatusUpdated: ProjectAutomationsStatusUpdatedMessage;
/** Subscribe to updates to automations in the project */
projectAutomationsUpdated: ProjectAutomationsUpdatedMessage;
/**
@@ -2971,11 +2847,6 @@ export type SubscriptionCommitUpdatedArgs = {
};
export type SubscriptionProjectAutomationsStatusUpdatedArgs = {
projectId: Scalars['String']['input'];
};
export type SubscriptionProjectAutomationsUpdatedArgs = {
projectId: Scalars['String']['input'];
};
@@ -3317,7 +3188,6 @@ export type UserUpdateInput = {
export type Version = {
__typename?: 'Version';
authorUser?: Maybe<LimitedUser>;
automationStatus?: Maybe<AutomationsStatus>;
automationsStatus?: Maybe<TriggeredAutomationsStatus>;
/** All comment threads in this version */
commentThreads: CommentCollection;
@@ -1,15 +0,0 @@
import { type SpeckleObject, type SpeckleReference } from '@speckle/viewer'
// Note: minor typing hacks for less squiggly lines in the explorer.
// TODO: ask alex re viewer data tree types
export type ExplorerNode = {
guid?: string
data?: SpeckleObject
raw?: SpeckleObject
atomic?: boolean
model?: Record<string, unknown> & { id?: string }
children: ExplorerNode[]
}
export type { SpeckleObject, SpeckleReference }
@@ -1,9 +1,10 @@
import { useScopedState } from '~~/lib/common/composables/scopedState'
import * as Observability from '@speckle/shared/dist/esm/observability/index'
import type {
AbstractErrorHandler,
AbstractErrorHandlerParams,
AbstractUnhandledErrorHandler
import {
prettify,
type AbstractErrorHandler,
type AbstractErrorHandlerParams,
type AbstractUnhandledErrorHandler
} from '~/lib/core/helpers/observability'
const ENTER_STATE_AT_ERRORS_PER_MIN = 100
@@ -65,7 +66,11 @@ export const useGetErrorLoggingTransports = () => {
export const useLogToErrorLoggingTransports = () => {
const transports = useGetErrorLoggingTransports()
const invokeTransportsWithPayload = (payload: AbstractErrorHandlerParams) => {
transports.forEach((handler) => handler.onError(payload))
transports.forEach((handler) =>
handler.onError(payload, {
prettifyMessage: (msg) => prettify(payload.otherData || {}, msg)
})
)
}
return {
@@ -8,7 +8,7 @@ import createUploadLink from 'apollo-upload-client/createUploadLink.mjs'
import { WebSocketLink } from '@apollo/client/link/ws'
import { getMainDefinition } from '@apollo/client/utilities'
import { Kind } from 'graphql'
import type { OperationDefinitionNode } from 'graphql'
import type { GraphQLError, OperationDefinitionNode } from 'graphql'
import type { CookieRef, NuxtApp } from '#app'
import type { Optional } from '@speckle/shared'
import { useAuthCookie } from '~~/lib/auth/composables/auth'
@@ -22,7 +22,7 @@ import { onError } from '@apollo/client/link/error'
import { useNavigateToLogin, loginRoute } from '~~/lib/common/helpers/route'
import { useAppErrorState } from '~~/lib/core/composables/error'
import { isInvalidAuth } from '~~/lib/common/helpers/graphql'
import { isBoolean, omit } from 'lodash-es'
import { isArray, isBoolean, omit } from 'lodash-es'
import { useRequestId } from '~/lib/core/composables/server'
const appName = 'frontend-2'
@@ -337,12 +337,15 @@ function createLink(params: {
? skipLoggingErrors
: skipLoggingErrors?.(res)
if (!isSubTokenMissingError && !shouldSkip) {
const errMsg = res.networkError?.message || res.graphQLErrors?.[0]?.message
const gqlErrors: Array<GraphQLError> = isArray(res.graphQLErrors)
? res.graphQLErrors
: []
const errMsg = res.networkError?.message || gqlErrors[0]?.message
logger.error(
{
...omit(res, ['forward', 'response']),
networkErrorMessage: res.networkError?.message,
gqlErrorMessages: res.graphQLErrors?.map((e) => e.message),
gqlErrorMessages: gqlErrors.map((e) => e.message),
errorMessage: errMsg,
graphql: true
},
@@ -19,7 +19,7 @@ import type { Logger } from 'pino'
/**
* Add pino-pretty like formatting
*/
const prettify = (log: object, msg: string) =>
export const prettify = (log: object, msg: string) =>
msg.replace(/{([^{}]+)}/g, (match: string, p1: string) => {
const val = get(log, p1)
if (val === undefined) return match
@@ -33,7 +33,7 @@ const prettify = (log: object, msg: string) =>
* Wrap any logger call w/ logic that prettifies the error message like pino-pretty does
* and emits bindings if they are provided
*/
const log =
const prettifiedLoggerFactory =
(logger: (...args: unknown[]) => void, bindings?: () => Record<string, unknown>) =>
(...vals: unknown[]) => {
const finalVals = vals.slice()
@@ -72,16 +72,16 @@ export function buildFakePinoLogger(
const errLogger = (...args: unknown[]) => {
const { onError } = options || {}
if (onError) onError(...args)
log(console.error, bindings)(...args)
prettifiedLoggerFactory(console.error, bindings)(...args)
}
const logger = {
debug: log(console.debug, bindings),
info: log(console.info, bindings),
warn: log(console.warn, bindings),
debug: prettifiedLoggerFactory(console.debug, bindings),
info: prettifiedLoggerFactory(console.info, bindings),
warn: prettifiedLoggerFactory(console.warn, bindings),
error: errLogger,
fatal: errLogger,
trace: log(console.trace, bindings),
trace: prettifiedLoggerFactory(console.trace, bindings),
silent: noop
} as unknown as ReturnType<typeof Observability.getLogger>
@@ -121,13 +121,18 @@ export const formatAppError = (err: SimpleError) => {
}
}
export type AbstractErrorHandler = (params: {
args: unknown[]
firstString: Optional<string>
firstError: Optional<Error>
otherData: Record<string, unknown>
nonObjectOtherData: unknown[]
}) => void
export type AbstractErrorHandler = (
params: {
args: unknown[]
firstString: Optional<string>
firstError: Optional<Error>
otherData: Record<string, unknown>
nonObjectOtherData: unknown[]
},
helpers: {
prettifyMessage: (msg: string) => string
}
) => void
export type AbstractUnhandledErrorHandler = (params: {
event: ErrorEvent | PromiseRejectionEvent
@@ -175,13 +180,16 @@ export function enableCustomErrorHandling(params: {
{},
...otherDataObjects
) as Record<string, unknown>
onError({
args,
firstError,
firstString,
otherData: mergedOtherDataObject,
nonObjectOtherData: otherDataNonObjects
})
onError(
{
args,
firstError,
firstString,
otherData: mergedOtherDataObject,
nonObjectOtherData: otherDataNonObjects
},
{ prettifyMessage: (msg) => prettify(mergedOtherDataObject, msg) }
)
}
return log(...args)
@@ -1,4 +1,4 @@
import type { SpeckleObject } from '~~/lib/common/helpers/sceneExplorer'
import type { SpeckleObject } from '~/lib/viewer/helpers/sceneExplorer'
export type HeaderSubheader = {
header: string
@@ -49,7 +49,7 @@ import { nanoid } from 'nanoid'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import type { CommentBubbleModel } from '~~/lib/viewer/composables/commentBubbles'
import { setupUrlHashState } from '~~/lib/viewer/composables/setup/urlHashState'
import type { SpeckleObject } from '~~/lib/common/helpers/sceneExplorer'
import type { SpeckleObject } from '~/lib/viewer/helpers/sceneExplorer'
import type { Box3 } from 'three'
import { Vector3 } from 'three'
import { writableAsyncComputed } from '~~/lib/common/composables/async'
@@ -111,11 +111,18 @@ export type InjectableViewerState = Readonly<{
* Various values that represent the current Viewer instance state
*/
metadata: {
/**
* Based on a shallow ref
*/
worldTree: ComputedRef<Optional<WorldTree>>
availableFilters: ComputedRef<Optional<PropertyInfo[]>>
views: ComputedRef<SpeckleView[]>
filteringState: ComputedRef<Optional<FilteringState>>
}
/**
* Whether the Viewer has finished doing the initial object loading
*/
hasDoneInitialLoad: Ref<boolean>
}
/**
* Loaded/loadable resources
@@ -395,6 +402,7 @@ function setupInitialState(params: UseSetupViewerParams): InitialSetupState {
createViewerDataBuilder({ viewerDebug })
) || { initPromise: Promise.resolve() }
initPromise.then(() => (isInitialized.value = true))
const hasDoneInitialLoad = ref(false)
return {
projectId,
@@ -412,7 +420,8 @@ function setupInitialState(params: UseSetupViewerParams): InitialSetupState {
availableFilters: computed(() => undefined),
views: computed(() => []),
filteringState: computed(() => undefined)
}
},
hasDoneInitialLoad
} as unknown as InitialSetupState['viewer'])
: {
instance,
@@ -421,7 +430,8 @@ function setupInitialState(params: UseSetupViewerParams): InitialSetupState {
promise: initPromise,
ref: computed(() => isInitialized.value)
},
metadata: setupViewerMetadata({ viewer: instance })
metadata: setupViewerMetadata({ viewer: instance }),
hasDoneInitialLoad
},
urlHashState: setupUrlHashState()
}
@@ -82,7 +82,8 @@ function useViewerObjectAutoLoading() {
projectId,
viewer: {
instance: viewer,
init: { ref: isInitialized }
init: { ref: isInitialized },
hasDoneInitialLoad
},
resources: {
response: { resourceItems }
@@ -112,21 +113,24 @@ function useViewerObjectAutoLoading() {
uniq(resourceItems.map((i) => i.objectId))
watch(
() => <const>[resourceItems.value, isInitialized.value],
async ([newResources, newIsInitialized], oldData) => {
() => <const>[resourceItems.value, isInitialized.value, hasDoneInitialLoad.value],
async ([newResources, newIsInitialized, newHasDoneInitialLoad], oldData) => {
// Wait till viewer loaded in
if (!newIsInitialized) return
const [oldResources, oldIsInitialized] = oldData || [[], false]
const [oldResources] = oldData || [[], false]
const zoomToObject = !focusedThreadId.value // we want to zoom to the thread instead
// Viewer initialized - load in all resources
if (newIsInitialized && !oldIsInitialized) {
if (!newHasDoneInitialLoad) {
const allObjectIds = getUniqueObjectIds(newResources)
await Promise.all(
const res = await Promise.all(
allObjectIds.map((i) => loadObject(i, false, { zoomToObject }))
)
if (res.length) {
hasDoneInitialLoad.value = true
}
return
}
@@ -139,7 +143,7 @@ function useViewerObjectAutoLoading() {
await Promise.all(removableObjectIds.map((i) => loadObject(i, true)))
await Promise.all(
addableObjectIds.map((i) => loadObject(i, false, { zoomToObject }))
addableObjectIds.map((i) => loadObject(i, false, { zoomToObject: false }))
)
},
{ deep: true, immediate: true }
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { MeasurementType } from '@speckle/viewer'
import type { SpeckleObject } from '~~/lib/common/helpers/sceneExplorer'
import type { SpeckleObject } from '~/lib/viewer/helpers/sceneExplorer'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import { useCameraUtilities, useSelectionUtilities } from '~~/lib/viewer/composables/ui'
@@ -4,7 +4,7 @@ import { CameraController, MeasurementsExtension } from '@speckle/viewer'
import { until } from '@vueuse/shared'
import { difference, isString, uniq } from 'lodash-es'
import { useEmbedState } from '~/lib/viewer/composables/setup/embed'
import type { SpeckleObject } from '~~/lib/common/helpers/sceneExplorer'
import type { SpeckleObject } from '~/lib/viewer/helpers/sceneExplorer'
import { isNonNullable } from '~~/lib/common/helpers/utils'
import {
useInjectedViewer,
@@ -16,7 +16,7 @@ import {
type SelectionEvent,
type TreeNode
} from '@speckle/viewer'
import type { SpeckleObject } from '~~/lib/common/helpers/sceneExplorer'
import type { SpeckleObject } from '~/lib/viewer/helpers/sceneExplorer'
// NOTE: this is a preformance optimisation - this function is hot, and has to do
// potentially large searches if many elements are hidden/isolated. We cache the
@@ -0,0 +1,34 @@
import type { MaybeNullOrUndefined } from '@speckle/shared'
import {
type NumericPropertyInfo,
type PropertyInfo,
type SpeckleObject,
type SpeckleReference,
type StringPropertyInfo
} from '@speckle/viewer'
import type { Raw } from 'vue'
export const isStringPropertyInfo = (
info: MaybeNullOrUndefined<PropertyInfo>
): info is StringPropertyInfo => info?.type === 'string'
export const isNumericPropertyInfo = (
info: MaybeNullOrUndefined<PropertyInfo>
): info is NumericPropertyInfo => info?.type === 'number'
// Note: minor typing hacks for less squiggly lines in the explorer.
// TODO: ask alex re viewer data tree types
export type ExplorerNode = {
guid?: string
data?: SpeckleObject
raw?: SpeckleObject
atomic?: boolean
model?: Record<string, unknown> & { id?: string }
children: ExplorerNode[]
}
export type TreeItemComponentModel = {
rawNode: Raw<ExplorerNode>
}
export type { SpeckleObject, SpeckleReference }
+9 -14
View File
@@ -136,6 +136,10 @@ export default defineNuxtPlugin(async (nuxtApp) => {
...collectMainInfo({ isBrowser: true })
})
logger = buildFakePinoLogger({
consoleBindings: logCsrEmitProps ? collectCoreInfo : undefined
})
// SEQ Browser integration
if (logClientApiToken?.length && logClientApiEndpoint?.length) {
const seq = await import('seq-logging/browser')
@@ -195,24 +199,15 @@ export default defineNuxtPlugin(async (nuxtApp) => {
})
}
errorHandlers.push(errorLogger)
logger = buildFakePinoLogger({
consoleBindings: logCsrEmitProps ? collectCoreInfo : undefined
})
logger.debug('Set up seq ingestion...')
} else {
// No seq integration, fallback to basic console logging
logger = buildFakePinoLogger({
consoleBindings: logCsrEmitProps ? collectCoreInfo : undefined
})
}
}
// Register seq transports, if any
if (errorHandlers.length) {
registerErrorTransport({
onError: (params) => {
errorHandlers.forEach((handler) => handler(params))
onError: (...params) => {
errorHandlers.forEach((handler) => handler(...params))
},
onUnhandledError: (event) => {
unhandledErrorHandlers.forEach((handler) => handler(event))
@@ -220,19 +215,19 @@ export default defineNuxtPlugin(async (nuxtApp) => {
})
}
// Global error handler - handle all transports
// Global error handler - handle all transports besides the core pino/console.log logger
const transports = useGetErrorLoggingTransports()
let serverFatalError: Optional<AbstractErrorHandlerParams> = undefined
logger = enableCustomErrorHandling({
logger,
onError: (params) => {
onError: (params, helpers) => {
const { otherData } = params
if (import.meta.server && otherData?.isAppError) {
serverFatalError = params
}
transports.forEach((handler) => handler.onError(params))
transports.forEach((handler) => handler.onError(params, helpers))
}
})
+17 -3
View File
@@ -7,6 +7,7 @@ import type { Plugin } from 'nuxt/dist/app/nuxt'
import { isH3Error } from '~/lib/common/helpers/error'
import { useRequestId, useServerRequestId } from '~/lib/core/composables/server'
import { isBrave, isSafari } from '@speckle/shared'
import { isString } from 'lodash-es'
type PluginNuxtApp = Parameters<Plugin>[0]
@@ -69,15 +70,28 @@ function initRumClient(app: PluginNuxtApp) {
: {}
registerErrorTransport({
onError: ({ args, firstError, firstString, otherData, nonObjectOtherData }) => {
onError: (
{ args, firstError, firstString, otherData, nonObjectOtherData },
{ prettifyMessage }
) => {
if (!datadog || !('addError' in datadog)) return
const error = firstError || firstString || args[0]
let error = firstError || firstString || args[0]
const mainErrorMessageTemplate = firstString
const mainErrorMessage = mainErrorMessageTemplate
? prettifyMessage(mainErrorMessageTemplate)
: undefined
if (isString(error)) {
error = prettifyMessage(error)
}
datadog.addError(error, {
...otherData,
...resolveH3Data(firstError),
extraData: nonObjectOtherData,
mainErrorMessage: firstString,
mainErrorMessageTemplate,
mainErrorMessage,
isProperlySentError: true
})
},
File diff suppressed because it is too large Load Diff
@@ -53,7 +53,7 @@ export class Serializer implements IDisposable {
for (const propKey in obj) {
const value = obj[propKey]
// 0. skip some props
if (!value || propKey === 'id' || propKey.startsWith('_')) continue
if (value === undefined || propKey === 'id' || propKey.startsWith('_')) continue
// 1. primitives (numbers, bools, strings)
if (typeof value !== 'object') {
+1 -1
View File
@@ -83,7 +83,7 @@ function onError(error) {
function onListening() {
const addr = server.address()
const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port
const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr?.port
serverLogger.info('Listening on ' + bind)
startPreviewService()
+4 -4
View File
@@ -62,11 +62,11 @@ async function getScreenshot(objectUrl, boundLogger = logger) {
headless: shouldBeHeadless,
userDataDir: '/tmp/puppeteer',
executablePath: '/usr/bin/google-chrome-stable',
args: ['--disable-dev-shm-usage']
// we trust the web content that is running, so can disable the sandbox
// disabling the sandbox allows us to run the docker image without linux kernel privileges
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
}
// if ( process.env.PUPPETEER_SKIP_CHROMIUM_DOWNLOAD === 'true' ) {
// launchParams.executablePath = 'chromium'
// }
const browser = await puppeteer.launch(launchParams)
const page = await browser.newPage()
@@ -1,147 +0,0 @@
enum AutomationRunStatus {
INITIALIZING
RUNNING
SUCCEEDED
FAILED
}
extend type Model {
automationStatus: AutomationsStatus
}
extend type Version {
automationStatus: AutomationsStatus
}
type AutomationsStatus {
id: ID!
status: AutomationRunStatus!
statusMessage: String
automationRuns: [AutomationRun!]!
}
# TODO: Currently not needed
# type Automation {
# automationName: String!
# automationId: String!
# automationRevisionId: String!
# createdAt: DateTime!
# runs(cursor: String, limit: Int! = 25): AutomationRunsCollection!
# }
# type AutomationRunsCollection {
# totalCount: Int!
# cursor: String
# items: [AutomationRun!]!
# }
type AutomationRun {
id: ID!
automationId: String!
automationName: String!
versionId: String!
createdAt: DateTime!
updatedAt: DateTime!
functionRuns: [AutomationFunctionRun!]!
"""
Resolved from all function run statuses
"""
status: AutomationRunStatus!
}
type AutomationFunctionRun {
id: ID!
functionId: String!
functionName: String!
functionLogo: String
elapsed: Float!
status: AutomationRunStatus!
# Context view is just a url (most likely overlaid models)
contextView: String
resultVersions: [Version!]!
statusMessage: String
"""
NOTE: this is the schema for the results field below!
Current schema: {
version: "1.0.0",
values: {
objectResults: Record<str, {
category: string
level: ObjectResultLevel
objectIds: string[]
message: str | null
metadata: Records<str, unknown> | null
visualoverrides: Records<str, unknown> | null
}[]>
blobIds?: string[]
}
}
"""
results: JSONObject # blobIds are in here
}
input AutomationCreateInput {
projectId: String!
modelId: String!
automationName: String!
automationId: String!
automationRevisionId: String!
webhookId: String
}
input FunctionRunStatusInput {
# we cannot strictly require these values, cause local testers of function, wont have it...
# Or should we?
functionId: String!
functionName: String!
functionLogo: String
elapsed: Float!
status: AutomationRunStatus!
contextView: String
resultVersionIds: [String!]!
statusMessage: String
"""
Current schema: {
version: "1.0.0",
values: {
speckleObjects: Record<ObjectId, {level: string; statusMessage: string}[]>
blobIds?: string[]
}
}
"""
results: JSONObject
}
input AutomationRunStatusUpdateInput {
versionId: String!
automationId: String!
automationRevisionId: String!
automationRunId: String!
functionRuns: [FunctionRunStatusInput!]!
}
type AutomationMutations {
functionRunStatusReport(input: AutomationRunStatusUpdateInput!): Boolean!
@hasServerRole(role: SERVER_USER)
@hasScope(scope: "automate:report-results")
create(input: AutomationCreateInput!): Boolean!
@hasServerRole(role: SERVER_USER)
@hasScope(scope: "automate:report-results")
}
extend type Mutation {
automationMutations: AutomationMutations!
}
type ProjectAutomationsStatusUpdatedMessage {
status: AutomationsStatus!
version: Version!
model: Model!
project: Project!
}
extend type Subscription {
projectAutomationsStatusUpdated(
projectId: String!
): ProjectAutomationsStatusUpdatedMessage!
}
-2
View File
@@ -32,8 +32,6 @@ generates:
Comment: '@/modules/comments/helpers/graphTypes#CommentGraphQLReturn'
PendingStreamCollaborator: '@/modules/serverinvites/helpers/graphTypes#PendingStreamCollaboratorGraphQLReturn'
FileUpload: '@/modules/fileuploads/helpers/types#FileUploadGraphQLReturn'
AutomationMutations: '@/modules/core/helpers/graphTypes#MutationsObjectGraphQLReturn'
AutomationFunctionRun: '@/modules/betaAutomations/helpers/graphTypes#AutomationFunctionRunGraphQLReturn'
AutomateFunction: '@/modules/automate/helpers/graphTypes#AutomateFunctionGraphQLReturn'
AutomateFunctionRelease: '@/modules/automate/helpers/graphTypes#AutomateFunctionReleaseGraphQLReturn'
Automation: '@/modules/automate/helpers/graphTypes#AutomationGraphQLReturn'
@@ -15,7 +15,7 @@ import {
ServerAccessRequestRecord
} from '@/modules/accessrequests/repositories'
import { StreamInvalidAccessError } from '@/modules/core/errors/stream'
import { TokenResourceIdentifier } from '@/modules/core/graph/generated/graphql'
import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types'
import { Roles, StreamRoles } from '@/modules/core/helpers/mainConstants'
import { getStream } from '@/modules/core/repositories/streams'
import {
+8 -2
View File
@@ -1,5 +1,9 @@
import { Scopes } from '@/modules/core/helpers/mainConstants'
import { speckleAutomateUrl, getServerOrigin } from '@/modules/shared/helpers/envHelper'
import {
speckleAutomateUrl,
getServerOrigin,
getFeatureFlags
} from '@/modules/shared/helpers/envHelper'
export enum DefaultAppIds {
Web = 'spklwebapp',
@@ -123,7 +127,9 @@ const SpeckleAutomate = {
Scopes.Tokens.Write,
Scopes.Streams.Read,
Scopes.Streams.Write,
Scopes.Automate.ReportResults
...(getFeatureFlags().FF_AUTOMATE_MODULE_ENABLED
? [Scopes.Automate.ReportResults]
: [])
]
}
@@ -16,6 +16,11 @@ const {
UserInputError,
UnverifiedEmailSSOLoginError
} = require('@/modules/core/errors/userinput')
const db = require('@/db/knex')
const {
deleteServerOnlyInvitesFactory,
updateAllInviteTargetsFactory
} = require('@/modules/serverinvites/repositories/serverInvites')
module.exports = async (app, session, sessionStorage, finalizeAuth) => {
const strategy = new OIDCStrategy(
@@ -95,7 +100,10 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => {
// process invites
if (myUser.isNewUser) {
await finalizeInvitedServerRegistration(user.email, myUser.id)
await finalizeInvitedServerRegistration({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
})(user.email, myUser.id)
}
return next()
@@ -126,7 +134,10 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => {
req.log = req.log.child({ userId: myUser.id })
// use the invite
await finalizeInvitedServerRegistration(user.email, myUser.id)
await finalizeInvitedServerRegistration({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
})(user.email, myUser.id)
// Resolve redirect path
req.authRedirectPath = resolveAuthRedirectPath(validInvite)
@@ -17,6 +17,11 @@ const {
UserInputError,
UnverifiedEmailSSOLoginError
} = require('@/modules/core/errors/userinput')
const db = require('@/db/knex')
const {
deleteServerOnlyInvitesFactory,
updateAllInviteTargetsFactory
} = require('@/modules/serverinvites/repositories/serverInvites')
module.exports = async (app, session, sessionStorage, finalizeAuth) => {
const strategy = {
@@ -72,7 +77,10 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => {
// process invites
if (myUser.isNewUser) {
await finalizeInvitedServerRegistration(user.email, myUser.id)
await finalizeInvitedServerRegistration({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
})(user.email, myUser.id)
}
return done(null, myUser)
@@ -98,7 +106,10 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => {
})
// use the invite
await finalizeInvitedServerRegistration(user.email, myUser.id)
await finalizeInvitedServerRegistration({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
})(user.email, myUser.id)
// Resolve redirect path
req.authRedirectPath = resolveAuthRedirectPath(validInvite)
@@ -15,6 +15,11 @@ const {
UserInputError,
UnverifiedEmailSSOLoginError
} = require('@/modules/core/errors/userinput')
const db = require('@/db/knex')
const {
deleteServerOnlyInvitesFactory,
updateAllInviteTargetsFactory
} = require('@/modules/serverinvites/repositories/serverInvites')
module.exports = async (app, session, sessionStorage, finalizeAuth) => {
const strategy = {
@@ -66,7 +71,10 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => {
// process invites
if (myUser.isNewUser) {
await finalizeInvitedServerRegistration(user.email, myUser.id)
await finalizeInvitedServerRegistration({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
})(user.email, myUser.id)
}
return done(null, myUser)
@@ -92,7 +100,10 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => {
})
// use the invite
await finalizeInvitedServerRegistration(user.email, myUser.id)
await finalizeInvitedServerRegistration({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
})(user.email, myUser.id)
// Resolve redirect path
req.authRedirectPath = resolveAuthRedirectPath(validInvite)
@@ -21,6 +21,12 @@ const {
UserInputError,
PasswordTooShortError
} = require('@/modules/core/errors/userinput')
const {
findServerInviteFactory,
deleteServerOnlyInvitesFactory,
updateAllInviteTargetsFactory
} = require('@/modules/serverinvites/repositories/serverInvites')
const db = require('@/db/knex')
module.exports = async (app, session, sessionAppId, finalizeAuth) => {
const strategy = {
@@ -82,10 +88,12 @@ module.exports = async (app, session, sessionAppId, finalizeAuth) => {
)
// 2. if you have an invite it must be valid, both for invite only and public servers
/** @type {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} */
/** @type {import('@/modules/serverinvites/domain/types').ServerInviteRecord} */
let invite
if (req.session.token) {
invite = await validateServerInvite(user.email, req.session.token)
invite = await validateServerInvite({
findServerInvite: findServerInviteFactory({ db })
})(user.email, req.session.token)
}
// 3. at this point we know, that we have one of these cases:
@@ -106,7 +114,10 @@ module.exports = async (app, session, sessionAppId, finalizeAuth) => {
req.log = req.log.child({ userId })
// 4. use up all server-only invites the email had attached to it
await finalizeInvitedServerRegistration(user.email, userId)
await finalizeInvitedServerRegistration({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
})(user.email, userId)
// Resolve redirect path
req.authRedirectPath = resolveAuthRedirectPath(invite)
@@ -21,6 +21,11 @@ const {
} = require('@/modules/shared/helpers/envHelper')
const { passportAuthenticate } = require('@/modules/auth/services/passportService')
const { UnverifiedEmailSSOLoginError } = require('@/modules/core/errors/userinput')
const {
deleteServerOnlyInvitesFactory,
updateAllInviteTargetsFactory
} = require('@/modules/serverinvites/repositories/serverInvites')
const db = require('@/db/knex')
const { getNameFromUserInfo } = require('@/modules/auth/domain/logic')
module.exports = async (app, session, sessionStorage, finalizeAuth) => {
@@ -83,7 +88,10 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => {
// process invites
if (myUser.isNewUser) {
await finalizeInvitedServerRegistration(user.email, myUser.id)
await finalizeInvitedServerRegistration({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
})(user.email, myUser.id)
}
return done(null, myUser)
}
@@ -108,7 +116,10 @@ module.exports = async (app, session, sessionStorage, finalizeAuth) => {
rawProfile: userinfo
})
await finalizeInvitedServerRegistration(user.email, myUser.id)
await finalizeInvitedServerRegistration({
deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }),
updateAllInviteTargets: updateAllInviteTargetsFactory({ db })
})(user.email, myUser.id)
// Resolve redirect path
req.authRedirectPath = resolveAuthRedirectPath(validInvite)
@@ -9,10 +9,15 @@ const { getUserByEmail } = require('@/modules/core/services/users')
const { TIME } = require('@speckle/shared')
const { RATE_LIMITERS, createConsumer } = require('@/modules/core/services/ratelimiter')
const { beforeEachContext, initializeTestServer } = require('@/test/hooks')
const { createInviteDirectly } = require('@/test/speckle-helpers/inviteHelper')
const { getInvite } = require('@/modules/serverinvites/repositories')
const { createInviteDirectlyFactory } = require('@/test/speckle-helpers/inviteHelper')
const { RateLimiterMemory } = require('rate-limiter-flexible')
const {
findInviteFactory
} = require('@/modules/serverinvites/repositories/serverInvites')
const db = require('@/db/knex')
const createInviteDirectly = createInviteDirectlyFactory({ db })
const findInvite = findInviteFactory({ db })
const expect = chai.expect
let app
@@ -155,7 +160,7 @@ describe('Auth @auth', () => {
expect(newUser).to.be.ok
// Check that in the case of a stream invite, it remainds valid post registration
const inviteRecord = await getInvite(inviteId)
const inviteRecord = await findInvite(inviteId)
if (streamInvite) {
expect(inviteRecord).to.be.ok
} else {
@@ -0,0 +1,102 @@
import { Knex } from 'knex'
const AUTOMATIONS_TABLE_NAME_NEW = 'beta_automations'
const AUTOMATION_RUNS_TABLE_NAME_NEW = 'beta_automation_runs'
const AUTOMATION_FUNCTION_RUNS_TABLE_NAME_NEW = 'beta_automation_function_runs'
const AUTOMATION_FUNCTION_RUNS_RESULT_VERSIONS_TABLE_NAME_NEW =
'beta_automation_function_runs_result_versions'
const tableNames = [
AUTOMATION_FUNCTION_RUNS_RESULT_VERSIONS_TABLE_NAME_NEW,
AUTOMATION_FUNCTION_RUNS_TABLE_NAME_NEW,
AUTOMATION_RUNS_TABLE_NAME_NEW,
AUTOMATIONS_TABLE_NAME_NEW
]
const currentBetaTableCreationScript = `
create table beta_automations
(
"automationId" varchar(255) not null,
"automationRevisionId" varchar(255) not null,
"automationName" varchar(255) not null,
"projectId" varchar(255) not null
constraint automations_projectid_foreign
references streams
on delete cascade,
"modelId" varchar(255) not null
constraint automations_modelid_foreign
references branches
on delete cascade,
"createdAt" timestamp(3) with time zone default CURRENT_TIMESTAMP not null,
"updatedAt" timestamp(3) with time zone default CURRENT_TIMESTAMP not null,
"webhookId" varchar(255)
constraint automations_webhookid_foreign
references webhooks_config
on delete set null,
constraint automations_pkey
primary key ("automationId", "automationRevisionId")
);
create table beta_automation_runs
(
"automationId" varchar(255) not null,
"automationRevisionId" varchar(255) not null,
"versionId" varchar(255) not null
constraint automation_runs_versionid_foreign
references commits
on delete cascade,
"automationRunId" varchar(255) not null
constraint automation_runs_pkey
primary key,
"createdAt" timestamp(3) with time zone default CURRENT_TIMESTAMP not null,
"updatedAt" timestamp(3) with time zone default CURRENT_TIMESTAMP not null,
constraint automation_runs_automationid_automationrevisionid_foreign
foreign key ("automationId", "automationRevisionId") references beta_automations
on delete cascade
);
create table beta_automation_function_runs
(
"automationRunId" varchar(255) not null
constraint automation_function_runs_automationrunid_foreign
references beta_automation_runs
on delete cascade,
"functionId" varchar(255) not null,
elapsed real not null,
status varchar(255) not null,
"contextView" varchar(255),
"statusMessage" varchar(255),
results jsonb,
"functionName" varchar(255) default 'majestic function'::character varying not null,
"functionLogo" text,
constraint automation_function_runs_pkey
primary key ("automationRunId", "functionId")
);
create table beta_automation_function_runs_result_versions
(
"automationRunId" varchar(255) not null,
"functionId" varchar(255) not null,
"resultVersionId" varchar(255) not null
constraint automation_function_runs_result_versions_resultversionid_foreig
references commits
on delete cascade,
constraint automation_function_runs_result_versions_pkey
primary key ("automationRunId", "functionId", "resultVersionId"),
constraint automation_function_runs_result_versions_automationrunid_functi
foreign key ("automationRunId", "functionId") references beta_automation_function_runs
on delete cascade
);
`
export async function up(knex: Knex): Promise<void> {
// Delete in order
for (const tableName of tableNames) {
await knex.schema.dropTableIfExists(tableName)
}
}
export async function down(knex: Knex): Promise<void> {
// recreate tables from script
await knex.raw(currentBetaTableCreationScript)
}
@@ -1,6 +0,0 @@
import { BaseError } from '@/modules/shared/errors/base'
export class AutomationNotFoundError extends BaseError {
static defaultMessage = 'Attempting to work with a non-existant automation'
static code = 'AUTOMATION_NOT_FOUND'
}
@@ -1,100 +0,0 @@
import {
createModelAutomation,
getAutomationsStatus,
upsertModelAutomationRunResult
} from '@/modules/betaAutomations/services/management'
import { formatResults } from '@/modules/betaAutomations/services/results'
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import { getStream } from '@/modules/core/repositories/streams'
import {
ProjectSubscriptions,
filteredSubscribe
} from '@/modules/shared/utils/subscriptions'
import { ForbiddenError } from 'apollo-server-express'
export = {
Model: {
async automationStatus(parent, _, ctx) {
const modelId = parent.id
const projectId = parent.streamId
const latestCommit = await ctx.loaders.branches.getLatestCommit.load(parent.id)
// if the model has no versions, no automations could have run
if (!latestCommit) return null
return await getAutomationsStatus({
projectId,
modelId,
versionId: latestCommit.id
})
}
},
Version: {
async automationStatus(parent, _, ctx) {
const versionId = parent.id
const branch = await ctx.loaders.commits.getCommitBranch.load(versionId)
if (!branch) throw Error('Invalid version Id')
const projectId = branch.streamId
const modelId = branch.id
return await getAutomationsStatus({
projectId,
modelId,
versionId
})
}
},
AutomationFunctionRun: {
async resultVersions(parent, _, ctx) {
return ctx.loaders.automationFunctionRuns.getResultVersions.load([
parent.automationRunId,
parent.functionId
])
},
async results(parent) {
const originalResults = parent.results
if (!originalResults) return null
return formatResults(originalResults)
},
id(parent) {
return `${parent.automationRunId}-${parent.functionId}`
}
},
Mutation: {
automationMutations: () => ({})
},
AutomationMutations: {
async create(_, args, context) {
await createModelAutomation(args.input, context.userId)
return true
},
async functionRunStatusReport(_, args, context) {
const { userId, log } = context
await upsertModelAutomationRunResult({ userId, input: args.input, logger: log })
return true
}
},
Subscription: {
projectAutomationsStatusUpdated: {
subscribe: filteredSubscribe(
ProjectSubscriptions.ProjectAutomationStatusUpdated,
async (payload, variables, context) => {
if (payload.projectId !== variables.projectId) return false
const stream = await getStream({
streamId: variables.projectId,
userId: context.userId
})
if (
!stream ||
(!(stream.isDiscoverable || stream.isPublic) && !stream.role)
) {
throw new ForbiddenError('You are not authorized.')
}
return true
}
)
}
}
} as Resolvers
@@ -1,3 +0,0 @@
import { AutomationFunctionRunRecord } from '@/modules/betaAutomations/helpers/types'
export type AutomationFunctionRunGraphQLReturn = AutomationFunctionRunRecord
@@ -1,79 +0,0 @@
import { AutomationRunStatus } from '@/modules/core/graph/generated/graphql'
import { z } from 'zod'
const CurrentObjectResultsVersion = '1.0.0'
const SupportedObjectResultsVersions = [CurrentObjectResultsVersion] as const
const ObjectResultLevel = ['INFO', 'WARNING', 'ERROR'] as const
const ObjectResultLevelEnum = z.enum(ObjectResultLevel)
const ObjectResultValuesSchema = z.object({
level: ObjectResultLevelEnum,
message: z.string().nullable(),
category: z.string(),
objectIds: z.string().array().nonempty(),
metadata: z.record(z.string(), z.unknown()).nullable(),
visualOverrides: z.record(z.string(), z.unknown()).nullable()
})
const FirstVersionResultsSchema = z.object({
version: z.enum(SupportedObjectResultsVersions),
values: z.object({
objectResults: ObjectResultValuesSchema.array(),
blobIds: z.string().array().optional()
})
})
export type FirstVersionResults = z.infer<typeof FirstVersionResultsSchema>
export type CurrentVersionResults = FirstVersionResults
// As new versions are added, add the type to this union
export type Results = FirstVersionResults // | SecondVersionResults | ThirdVersionResults
const FunctionRunStatusSchema = z
.object({
functionId: z.string().min(1),
functionName: z.string().min(1),
functionLogo: z.string().nullable(),
elapsed: z.number(),
status: z.nativeEnum(AutomationRunStatus),
contextView: z
.string()
.nullable()
.default(null)
.refine(
(v) => {
if (v === null) return true
return !!/^\/projects\/[a-zA-Z0-9]+\/models\//i.exec(v)
},
{ message: 'Invalid relative viewer URL' }
),
resultVersionIds: z.string().array(),
statusMessage: z.string().nullable().default(null),
results: FirstVersionResultsSchema.nullable().default(null)
})
.refine(
(schema) => {
if (schema.status === AutomationRunStatus.Succeeded && !schema.results) {
return false
}
return true
},
{ message: 'Results must be provided for successful runs' }
)
export const AutomationRunSchema = z.object({
automationId: z.string().min(1),
automationRevisionId: z.string().min(1),
automationRunId: z.string().min(1),
versionId: z.string().min(1),
createdAt: z.date(),
updatedAt: z.date(),
functionRuns: z.array(FunctionRunStatusSchema).min(1)
})
export type AutomationRun = z.infer<typeof AutomationRunSchema>
@@ -1,41 +0,0 @@
import { Results } from '@/modules/betaAutomations/helpers/inputTypes'
import { AutomationRunStatus } from '@/modules/core/graph/generated/graphql'
import { Nullable } from '@speckle/shared'
export type AutomationRecord = {
automationId: string
projectId: string
modelId: string
createdAt: Date
updatedAt: Date
automationRevisionId: string
automationName: string
}
export type AutomationRunRecord = {
automationId: string
automationRevisionId: string
automationRunId: string
versionId: string
createdAt: Date
updatedAt: Date
automationName: string
}
export type AutomationFunctionRunRecord = {
automationRunId: string
functionId: string
functionName: string
functionLogo: string | null
elapsed: number
status: AutomationRunStatus
contextView: Nullable<string>
statusMessage: Nullable<string>
results: Nullable<Results>
}
export type AutomationFunctionRunsResultVersionRecord = {
automationRunId: string
functionId: string
resultVersionId: string
}
@@ -1,30 +0,0 @@
import { moduleLogger } from '@/logging/logging'
import { TokenScopeData } from '@/modules/shared/domain/rolesAndScopes/types'
import { registerOrUpdateScope } from '@/modules/shared/repositories/scopes'
import { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
import { Scopes } from '@speckle/shared'
import db from '@/db/knex'
async function initScopes() {
const scopes: TokenScopeData[] = [
{
name: Scopes.Automate.ReportResults,
description: 'Report automation results to the server.',
public: true
}
]
const registerFunc = registerOrUpdateScope({ db })
for (const scope of scopes) {
await registerFunc({ scope })
}
}
const automationModule: SpeckleModule = {
async init() {
moduleLogger.info('🤖 Init BETA automate module')
await initScopes()
}
}
export = automationModule
@@ -1,216 +0,0 @@
import { Logger } from '@/logging/logging'
import {
AutomationFunctionRunRecord,
AutomationFunctionRunsResultVersionRecord,
AutomationRecord,
AutomationRunRecord
} from '@/modules/betaAutomations/helpers/types'
import {
BetaAutomations,
BetaAutomationRuns,
BetaAutomationFunctionRuns,
BetaAutomationFunctionRunsResultVersions,
Commits
} from '@/modules/core/dbSchema'
import { CommitRecord } from '@/modules/core/helpers/types'
import { Optional } from '@speckle/shared'
import { isArray, pick } from 'lodash'
import { SetOptional } from 'type-fest'
export const upsertAutomation = async (
automation: SetOptional<AutomationRecord, 'createdAt' | 'updatedAt'>
) =>
await BetaAutomations.knex()
.insert(pick(automation, BetaAutomations.withoutTablePrefix.cols))
.onConflict([
BetaAutomations.withoutTablePrefix.col.automationId,
BetaAutomations.withoutTablePrefix.col.automationRevisionId
])
.merge(
BetaAutomations.withoutTablePrefix.cols.filter(
(c) => c !== BetaAutomations.withoutTablePrefix.col.createdAt
)
)
export const getAutomation = async (
automationId: string
): Promise<Optional<AutomationRecord>> => {
return await BetaAutomations.knex<AutomationRecord>()
.where({ [BetaAutomations.col.automationId]: automationId })
.first()
}
export const upsertAutomationRunData = async (automationRun: AutomationRunRecord) => {
const insertModel = pick(
automationRun,
BetaAutomationRuns.withoutTablePrefix.cols
) as AutomationRunRecord
return await BetaAutomationRuns.knex()
.insert(insertModel)
.onConflict(BetaAutomationRuns.withoutTablePrefix.col.automationRunId)
.merge()
}
export const upsertAutomationFunctionRunData = async (
automationFunctionRuns: AutomationFunctionRunRecord | AutomationFunctionRunRecord[],
logger: Logger
) => {
const runs = isArray(automationFunctionRuns)
? automationFunctionRuns
: [automationFunctionRuns]
logger.info({ runs }, 'Upserting runs.')
const normalizedModels = runs.map((run) => {
return pick(
run,
BetaAutomationFunctionRuns.withoutTablePrefix.cols
) as AutomationFunctionRunRecord
})
logger.info({ normalizedModels }, 'Normalized runs.')
return await BetaAutomationFunctionRuns.knex()
.insert(normalizedModels)
.onConflict([
BetaAutomationFunctionRuns.withoutTablePrefix.col.automationRunId,
BetaAutomationFunctionRuns.withoutTablePrefix.col.functionId
])
.merge()
}
export const insertAutomationFunctionRunResultVersion = async (
functionRunVersions:
| AutomationFunctionRunsResultVersionRecord
| AutomationFunctionRunsResultVersionRecord[]
) => {
const versions = isArray(functionRunVersions)
? functionRunVersions
: [functionRunVersions]
if (!versions.length) return
const normalizedModels = versions.map((run) => {
return pick(
run,
BetaAutomationFunctionRunsResultVersions.withoutTablePrefix.cols
) as AutomationFunctionRunsResultVersionRecord
})
return await BetaAutomationFunctionRunsResultVersions.knex().insert(normalizedModels)
}
export const deleteResultVersionsForRuns = async (
keyPairs: [functionId: string, automationRunId: string][]
) => {
return await BetaAutomationFunctionRunsResultVersions.knex()
.whereIn(
[
BetaAutomationFunctionRunsResultVersions.col.functionId,
BetaAutomationFunctionRunsResultVersions.col.automationRunId
],
keyPairs
)
.del()
}
export const getAutomationRun = async (
automationRunId: string
): Promise<Optional<AutomationRunRecord>> => {
return await BetaAutomationRuns.knex<AutomationRunRecord>()
.where({ [BetaAutomationRuns.col.automationRunId]: automationRunId })
.first()
}
export const getLatestAutomationRunsFor = async (
params: {
projectId: string
modelId: string
versionId: string
},
options?: Partial<{
limit: number
}>
): Promise<AutomationRunRecord[]> => {
const { projectId, modelId, versionId } = params
const { limit = 20 } = options || {}
const runs = await BetaAutomationRuns.knex()
.select<AutomationRunRecord[]>([
...BetaAutomationRuns.cols,
`${BetaAutomations.name}.${BetaAutomations.withoutTablePrefix.col.automationName}`
])
.join(
BetaAutomations.name,
BetaAutomationRuns.col.automationId,
BetaAutomations.col.automationId
)
.where({ [BetaAutomations.col.projectId]: projectId })
.andWhere({ [BetaAutomations.col.modelId]: modelId })
.andWhere({ [BetaAutomationRuns.col.versionId]: versionId })
.distinctOn(BetaAutomationRuns.col.automationId)
.orderBy([
{ column: BetaAutomationRuns.col.automationId },
{ column: BetaAutomationRuns.col.createdAt, order: 'desc' }
])
.limit(limit)
return runs
}
/**
* Get function runs for automation runs. The result is an object keyed by automationRunId,
* with each value being a map between functionId and function run.
*/
export const getFunctionRunsForAutomationRuns = async (
automationRunids: string[]
): Promise<Record<string, Record<string, AutomationFunctionRunRecord>>> => {
const runs = await BetaAutomationFunctionRuns.knex()
.select<AutomationFunctionRunRecord[]>(BetaAutomationFunctionRuns.cols)
.whereIn(BetaAutomationFunctionRuns.col.automationRunId, automationRunids)
const grouped = runs.reduce((acc, run) => {
if (!acc[run.automationRunId]) acc[run.automationRunId] = {}
acc[run.automationRunId][run.functionId] = run
return acc
}, {} as Record<string, Record<string, AutomationFunctionRunRecord>>)
return grouped
}
/**
* Get versions/commits for specified function runs. The result is an object keyed by automationRunId,
* with each value being a map between functionId and a commit.
*/
export const getAutomationFunctionRunResultVersions = async (
idPairs: Array<[automationRunId: string, functionId: string]>
): Promise<Record<string, Record<string, CommitRecord[]>>> => {
const q = BetaAutomationFunctionRunsResultVersions.knex()
.select<Array<CommitRecord & AutomationFunctionRunsResultVersionRecord>>(
...Commits.cols,
...BetaAutomationFunctionRunsResultVersions.cols
)
.innerJoin(
Commits.name,
BetaAutomationFunctionRunsResultVersions.col.resultVersionId,
Commits.col.id
)
.whereIn(
[
BetaAutomationFunctionRunsResultVersions.col.automationRunId,
BetaAutomationFunctionRunsResultVersions.col.functionId
],
idPairs
)
const versions = await q
const grouped = versions.reduce((acc, version) => {
if (!acc[version.automationRunId]) acc[version.automationRunId] = {}
if (!acc[version.automationRunId][version.functionId])
acc[version.automationRunId][version.functionId] = []
acc[version.automationRunId][version.functionId].push(version)
return acc
}, {} as Record<string, Record<string, CommitRecord[]>>)
return grouped
}
@@ -1,283 +0,0 @@
import { getBranchById } from '@/modules/core/repositories/branches'
import { getStream } from '@/modules/core/repositories/streams'
import { MaybeNullOrUndefined, Roles } from '@speckle/shared'
import {
getAutomationRun,
getAutomation,
upsertAutomation,
upsertAutomationFunctionRunData,
insertAutomationFunctionRunResultVersion,
getLatestAutomationRunsFor,
getFunctionRunsForAutomationRuns,
deleteResultVersionsForRuns
} from '@/modules/betaAutomations/repositories/automations'
import _, { flatMap, uniqBy } from 'lodash'
import {
AutomationCreateInput,
AutomationRunStatusUpdateInput,
AutomationRunStatus,
AutomationRun
} from '@/modules/core/graph/generated/graphql'
import { upsertAutomationRunData } from '@/modules/betaAutomations/repositories/automations'
import {
AutomationFunctionRunRecord,
AutomationFunctionRunsResultVersionRecord,
AutomationRunRecord
} from '@/modules/betaAutomations/helpers/types'
import { ForbiddenError } from '@/modules/shared/errors'
import { Merge } from 'type-fest'
import { AutomationFunctionRunGraphQLReturn } from '@/modules/betaAutomations/helpers/graphTypes'
import { AutomationRunSchema } from '@/modules/betaAutomations/helpers/inputTypes'
import { StreamNotFoundError } from '@/modules/core/errors/stream'
import { BranchNotFoundError } from '@/modules/core/errors/branch'
import {
getCommits,
getCommit,
getCommitBranch
} from '@/modules/core/repositories/commits'
import { AutomationNotFoundError } from '@/modules/betaAutomations/errors/automations'
import { CommitNotFoundError } from '@/modules/core/errors/commit'
import { ProjectSubscriptions, publish } from '@/modules/shared/utils/subscriptions'
import { Logger } from '@/logging/logging'
type AutomationRunWithFunctionRunsRecord = AutomationRunRecord & {
functionRuns: AutomationFunctionRunRecord[]
}
export const createModelAutomation = async (
automation: AutomationCreateInput,
userId?: string
) => {
// stream acl for user
const stream = await getStream({ userId, streamId: automation.projectId })
if (!stream) throw new StreamNotFoundError('Project not found')
if (stream.role !== Roles.Stream.Owner)
throw new ForbiddenError('Only project owners are allowed.')
const branch = await getBranchById(automation.modelId, {
streamId: automation.projectId
})
if (!branch) throw new BranchNotFoundError('Model not found')
const insertModel = { ...automation, modelId: branch.id, createdAt: new Date() }
await upsertAutomation(insertModel)
}
export async function upsertModelAutomationRunResult({
userId,
input,
logger
}: {
userId: MaybeNullOrUndefined<string>
input: AutomationRunStatusUpdateInput
logger: Logger
}) {
logger.info({ input }, 'Received automation run result data')
// validate input against schema
const validatedInput = AutomationRunSchema.parse({
...input,
createdAt: new Date(),
updatedAt: new Date()
})
logger.info({ validatedInput }, 'Validated automation run result data')
// get the automation from the DB
const automation = await getAutomation(input.automationId)
if (!automation) throw new AutomationNotFoundError()
const [stream, version, model] = await Promise.all([
getStream({
userId: userId || undefined,
streamId: automation.projectId
}),
getCommit(validatedInput.versionId, {
streamId: automation.projectId
}),
getCommitBranch(validatedInput.versionId)
])
// this is never going to happen, cause the automation has an FK to the streamId
if (!stream) throw new StreamNotFoundError('Project not found')
if (stream.role !== Roles.Stream.Owner)
throw new ForbiddenError('Only project owners are allowed')
if (!version) throw new CommitNotFoundError()
if (!model) throw new BranchNotFoundError()
// store the result of the run, if it already exists, patch it
const maybeAutomationRun = await getAutomationRun(input.automationRunId)
if (maybeAutomationRun) {
// some bits we do not allow overriding
validatedInput.createdAt = maybeAutomationRun.createdAt
validatedInput.versionId = maybeAutomationRun.versionId
validatedInput.automationId = maybeAutomationRun.automationId
validatedInput.automationRevisionId = maybeAutomationRun.automationRevisionId
}
await upsertAutomationRunData({ ...validatedInput, automationName: 'pasta' })
// upsert run function runs
const runs = uniqBy(
validatedInput.functionRuns.map(
(s): AutomationFunctionRunRecord => ({
...s,
automationRunId: validatedInput.automationRunId
})
),
(v) => `${v.automationRunId}-${v.functionId}`
)
logger.info({ runs }, 'Uniqued automation run result data')
await upsertAutomationFunctionRunData(runs, logger)
// create new result version records
const versionsRecords: AutomationFunctionRunsResultVersionRecord[] = flatMap(
validatedInput.functionRuns
.filter((s) => s.resultVersionIds?.length)
.map((s) => ({
functionId: s.functionId,
automationRunId: validatedInput.automationRunId,
resultVersionIds: s.resultVersionIds
})),
(i) => {
return i.resultVersionIds.map((v) => ({
functionId: i.functionId,
automationRunId: i.automationRunId,
resultVersionId: v
}))
}
)
logger.info({ versionsRecords }, 'Version records flat mapped')
const validatedVersions = await getCommits(
versionsRecords.map((r) => r.resultVersionId)
)
const validVersionsRecords = uniqBy(
versionsRecords.filter((r) =>
validatedVersions.find(
(vv) => vv.id === r.resultVersionId && vv.streamId === stream.id
)
),
(v) => `${v.automationRunId}-${v.functionId}-${v.resultVersionId}`
)
// delete old/stale versions and re-insert new valid ones (in case this is an update to an existing run)
await deleteResultVersionsForRuns(
validatedInput.functionRuns.map((s) => [
s.functionId,
validatedInput.automationRunId
])
)
await insertAutomationFunctionRunResultVersion(validVersionsRecords)
// Emit subscription
const newStatus = await getAutomationsStatus({
modelId: version.branchId,
projectId: stream.id,
versionId: version.id
})
logger.info({ newStatus }, 'Emiting new status event')
if (newStatus) {
await publish(ProjectSubscriptions.ProjectAutomationStatusUpdated, {
projectId: stream.id,
projectAutomationsStatusUpdated: {
status: newStatus,
version,
project: stream,
model
}
})
}
}
const anyFunctionRunsHaveStatus = (
ar: AutomationRunWithFunctionRunsRecord,
status: AutomationRunStatus
) => ar.functionRuns.some((st) => st.status === status)
const anyFunctionRunsHaveFailed = (ar: AutomationRunWithFunctionRunsRecord): boolean =>
anyFunctionRunsHaveStatus(ar, AutomationRunStatus.Failed)
const anyFunctionRunsRunning = (ar: AutomationRunWithFunctionRunsRecord): boolean =>
anyFunctionRunsHaveStatus(ar, AutomationRunStatus.Running)
const anyFunctionRunsInitializing = (
ar: AutomationRunWithFunctionRunsRecord
): boolean => anyFunctionRunsHaveStatus(ar, AutomationRunStatus.Initializing)
export const getAutomationsStatus = async ({
projectId,
modelId,
versionId
}: {
projectId: string
modelId: string
versionId: string
}) => {
const automationRunRecords = await getLatestAutomationRunsFor({
projectId,
modelId,
versionId
})
if (!automationRunRecords.length) return null
const functionRuns = await getFunctionRunsForAutomationRuns(
automationRunRecords.map((r) => r.automationRunId)
)
const runsWithFunctionRuns: AutomationRunWithFunctionRunsRecord[] =
automationRunRecords.map((ar) => {
return {
...ar,
functionRuns: Object.values(functionRuns[ar.automationRunId] || {})
}
})
const automationRuns: Array<
Merge<AutomationRun, { functionRuns: AutomationFunctionRunGraphQLReturn[] }>
> = runsWithFunctionRuns.map((ar) => {
let status: AutomationRunStatus = AutomationRunStatus.Succeeded
if (anyFunctionRunsHaveFailed(ar)) {
status = AutomationRunStatus.Failed
} else if (anyFunctionRunsRunning(ar)) {
status = AutomationRunStatus.Running
} else if (anyFunctionRunsInitializing(ar)) {
status = AutomationRunStatus.Initializing
}
return { ..._.cloneDeep(ar), status, id: ar.automationRunId }
})
const failedAutomations = automationRuns.filter(
(a) => a.status === AutomationRunStatus.Failed
)
const runningAutomations = automationRuns.filter(
(a) => a.status === AutomationRunStatus.Running
)
const initializingAutomations = automationRuns.filter(
(a) => a.status === AutomationRunStatus.Initializing
)
let status = AutomationRunStatus.Succeeded
let statusMessage = 'All automations have succeeded'
if (failedAutomations.length) {
status = AutomationRunStatus.Failed
statusMessage = 'Some automations have failed:'
for (const fa of failedAutomations) {
for (const functionRunStatus of fa.functionRuns) {
if (functionRunStatus.status === AutomationRunStatus.Failed)
statusMessage += `\n${functionRunStatus.statusMessage}`
}
}
} else if (runningAutomations.length) {
status = AutomationRunStatus.Running
statusMessage = 'Some automations are running'
} else if (initializingAutomations.length) {
status = AutomationRunStatus.Initializing
statusMessage = 'Some automations are initializing'
}
return {
status,
automationRuns,
statusMessage,
id: versionId
}
}
@@ -1,10 +0,0 @@
import {
Results,
CurrentVersionResults
} from '@/modules/betaAutomations/helpers/inputTypes'
export const formatResults = (results: Results): CurrentVersionResults => {
// TODO: As new versions are introduced, make sure this function is updated
// and able to convert all of them to `CurrentVersionResults`
return results
}
-40
View File
@@ -490,46 +490,6 @@ export const FileUploads = buildTableHelper('file_uploads', [
'convertedCommitId'
])
export const BetaAutomations = buildTableHelper('beta_automations', [
'automationId',
'automationRevisionId',
'automationName',
'projectId',
'modelId',
'createdAt',
'updatedAt',
'webhookId'
])
export const BetaAutomationRuns = buildTableHelper('beta_automation_runs', [
'automationId',
'automationRevisionId',
'automationRunId',
'versionId',
'createdAt',
'updatedAt'
])
export const BetaAutomationFunctionRuns = buildTableHelper(
'beta_automation_function_runs',
[
'automationRunId',
'functionId',
'functionName',
'functionLogo',
'elapsed',
'status',
'contextView',
'statusMessage',
'results'
]
)
export const BetaAutomationFunctionRunsResultVersions = buildTableHelper(
'beta_automation_function_runs_result_versions',
['automationRunId', 'functionId', 'resultVersionId']
)
export const ServerAppsScopes = buildTableHelper('server_apps_scopes', [
'appId',
'scopeName'
@@ -0,0 +1,9 @@
export const TokenResourceIdentifierType = {
Project: 'project'
} as const
export type TokenResourceIdentifierType =
(typeof TokenResourceIdentifierType)[keyof typeof TokenResourceIdentifierType]
// TODO: these should be moved to domain
export type TokenResourceIdentifier = { id: string; type: TokenResourceIdentifierType }
@@ -4,7 +4,6 @@ import { StreamAccessRequestGraphQLReturn } from '@/modules/accessrequests/helpe
import { CommentReplyAuthorCollectionGraphQLReturn, CommentGraphQLReturn } from '@/modules/comments/helpers/graphTypes';
import { PendingStreamCollaboratorGraphQLReturn } from '@/modules/serverinvites/helpers/graphTypes';
import { FileUploadGraphQLReturn } from '@/modules/fileuploads/helpers/types';
import { AutomationFunctionRunGraphQLReturn } from '@/modules/betaAutomations/helpers/graphTypes';
import { AutomateFunctionGraphQLReturn, AutomateFunctionReleaseGraphQLReturn, AutomationGraphQLReturn, AutomationRevisionGraphQLReturn, AutomationRevisionFunctionGraphQLReturn, AutomateRunGraphQLReturn, AutomationRunTriggerGraphQLReturn, AutomationRevisionTriggerDefinitionGraphQLReturn, AutomateFunctionRunGraphQLReturn, TriggeredAutomationsStatusGraphQLReturn, ProjectAutomationMutationsGraphQLReturn, ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn, ProjectAutomationsUpdatedMessageGraphQLReturn, UserAutomateInfoGraphQLReturn } from '@/modules/automate/helpers/graphTypes';
import { GraphQLContext } from '@/modules/shared/helpers/typeHelper';
export type Maybe<T> = T | null;
@@ -384,62 +383,6 @@ export type AutomationCollection = {
totalCount: Scalars['Int']['output'];
};
export type AutomationCreateInput = {
automationId: Scalars['String']['input'];
automationName: Scalars['String']['input'];
automationRevisionId: Scalars['String']['input'];
modelId: Scalars['String']['input'];
projectId: Scalars['String']['input'];
webhookId?: InputMaybe<Scalars['String']['input']>;
};
export type AutomationFunctionRun = {
__typename?: 'AutomationFunctionRun';
contextView?: Maybe<Scalars['String']['output']>;
elapsed: Scalars['Float']['output'];
functionId: Scalars['String']['output'];
functionLogo?: Maybe<Scalars['String']['output']>;
functionName: Scalars['String']['output'];
id: Scalars['ID']['output'];
resultVersions: Array<Version>;
/**
* NOTE: this is the schema for the results field below!
* Current schema: {
* version: "1.0.0",
* values: {
* objectResults: Record<str, {
* category: string
* level: ObjectResultLevel
* objectIds: string[]
* message: str | null
* metadata: Records<str, unknown> | null
* visualoverrides: Records<str, unknown> | null
* }[]>
* blobIds?: string[]
* }
* }
*/
results?: Maybe<Scalars['JSONObject']['output']>;
status: AutomationRunStatus;
statusMessage?: Maybe<Scalars['String']['output']>;
};
export type AutomationMutations = {
__typename?: 'AutomationMutations';
create: Scalars['Boolean']['output'];
functionRunStatusReport: Scalars['Boolean']['output'];
};
export type AutomationMutationsCreateArgs = {
input: AutomationCreateInput;
};
export type AutomationMutationsFunctionRunStatusReportArgs = {
input: AutomationRunStatusUpdateInput;
};
export type AutomationRevision = {
__typename?: 'AutomationRevision';
functions: Array<AutomationRevisionFunction>;
@@ -463,44 +406,8 @@ export type AutomationRevisionFunction = {
export type AutomationRevisionTriggerDefinition = VersionCreatedTriggerDefinition;
export type AutomationRun = {
__typename?: 'AutomationRun';
automationId: Scalars['String']['output'];
automationName: Scalars['String']['output'];
createdAt: Scalars['DateTime']['output'];
functionRuns: Array<AutomationFunctionRun>;
id: Scalars['ID']['output'];
/** Resolved from all function run statuses */
status: AutomationRunStatus;
updatedAt: Scalars['DateTime']['output'];
versionId: Scalars['String']['output'];
};
export enum AutomationRunStatus {
Failed = 'FAILED',
Initializing = 'INITIALIZING',
Running = 'RUNNING',
Succeeded = 'SUCCEEDED'
}
export type AutomationRunStatusUpdateInput = {
automationId: Scalars['String']['input'];
automationRevisionId: Scalars['String']['input'];
automationRunId: Scalars['String']['input'];
functionRuns: Array<FunctionRunStatusInput>;
versionId: Scalars['String']['input'];
};
export type AutomationRunTrigger = VersionCreatedTrigger;
export type AutomationsStatus = {
__typename?: 'AutomationsStatus';
automationRuns: Array<AutomationRun>;
id: Scalars['ID']['output'];
status: AutomationRunStatus;
statusMessage?: Maybe<Scalars['String']['output']>;
};
export type AvatarUser = {
__typename?: 'AvatarUser';
avatar?: Maybe<Scalars['String']['output']>;
@@ -950,27 +857,6 @@ export type FileUpload = {
userId: Scalars['String']['output'];
};
export type FunctionRunStatusInput = {
contextView?: InputMaybe<Scalars['String']['input']>;
elapsed: Scalars['Float']['input'];
functionId: Scalars['String']['input'];
functionLogo?: InputMaybe<Scalars['String']['input']>;
functionName: Scalars['String']['input'];
resultVersionIds: Array<Scalars['String']['input']>;
/**
* Current schema: {
* version: "1.0.0",
* values: {
* speckleObjects: Record<ObjectId, {level: string; statusMessage: string}[]>
* blobIds?: string[]
* }
* }
*/
results?: InputMaybe<Scalars['JSONObject']['input']>;
status: AutomationRunStatus;
statusMessage?: InputMaybe<Scalars['String']['input']>;
};
export type GendoAiRender = {
__typename?: 'GendoAIRender';
camera?: Maybe<Scalars['JSONObject']['output']>;
@@ -1096,7 +982,6 @@ export type LimitedUserTimelineArgs = {
export type Model = {
__typename?: 'Model';
author: LimitedUser;
automationStatus?: Maybe<AutomationsStatus>;
automationsStatus?: Maybe<TriggeredAutomationsStatus>;
/** Return a model tree of children */
childrenTree: Array<ModelsTreeItem>;
@@ -1229,7 +1114,6 @@ export type Mutation = {
appUpdate: Scalars['Boolean']['output'];
automateFunctionRunStatusReport: Scalars['Boolean']['output'];
automateMutations: AutomateMutations;
automationMutations: AutomationMutations;
branchCreate: Scalars['String']['output'];
branchDelete: Scalars['Boolean']['output'];
branchUpdate: Scalars['Boolean']['output'];
@@ -1915,14 +1799,6 @@ export type ProjectAutomationUpdateInput = {
name?: InputMaybe<Scalars['String']['input']>;
};
export type ProjectAutomationsStatusUpdatedMessage = {
__typename?: 'ProjectAutomationsStatusUpdatedMessage';
model: Model;
project: Project;
status: AutomationsStatus;
version: Version;
};
export type ProjectAutomationsUpdatedMessage = {
__typename?: 'ProjectAutomationsUpdatedMessage';
automation?: Maybe<Automation>;
@@ -2889,7 +2765,6 @@ export type Subscription = {
commitDeleted?: Maybe<Scalars['JSONObject']['output']>;
/** Subscribe to commit updated event. */
commitUpdated?: Maybe<Scalars['JSONObject']['output']>;
projectAutomationsStatusUpdated: ProjectAutomationsStatusUpdatedMessage;
/** Subscribe to updates to automations in the project */
projectAutomationsUpdated: ProjectAutomationsUpdatedMessage;
/**
@@ -2985,11 +2860,6 @@ export type SubscriptionCommitUpdatedArgs = {
};
export type SubscriptionProjectAutomationsStatusUpdatedArgs = {
projectId: Scalars['String']['input'];
};
export type SubscriptionProjectAutomationsUpdatedArgs = {
projectId: Scalars['String']['input'];
};
@@ -3331,7 +3201,6 @@ export type UserUpdateInput = {
export type Version = {
__typename?: 'Version';
authorUser?: Maybe<LimitedUser>;
automationStatus?: Maybe<AutomationsStatus>;
automationsStatus?: Maybe<TriggeredAutomationsStatus>;
/** All comment threads in this version */
commentThreads: CommentCollection;
@@ -3634,18 +3503,11 @@ export type ResolversTypes = {
AutomateRunTriggerType: AutomateRunTriggerType;
Automation: ResolverTypeWrapper<AutomationGraphQLReturn>;
AutomationCollection: ResolverTypeWrapper<Omit<AutomationCollection, 'items'> & { items: Array<ResolversTypes['Automation']> }>;
AutomationCreateInput: AutomationCreateInput;
AutomationFunctionRun: ResolverTypeWrapper<AutomationFunctionRunGraphQLReturn>;
AutomationMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
AutomationRevision: ResolverTypeWrapper<AutomationRevisionGraphQLReturn>;
AutomationRevisionCreateFunctionInput: AutomationRevisionCreateFunctionInput;
AutomationRevisionFunction: ResolverTypeWrapper<AutomationRevisionFunctionGraphQLReturn>;
AutomationRevisionTriggerDefinition: ResolverTypeWrapper<AutomationRevisionTriggerDefinitionGraphQLReturn>;
AutomationRun: ResolverTypeWrapper<Omit<AutomationRun, 'functionRuns'> & { functionRuns: Array<ResolversTypes['AutomationFunctionRun']> }>;
AutomationRunStatus: AutomationRunStatus;
AutomationRunStatusUpdateInput: AutomationRunStatusUpdateInput;
AutomationRunTrigger: ResolverTypeWrapper<AutomationRunTriggerGraphQLReturn>;
AutomationsStatus: ResolverTypeWrapper<Omit<AutomationsStatus, 'automationRuns'> & { automationRuns: Array<ResolversTypes['AutomationRun']> }>;
AvatarUser: ResolverTypeWrapper<AvatarUser>;
BasicGitRepositoryMetadata: ResolverTypeWrapper<BasicGitRepositoryMetadata>;
BigInt: ResolverTypeWrapper<Scalars['BigInt']['output']>;
@@ -3690,7 +3552,6 @@ export type ResolversTypes = {
EmailAddress: ResolverTypeWrapper<Scalars['EmailAddress']['output']>;
FileUpload: ResolverTypeWrapper<FileUploadGraphQLReturn>;
Float: ResolverTypeWrapper<Scalars['Float']['output']>;
FunctionRunStatusInput: FunctionRunStatusInput;
GendoAIRender: ResolverTypeWrapper<GendoAiRender>;
GendoAIRenderCollection: ResolverTypeWrapper<GendoAiRenderCollection>;
GendoAIRenderInput: GendoAiRenderInput;
@@ -3718,7 +3579,6 @@ export type ResolversTypes = {
ProjectAutomationMutations: ResolverTypeWrapper<ProjectAutomationMutationsGraphQLReturn>;
ProjectAutomationRevisionCreateInput: ProjectAutomationRevisionCreateInput;
ProjectAutomationUpdateInput: ProjectAutomationUpdateInput;
ProjectAutomationsStatusUpdatedMessage: ResolverTypeWrapper<Omit<ProjectAutomationsStatusUpdatedMessage, 'model' | 'project' | 'status' | 'version'> & { model: ResolversTypes['Model'], project: ResolversTypes['Project'], status: ResolversTypes['AutomationsStatus'], version: ResolversTypes['Version'] }>;
ProjectAutomationsUpdatedMessage: ResolverTypeWrapper<ProjectAutomationsUpdatedMessageGraphQLReturn>;
ProjectAutomationsUpdatedMessageType: ProjectAutomationsUpdatedMessageType;
ProjectCollaborator: ResolverTypeWrapper<Omit<ProjectCollaborator, 'user'> & { user: ResolversTypes['LimitedUser'] }>;
@@ -3858,17 +3718,11 @@ export type ResolversParentTypes = {
AutomateRunCollection: Omit<AutomateRunCollection, 'items'> & { items: Array<ResolversParentTypes['AutomateRun']> };
Automation: AutomationGraphQLReturn;
AutomationCollection: Omit<AutomationCollection, 'items'> & { items: Array<ResolversParentTypes['Automation']> };
AutomationCreateInput: AutomationCreateInput;
AutomationFunctionRun: AutomationFunctionRunGraphQLReturn;
AutomationMutations: MutationsObjectGraphQLReturn;
AutomationRevision: AutomationRevisionGraphQLReturn;
AutomationRevisionCreateFunctionInput: AutomationRevisionCreateFunctionInput;
AutomationRevisionFunction: AutomationRevisionFunctionGraphQLReturn;
AutomationRevisionTriggerDefinition: AutomationRevisionTriggerDefinitionGraphQLReturn;
AutomationRun: Omit<AutomationRun, 'functionRuns'> & { functionRuns: Array<ResolversParentTypes['AutomationFunctionRun']> };
AutomationRunStatusUpdateInput: AutomationRunStatusUpdateInput;
AutomationRunTrigger: AutomationRunTriggerGraphQLReturn;
AutomationsStatus: Omit<AutomationsStatus, 'automationRuns'> & { automationRuns: Array<ResolversParentTypes['AutomationRun']> };
AvatarUser: AvatarUser;
BasicGitRepositoryMetadata: BasicGitRepositoryMetadata;
BigInt: Scalars['BigInt']['output'];
@@ -3912,7 +3766,6 @@ export type ResolversParentTypes = {
EmailAddress: Scalars['EmailAddress']['output'];
FileUpload: FileUploadGraphQLReturn;
Float: Scalars['Float']['output'];
FunctionRunStatusInput: FunctionRunStatusInput;
GendoAIRender: GendoAiRender;
GendoAIRenderCollection: GendoAiRenderCollection;
GendoAIRenderInput: GendoAiRenderInput;
@@ -3940,7 +3793,6 @@ export type ResolversParentTypes = {
ProjectAutomationMutations: ProjectAutomationMutationsGraphQLReturn;
ProjectAutomationRevisionCreateInput: ProjectAutomationRevisionCreateInput;
ProjectAutomationUpdateInput: ProjectAutomationUpdateInput;
ProjectAutomationsStatusUpdatedMessage: Omit<ProjectAutomationsStatusUpdatedMessage, 'model' | 'project' | 'status' | 'version'> & { model: ResolversParentTypes['Model'], project: ResolversParentTypes['Project'], status: ResolversParentTypes['AutomationsStatus'], version: ResolversParentTypes['Version'] };
ProjectAutomationsUpdatedMessage: ProjectAutomationsUpdatedMessageGraphQLReturn;
ProjectCollaborator: Omit<ProjectCollaborator, 'user'> & { user: ResolversParentTypes['LimitedUser'] };
ProjectCollection: Omit<ProjectCollection, 'items'> & { items: Array<ResolversParentTypes['Project']> };
@@ -4265,26 +4117,6 @@ export type AutomationCollectionResolvers<ContextType = GraphQLContext, ParentTy
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AutomationFunctionRunResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AutomationFunctionRun'] = ResolversParentTypes['AutomationFunctionRun']> = {
contextView?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
elapsed?: Resolver<ResolversTypes['Float'], ParentType, ContextType>;
functionId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
functionLogo?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
functionName?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
resultVersions?: Resolver<Array<ResolversTypes['Version']>, ParentType, ContextType>;
results?: Resolver<Maybe<ResolversTypes['JSONObject']>, ParentType, ContextType>;
status?: Resolver<ResolversTypes['AutomationRunStatus'], ParentType, ContextType>;
statusMessage?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AutomationMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AutomationMutations'] = ResolversParentTypes['AutomationMutations']> = {
create?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<AutomationMutationsCreateArgs, 'input'>>;
functionRunStatusReport?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<AutomationMutationsFunctionRunStatusReportArgs, 'input'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AutomationRevisionResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AutomationRevision'] = ResolversParentTypes['AutomationRevision']> = {
functions?: Resolver<Array<ResolversTypes['AutomationRevisionFunction']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
@@ -4302,30 +4134,10 @@ export type AutomationRevisionTriggerDefinitionResolvers<ContextType = GraphQLCo
__resolveType: TypeResolveFn<'VersionCreatedTriggerDefinition', ParentType, ContextType>;
};
export type AutomationRunResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AutomationRun'] = ResolversParentTypes['AutomationRun']> = {
automationId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
automationName?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
functionRuns?: Resolver<Array<ResolversTypes['AutomationFunctionRun']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
status?: Resolver<ResolversTypes['AutomationRunStatus'], ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
versionId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AutomationRunTriggerResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AutomationRunTrigger'] = ResolversParentTypes['AutomationRunTrigger']> = {
__resolveType: TypeResolveFn<'VersionCreatedTrigger', ParentType, ContextType>;
};
export type AutomationsStatusResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AutomationsStatus'] = ResolversParentTypes['AutomationsStatus']> = {
automationRuns?: Resolver<Array<ResolversTypes['AutomationRun']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
status?: Resolver<ResolversTypes['AutomationRunStatus'], ParentType, ContextType>;
statusMessage?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AvatarUserResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AvatarUser'] = ResolversParentTypes['AvatarUser']> = {
avatar?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
@@ -4569,7 +4381,6 @@ export type LimitedUserResolvers<ContextType = GraphQLContext, ParentType extend
export type ModelResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['Model'] = ResolversParentTypes['Model']> = {
author?: Resolver<ResolversTypes['LimitedUser'], ParentType, ContextType>;
automationStatus?: Resolver<Maybe<ResolversTypes['AutomationsStatus']>, ParentType, ContextType>;
automationsStatus?: Resolver<Maybe<ResolversTypes['TriggeredAutomationsStatus']>, ParentType, ContextType>;
childrenTree?: Resolver<Array<ResolversTypes['ModelsTreeItem']>, ParentType, ContextType>;
commentThreads?: Resolver<ResolversTypes['CommentCollection'], ParentType, ContextType, RequireFields<ModelCommentThreadsArgs, 'limit'>>;
@@ -4631,7 +4442,6 @@ export type MutationResolvers<ContextType = GraphQLContext, ParentType extends R
appUpdate?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationAppUpdateArgs, 'app'>>;
automateFunctionRunStatusReport?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationAutomateFunctionRunStatusReportArgs, 'input'>>;
automateMutations?: Resolver<ResolversTypes['AutomateMutations'], ParentType, ContextType>;
automationMutations?: Resolver<ResolversTypes['AutomationMutations'], ParentType, ContextType>;
branchCreate?: Resolver<ResolversTypes['String'], ParentType, ContextType, RequireFields<MutationBranchCreateArgs, 'branch'>>;
branchDelete?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationBranchDeleteArgs, 'branch'>>;
branchUpdate?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationBranchUpdateArgs, 'branch'>>;
@@ -4769,14 +4579,6 @@ export type ProjectAutomationMutationsResolvers<ContextType = GraphQLContext, Pa
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ProjectAutomationsStatusUpdatedMessageResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ProjectAutomationsStatusUpdatedMessage'] = ResolversParentTypes['ProjectAutomationsStatusUpdatedMessage']> = {
model?: Resolver<ResolversTypes['Model'], ParentType, ContextType>;
project?: Resolver<ResolversTypes['Project'], ParentType, ContextType>;
status?: Resolver<ResolversTypes['AutomationsStatus'], ParentType, ContextType>;
version?: Resolver<ResolversTypes['Version'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ProjectAutomationsUpdatedMessageResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ProjectAutomationsUpdatedMessage'] = ResolversParentTypes['ProjectAutomationsUpdatedMessage']> = {
automation?: Resolver<Maybe<ResolversTypes['Automation']>, ParentType, ContextType>;
automationId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
@@ -5112,7 +4914,6 @@ export type SubscriptionResolvers<ContextType = GraphQLContext, ParentType exten
commitCreated?: SubscriptionResolver<Maybe<ResolversTypes['JSONObject']>, "commitCreated", ParentType, ContextType, RequireFields<SubscriptionCommitCreatedArgs, 'streamId'>>;
commitDeleted?: SubscriptionResolver<Maybe<ResolversTypes['JSONObject']>, "commitDeleted", ParentType, ContextType, RequireFields<SubscriptionCommitDeletedArgs, 'streamId'>>;
commitUpdated?: SubscriptionResolver<Maybe<ResolversTypes['JSONObject']>, "commitUpdated", ParentType, ContextType, RequireFields<SubscriptionCommitUpdatedArgs, 'streamId'>>;
projectAutomationsStatusUpdated?: SubscriptionResolver<ResolversTypes['ProjectAutomationsStatusUpdatedMessage'], "projectAutomationsStatusUpdated", ParentType, ContextType, RequireFields<SubscriptionProjectAutomationsStatusUpdatedArgs, 'projectId'>>;
projectAutomationsUpdated?: SubscriptionResolver<ResolversTypes['ProjectAutomationsUpdatedMessage'], "projectAutomationsUpdated", ParentType, ContextType, RequireFields<SubscriptionProjectAutomationsUpdatedArgs, 'projectId'>>;
projectCommentsUpdated?: SubscriptionResolver<ResolversTypes['ProjectCommentsUpdatedMessage'], "projectCommentsUpdated", ParentType, ContextType, RequireFields<SubscriptionProjectCommentsUpdatedArgs, 'target'>>;
projectFileImportUpdated?: SubscriptionResolver<ResolversTypes['ProjectFileImportUpdatedMessage'], "projectFileImportUpdated", ParentType, ContextType, RequireFields<SubscriptionProjectFileImportUpdatedArgs, 'id'>>;
@@ -5218,7 +5019,6 @@ export type UserSearchResultCollectionResolvers<ContextType = GraphQLContext, Pa
export type VersionResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['Version'] = ResolversParentTypes['Version']> = {
authorUser?: Resolver<Maybe<ResolversTypes['LimitedUser']>, ParentType, ContextType>;
automationStatus?: Resolver<Maybe<ResolversTypes['AutomationsStatus']>, ParentType, ContextType>;
automationsStatus?: Resolver<Maybe<ResolversTypes['TriggeredAutomationsStatus']>, ParentType, ContextType>;
commentThreads?: Resolver<ResolversTypes['CommentCollection'], ParentType, ContextType, RequireFields<VersionCommentThreadsArgs, 'limit'>>;
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
@@ -5346,14 +5146,10 @@ export type Resolvers<ContextType = GraphQLContext> = {
AutomateRunCollection?: AutomateRunCollectionResolvers<ContextType>;
Automation?: AutomationResolvers<ContextType>;
AutomationCollection?: AutomationCollectionResolvers<ContextType>;
AutomationFunctionRun?: AutomationFunctionRunResolvers<ContextType>;
AutomationMutations?: AutomationMutationsResolvers<ContextType>;
AutomationRevision?: AutomationRevisionResolvers<ContextType>;
AutomationRevisionFunction?: AutomationRevisionFunctionResolvers<ContextType>;
AutomationRevisionTriggerDefinition?: AutomationRevisionTriggerDefinitionResolvers<ContextType>;
AutomationRun?: AutomationRunResolvers<ContextType>;
AutomationRunTrigger?: AutomationRunTriggerResolvers<ContextType>;
AutomationsStatus?: AutomationsStatusResolvers<ContextType>;
AvatarUser?: AvatarUserResolvers<ContextType>;
BasicGitRepositoryMetadata?: BasicGitRepositoryMetadataResolvers<ContextType>;
BigInt?: GraphQLScalarType;
@@ -5392,7 +5188,6 @@ export type Resolvers<ContextType = GraphQLContext> = {
PendingStreamCollaborator?: PendingStreamCollaboratorResolvers<ContextType>;
Project?: ProjectResolvers<ContextType>;
ProjectAutomationMutations?: ProjectAutomationMutationsResolvers<ContextType>;
ProjectAutomationsStatusUpdatedMessage?: ProjectAutomationsStatusUpdatedMessageResolvers<ContextType>;
ProjectAutomationsUpdatedMessage?: ProjectAutomationsUpdatedMessageResolvers<ContextType>;
ProjectCollaborator?: ProjectCollaboratorResolvers<ContextType>;
ProjectCollection?: ProjectCollectionResolvers<ContextType>;
@@ -1,3 +1,4 @@
import db from '@/db/knex'
import { RateLimitError } from '@/modules/core/errors/ratelimit'
import { StreamNotFoundError } from '@/modules/core/errors/stream'
import {
@@ -26,13 +27,27 @@ import {
import { createOnboardingStream } from '@/modules/core/services/streams/onboarding'
import { removeStreamCollaborator } from '@/modules/core/services/streams/streamAccessService'
import { InviteCreateValidationError } from '@/modules/serverinvites/errors'
import { cancelStreamInvite } from '@/modules/serverinvites/services/inviteProcessingService'
import {
deleteInvitesByTargetFactory,
deleteStreamInviteFactory,
findResourceFactory,
findStreamInviteFactory,
findUserByTargetFactory,
insertInviteAndDeleteOldFactory,
queryAllStreamInvitesFactory,
queryAllUserStreamInvitesFactory
} from '@/modules/serverinvites/repositories/serverInvites'
import { createAndSendInviteFactory } from '@/modules/serverinvites/services/inviteCreationService'
import {
cancelStreamInvite,
finalizeStreamInvite
} from '@/modules/serverinvites/services/inviteProcessingService'
import {
getPendingStreamCollaborators,
getUserPendingStreamInvites
} from '@/modules/serverinvites/services/inviteRetrievalService'
import {
createStreamInviteAndNotify,
createStreamInviteAndNotifyFactory,
useStreamInviteAndNotify
} from '@/modules/serverinvites/services/management'
import { authorizeResolver, validateScopes } from '@/modules/shared'
@@ -129,7 +144,14 @@ export = {
Roles.Stream.Owner,
ctx.resourceAccessRules
)
await createStreamInviteAndNotify(
const createAndSendInvite = createAndSendInviteFactory({
findResource: findResourceFactory(),
findUserByTarget: findUserByTargetFactory(),
insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db })
})
await createStreamInviteAndNotifyFactory({
createAndSendInvite
})(
{
...args.input,
projectId: args.projectId
@@ -158,7 +180,15 @@ export = {
for (const batch of inputBatches) {
await Promise.all(
batch.map((i) =>
createStreamInviteAndNotify(
createStreamInviteAndNotifyFactory({
createAndSendInvite: createAndSendInviteFactory({
findResource: findResourceFactory(),
findUserByTarget: findUserByTargetFactory(),
insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({
db
})
})
})(
{ ...i, projectId: args.projectId },
ctx.userId!,
ctx.resourceAccessRules
@@ -169,7 +199,12 @@ export = {
return ctx.loaders.streams.getStream.load(args.projectId)
},
async use(_parent, args, ctx) {
await useStreamInviteAndNotify(args.input, ctx.userId!, ctx.resourceAccessRules)
await useStreamInviteAndNotify({
finalizeStreamInvite: finalizeStreamInvite({
findStreamInvite: findStreamInviteFactory({ db }),
deleteInvitesByTarget: deleteInvitesByTargetFactory({ db })
})
})(args.input, ctx.userId!, ctx.resourceAccessRules)
return true
},
async cancel(_parent, args, ctx) {
@@ -179,7 +214,10 @@ export = {
Roles.Stream.Owner,
ctx.resourceAccessRules
)
await cancelStreamInvite(args.projectId, args.inviteId)
await cancelStreamInvite({
findStreamInvite: findStreamInviteFactory({ db }),
deleteStreamInvite: deleteStreamInviteFactory({ db })
})(args.projectId, args.inviteId)
return ctx.loaders.streams.getStream.load(args.projectId)
}
},
@@ -216,7 +254,11 @@ export = {
},
async projectInvites(_parent, _args, context) {
const { userId } = context
return await getUserPendingStreamInvites(userId!)
return await getUserPendingStreamInvites({
queryAllUserStreamInvites: queryAllUserStreamInvitesFactory({
db
})
})(userId!)
}
},
Project: {
@@ -238,7 +280,9 @@ export = {
return ctx.loaders.streams.getSourceApps.load(parent.id) || []
},
async invitedTeam(parent) {
return await getPendingStreamCollaborators(parent.id)
return getPendingStreamCollaborators({
queryAllStreamInvites: queryAllStreamInvitesFactory({ db })
})(parent.id)
},
async visibility(parent) {
const { isPublic, isDiscoverable } = parent
@@ -57,6 +57,10 @@ const {
const {
TokenResourceIdentifierType
} = require('@/modules/core/graph/generated/graphql')
const {
queryAllStreamInvitesFactory
} = require('@/modules/serverinvites/repositories/serverInvites')
const db = require('@/db/knex')
// subscription events
const USER_STREAM_ADDED = StreamPubsubEvents.UserStreamAdded
@@ -167,7 +171,9 @@ module.exports = {
async pendingCollaborators(parent) {
const { id: streamId } = parent
return await getPendingStreamCollaborators(streamId)
return await getPendingStreamCollaborators({
queryAllStreamInvites: queryAllStreamInvitesFactory({ db })
})(streamId)
},
async favoritedDate(parent, _args, ctx) {
@@ -14,13 +14,20 @@ const { ActionTypes } = require('@/modules/activitystream/helpers/types')
const { validateScopes } = require(`@/modules/shared`)
const zxcvbn = require('zxcvbn')
const {
getAdminUsersListCollection
getAdminUsersListCollection,
getTotalCounts
} = require('@/modules/core/services/users/adminUsersListService')
const { Roles, Scopes } = require('@speckle/shared')
const { markOnboardingComplete } = require('@/modules/core/repositories/users')
const { UsersMeta } = require('@/modules/core/dbSchema')
const { getServerInfo } = require('@/modules/core/services/generic')
const { throwForNotHavingServerRole } = require('@/modules/shared/authz')
const {
deleteAllUserInvitesFactory,
countServerInvitesFactory,
findServerInvitesFactory
} = require('@/modules/serverinvites/repositories/serverInvites')
const db = require('@/db/knex')
/** @type {import('@/modules/core/graph/generated/graphql').Resolvers} */
module.exports = {
@@ -60,7 +67,12 @@ module.exports = {
},
async adminUsers(_parent, args) {
return await getAdminUsersListCollection(args)
return await getAdminUsersListCollection({
findServerInvites: findServerInvitesFactory({ db }),
getTotalCounts: getTotalCounts({
countServerInvites: countServerInvitesFactory({ db })
})
})(args)
},
async userSearch(parent, args, context) {
@@ -151,7 +163,9 @@ module.exports = {
const user = await getUserByEmail({ email: args.userConfirmation.email })
if (!user) return false
await deleteUser(user.id)
await deleteUser({
deleteAllUserInvites: deleteAllUserInvitesFactory({ db })
})(user.id)
return true
},
@@ -168,7 +182,9 @@ module.exports = {
await throwForNotHavingServerRole(context, Roles.Server.Guest)
await validateScopes(context.scopes, Scopes.Profile.Delete)
await deleteUser(context.userId, args.user)
await deleteUser({
deleteAllUserInvites: deleteAllUserInvitesFactory({ db })
})(context.userId, args.user)
await saveActivity({
streamId: null,
@@ -1,10 +1,11 @@
import { TokenCreateError } from '@/modules/core/errors/user'
import {
TokenResourceIdentifier,
TokenResourceIdentifierType
} from '@/modules/core/graph/generated/graphql'
} from '@/modules/core/domain/tokens/types'
import { TokenCreateError } from '@/modules/core/errors/user'
import { TokenResourceAccessRecord } from '@/modules/core/helpers/types'
import { ResourceTargets } from '@/modules/serverinvites/helpers/inviteHelper'
import {} from '@/modules/serverinvites/services/operations'
import { MaybeNullOrUndefined, Nullable, Optional, Scopes } from '@speckle/shared'
import { differenceBy } from 'lodash'
@@ -24,7 +25,7 @@ export const roleResourceTypeToTokenResourceType = (
): Nullable<TokenResourceIdentifierType> => {
switch (type) {
case ResourceTargets.Streams:
return TokenResourceIdentifierType.Project
return 'project'
default:
return null
}
@@ -52,9 +53,7 @@ export const isNewResourceAllowed = (params: {
export const toProjectIdWhitelist = (
resourceAccessRules: ContextResourceAccessRules
): Optional<string[]> => {
const projectRules = resourceAccessRules?.filter(
(r) => r.type === TokenResourceIdentifierType.Project
)
const projectRules = resourceAccessRules?.filter((r) => r.type === 'project')
return projectRules?.map((r) => r.id)
}
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { TokenResourceIdentifierType } from '@/modules/core/graph/generated/graphql'
import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
import { BaseMetaRecord } from '@/modules/core/helpers/meta'
import { Nullable } from '@/modules/shared/helpers/typeHelper'
import { ServerRoles } from '@speckle/shared'
+4 -23
View File
@@ -12,7 +12,6 @@ import {
} from '@/modules/core/repositories/streams'
import { UserWithOptionalRole, getUsers } from '@/modules/core/repositories/users'
import { keyBy } from 'lodash'
import { getInvites } from '@/modules/serverinvites/repositories'
import { AuthContext } from '@/modules/shared/authz'
import {
BranchRecord,
@@ -23,7 +22,7 @@ import {
UsersMetaRecord
} from '@/modules/core/helpers/types'
import { Nullable } from '@/modules/shared/helpers/typeHelper'
import { ServerInviteRecord } from '@/modules/serverinvites/helpers/types'
import { ServerInviteRecord } from '@/modules/serverinvites/domain/types'
import {
getCommitBranches,
getCommits,
@@ -55,7 +54,6 @@ import { metaHelpers } from '@/modules/core/helpers/meta'
import { Users } from '@/modules/core/dbSchema'
import { getStreamPendingModels } from '@/modules/fileuploads/repositories/fileUploads'
import { FileUploadRecord } from '@/modules/fileuploads/helpers/types'
import { getAutomationFunctionRunResultVersions } from '@/modules/betaAutomations/repositories/automations'
import { getAppScopes } from '@/modules/auth/repositories'
import {
AutomateRevisionFunctionRecord,
@@ -85,6 +83,8 @@ import {
ExecutionEngineFailedResponseError,
ExecutionEngineNetworkError
} from '@/modules/automate/errors/executionEngine'
import { queryInvitesFactory } from '@/modules/serverinvites/repositories/serverInvites'
import db from '@/db/knex'
const simpleTupleCacheKey = (key: [string, string]) => `${key[0]}:${key[1]}`
@@ -519,7 +519,7 @@ export function buildRequestLoaders(
*/
getInvite: createLoader<string, Nullable<ServerInviteRecord>>(
async (inviteIds) => {
const results = keyBy(await getInvites(inviteIds), 'id')
const results = keyBy(await queryInvitesFactory({ db })(inviteIds), 'id')
return inviteIds.map((i) => results[i] || null)
}
)
@@ -530,25 +530,6 @@ export function buildRequestLoaders(
return appIds.map((i) => results[i] || [])
})
},
automationFunctionRuns: {
/**
* Get result versions/commits from function runs
*/
getResultVersions: createLoader<
[automationRunId: string, functionId: string],
CommitRecord[],
string
>(
async (ids) => {
const results = await getAutomationFunctionRunResultVersions(ids.slice())
return ids.map((i) => {
const [automationRunId, functionId] = i
return results[automationRunId]?.[functionId] || []
})
},
{ cacheKeyFn: (key) => `${key[0]}:${key[1]}` }
)
},
automations: {
getFunctionAutomationCount: createLoader<string, number>(async (functionIds) => {
const results = await getFunctionAutomationCounts({
@@ -143,9 +143,9 @@ export async function getStreams(
* Get a single stream. If userId is specified, the role will be resolved as well.
*/
export async function getStream(
params: { streamId: string; userId?: string },
params: { streamId?: string; userId?: string },
options?: Partial<{ trx: Knex.Transaction }>
) {
): Promise<Optional<StreamWithOptionalRole>> {
const { streamId, userId } = params
if (!streamId) throw new InvalidArgumentError('Invalid stream ID')
@@ -1,12 +1,13 @@
import db from '@/db/knex'
import { ServerInviteGraphQLReturnType } from '@/modules/core/helpers/graphTypes'
import { StreamRecord, UserRecord } from '@/modules/core/helpers/types'
import { listUsers, countUsers } from '@/modules/core/repositories/users'
import { getStreams } from '@/modules/core/services/streams'
import { ServerInviteRecord } from '@/modules/serverinvites/helpers/types'
import { ServerInviteRecord } from '@/modules/serverinvites/domain/types'
import {
countServerInvites,
queryServerInvites
} from '@/modules/serverinvites/repositories'
countServerInvitesFactory,
queryServerInvitesFactory
} from '@/modules/serverinvites/repositories/serverInvites'
import { BaseError } from '@/modules/shared/errors/base'
import { ServerRoles } from '@speckle/shared'
@@ -75,9 +76,10 @@ export const adminInviteList = async (
args: CollectionQueryArgs
): Promise<Collection<ServerInviteGraphQLReturnType>> => {
const parsedCursor = args.cursor ? parseCursorToDate(args.cursor) : null
// TODO: injection
const [totalCount, inviteItems] = await Promise.all([
countServerInvites(args.query),
queryServerInvites(args.query, args.limit, parsedCursor)
countServerInvitesFactory({ db })(args.query),
queryServerInvitesFactory({ db })(args.query, args.limit, parsedCursor)
])
const items = inviteItems.map((invite: ServerInviteRecord) => {
return {
@@ -11,9 +11,7 @@ import {
StreamCreateInput,
StreamRevokePermissionInput,
StreamUpdateInput,
StreamUpdatePermissionInput,
TokenResourceIdentifier,
TokenResourceIdentifierType
StreamUpdatePermissionInput
} from '@/modules/core/graph/generated/graphql'
import { StreamRecord } from '@/modules/core/helpers/types'
import {
@@ -23,7 +21,10 @@ import {
updateStream
} from '@/modules/core/repositories/streams'
import { createBranch } from '@/modules/core/services/branches'
import { inviteUsersToStream } from '@/modules/serverinvites/services/inviteCreationService'
import {
createAndSendInviteFactory,
inviteUsersToStreamFactory
} from '@/modules/serverinvites/services/inviteCreationService'
import {
StreamInvalidAccessError,
StreamUpdateError
@@ -35,12 +36,22 @@ import {
isStreamCollaborator,
removeStreamCollaborator
} from '@/modules/core/services/streams/streamAccessService'
import { deleteAllStreamInvites } from '@/modules/serverinvites/repositories'
import {
ContextResourceAccessRules,
isNewResourceAllowed
} from '@/modules/core/helpers/token'
import { authorizeResolver } from '@/modules/shared'
import {
deleteAllStreamInvitesFactory,
findResourceFactory,
findUserByTargetFactory,
insertInviteAndDeleteOldFactory
} from '@/modules/serverinvites/repositories/serverInvites'
import db from '@/db/knex'
import {
TokenResourceIdentifier,
TokenResourceIdentifierType
} from '@/modules/core/domain/tokens/types'
export async function createStreamReturnRecord(
params: (StreamCreateInput | ProjectCreateInput) & {
@@ -75,12 +86,14 @@ export async function createStreamReturnRecord(
// Invite contributors?
if (!isProjectCreateInput(params) && params.withContributors?.length) {
await inviteUsersToStream(
ownerId,
streamId,
params.withContributors,
ownerResourceAccessRules
)
// TODO: should be injected in the resolver
await inviteUsersToStreamFactory({
createAndSendInvite: createAndSendInviteFactory({
findResource: findResourceFactory(),
findUserByTarget: findUserByTargetFactory(),
insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db })
})
})(ownerId, streamId, params.withContributors, ownerResourceAccessRules)
}
// Save activity
@@ -126,7 +139,9 @@ export async function deleteStreamAndNotify(
// delay deletion by a bit so we can do auth checks
await wait(250)
// TODO: use proper injection once we refactor this module
// Delete after event so we can do authz
const deleteAllStreamInvites = deleteAllStreamInvitesFactory({ db })
await Promise.all([deleteAllStreamInvites(streamId), deleteStream(streamId)])
return true
}
@@ -41,7 +41,7 @@ async function isStreamCollaborator(userId, streamId) {
* @param {string} [userId] If falsy, will throw for non-public streams
* @param {string} streamId
* @param {string} [expectedRole] Defaults to reviewer
* @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} [userResourceAccessLimits]
* @param {import('@/modules/serverinvites/services/operations').TokenResourceIdentifier[] | undefined | null} [userResourceAccessLimits]
* @returns {Promise<boolean>}
*/
async function validateStreamAccess(
@@ -90,7 +90,7 @@ async function validateStreamAccess(
* @param {string} streamId
* @param {string} userId ID of user that should be removed
* @param {string} removedById ID of user that is doing the removing
* @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} [removerResourceAccessRules] Resource access rules (if any) for the user doing the removing
* @param {import('@/modules/serverinvites/services/operations').TokenResourceIdentifier[] | undefined | null} [removerResourceAccessRules] Resource access rules (if any) for the user doing the removing
*/
async function removeStreamCollaborator(
streamId,
@@ -134,7 +134,7 @@ async function removeStreamCollaborator(
* @param {string} userId ID of user who is being added
* @param {string} role
* @param {string} addedById ID of user who is adding the new collaborator
* @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} [adderResourceAccessRules] Resource access rules (if any) for the user doing the adding
* @param {import('@/modules/serverinvites/services/operations').TokenResourceIdentifier[] | undefined | null} [adderResourceAccessRules] Resource access rules (if any) for the user doing the adding
* @param {{
* fromInvite?: boolean,
* }} param4
+21 -16
View File
@@ -17,7 +17,6 @@ const Acl = () => ServerAclSchema.knex()
const { deleteStream } = require('./streams')
const { LIMITED_USER_FIELDS } = require('@/modules/core/helpers/userHelper')
const { deleteAllUserInvites } = require('@/modules/serverinvites/repositories')
const { getUserByEmail } = require('@/modules/core/repositories/users')
const { UsersEmitter, UsersEvents } = require('@/modules/core/events/usersEmitter')
const { pick } = require('lodash')
@@ -228,12 +227,16 @@ module.exports = {
})
},
async deleteUser(id) {
//TODO: check for the last admin user to survive
dbLogger.info('Deleting user ' + id)
await _ensureAtleastOneAdminRemains(id)
const streams = await knex.raw(
`
/**
* @param {{ deleteAllUserInvites: import('@/modules/serverinvites/domain/operations').DeleteAllUserInvites }} param0
*/
deleteUser({ deleteAllUserInvites }) {
return async (id) => {
//TODO: check for the last admin user to survive
dbLogger.info('Deleting user ' + id)
await _ensureAtleastOneAdminRemains(id)
const streams = await knex.raw(
`
-- Get the stream ids with only this user as owner
SELECT "resourceId" as id
FROM (
@@ -251,16 +254,18 @@ module.exports = {
) AS soc
WHERE cnt = 1
`,
[id]
)
for (const i in streams.rows) {
await deleteStream({ streamId: streams.rows[i].id })
[id]
)
for (const i in streams.rows) {
await deleteStream({ streamId: streams.rows[i].id })
}
// Delete all invites (they don't have a FK, so we need to do this manually)
// THIS REALLY SHOULD BE A REACTION TO THE USER DELETED EVENT EMITTED HER
await deleteAllUserInvites(id)
return await Users().where({ id }).del()
}
// Delete all invites (they don't have a FK, so we need to do this manually)
await deleteAllUserInvites(id)
return await Users().where({ id }).del()
},
/**
@@ -1,9 +1,5 @@
const { countUsers, getUsers } = require('@/modules/core/services/users')
const { resolveTarget } = require('@/modules/serverinvites/helpers/inviteHelper')
const {
countServerInvites,
findServerInvites
} = require('@/modules/serverinvites/repositories')
const { clamp } = require('lodash')
/**
@@ -64,21 +60,27 @@ function sanitizeParams(params) {
/**
* Get total users & invites that we can find using these params
* @param {PaginationParams} params
* @returns {Promise<TotalCounts>}
* @param {{ countServerInvites: import('@/modules/serverinvites/domain/operations').CountServerInvites}} param0
*/
async function getTotalCounts(params) {
const { query } = params
function getTotalCounts({ countServerInvites }) {
/**
* Get total users & invites that we can find using these params
* @param {PaginationParams} params
* @returns {Promise<TotalCounts>}
*/
return async (params) => {
const { query } = params
const [userCount, inviteCount] = await Promise.all([
// Actual users
countUsers(query),
// Invites
countServerInvites(query)
])
const totalCount = userCount + inviteCount
const [userCount, inviteCount] = await Promise.all([
// Actual users
countUsers(query),
// Invites
countServerInvites(query)
])
const totalCount = userCount + inviteCount
return { userCount, inviteCount, totalCount }
return { userCount, inviteCount, totalCount }
}
}
/**
@@ -118,7 +120,7 @@ function mapUserToListItem(user) {
}
/**
* @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite
* @param {import('@/modules/serverinvites/domain/types').ServerInviteRecord} invite
* @returns {AdminUsersListItem}
*/
function mapInviteToListItem(invite) {
@@ -134,49 +136,62 @@ function mapInviteToListItem(invite) {
}
/**
* Retrieve all list items from DB and convert them to the target model
* @param {PaginationParams} params
* @param {TotalCounts} counts
* @returns {Promise<AdminUsersListItem[]>}
*
* @param {{findServerInvites: import('@/modules/serverinvites/domain/operations').FindServerInvites}} param0
*/
async function retrieveItems(params, counts) {
const { invitesFilter, usersFilter } = resolveLimitsAndOffsets(params, counts)
const { query } = params
function retrieveItems({ findServerInvites }) {
/**
* Retrieve all list items from DB and convert them to the target model
* @param {PaginationParams} params
* @param {TotalCounts} counts
* @returns {Promise<AdminUsersListItem[]>}
*/
return async (params, counts) => {
const { invitesFilter, usersFilter } = resolveLimitsAndOffsets(params, counts)
const { query } = params
const [invites, users] = await Promise.all([
// Invites
invitesFilter
? findServerInvites(query, invitesFilter.limit, invitesFilter.offset)
: [],
// Users
usersFilter ? getUsers(usersFilter.limit, usersFilter.offset, query) : []
])
const [invites, users] = await Promise.all([
// Invites
invitesFilter
? findServerInvites(query, invitesFilter.limit, invitesFilter.offset)
: [],
// Users
usersFilter ? getUsers(usersFilter.limit, usersFilter.offset, query) : []
])
return [
// Invites first
...invites.map((i) => mapInviteToListItem(i)),
// Users after
...users.map((u) => mapUserToListItem(u))
]
return [
// Invites first
...invites.map((i) => mapInviteToListItem(i)),
// Users after
...users.map((u) => mapUserToListItem(u))
]
}
}
/**
* Resolve admin users list data using the specified filter params
* @param {PaginationParams} params
* @returns {Promise<AdminUsersListCollection>}
*
* @param {{ getTotalCounts: (params: PaginationParams) => Promise<number>, findServerInvites: import('@/modules/serverinvites/domain/operations').FindServerInvites}} param0
*/
async function getAdminUsersListCollection(params) {
sanitizeParams(params)
function getAdminUsersListCollection({ getTotalCounts, findServerInvites }) {
/**
* Resolve admin users list data using the specified filter params
* @param {PaginationParams} params
* @returns {Promise<AdminUsersListCollection>}
*/
return async (params) => {
sanitizeParams(params)
const totalCounts = await getTotalCounts(params)
const items = await retrieveItems(params, totalCounts)
const totalCounts = await getTotalCounts(params)
const items = await retrieveItems({ findServerInvites })(params, totalCounts)
return {
items,
totalCount: totalCounts.totalCount
return {
items,
totalCount: totalCounts.totalCount
}
}
}
module.exports = {
getAdminUsersListCollection
getAdminUsersListCollection,
getTotalCounts
}
@@ -226,7 +226,7 @@ describe('Actors & Tokens @user-services', () => {
authorId: ballmerUserId
})
await deleteUser(ballmerUserId)
await deleteUser({ deleteAllUserInvites: async () => true })(ballmerUserId)
if ((await getStream({ streamId: soloOwnerStream.id })) !== undefined) {
assert.fail('user stream not deleted')
@@ -263,7 +263,7 @@ describe('Actors & Tokens @user-services', () => {
it('Should not delete the last admin user', async () => {
try {
await deleteUser(myTestActor.id)
await deleteUser({ deleteAllUserInvites: async () => true })(myTestActor.id)
assert.fail('boom')
} catch (err) {
expect(err.message).to.equal(
@@ -48,7 +48,7 @@ describe('User admin @user-services', () => {
expect(await countUsers()).to.equal(2)
await deleteUser(actorId)
await deleteUser({ deleteAllUserInvites: async () => true })(actorId)
expect(await countUsers()).to.equal(1)
})
@@ -3,7 +3,7 @@ import { truncateTables } from '@/test/hooks'
import { createUser } from '@/modules/core/services/users'
import { createStream } from '@/modules/core/services/streams'
import { times, clamp } from 'lodash'
import { createInviteDirectly } from '@/test/speckle-helpers/inviteHelper'
import { createInviteDirectlyFactory } from '@/test/speckle-helpers/inviteHelper'
import { getAdminUsersList } from '@/test/graphql/users'
import { buildApolloServer } from '@/app'
import { addLoadersToCtx } from '@/modules/shared/middleware'
@@ -12,10 +12,13 @@ import { expect } from 'chai'
import { ApolloServer } from 'apollo-server-express'
import { Optional } from '@/modules/shared/helpers/typeHelper'
import { wait } from '@speckle/shared'
import db from '@/db/knex'
// To ensure that the invites are created in the correct order, we need to wait a bit between each creation
const WAIT_TIMEOUT = 5
const createInviteDirectly = createInviteDirectlyFactory({ db })
function randomEl<T>(array: T[]): T {
return array[Math.floor(Math.random() * array.length)]
}
@@ -373,62 +373,6 @@ export type AutomationCollection = {
totalCount: Scalars['Int']['output'];
};
export type AutomationCreateInput = {
automationId: Scalars['String']['input'];
automationName: Scalars['String']['input'];
automationRevisionId: Scalars['String']['input'];
modelId: Scalars['String']['input'];
projectId: Scalars['String']['input'];
webhookId?: InputMaybe<Scalars['String']['input']>;
};
export type AutomationFunctionRun = {
__typename?: 'AutomationFunctionRun';
contextView?: Maybe<Scalars['String']['output']>;
elapsed: Scalars['Float']['output'];
functionId: Scalars['String']['output'];
functionLogo?: Maybe<Scalars['String']['output']>;
functionName: Scalars['String']['output'];
id: Scalars['ID']['output'];
resultVersions: Array<Version>;
/**
* NOTE: this is the schema for the results field below!
* Current schema: {
* version: "1.0.0",
* values: {
* objectResults: Record<str, {
* category: string
* level: ObjectResultLevel
* objectIds: string[]
* message: str | null
* metadata: Records<str, unknown> | null
* visualoverrides: Records<str, unknown> | null
* }[]>
* blobIds?: string[]
* }
* }
*/
results?: Maybe<Scalars['JSONObject']['output']>;
status: AutomationRunStatus;
statusMessage?: Maybe<Scalars['String']['output']>;
};
export type AutomationMutations = {
__typename?: 'AutomationMutations';
create: Scalars['Boolean']['output'];
functionRunStatusReport: Scalars['Boolean']['output'];
};
export type AutomationMutationsCreateArgs = {
input: AutomationCreateInput;
};
export type AutomationMutationsFunctionRunStatusReportArgs = {
input: AutomationRunStatusUpdateInput;
};
export type AutomationRevision = {
__typename?: 'AutomationRevision';
functions: Array<AutomationRevisionFunction>;
@@ -452,44 +396,8 @@ export type AutomationRevisionFunction = {
export type AutomationRevisionTriggerDefinition = VersionCreatedTriggerDefinition;
export type AutomationRun = {
__typename?: 'AutomationRun';
automationId: Scalars['String']['output'];
automationName: Scalars['String']['output'];
createdAt: Scalars['DateTime']['output'];
functionRuns: Array<AutomationFunctionRun>;
id: Scalars['ID']['output'];
/** Resolved from all function run statuses */
status: AutomationRunStatus;
updatedAt: Scalars['DateTime']['output'];
versionId: Scalars['String']['output'];
};
export enum AutomationRunStatus {
Failed = 'FAILED',
Initializing = 'INITIALIZING',
Running = 'RUNNING',
Succeeded = 'SUCCEEDED'
}
export type AutomationRunStatusUpdateInput = {
automationId: Scalars['String']['input'];
automationRevisionId: Scalars['String']['input'];
automationRunId: Scalars['String']['input'];
functionRuns: Array<FunctionRunStatusInput>;
versionId: Scalars['String']['input'];
};
export type AutomationRunTrigger = VersionCreatedTrigger;
export type AutomationsStatus = {
__typename?: 'AutomationsStatus';
automationRuns: Array<AutomationRun>;
id: Scalars['ID']['output'];
status: AutomationRunStatus;
statusMessage?: Maybe<Scalars['String']['output']>;
};
export type AvatarUser = {
__typename?: 'AvatarUser';
avatar?: Maybe<Scalars['String']['output']>;
@@ -939,27 +847,6 @@ export type FileUpload = {
userId: Scalars['String']['output'];
};
export type FunctionRunStatusInput = {
contextView?: InputMaybe<Scalars['String']['input']>;
elapsed: Scalars['Float']['input'];
functionId: Scalars['String']['input'];
functionLogo?: InputMaybe<Scalars['String']['input']>;
functionName: Scalars['String']['input'];
resultVersionIds: Array<Scalars['String']['input']>;
/**
* Current schema: {
* version: "1.0.0",
* values: {
* speckleObjects: Record<ObjectId, {level: string; statusMessage: string}[]>
* blobIds?: string[]
* }
* }
*/
results?: InputMaybe<Scalars['JSONObject']['input']>;
status: AutomationRunStatus;
statusMessage?: InputMaybe<Scalars['String']['input']>;
};
export type GendoAiRender = {
__typename?: 'GendoAIRender';
camera?: Maybe<Scalars['JSONObject']['output']>;
@@ -1085,7 +972,6 @@ export type LimitedUserTimelineArgs = {
export type Model = {
__typename?: 'Model';
author: LimitedUser;
automationStatus?: Maybe<AutomationsStatus>;
automationsStatus?: Maybe<TriggeredAutomationsStatus>;
/** Return a model tree of children */
childrenTree: Array<ModelsTreeItem>;
@@ -1218,7 +1104,6 @@ export type Mutation = {
appUpdate: Scalars['Boolean']['output'];
automateFunctionRunStatusReport: Scalars['Boolean']['output'];
automateMutations: AutomateMutations;
automationMutations: AutomationMutations;
branchCreate: Scalars['String']['output'];
branchDelete: Scalars['Boolean']['output'];
branchUpdate: Scalars['Boolean']['output'];
@@ -1904,14 +1789,6 @@ export type ProjectAutomationUpdateInput = {
name?: InputMaybe<Scalars['String']['input']>;
};
export type ProjectAutomationsStatusUpdatedMessage = {
__typename?: 'ProjectAutomationsStatusUpdatedMessage';
model: Model;
project: Project;
status: AutomationsStatus;
version: Version;
};
export type ProjectAutomationsUpdatedMessage = {
__typename?: 'ProjectAutomationsUpdatedMessage';
automation?: Maybe<Automation>;
@@ -2878,7 +2755,6 @@ export type Subscription = {
commitDeleted?: Maybe<Scalars['JSONObject']['output']>;
/** Subscribe to commit updated event. */
commitUpdated?: Maybe<Scalars['JSONObject']['output']>;
projectAutomationsStatusUpdated: ProjectAutomationsStatusUpdatedMessage;
/** Subscribe to updates to automations in the project */
projectAutomationsUpdated: ProjectAutomationsUpdatedMessage;
/**
@@ -2974,11 +2850,6 @@ export type SubscriptionCommitUpdatedArgs = {
};
export type SubscriptionProjectAutomationsStatusUpdatedArgs = {
projectId: Scalars['String']['input'];
};
export type SubscriptionProjectAutomationsUpdatedArgs = {
projectId: Scalars['String']['input'];
};
@@ -3320,7 +3191,6 @@ export type UserUpdateInput = {
export type Version = {
__typename?: 'Version';
authorUser?: Maybe<LimitedUser>;
automationStatus?: Maybe<AutomationsStatus>;
automationsStatus?: Maybe<TriggeredAutomationsStatus>;
/** All comment threads in this version */
commentThreads: CommentCollection;
-1
View File
@@ -48,7 +48,6 @@ const getEnabledModuleNames = () => {
'activitystream',
'apiexplorer',
'auth',
'betaAutomations',
'blobstorage',
'comments',
'core',
@@ -0,0 +1,114 @@
import { ServerRoles, StreamRoles } from '@speckle/shared'
import { UserWithOptionalRole } from '@/modules/core/repositories/users'
import { ResourceTargets } from '@/modules/serverinvites/helpers/inviteHelper'
import {
ServerInviteRecord,
StreamInviteRecord
} from '@/modules/serverinvites/domain/types'
import { StreamWithOptionalRole } from '@/modules/core/repositories/streams'
export type QueryAllUserStreamInvites = (
userId: string
) => Promise<StreamInviteRecord[]>
type FindStreamInviteArgs = {
target?: string | null
token?: string | null
inviteId?: string | null
}
export type FindStreamInvite = (
streamId: string,
args: FindStreamInviteArgs
) => Promise<StreamInviteRecord | null>
export type FindUserByTarget = (target: string) => Promise<UserWithOptionalRole | null>
type Invite = {
resourceId?: string | null
resourceTarget?: typeof ResourceTargets.Streams | null
}
export type FindResource = (
args: Invite
) => Promise<StreamWithOptionalRole | undefined | null>
type ServerInviteRecordInsertModel = Pick<
ServerInviteRecord,
| 'id'
| 'target'
| 'inviterId'
| 'message'
| 'resourceTarget'
| 'resourceId'
| 'role'
| 'token'
| 'serverRole'
>
export type InsertInviteAndDeleteOld = (
invite: ServerInviteRecordInsertModel,
alternateTargets: string[]
) => Promise<number[]>
export type FindServerInvite = (
email?: string,
token?: string
) => Promise<ServerInviteRecord | null>
export type QueryAllStreamInvites = (streamId: string) => Promise<StreamInviteRecord[]>
export type DeleteAllStreamInvites = (streamId: string) => Promise<boolean>
export type DeleteServerOnlyInvites = (email?: string) => Promise<number | undefined>
export type UpdateAllInviteTargets = (
oldTargets?: string | string[],
newTarget?: string
) => Promise<void>
export type DeleteStreamInvite = (inviteId?: string) => Promise<number | undefined>
export type CountServerInvites = (searchQuery: string | null) => Promise<number>
export type FindServerInvites = (
searchQuery: string | null,
limit: number,
offset: number
) => Promise<ServerInviteRecord[]>
export type QueryServerInvites = (
searchQuery: string | null,
limit: number,
cursor: Date | null
) => Promise<ServerInviteRecord[]>
export type FindInvite = (inviteId?: string) => Promise<ServerInviteRecord | null>
export type DeleteInvite = (inviteId?: string) => Promise<boolean>
export type DeleteInvitesByTarget = (
targets?: string | string[],
resourceTarget?: string,
resourceId?: string
) => Promise<boolean>
export type QueryInvites = (
inviteIds?: readonly string[]
) => Promise<ServerInviteRecord[]>
export type DeleteAllUserInvites = (userId: string) => Promise<boolean>
export type FindInviteByToken = (
inviteToken?: string
) => Promise<ServerInviteRecord | null>
export type CreateInviteParams = {
target: string
inviterId: string
message?: string | null
resourceTarget?: typeof ResourceTargets.Streams
resourceId?: string
role?: StreamRoles
serverRole?: ServerRoles | null
}
@@ -5,15 +5,19 @@ import {
buildUserTarget,
ResourceTargets
} from '@/modules/serverinvites/helpers/inviteHelper'
import { createAndSendInvite } from '@/modules/serverinvites/services/inviteCreationService'
import {
createStreamInviteAndNotify,
createAndSendInviteFactory,
resendInviteEmailFactory
} from '@/modules/serverinvites/services/inviteCreationService'
import {
createStreamInviteAndNotifyFactory,
useStreamInviteAndNotify
} from '@/modules/serverinvites/services/management'
import {
cancelStreamInvite,
resendInvite,
deleteInvite
deleteInvite,
finalizeStreamInvite
} from '@/modules/serverinvites/services/inviteProcessingService'
import {
getServerInviteForToken,
@@ -23,24 +27,48 @@ import {
import { authorizeResolver } from '@/modules/shared'
import { chunk } from 'lodash'
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import db from '@/db/knex'
import { ServerRoles, StreamRoles } from '@speckle/shared'
import {
deleteInvitesByTargetFactory,
deleteStreamInviteFactory,
findInviteFactory,
findResourceFactory,
findServerInviteFactory,
findStreamInviteFactory,
findUserByTargetFactory,
insertInviteAndDeleteOldFactory,
queryAllUserStreamInvitesFactory,
deleteInviteFactory
} from '@/modules/serverinvites/repositories/serverInvites'
export = {
Query: {
async streamInvite(_parent, args, context) {
const { streamId, token } = args
return await getUserPendingStreamInvite(streamId, context.userId, token)
return getUserPendingStreamInvite({
findStreamInvite: findStreamInviteFactory({ db })
})(streamId, context.userId, token)
},
async projectInvite(_parent, args, context) {
const { projectId, token } = args
return await getUserPendingStreamInvite(projectId, context.userId, token)
return await getUserPendingStreamInvite({
findStreamInvite: findStreamInviteFactory({ db })
})(projectId, context.userId, token)
},
async streamInvites(_parent, _args, context) {
const { userId } = context
return await getUserPendingStreamInvites(userId!)
return getUserPendingStreamInvites({
queryAllUserStreamInvites: queryAllUserStreamInvitesFactory({
db
})
})(userId!)
},
async serverInviteByToken(_parent, args) {
const { token } = args
return await getServerInviteForToken(token)
return getServerInviteForToken({
findServerInvite: findServerInviteFactory({ db })
})(token)
}
},
ServerInvite: {
@@ -54,12 +82,16 @@ export = {
},
Mutation: {
async serverInviteCreate(_parent, args, context) {
await createAndSendInvite(
await createAndSendInviteFactory({
findResource: findResourceFactory(),
findUserByTarget: findUserByTargetFactory(),
insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db })
})(
{
target: args.input.email,
inviterId: context.userId!,
message: args.input.message,
serverRole: args.input.serverRole
serverRole: args.input.serverRole as null | undefined | ServerRoles
},
context.resourceAccessRules
)
@@ -74,11 +106,15 @@ export = {
Roles.Stream.Owner,
context.resourceAccessRules
)
await createStreamInviteAndNotify(
args.input,
context.userId!,
context.resourceAccessRules
)
await createStreamInviteAndNotifyFactory({
createAndSendInvite: createAndSendInviteFactory({
findResource: findResourceFactory(),
findUserByTarget: findUserByTargetFactory(),
insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({
db
})
})
})(args.input, context.userId!, context.resourceAccessRules)
return true
},
@@ -98,12 +134,18 @@ export = {
for (const paramsBatchArray of batches) {
await Promise.all(
paramsBatchArray.map((params) =>
createAndSendInvite(
createAndSendInviteFactory({
findResource: findResourceFactory(),
findUserByTarget: findUserByTargetFactory(),
insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({
db
})
})(
{
target: params.email,
inviterId: context.userId!,
message: params.message,
serverRole: params.serverRole
serverRole: params.serverRole as ServerRoles | null | undefined
},
context.resourceAccessRules
)
@@ -134,15 +176,21 @@ export = {
paramsBatchArray.map((params) => {
const { email, userId, message, streamId, role, serverRole } = params
const target = (userId ? buildUserTarget(userId) : email)!
return createAndSendInvite(
return createAndSendInviteFactory({
findResource: findResourceFactory(),
findUserByTarget: findUserByTargetFactory(),
insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({
db
})
})(
{
target,
inviterId: context.userId!,
message,
resourceTarget: ResourceTargets.Streams,
resourceId: streamId,
role: role || Roles.Stream.Contributor,
serverRole
role: (role as unknown as StreamRoles) || Roles.Stream.Contributor,
serverRole: serverRole as ServerRoles | null | undefined
},
context.resourceAccessRules
)
@@ -154,7 +202,12 @@ export = {
},
async streamInviteUse(_parent, args, ctx) {
await useStreamInviteAndNotify(args, ctx.userId!, ctx.resourceAccessRules)
await useStreamInviteAndNotify({
finalizeStreamInvite: finalizeStreamInvite({
findStreamInvite: findStreamInviteFactory({ db }),
deleteInvitesByTarget: deleteInvitesByTargetFactory({ db })
})
})(args, ctx.userId!, ctx.resourceAccessRules)
return true
},
@@ -163,7 +216,10 @@ export = {
const { userId, resourceAccessRules } = ctx
await authorizeResolver(userId, streamId, Roles.Stream.Owner, resourceAccessRules)
await cancelStreamInvite(streamId, inviteId)
await cancelStreamInvite({
findStreamInvite: findStreamInviteFactory({ db }),
deleteStreamInvite: deleteStreamInviteFactory({ db })
})(streamId, inviteId)
return true
},
@@ -171,7 +227,13 @@ export = {
async inviteResend(_parent, args) {
const { inviteId } = args
await resendInvite(inviteId)
await resendInvite({
findInvite: findInviteFactory({ db }),
resendInviteEmail: resendInviteEmailFactory({
findResource: findResourceFactory(),
findUserByTarget: findUserByTargetFactory()
})
})(inviteId)
return true
},
@@ -179,7 +241,10 @@ export = {
async inviteDelete(_parent, args) {
const { inviteId } = args
await deleteInvite(inviteId)
await deleteInvite({
findInvite: findInviteFactory({ db }),
deleteInvite: deleteInviteFactory({ db })
})(inviteId)
return true
}
@@ -4,8 +4,8 @@ const ResourceTargets = Object.freeze({
/**
* @typedef {{
* resourceTarget?: string,
* resourceId?: string
* resourceTarget?: string | null,
* resourceId?: string | null
* }} InviteResourceData
*/
@@ -77,7 +77,7 @@ function buildUserTarget(userId) {
/**
* Resolve a display name for the user being invited
* @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite
* @param {import('@/modules/serverinvites/domain/types').ServerInviteRecord} invite
* @param {import("@/modules/core/helpers/userHelper").LimitedUserRecord | null} user The user,
* if invite targets a registered user.
* @returns {string}
@@ -1,398 +0,0 @@
const { ServerInvites, Streams, knex } = require('@/modules/core/dbSchema')
const { getUserByEmail, getUser } = require('@/modules/core/repositories/users')
const { ResourceNotResolvableError } = require('@/modules/serverinvites/errors')
const {
resolveTarget,
ResourceTargets,
buildUserTarget,
isServerInvite
} = require('@/modules/serverinvites/helpers/inviteHelper')
const { uniq, isArray } = require('lodash')
const { getStream } = require('@/modules/core/repositories/streams')
/**
* Use this wherever you're retrieving invites, not necessarily where you're writing to them
*/
const getInvitesBaseQuery = (sort = 'asc') => {
const q = ServerInvites.knex().select(ServerInvites.cols)
// join just to ensure we don't retrieve invalid invites
q.leftJoin(Streams.name, (j) => {
j.onNotNull(ServerInvites.col.resourceId)
.andOnVal(ServerInvites.col.resourceTarget, ResourceTargets.Streams)
.andOn(Streams.col.id, ServerInvites.col.resourceId)
}).where((w1) => {
w1.whereNull(ServerInvites.col.resourceId).orWhereNotNull(Streams.col.id)
})
q.orderBy(ServerInvites.col.createdAt, sort)
return q
}
/**
*
* Resolve resource from invite
* @param {import('@/modules/serverinvites/helpers/inviteHelper').InviteResourceData} invite
* @returns {Promise<Object>}
*/
async function getResource(invite) {
if (isServerInvite(invite)) return null
const { resourceId, resourceTarget } = invite
if (resourceTarget === ResourceTargets.Streams) {
return await getStream({ streamId: resourceId })
} else {
throw new ResourceNotResolvableError('Unexpected invite resource type')
}
}
/**
* Try to find a user using the target value
* @param {string} target
* @returns {Promise<import('@/modules/core/repositories/users').UserWithOptionalRole | undefined>}
*/
async function getUserFromTarget(target) {
const { userEmail, userId } = resolveTarget(target)
return userEmail
? await getUserByEmail(userEmail, { withRole: true })
: await getUser(userId, { withRole: true })
}
/**
* Insert a new invite and delete the old ones
* @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite
* @param {string[]} alternateTargets If there are alternate targets for the same user
* (e.g. user ID & email), you can specify them to ensure those will be cleaned up
* also
*/
async function insertInviteAndDeleteOld(invite, alternateTargets = []) {
const allTargets = uniq(
[invite.target, ...alternateTargets].map((t) => t.toLowerCase())
)
// Delete old
await ServerInvites.knex()
.where({
[ServerInvites.col.resourceId]: invite.resourceId || null,
[ServerInvites.col.resourceTarget]: invite.resourceTarget || null
})
.whereIn(ServerInvites.col.target, allTargets)
.delete()
// Insert new
invite.target = invite.target.toLowerCase() // Extra safety cause our schema is case sensitive
await ServerInvites.knex().insert(invite)
}
/**
* Retrieve a valid server invite for the specified target
* @param {string|undefined} email Email address
* @param {string|undefined} token Specify an invite token, if you're looking for
* a specific invite. For backwards compatibility purposes, the token can also just be the invite ID.
* @returns {import('@/modules/serverinvites/helpers/types').ServerInviteRecord | null}
*/
async function getServerInvite(email = undefined, token = undefined) {
if (!email && !token) return null
const q = getInvitesBaseQuery()
if (email) {
q.andWhere({
[ServerInvites.col.target]: email.toLowerCase()
})
}
if (token) {
q.andWhere(ServerInvites.col.token, token)
}
return await q.first()
}
/**
* Use up/delete all server-only for the specified email
* @param {string} email
*/
async function deleteServerOnlyInvites(email) {
if (!email) return
await ServerInvites.knex()
.where({
[ServerInvites.col.target]: email.toLowerCase(),
[ServerInvites.col.resourceTarget]: null
})
.delete()
}
/**
* Update all invites that have the specified targets to have a new target value
* @param {string[]|string} oldTargets A single target or an array of targets
* @param {string} newTarget
* @returns
*/
async function updateAllInviteTargets(oldTargets, newTarget) {
if (!oldTargets || !newTarget) return
oldTargets = isArray(oldTargets) ? oldTargets : [oldTargets]
oldTargets = oldTargets.map((t) => t.toLowerCase())
if (!oldTargets.length) return
// PostgreSQL doesn't support aliases in update calls for some reason...
const ServerInvitesCols = ServerInvites.with({ withoutTablePrefix: true }).col
await ServerInvites.knex()
.whereIn(ServerInvitesCols.target, oldTargets)
.update(ServerInvitesCols.target, newTarget.toLowerCase())
}
/**
* Get all pending stream invites
* @param {string} streamId
* @returns {Promise<import('@/modules/serverinvites/helpers/types').StreamInviteRecord[]>}
*/
async function getAllStreamInvites(streamId) {
if (!streamId) return []
const q = getInvitesBaseQuery().where({
[ServerInvites.col.resourceTarget]: ResourceTargets.Streams,
[ServerInvites.col.resourceId]: streamId
})
return await q
}
/**
* Get all invitations to streams that the specified user has
* @param {string} userId
* @returns {Promise<import('@/modules/serverinvites/helpers/types').StreamInviteRecord[]>}
*/
async function getAllUserStreamInvites(userId) {
if (!userId) return []
const target = buildUserTarget(userId)
const q = getInvitesBaseQuery().where({
[ServerInvites.col.target]: target,
[ServerInvites.col.resourceTarget]: ResourceTargets.Streams
})
return await q
}
/**
* Retrieve a stream invite for the specified target, token or both.
* Note: Either the target, inviteId or token must be set
* @param {string} streamId
* @param {{target?: string|null|undefined, token?: string|null|undefined, inviteId?: string|null|undefined}} [param2]
* @returns {Promise<import('@/modules/serverinvites/helpers/types').StreamInviteRecord | null>}
*/
async function getStreamInvite(
streamId,
{ target = null, token = null, inviteId = null } = {}
) {
if (!target && !token && !inviteId) return null
const q = getInvitesBaseQuery().where({
[ServerInvites.col.resourceTarget]: ResourceTargets.Streams,
[ServerInvites.col.resourceId]: streamId
})
if (target) {
q.andWhere({
[ServerInvites.col.target]: target.toLowerCase()
})
} else if (inviteId) {
q.andWhere({
[ServerInvites.col.id]: inviteId
})
} else if (token) {
q.andWhere({
[ServerInvites.col.token]: token
})
}
return await q.first()
}
/**
* Delete a single stream invite
* @param {string} inviteId
*/
async function deleteStreamInvite(inviteId) {
if (!inviteId) return
await ServerInvites.knex()
.where({
[ServerInvites.col.id]: inviteId,
[ServerInvites.col.resourceTarget]: ResourceTargets.Streams
})
.delete()
}
function findServerInvitesBaseQuery(searchQuery, sort) {
const q = getInvitesBaseQuery(sort)
if (searchQuery) {
// TODO: Is this safe from SQL injection?
q.andWhere(ServerInvites.col.target, 'ILIKE', `%${searchQuery}%`)
}
// Not an invite for an already registered user
q.andWhere(ServerInvites.col.target, 'NOT ILIKE', '@%')
return q
}
/**
* Count all server invites, optionally filtering out unnecessary ones with the search query
* @param {string|null} searchQuery
* @returns {Promise<number>}
*/
async function countServerInvites(searchQuery) {
const q = findServerInvitesBaseQuery(searchQuery)
const [count] = await knex().count().from(q.as('sq1'))
return parseInt(count.count)
}
/**
*
* @param {string|null} searchQuery
* @param {number} limit
* @param {number} offset
* @returns {Promise<import('@/modules/serverinvites/helpers/types').ServerInviteRecord[]>}
*/
async function findServerInvites(searchQuery, limit, offset) {
const q = findServerInvitesBaseQuery(searchQuery)
q.limit(limit).offset(offset)
return await q
}
/**
*
* @param {string|null} searchQuery
* @param {number} limit
* @param {Date|null} cursor
* @returns {Promise<import('@/modules/serverinvites/helpers/types').ServerInviteRecord[]>}
*/
async function queryServerInvites(searchQuery, limit, cursor) {
const q = findServerInvitesBaseQuery(searchQuery, 'desc').limit(limit)
if (cursor) q.where(ServerInvites.col.createdAt, '<', cursor.toISOString())
return await q
}
/**
* Retrieve a specific invite (irregardless of the type)
* @param {string} inviteId
* @returns {Promise<import('@/modules/serverinvites/helpers/types').ServerInviteRecord | null>}
*/
async function getInvite(inviteId) {
if (!inviteId) return null
return await getInvitesBaseQuery().where(ServerInvites.col.id, inviteId).first()
}
/**
* Retrieve a specific invite (irregardless of the type) by the token
* @param {string} inviteId
* @returns {Promise<import('@/modules/serverinvites/helpers/types').ServerInviteRecord | null>}
*/
async function getInviteByToken(inviteToken) {
if (!inviteToken) return null
return await getInvitesBaseQuery().where(ServerInvites.col.token, inviteToken).first()
}
/**
* Delete a specific invite (irregardless of the type)
* @param {string} inviteId
* @returns {Promise<boolean>}
*/
async function deleteInvite(inviteId) {
if (!inviteId) return false
await ServerInvites.knex().where(ServerInvites.col.id, inviteId).delete()
return true
}
/**
* Delete invites by target - useful when there are potentially duplicate invites that need cleaning up
* (e.g. same target, but multiple inviters)
* @param {string|string[]} targets
* @param {string} resourceTarget
* @param {string} resourceId
* @returns
*/
async function deleteInvitesByTarget(targets, resourceTarget, resourceId) {
if (!targets) return false
targets = isArray(targets) ? targets : [targets]
if (!targets.length) return
resourceTarget = resourceTarget || null
resourceId = resourceId || null
await ServerInvites.knex()
.where({
[ServerInvites.col.resourceTarget]: resourceTarget,
[ServerInvites.col.resourceId]: resourceId
})
.whereIn(ServerInvites.col.target, targets)
.delete()
return true
}
/**
* Delete all invites that target the specified user
* @param {string} userId
* @returns {Promise<boolean>}
*/
async function deleteAllUserInvites(userId) {
if (!userId) return false
await ServerInvites.knex()
.where(ServerInvites.col.target, buildUserTarget(userId))
.delete()
return true
}
/**
* Delete all invites for the specified stream
* @param {string} streamId
* @returns {Promise<boolean>}
*/
async function deleteAllStreamInvites(streamId) {
if (!streamId) return false
await ServerInvites.knex()
.where(ServerInvites.col.resourceId, streamId)
.andWhere(ServerInvites.col.resourceTarget, ResourceTargets.Streams)
.delete()
return true
}
/**
* Get all invites by IDs
* @returns {Promise<import('@/modules/serverinvites/helpers/types').ServerInviteRecord[]>}
*/
async function getInvites(inviteIds) {
if (!inviteIds?.length) return []
return await getInvitesBaseQuery().whereIn(ServerInvites.col.id, inviteIds)
}
module.exports = {
insertInviteAndDeleteOld,
getServerInvite,
deleteServerOnlyInvites,
getUserFromTarget,
updateAllInviteTargets,
getStreamInvite,
deleteStreamInvite,
getAllStreamInvites,
countServerInvites,
findServerInvites,
getInvite,
deleteInvite,
deleteInvitesByTarget,
deleteAllUserInvites,
getResource,
getAllUserStreamInvites,
getInvites,
getInviteByToken,
deleteAllStreamInvites,
queryServerInvites
}
@@ -0,0 +1,352 @@
import { ServerInvites, Streams } from '@/modules/core/dbSchema'
import {
getUserByEmail,
getUser,
UserWithOptionalRole
} from '@/modules/core/repositories/users'
import { ResourceNotResolvableError } from '@/modules/serverinvites/errors'
import {
resolveTarget,
ResourceTargets,
buildUserTarget,
isServerInvite
} from '@/modules/serverinvites/helpers/inviteHelper'
import { uniq } from 'lodash'
import { StreamWithOptionalRole, getStream } from '@/modules/core/repositories/streams'
import { ServerInviteRecord } from '@/modules/serverinvites/domain/types'
import { Knex } from 'knex'
import {
CountServerInvites,
DeleteAllStreamInvites,
DeleteAllUserInvites,
DeleteInvite,
DeleteInvitesByTarget,
DeleteServerOnlyInvites,
DeleteStreamInvite,
FindInvite,
FindInviteByToken,
FindServerInvite,
FindServerInvites,
FindStreamInvite,
InsertInviteAndDeleteOld,
QueryAllStreamInvites,
QueryAllUserStreamInvites,
QueryInvites,
QueryServerInvites,
UpdateAllInviteTargets
} from '@/modules/serverinvites/domain/operations'
/**
* Use this wherever you're retrieving invites, not necessarily where you're writing to them
*/
const buildInvitesBaseQuery =
({ db }: { db: Knex }) =>
(sort: 'asc' | 'desc' = 'asc') => {
// join just to ensure we don't retrieve invalid invites
const q = db<ServerInviteRecord>(ServerInvites.name)
.select(ServerInvites.cols)
.leftJoin(Streams.name, (j) => {
j.onNotNull(ServerInvites.col.resourceId)
.andOnVal(ServerInvites.col.resourceTarget, ResourceTargets.Streams)
.andOn(Streams.col.id, ServerInvites.col.resourceId)
})
.where((w1) => {
w1.whereNull(ServerInvites.col.resourceId).orWhereNotNull(Streams.col.id)
})
.orderBy(ServerInvites.col.createdAt, sort)
return q
}
/**
* Resolve resource from invite
*/
export const findResourceFactory =
() =>
async (invite: {
resourceId?: string | null
resourceTarget?: typeof ResourceTargets.Streams | null
}): Promise<StreamWithOptionalRole | undefined | null> => {
if (isServerInvite(invite)) return null
const { resourceId, resourceTarget } = invite
if (resourceTarget === ResourceTargets.Streams) {
return getStream({ streamId: resourceId ?? undefined })
} else {
throw new ResourceNotResolvableError('Unexpected invite resource type')
}
}
/**
* Try to find a user using the target value
*/
export const findUserByTargetFactory =
() =>
(target: string): Promise<UserWithOptionalRole | null> => {
const { userEmail, userId } = resolveTarget(target)
return userEmail
? getUserByEmail(userEmail, { withRole: true })
: getUser(userId!, { withRole: true })
}
/**
* Insert a new invite and delete the old ones
* If there are alternate targets for the same user
* (e.g. user ID & email), you can specify them to ensure those will be cleaned up
* also
*/
export const insertInviteAndDeleteOldFactory =
({ db }: { db: Knex }): InsertInviteAndDeleteOld =>
async (invite, alternateTargets = []) => {
const allTargets = uniq(
[invite.target, ...alternateTargets].map((t) => t.toLowerCase())
)
// Delete old
await db<ServerInviteRecord>(ServerInvites.name)
.where({
[ServerInvites.col.resourceId]: invite.resourceId || null,
[ServerInvites.col.resourceTarget]: invite.resourceTarget || null
})
.whereIn(ServerInvites.col.target, allTargets)
.delete()
// Insert new
invite.target = invite.target.toLowerCase() // Extra safety cause our schema is case sensitive
return db<ServerInviteRecord>(ServerInvites.name).insert(invite)
}
/**
* Get all invitations to streams that the specified user has
*/
export const queryAllUserStreamInvitesFactory =
({ db }: { db: Knex }): QueryAllUserStreamInvites =>
async (userId) => {
if (!userId) return []
const target = buildUserTarget(userId)
return buildInvitesBaseQuery({ db })().where({
[ServerInvites.col.target]: target,
[ServerInvites.col.resourceTarget]: ResourceTargets.Streams
})
}
/**
* Retrieve a stream invite for the specified target, token or both.
* Note: Either the target, inviteId or token must be set
*/
export const findStreamInviteFactory =
({ db }: { db: Knex }): FindStreamInvite =>
async (streamId, { target = null, token = null, inviteId = null } = {}) => {
if (!target && !token && !inviteId) return null
const q = buildInvitesBaseQuery({ db })().where({
[ServerInvites.col.resourceTarget]: ResourceTargets.Streams,
[ServerInvites.col.resourceId]: streamId
})
if (target) {
q.andWhere({
[ServerInvites.col.target]: target.toLowerCase()
})
} else if (inviteId) {
q.andWhere({
[ServerInvites.col.id]: inviteId
})
} else if (token) {
q.andWhere({
[ServerInvites.col.token]: token
})
}
return q.first()
}
export const findServerInviteFactory =
({ db }: { db: Knex }): FindServerInvite =>
async (email, token) => {
if (!email && !token) return null
const q = buildInvitesBaseQuery({ db })()
if (email) {
q.andWhere({
[ServerInvites.col.target]: email.toLowerCase()
})
}
if (token) {
q.andWhere(ServerInvites.col.token, token)
}
return q.first()
}
export const queryAllStreamInvitesFactory =
({ db }: { db: Knex }): QueryAllStreamInvites =>
async (streamId) => {
if (!streamId) return []
return buildInvitesBaseQuery({ db })().where({
[ServerInvites.col.resourceTarget]: ResourceTargets.Streams,
[ServerInvites.col.resourceId]: streamId
})
}
export const deleteAllStreamInvitesFactory =
({ db }: { db: Knex }): DeleteAllStreamInvites =>
async (streamId) => {
if (!streamId) return false
await db(ServerInvites.name)
.where(ServerInvites.col.resourceId, streamId)
.andWhere(ServerInvites.col.resourceTarget, ResourceTargets.Streams)
.delete()
return true
}
export const deleteServerOnlyInvitesFactory =
({ db }: { db: Knex }): DeleteServerOnlyInvites =>
async (email) => {
if (!email) return
return db<ServerInviteRecord>(ServerInvites.name)
.where({
[ServerInvites.col.target]: email.toLowerCase(),
[ServerInvites.col.resourceTarget]: null
})
.delete()
}
export const updateAllInviteTargetsFactory =
({ db }: { db: Knex }): UpdateAllInviteTargets =>
async (oldTargets, newTarget) => {
if (!oldTargets || !newTarget) return
oldTargets = Array.isArray(oldTargets) ? oldTargets : [oldTargets]
oldTargets = oldTargets.map((t) => t.toLowerCase())
if (!oldTargets.length) return
// PostgreSQL doesn't support aliases in update calls for some reason...
const ServerInvitesCols = ServerInvites.with({ withoutTablePrefix: true }).col
return db(ServerInvites.name)
.whereIn(ServerInvitesCols.target, oldTargets)
.update(ServerInvitesCols.target, newTarget.toLowerCase())
}
export const deleteStreamInviteFactory =
({ db }: { db: Knex }): DeleteStreamInvite =>
async (inviteId) => {
if (!inviteId) return
return db(ServerInvites.name)
.where({
[ServerInvites.col.id]: inviteId,
[ServerInvites.col.resourceTarget]: ResourceTargets.Streams
})
.delete()
}
const findServerInvitesBaseQueryFactory =
({ db }: { db: Knex }) =>
(searchQuery: string | null, sort: 'asc' | 'desc' = 'asc'): Knex.QueryBuilder => {
const q = buildInvitesBaseQuery({ db })(sort)
if (searchQuery) {
// TODO: Is this safe from SQL injection?
q.andWhere(ServerInvites.col.target, 'ILIKE', `%${searchQuery}%`)
}
// Not an invite for an already registered user
q.andWhere(ServerInvites.col.target, 'NOT ILIKE', '@%')
return q
}
export const countServerInvitesFactory =
({ db }: { db: Knex }): CountServerInvites =>
async (searchQuery) => {
const q = findServerInvitesBaseQueryFactory({ db })(searchQuery)
const [count] = await db()
.count()
.from((q as Knex.QueryBuilder).as('sq1'))
return parseInt(count.count.toString())
}
export const findServerInvitesFactory =
({ db }: { db: Knex }): FindServerInvites =>
async (searchQuery, limit, offset) => {
const q = findServerInvitesBaseQueryFactory({ db })(
searchQuery
) as Knex.QueryBuilder
return q.limit(limit).offset(offset) as Promise<ServerInviteRecord[]>
}
export const queryServerInvitesFactory =
({ db }: { db: Knex }): QueryServerInvites =>
async (searchQuery, limit, cursor) => {
const q = findServerInvitesBaseQueryFactory({ db })(searchQuery, 'desc')
q.limit(limit)
if (cursor) q.where(ServerInvites.col.createdAt, '<', cursor.toISOString())
return q
}
export const findInviteFactory =
({ db }: { db: Knex }): FindInvite =>
async (inviteId) => {
if (!inviteId) return null
return buildInvitesBaseQuery({ db })().where(ServerInvites.col.id, inviteId).first()
}
export const deleteInviteFactory =
({ db }: { db: Knex }): DeleteInvite =>
async (inviteId) => {
if (!inviteId) return false
await db(ServerInvites.name).where(ServerInvites.col.id, inviteId).delete()
return true
}
/**
* Delete invites by target - useful when there are potentially duplicate invites that need cleaning up
* (e.g. same target, but multiple inviters)
*/
export const deleteInvitesByTargetFactory =
({ db }: { db: Knex }): DeleteInvitesByTarget =>
async (targets, resourceTarget, resourceId) => {
if (!targets) return false
targets = Array.isArray(targets) ? targets : [targets]
if (!targets.length) return false
await db(ServerInvites.name)
.where({
[ServerInvites.col.resourceTarget]: resourceTarget,
[ServerInvites.col.resourceId]: resourceId
})
.whereIn(ServerInvites.col.target, targets)
.delete()
return true
}
export const queryInvitesFactory =
({ db }: { db: Knex }): QueryInvites =>
async (inviteIds) => {
if (!inviteIds?.length) return []
return buildInvitesBaseQuery({ db })().whereIn(ServerInvites.col.id, inviteIds)
}
export const deleteAllUserInvitesFactory =
({ db }: { db: Knex }): DeleteAllUserInvites =>
async (userId) => {
if (!userId) return false
await db(ServerInvites.name)
.where(ServerInvites.col.target, buildUserTarget(userId))
.delete()
return true
}
export const findInviteByTokenFactory =
({ db }: { db: Knex }): FindInviteByToken =>
async (inviteToken) => {
if (!inviteToken) return null
return buildInvitesBaseQuery({ db })()
.where(ServerInvites.col.token, inviteToken)
.first()
}
@@ -0,0 +1,273 @@
import { ServerInfo, UserRecord } from '@/modules/core/helpers/types'
import { ServerInviteRecord } from '@/modules/serverinvites/domain/types'
import { getServerInfo } from '@/modules/core/services/generic'
import {
ResourceTargets,
isServerInvite
} from '@/modules/serverinvites/helpers/inviteHelper'
import {
getRegistrationRoute,
getStreamRoute
} from '@/modules/core/helpers/routeHelper'
import { getFrontendOrigin } from '@/modules/shared/helpers/envHelper'
import { InviteCreateValidationError } from '@/modules/serverinvites/errors'
import {
EmailTemplateParams,
renderEmail
} from '@/modules/emails/services/emailRendering'
import sanitizeHtml from 'sanitize-html'
import { CreateInviteParams } from '@/modules/serverinvites/domain/operations'
type InviteOrInputParams =
| CreateInviteParams
| Pick<
ServerInviteRecord,
| 'id'
| 'target'
| 'inviterId'
| 'message'
| 'resourceTarget'
| 'resourceId'
| 'role'
| 'token'
| 'serverRole'
>
/**
* Build invite email contents
*/
export async function buildEmailContents(
invite: Pick<
ServerInviteRecord,
| 'id'
| 'target'
| 'inviterId'
| 'message'
| 'resourceTarget'
| 'resourceId'
| 'role'
| 'token'
| 'serverRole'
>,
inviter: UserRecord,
resource?: { name: string } | null,
targetUser?: UserRecord | null
): Promise<{ to: string; subject: string; text: string; html: string }> {
const email = targetUser ? targetUser.email : invite.target
const serverInfo = await getServerInfo()
const inviteLink = buildInviteLink(invite)
const resourceName = resolveResourceName(invite, resource)
const templateParams = buildEmailTemplateParams(
invite,
inviter,
serverInfo,
inviteLink,
resourceName
)
const subject = buildEmailSubject(invite, inviter, resourceName)
const { text, html } = await renderEmail(
templateParams,
serverInfo,
targetUser || null
)
return {
to: email,
subject,
text,
html
}
}
function buildInviteLink(
invite: Pick<
ServerInviteRecord,
| 'id'
| 'target'
| 'inviterId'
| 'message'
| 'resourceTarget'
| 'resourceId'
| 'role'
| 'token'
| 'serverRole'
>
) {
const { resourceTarget, resourceId, token } = invite
if (isServerInvite(invite)) {
return new URL(
`${getRegistrationRoute()}?token=${token}`,
getFrontendOrigin()
).toString()
}
if (resourceTarget === 'streams') {
return new URL(
`${getStreamRoute(resourceId!)}?token=${token}&accept=true`,
getFrontendOrigin()
).toString()
} else {
throw new InviteCreateValidationError('Unexpected resource target type')
}
}
/**
* Build the email subject line
*/
function buildEmailSubject(
invite: Pick<
ServerInviteRecord,
| 'id'
| 'target'
| 'inviterId'
| 'message'
| 'resourceTarget'
| 'resourceId'
| 'role'
| 'token'
| 'serverRole'
>,
inviter: UserRecord,
resourceName: string | null
): string {
const { resourceTarget } = invite
if (isServerInvite(invite)) {
return 'Speckle Invitation from ' + inviter.name
}
if (resourceTarget === 'streams') {
return `${inviter.name} wants to share the project "${resourceName}" on Speckle with you`
} else {
throw new InviteCreateValidationError('Unexpected resource target type')
}
}
function buildEmailTemplateParams(
invite: Pick<
ServerInviteRecord,
| 'id'
| 'target'
| 'inviterId'
| 'message'
| 'resourceTarget'
| 'resourceId'
| 'role'
| 'token'
| 'serverRole'
>,
inviter: UserRecord,
serverInfo: ServerInfo,
inviteLink: string,
resourceName: string | null
): EmailTemplateParams {
return {
mjml: buildMjmlPreamble(invite, inviter, serverInfo, resourceName), // TODO: what happens when resourceName is null?
text: buildTextPreamble(invite, inviter, serverInfo, resourceName), // TODO: what happens when resourceName is null?
cta: {
title: 'Accept the invitation',
url: inviteLink
}
}
}
function buildMjmlPreamble(
invite: Pick<
ServerInviteRecord,
| 'id'
| 'target'
| 'inviterId'
| 'message'
| 'resourceTarget'
| 'resourceId'
| 'role'
| 'token'
| 'serverRole'
>,
inviter: UserRecord,
serverInfo: ServerInfo,
resourceName: string | null
) {
const { message } = invite
const forServer = isServerInvite(invite)
const dynamicText = forServer
? `join the <b>${serverInfo.name}</b> Speckle Server`
: `become a collaborator on the <b>${resourceName}</b> project`
const bodyStart = `
<mj-text>
Hello!
<br />
<br />
${inviter.name} has just sent you this invitation to ${dynamicText}!
${message ? inviter.name + ' said: <em>"' + message + '"</em>' : ''}
</mj-text>
`
return {
bodyStart,
bodyEnd:
'<mj-text>Feel free to ignore this invite if you do not know the person sending it.</mj-text>'
}
}
function buildTextPreamble(
invite: Pick<
ServerInviteRecord,
| 'id'
| 'target'
| 'inviterId'
| 'message'
| 'resourceTarget'
| 'resourceId'
| 'role'
| 'token'
| 'serverRole'
>,
inviter: UserRecord,
serverInfo: ServerInfo,
resourceName: string | null
) {
const { message } = invite
const forServer = isServerInvite(invite)
const dynamicText = forServer
? `join the ${serverInfo.name} Speckle Server`
: `become a collaborator on the "${resourceName}" project`
const bodyStart = `Hello!
${inviter.name} has just sent you this invitation to ${dynamicText}!
${message ? inviter.name + ' said: "' + sanitizeMessage(message, true) + '"' : ''}`
return {
bodyStart,
bodyEnd: 'Feel free to ignore this invite if you do not know the person sending it.'
}
}
/**
* Sanitize message that potentially has HTML in it
*/
function sanitizeMessage(message: string, stripAll: boolean = false): string {
return sanitizeHtml(message, {
allowedTags: stripAll ? [] : ['b', 'i', 'em', 'strong']
})
}
function resolveResourceName(
params: InviteOrInputParams,
resource?: { name: string } | null
) {
const { resourceTarget } = params
if (resourceTarget === ResourceTargets.Streams) {
return resource?.name || null
}
return null
}
@@ -1,486 +0,0 @@
const crs = require('crypto-random-string')
const { getServerInfo } = require('@/modules/core/services/generic')
const { sendEmail } = require('@/modules/emails')
const { InviteCreateValidationError } = require('@/modules/serverinvites/errors')
const { authorizeResolver } = require('@/modules/shared')
const {
insertInviteAndDeleteOld,
getUserFromTarget,
getResource
} = require('@/modules/serverinvites/repositories')
const { getStreamCollaborator } = require('@/modules/core/repositories/streams')
const { Roles } = require('@/modules/core/helpers/mainConstants')
const sanitizeHtml = require('sanitize-html')
const {
getRegistrationRoute,
getStreamRoute
} = require('@/modules/core/helpers/routeHelper')
const {
isServerInvite,
resolveTarget,
buildUserTarget,
ResourceTargets
} = require('@/modules/serverinvites/helpers/inviteHelper')
const { getUsers, getUser } = require('@/modules/core/repositories/users')
const {
addStreamInviteSentOutActivity
} = require('@/modules/activitystream/services/streamActivity')
const { renderEmail } = require('@/modules/emails/services/emailRendering')
const { getFrontendOrigin } = require('@/modules/shared/helpers/envHelper')
/**
* @typedef {{
* target: string;
* inviterId: string;
* message?: string | null;
* resourceTarget?: string;
* resourceId?: string;
* role?: string;
* serverRole?: string | null
* }} CreateInviteParams
*/
/**
* @typedef {CreateInviteParams|import('@/modules/serverinvites/helpers/types').ServerInviteRecord} InviteOrInputParams
*/
/**
* @param {InviteOrInputParams} params
* @param {Object | null} resource invite resource (e.g. stream)
*/
function resolveResourceName(params, resource) {
const { resourceTarget } = params
if (resourceTarget === ResourceTargets.Streams) {
return resource.name
}
return null
}
/**
* Validate that the inviter has access to the resources he's trying to invite people to
* @param {CreateInviteParams} params
* @param {import('@/modules/core/helpers/userHelper').UserRecord} inviter
* @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} inviterResourceAccessLimits
*/
async function validateInviter(params, inviter, inviterResourceAccessLimits) {
const { resourceId, resourceTarget } = params
if (!inviter) throw new InviteCreateValidationError('Invalid inviter')
if (isServerInvite(params)) return
try {
if (resourceTarget === ResourceTargets.Streams) {
await authorizeResolver(
inviter.id,
resourceId,
Roles.Stream.Owner,
inviterResourceAccessLimits
)
} else {
throw new InviteCreateValidationError('Unexpected resource target type')
}
} catch (e) {
throw new InviteCreateValidationError(
"Inviter doesn't have proper access to the resource",
{ cause: e }
)
}
}
/**
* Validate the target
* @param {CreateInviteParams} params
* @param {import('@/modules/core/helpers/userHelper').UserRecord | undefined} targetUser
*/
function validateTargetUser(params, targetUser) {
const { target } = params
const { userId } = resolveTarget(target)
if (userId && !targetUser) {
throw new InviteCreateValidationError('Attempting to invite an invalid user')
}
if (isServerInvite(params) && targetUser) {
throw new InviteCreateValidationError(
'This email is already associated with an account on this server'
)
}
}
/**
* Validate the target resource
* @param {CreateInviteParams} params
* @param {Object | null} resource
* @param {import('@/modules/core/helpers/userHelper').UserRecord | undefined} targetUser Target user, if one exists in our DB
*/
async function validateResource(params, resource, targetUser) {
const { resourceId, resourceTarget, role } = params
if (resourceId && !resource) {
throw new InviteCreateValidationError("Couldn't resolve invite resource")
}
if (resourceTarget === ResourceTargets.Streams) {
if (targetUser) {
// Check if user isn't already associated with the stream
const isStreamCollaborator = !!(await getStreamCollaborator(
resourceId,
targetUser.id
))
if (isStreamCollaborator) {
throw new InviteCreateValidationError(
'The target user is already a collaborator of the specified project'
)
}
}
if (!Object.values(Roles.Stream).includes(role)) {
throw new InviteCreateValidationError('Unexpected stream invite role')
}
}
}
/**
* Validate invite creation input data
* @param {CreateInviteParams} params
* @param {import('@/modules/core/helpers/userHelper').UserRecord} inviter Inviter, resolved from DB
* @param {import('@/modules/core/helpers/userHelper').UserRecord | undefined} targetUser Target user, if one exists in our DB
* @param {Object | null} resource Invite resource (stream or null)
* @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} inviterResourceAccessLimits
*/
async function validateInput(
params,
inviter,
targetUser,
resource,
inviterResourceAccessLimits
) {
const { message } = params
// validate inviter & invitee
validateTargetUser(params, targetUser)
await validateInviter(params, inviter, inviterResourceAccessLimits)
// validate resource
await validateResource(params, resource, targetUser)
// check if message too long
if (message) {
if (message.length >= 1024) {
throw new InviteCreateValidationError('Personal message too long')
}
}
}
/**
* Sanitize message that potentially has HTML in it
* @param {string} message
* @returns {string}
*/
function sanitizeMessage(message, stripAll = false) {
return sanitizeHtml(message, {
allowedTags: stripAll ? [] : [('b', 'i', 'em', 'strong')]
})
}
/**
* Build the email subject line
* @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite
* @param {import('@/modules/core/helpers/userHelper').UserRecord} inviter
* @param {string | null} resourceName
* @returns {string}
*/
function buildEmailSubject(invite, inviter, resourceName) {
const { resourceTarget } = invite
if (isServerInvite(invite)) {
return 'Speckle Invitation from ' + inviter.name
}
if (resourceTarget === 'streams') {
return `${inviter.name} wants to share the project "${resourceName}" on Speckle with you`
} else {
throw new InviteCreateValidationError('Unexpected resource target type')
}
}
/**
* Build invite link URL
* @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite
* @returns {string}
*/
function buildInviteLink(invite) {
const { resourceTarget, resourceId, token } = invite
if (isServerInvite(invite)) {
return new URL(
`${getRegistrationRoute()}?token=${token}`,
getFrontendOrigin()
).toString()
}
if (resourceTarget === 'streams') {
return new URL(
`${getStreamRoute(resourceId)}?token=${token}&accept=true`,
getFrontendOrigin()
).toString()
} else {
throw new InviteCreateValidationError('Unexpected resource target type')
}
}
function buildMjmlPreamble(invite, inviter, serverInfo, resourceName) {
const { message } = invite
const forServer = isServerInvite(invite)
const dynamicText = forServer
? `join the <b>${serverInfo.name}</b> Speckle Server`
: `become a collaborator on the <b>${resourceName}</b> project`
const bodyStart = `
<mj-text>
Hello!
<br />
<br />
${inviter.name} has just sent you this invitation to ${dynamicText}!
${message ? inviter.name + ' said: <em>"' + message + '"</em>' : ''}
</mj-text>
`
return {
bodyStart,
bodyEnd:
'<mj-text>Feel free to ignore this invite if you do not know the person sending it.</mj-text>'
}
}
function buildTextPreamble(invite, inviter, serverInfo, resourceName) {
const { message } = invite
const forServer = isServerInvite(invite)
const dynamicText = forServer
? `join the ${serverInfo.name} Speckle Server`
: `become a collaborator on the "${resourceName}" project`
const bodyStart = `Hello!
${inviter.name} has just sent you this invitation to ${dynamicText}!
${message ? inviter.name + ' said: "' + sanitizeMessage(message, true) + '"' : ''}`
return {
bodyStart,
bodyEnd: 'Feel free to ignore this invite if you do not know the person sending it.'
}
}
/**
* @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite
* @param {import('@/modules/core/helpers/userHelper').UserRecord} inviter
* @param {import('@/modules/core/helpers/types').ServerInfo} serverInfo
* @param {string} resourceName
* @returns {import('@/modules/emails/services/emailRendering').EmailTemplateParams}
*/
function buildEmailTemplateParams(
invite,
inviter,
serverInfo,
inviteLink,
resourceName
) {
return {
mjml: buildMjmlPreamble(invite, inviter, serverInfo, resourceName),
text: buildTextPreamble(invite, inviter, serverInfo, resourceName),
cta: {
title: 'Accept the invitation',
url: inviteLink
}
}
}
/**
* Build invite email contents
* @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite
* @param {import('@/modules/core/helpers/userHelper').UserRecord} inviter
* @param {import('@/modules/core/helpers/userHelper').UserRecord | undefined} targetUser
* @param {Object | null} resource
* @returns {Promise<{to: string, subject: string, text: string, html: string}>}
*/
async function buildEmailContents(invite, inviter, targetUser, resource) {
const email = targetUser ? targetUser.email : invite.target
const serverInfo = await getServerInfo()
const inviteLink = buildInviteLink(invite)
const resourceName = resolveResourceName(invite, resource)
const templateParams = buildEmailTemplateParams(
invite,
inviter,
serverInfo,
inviteLink,
resourceName
)
const subject = buildEmailSubject(invite, inviter, resourceName)
const { text, html } = await renderEmail(
templateParams,
serverInfo,
targetUser || null
)
return {
to: email,
subject,
text,
html
}
}
/**
* Create and send out an invite
* @param {CreateInviteParams} params
* @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} inviterResourceAccessLimits
* @returns {Promise<string>} The ID of the created invite
*/
async function createAndSendInvite(params, inviterResourceAccessLimits) {
const { inviterId, resourceTarget, resourceId, role, serverRole } = params
let { message, target } = params
const [inviter, targetUser, resource, serverInfo] = await Promise.all([
getUser(inviterId, { withRole: true }),
getUserFromTarget(target),
getResource(params),
getServerInfo()
])
// if target user found, always use the user ID
if (targetUser) target = buildUserTarget(targetUser.id)
const { userEmail, userId } = resolveTarget(target)
// validate inputs
await validateInput(
params,
inviter,
targetUser,
resource,
inviterResourceAccessLimits
)
// Sanitize msg
// TODO: We should just use TipTap here
if (message) {
message = sanitizeMessage(message)
}
// validate server role
if (serverRole && !Object.values(Roles.Server).includes(serverRole)) {
throw new InviteCreateValidationError('Invalid server role')
}
if (inviter.role !== Roles.Server.Admin && serverRole === Roles.Server.Admin) {
throw new InviteCreateValidationError(
'Only server admins can assign the admin server role'
)
}
if (serverRole === Roles.Server.Guest && !serverInfo.guestModeEnabled) {
throw new InviteCreateValidationError('Guest mode is not enabled on this server')
}
if (targetUser && targetUser.role === Roles.Server.Guest) {
if (role === Roles.Stream.Owner) {
throw new InviteCreateValidationError('Guest users cannot be owners of projects')
}
}
// write to DB
const invite = {
id: crs({ length: 20 }),
target,
inviterId,
message,
resourceTarget,
resourceId,
role,
token: crs({ length: 50 }),
serverRole
}
await insertInviteAndDeleteOld(
invite,
targetUser ? [targetUser.email, buildUserTarget(targetUser.id)] : []
)
// generate and send email
const emailParams = await buildEmailContents(invite, inviter, targetUser, resource)
// send email and create activity stream item, if stream invite
await Promise.all([
sendEmail(emailParams),
...(resourceTarget === ResourceTargets.Streams
? [
addStreamInviteSentOutActivity({
streamId: resourceId,
inviterId,
inviteTargetEmail: userEmail,
inviteTargetId: userId
})
]
: [])
])
return {
inviteId: invite.id,
token: invite.token
}
}
/**
* Re-send existing invite email
* @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord} invite
*/
async function resendInviteEmail(invite) {
const { inviterId, target } = invite
const [inviter, targetUser, resource] = await Promise.all([
getUser(inviterId),
getUserFromTarget(target),
getResource(invite)
])
const emailParams = await buildEmailContents(invite, inviter, targetUser, resource)
await sendEmail(emailParams)
}
/**
* Invite users to be contributors for the specified stream
* @param {string} inviterId
* @param {string} streamId
* @param {string[]} userIds
* @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} inviterResourceAccessLimits
* @returns {Promise<boolean>}
*/
async function inviteUsersToStream(
inviterId,
streamId,
userIds,
inviterResourceAccessLimits
) {
const users = await getUsers(userIds)
if (!users.length) return false
const inviteParamsArray = users.map((u) => ({
target: buildUserTarget(u.id),
inviterId,
resourceTarget: ResourceTargets.Streams,
resourceId: streamId,
role: Roles.Stream.Contributor
}))
await Promise.all(
inviteParamsArray.map((p) => createAndSendInvite(p, inviterResourceAccessLimits))
)
return true
}
module.exports = {
createAndSendInvite,
resendInviteEmail,
inviteUsersToStream
}
@@ -0,0 +1,197 @@
import crs from 'crypto-random-string'
import { getServerInfo } from '@/modules/core/services/generic'
import emailsModule from '@/modules/emails'
import { InviteCreateValidationError } from '@/modules/serverinvites/errors'
import { Roles } from '@/modules/core/helpers/mainConstants'
import sanitizeHtml from 'sanitize-html'
import {
resolveTarget,
buildUserTarget,
ResourceTargets
} from '@/modules/serverinvites/helpers/inviteHelper'
import { getUser, getUsers } from '@/modules/core/repositories/users'
import { addStreamInviteSentOutActivity } from '@/modules/activitystream/services/streamActivity'
import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types'
import {
FindResource,
FindUserByTarget,
InsertInviteAndDeleteOld
} from '@/modules/serverinvites/domain/operations'
import { validateInput } from '@/modules/serverinvites/services/validation'
import { buildEmailContents } from '@/modules/serverinvites/services/buildEmailContents'
import {
CreateAndSendInvite,
ResendInviteEmail
} from '@/modules/serverinvites/services/operations'
/**
* Create and send out an invite
*/
export const createAndSendInviteFactory =
({
findUserByTarget,
findResource,
insertInviteAndDeleteOld
}: {
findUserByTarget: FindUserByTarget
findResource: FindResource
insertInviteAndDeleteOld: InsertInviteAndDeleteOld
}): CreateAndSendInvite =>
async (params, inviterResourceAccessLimits?) => {
const { inviterId, resourceTarget, resourceId, role, serverRole } = params
let { message, target } = params
const [inviter, targetUser, resource, serverInfo] = await Promise.all([
getUser(inviterId, { withRole: true }),
findUserByTarget(target),
findResource(params),
getServerInfo()
])
// if target user found, always use the user ID
if (targetUser) target = buildUserTarget(targetUser.id)!
const { userEmail, userId } = resolveTarget(target)
// validate inputs
await validateInput(
params,
inviter,
resource,
targetUser,
inviterResourceAccessLimits
)
// Sanitize msg
// TODO: We should just use TipTap here
if (message) {
message = sanitizeMessage(message)
}
// validate server role
if (serverRole && !Object.values(Roles.Server).includes(serverRole)) {
throw new InviteCreateValidationError('Invalid server role')
}
if (inviter?.role !== Roles.Server.Admin && serverRole === Roles.Server.Admin) {
throw new InviteCreateValidationError(
'Only server admins can assign the admin server role'
)
}
if (serverRole === Roles.Server.Guest && !serverInfo.guestModeEnabled) {
throw new InviteCreateValidationError('Guest mode is not enabled on this server')
}
if (targetUser && targetUser.role === Roles.Server.Guest) {
if (role === Roles.Stream.Owner) {
throw new InviteCreateValidationError(
'Guest users cannot be owners of projects'
)
}
}
// write to DB
const invite = {
id: crs({ length: 20 }),
target,
inviterId,
message: message ?? null,
resourceTarget: resourceTarget ?? null,
resourceId: resourceId ?? null,
role: role ?? null,
token: crs({ length: 50 }),
serverRole: serverRole ?? null
}
await insertInviteAndDeleteOld(
invite,
targetUser ? [targetUser.email, buildUserTarget(targetUser.id)!] : []
)
// generate and send email
const emailParams = await buildEmailContents(invite, inviter!, resource, targetUser)
// send email and create activity stream item, if stream invite
await Promise.all([
emailsModule.sendEmail(emailParams),
...(resourceTarget === ResourceTargets.Streams
? [
addStreamInviteSentOutActivity({
streamId: resourceId!, // TODO: check null
inviterId,
inviteTargetEmail: userEmail!, // TODO: this should be properly typed
inviteTargetId: userId! // TODO: this should be properly typed
})
]
: [])
])
return {
inviteId: invite.id,
token: invite.token
}
}
/**
* Sanitize message that potentially has HTML in it
*/
function sanitizeMessage(message: string, stripAll: boolean = false) {
return sanitizeHtml(message, {
allowedTags: stripAll ? [] : ['b', 'i', 'em', 'strong']
})
}
/**
* Re-send existing invite email
*/
export const resendInviteEmailFactory =
({
findResource,
findUserByTarget
}: {
findResource: FindResource
findUserByTarget: FindUserByTarget
}): ResendInviteEmail =>
async (invite) => {
const { inviterId, target } = invite
const [inviter, targetUser, resource] = await Promise.all([
getUser(inviterId),
findUserByTarget(target),
findResource(
invite as {
resourceId?: string | null
resourceTarget?: typeof ResourceTargets.Streams | null
}
)
])
// TODO: check nullable inviter
const emailParams = await buildEmailContents(invite, inviter!, resource, targetUser)
await emailsModule.sendEmail(emailParams)
}
/**
* Invite users to be contributors for the specified stream
*/
export const inviteUsersToStreamFactory =
({ createAndSendInvite }: { createAndSendInvite: CreateAndSendInvite }) =>
async (
inviterId: string,
streamId: string,
userIds: string[],
inviterResourceAccessLimits?: TokenResourceIdentifier[] | null
): Promise<boolean> => {
const users = await getUsers(userIds)
if (!users.length) return false
const inviteParamsArray = users.map((u) => ({
target: buildUserTarget(u.id)!,
inviterId,
resourceTarget: ResourceTargets.Streams,
resourceId: streamId,
role: Roles.Stream.Contributor
}))
await Promise.all(
inviteParamsArray.map((p) => createAndSendInvite(p, inviterResourceAccessLimits))
)
return true
}
@@ -1,196 +0,0 @@
const { Roles } = require('@/modules/core/helpers/mainConstants')
const { getStreamRoute } = require('@/modules/core/helpers/routeHelper')
const { NoInviteFoundError } = require('@/modules/serverinvites/errors')
const {
isStreamInvite,
buildUserTarget,
ResourceTargets
} = require('@/modules/serverinvites/helpers/inviteHelper')
const {
getServerInvite,
deleteServerOnlyInvites,
updateAllInviteTargets,
getStreamInvite,
deleteStreamInvite,
getInvite,
deleteInvite: deleteInviteFromDb,
deleteInvitesByTarget
} = require('@/modules/serverinvites/repositories')
const {
resendInviteEmail
} = require('@/modules/serverinvites/services/inviteCreationService')
const {
addOrUpdateStreamCollaborator
} = require('@/modules/core/services/streams/streamAccessService')
const {
addStreamInviteDeclinedActivity
} = require('@/modules/activitystream/services/streamActivity')
const { getFrontendOrigin } = require('@/modules/shared/helpers/envHelper')
/**
* Resolve the relative auth redirect path, after registering with an invite
* Note: Important auth query string params like the access_code are added separately
* in auth middlewares
* @param {import('@/modules/serverinvites/helpers/types').ServerInviteRecord | undefined} invite
* @returns {string}
*/
function resolveAuthRedirectPath(invite) {
if (invite) {
const { resourceId } = invite
if (isStreamInvite(invite)) {
return `${getStreamRoute(resourceId)}`
}
}
// Fall-back to base URL (for server invites)
return getFrontendOrigin()
}
/**
* Validate that the new user has a valid invite for registering to the server
* @param {Object} email User's email address
* @param {string} token Invite token
* @returns {import('@/modules/serverinvites/helpers/types').ServerInviteRecord}
*/
async function validateServerInvite(email, token) {
const invite = await getServerInvite(email, token)
if (!invite) {
throw new NoInviteFoundError(
token
? "Wrong e-mail address or invite token. Make sure you're using the same e-mail address that received the invite."
: "Wrong e-mail address. Make sure you're using the same e-mail address that received the invite.",
{
info: {
email,
token
}
}
)
}
return invite
}
/**
* Finalize server registration by deleting unnecessary invites and updating
* the remaining ones
* @param {string} email
* @param {string} userId
*/
async function finalizeInvitedServerRegistration(email, userId) {
// Delete all server-only invites for this email
await deleteServerOnlyInvites(email)
// Update all remaining invites to use a userId target, not the e-mail
// (in case the user changes his e-mail right after)
await updateAllInviteTargets(email, buildUserTarget(userId))
}
/**
* Accept or decline a stream invite
* @param {boolean} accept
* @param {string} streamId
* @param {string} token
* @param {string} userId User who's accepting the invite
*/
async function finalizeStreamInvite(accept, streamId, token, userId) {
const invite = await getStreamInvite(streamId, {
token,
target: buildUserTarget(userId)
})
if (!invite) {
throw new NoInviteFoundError('Attempted to finalize nonexistant stream invite', {
info: {
streamId,
token,
userId
}
})
}
// Invite found - accept or decline
if (accept) {
// Add access for user
const { role = Roles.Stream.Contributor, inviterId } = invite
await addOrUpdateStreamCollaborator(streamId, userId, role, inviterId, null, {
fromInvite: true
})
// Delete all invites to this stream
await deleteInvitesByTarget(
buildUserTarget(userId),
ResourceTargets.Streams,
streamId
)
} else {
await addStreamInviteDeclinedActivity({
streamId,
inviteTargetId: userId,
inviterId: invite.inviterId
})
}
// Delete all invites to this stream
await deleteInvitesByTarget(
buildUserTarget(userId),
ResourceTargets.Streams,
streamId
)
}
/**
* Cancel/decline a stream invite
* @param {string} streamId
* @param {string} inviteId
*/
async function cancelStreamInvite(streamId, inviteId) {
const invite = await getStreamInvite(streamId, { inviteId })
if (!invite) {
throw new NoInviteFoundError('Attempted to process nonexistant stream invite', {
info: {
streamId,
inviteId
}
})
}
// Delete invite
await deleteStreamInvite(invite.id)
}
/**
* Re-send pending invite e-mail, without creating a new invite
* @param {string} inviteId
*/
async function resendInvite(inviteId) {
const invite = await getInvite(inviteId)
if (!invite) {
throw new NoInviteFoundError('Attempted to re-send a nonexistant invite')
}
await resendInviteEmail(invite)
}
/**
* Delete pending invite
* @param {string} inviteId
*/
async function deleteInvite(inviteId) {
const invite = await getInvite(inviteId)
if (!invite) {
throw new NoInviteFoundError('Attempted to delete a nonexistant invite')
}
await deleteInviteFromDb(invite.id)
}
module.exports = {
validateServerInvite,
resolveAuthRedirectPath,
finalizeInvitedServerRegistration,
finalizeStreamInvite,
cancelStreamInvite,
resendInvite,
deleteInvite
}
@@ -0,0 +1,212 @@
import { Roles } from '@/modules/core/helpers/mainConstants'
import { getStreamRoute } from '@/modules/core/helpers/routeHelper'
import { NoInviteFoundError } from '@/modules/serverinvites/errors'
import {
isStreamInvite,
buildUserTarget,
ResourceTargets
} from '@/modules/serverinvites/helpers/inviteHelper'
import { addOrUpdateStreamCollaborator } from '@/modules/core/services/streams/streamAccessService'
import { addStreamInviteDeclinedActivity } from '@/modules/activitystream/services/streamActivity'
import { getFrontendOrigin } from '@/modules/shared/helpers/envHelper'
import { ServerInviteRecord } from '@/modules/serverinvites/domain/types'
import {
DeleteInvite,
DeleteInvitesByTarget,
DeleteServerOnlyInvites,
DeleteStreamInvite,
FindInvite,
FindServerInvite,
FindStreamInvite,
UpdateAllInviteTargets
} from '@/modules/serverinvites/domain/operations'
import {
FinalizeStreamInvite,
ResendInviteEmail
} from '@/modules/serverinvites/services/operations'
/**
* Resolve the relative auth redirect path, after registering with an invite
* Note: Important auth query string params like the access_code are added separately
* in auth middlewares
*/
export const resolveAuthRedirectPath = () => (invite?: ServerInviteRecord) => {
if (invite) {
const { resourceId } = invite
if (isStreamInvite(invite)) {
// TODO: check nullability
return `${getStreamRoute(resourceId!)}`
}
}
// Fall-back to base URL (for server invites)
return getFrontendOrigin()
}
/**
* Validate that the new user has a valid invite for registering to the server
*/
export const validateServerInvite =
({ findServerInvite }: { findServerInvite: FindServerInvite }) =>
async (email: string, token: string): Promise<ServerInviteRecord> => {
const invite = await findServerInvite(email, token)
if (!invite) {
throw new NoInviteFoundError(
token
? "Wrong e-mail address or invite token. Make sure you're using the same e-mail address that received the invite."
: "Wrong e-mail address. Make sure you're using the same e-mail address that received the invite.",
{
info: {
email,
token
}
}
)
}
return invite
}
/**
* Finalize server registration by deleting unnecessary invites and updating
* the remaining ones
*/
export const finalizeInvitedServerRegistration =
({
deleteServerOnlyInvites,
updateAllInviteTargets
}: {
deleteServerOnlyInvites: DeleteServerOnlyInvites
updateAllInviteTargets: UpdateAllInviteTargets
}) =>
async (email: string, userId: string) => {
// Delete all server-only invites for this email
await deleteServerOnlyInvites(email)
// Update all remaining invites to use a userId target, not the e-mail
// (in case the user changes his e-mail right after)
await updateAllInviteTargets(email, buildUserTarget(userId)!)
}
/**
* Accept or decline a stream invite
*/
export const finalizeStreamInvite =
({
findStreamInvite,
deleteInvitesByTarget
}: {
findStreamInvite: FindStreamInvite
deleteInvitesByTarget: DeleteInvitesByTarget
}): FinalizeStreamInvite =>
async (accept, streamId, token, userId) => {
const invite = await findStreamInvite(streamId, {
token,
target: buildUserTarget(userId)
})
if (!invite) {
throw new NoInviteFoundError('Attempted to finalize nonexistant stream invite', {
info: {
streamId,
token,
userId
}
})
}
// Invite found - accept or decline
if (accept) {
// Add access for user
const { role = Roles.Stream.Contributor, inviterId } = invite
// TODO: check role nullability
await addOrUpdateStreamCollaborator(streamId, userId, role!, inviterId, null, {
fromInvite: true
})
// Delete all invites to this stream
await deleteInvitesByTarget(
buildUserTarget(userId)!,
ResourceTargets.Streams,
streamId
)
} else {
await addStreamInviteDeclinedActivity({
streamId,
inviteTargetId: userId,
inviterId: invite.inviterId
})
}
// Delete all invites to this stream
await deleteInvitesByTarget(
buildUserTarget(userId)!,
ResourceTargets.Streams,
streamId
)
}
/**
* Cancel/decline a stream invite
*/
export const cancelStreamInvite =
({
findStreamInvite,
deleteStreamInvite
}: {
findStreamInvite: FindStreamInvite
deleteStreamInvite: DeleteStreamInvite
}) =>
async (streamId: string, inviteId: string) => {
const invite = await findStreamInvite(streamId, {
inviteId
})
if (!invite) {
throw new NoInviteFoundError('Attempted to process nonexistant stream invite', {
info: {
streamId,
inviteId
}
})
}
await deleteStreamInvite(invite.id)
}
/**
* Re-send pending invite e-mail, without creating a new invite
*/
export const resendInvite =
({
findInvite,
resendInviteEmail
}: {
resendInviteEmail: ResendInviteEmail
findInvite: FindInvite
}) =>
async (inviteId: string) => {
const invite = await findInvite(inviteId)
if (!invite) {
throw new NoInviteFoundError('Attempted to re-send a nonexistant invite')
}
await resendInviteEmail(invite)
}
/**
* Delete pending invite
*/
export const deleteInvite =
({
findInvite,
deleteInvite
}: {
findInvite: FindInvite
deleteInvite: DeleteInvite
}) =>
async (inviteId: string) => {
const invite = await findInvite(inviteId)
if (!invite) {
throw new NoInviteFoundError('Attempted to delete a nonexistant invite')
}
await deleteInvite(invite.id)
}
@@ -12,15 +12,15 @@ import {
import {
ServerInviteRecord,
StreamInviteRecord
} from '@/modules/serverinvites/helpers/types'
import {
getAllStreamInvites,
getStreamInvite,
getAllUserStreamInvites,
getServerInvite
} from '@/modules/serverinvites/repositories'
} from '@/modules/serverinvites/domain/types'
import { MaybeNullOrUndefined, Nullable, Roles } from '@speckle/shared'
import { keyBy, uniq } from 'lodash'
import {
FindServerInvite,
FindStreamInvite,
QueryAllStreamInvites,
QueryAllUserStreamInvites
} from '@/modules/serverinvites/domain/operations'
/**
* The token field is intentionally ommited from this and only managed through the .token resolver
@@ -67,82 +67,89 @@ async function getInvitationTargetUsers(invites: ServerInviteRecord[]) {
/**
* Get pending stream collaborators (invited, but not accepted)
*/
export async function getPendingStreamCollaborators(
streamId: string
): Promise<PendingStreamCollaboratorGraphQLType[]> {
// Get all pending invites
const invites = await getAllStreamInvites(streamId)
export const getPendingStreamCollaborators =
({ queryAllStreamInvites }: { queryAllStreamInvites: QueryAllStreamInvites }) =>
async (streamId: string): Promise<PendingStreamCollaboratorGraphQLType[]> => {
// Get all pending invites
const invites = await queryAllStreamInvites(streamId)
// Get all target users, if any
const usersById = await getInvitationTargetUsers(invites)
// Get all target users, if any
const usersById = await getInvitationTargetUsers(invites)
// Build results
const results = []
for (const invite of invites) {
/** @type {import("@/modules/core/helpers/userHelper").LimitedUserRecord} */
let user
const { userId } = resolveTarget(invite.target)
if (userId && usersById[userId]) {
user = removePrivateFields(usersById[userId])
// Build results
const results = []
for (const invite of invites) {
let user: LimitedUserRecord | null = null
const { userId } = resolveTarget(invite.target)
if (userId && usersById[userId]) {
user = removePrivateFields(usersById[userId])
}
results.push(buildPendingStreamCollaboratorModel(invite, user))
}
results.push(buildPendingStreamCollaboratorModel(invite, user || null))
return results
}
return results
}
/**
* Find a pending invitation to the specified stream for the specified user
* Either the user ID or invite ID must be set
*/
export async function getUserPendingStreamInvite(
streamId: string,
userId: MaybeNullOrUndefined<string>,
token: MaybeNullOrUndefined<string>
): Promise<Nullable<PendingStreamCollaboratorGraphQLType>> {
if (!userId && !token) return null
export const getUserPendingStreamInvite =
({ findStreamInvite }: { findStreamInvite: FindStreamInvite }) =>
async (
streamId: string,
userId: MaybeNullOrUndefined<string>,
token: MaybeNullOrUndefined<string>
): Promise<Nullable<PendingStreamCollaboratorGraphQLType>> => {
if (!userId && !token) return null
const invite = await getStreamInvite(streamId, {
target: buildUserTarget(userId),
token
})
if (!invite) return null
const invite = await findStreamInvite(streamId, {
target: buildUserTarget(userId),
token
})
if (!invite) return null
const targetUser = userId ? await getUser(userId) : null
// TODO: user repo should be injected
const targetUser = userId ? await getUser(userId) : null
return buildPendingStreamCollaboratorModel(invite, targetUser)
}
return buildPendingStreamCollaboratorModel(invite, targetUser)
}
/**
* Get all pending invitations to streams that this user has
*/
export async function getUserPendingStreamInvites(
userId: string
): Promise<PendingStreamCollaboratorGraphQLType[]> {
if (!userId) return []
export const getUserPendingStreamInvites =
({
queryAllUserStreamInvites
}: {
queryAllUserStreamInvites: QueryAllUserStreamInvites
}) =>
async (userId: string): Promise<PendingStreamCollaboratorGraphQLType[]> => {
if (!userId) return []
const targetUser = await getUser(userId)
if (!targetUser) {
throw new NoInviteFoundError('Nonexistant user specified')
// TODO: user repository should be injected
const targetUser = await getUser(userId)
if (!targetUser) {
throw new NoInviteFoundError('Nonexistant user specified')
}
const invites = await queryAllUserStreamInvites(userId)
return invites.map((i) => buildPendingStreamCollaboratorModel(i, targetUser))
}
const invites = await getAllUserStreamInvites(userId)
return invites.map((i) => buildPendingStreamCollaboratorModel(i, targetUser))
}
export const getServerInviteForToken =
({ findServerInvite }: { findServerInvite: FindServerInvite }) =>
async (token: string): Promise<Nullable<ServerInviteGraphQLReturnType>> => {
const invite = await findServerInvite(undefined, token)
if (!invite) return null
export async function getServerInviteForToken(
token: string
): Promise<Nullable<ServerInviteGraphQLReturnType>> {
const invite = await getServerInvite(undefined, token)
if (!invite) return null
const target = resolveTarget(invite.target)
if (!target.userEmail) return null
const target = resolveTarget(invite.target)
if (!target.userEmail) return null
return {
id: invite.id,
invitedById: invite.inviterId,
email: target.userEmail
return {
id: invite.id,
invitedById: invite.inviterId,
email: target.userEmail
}
}
}
@@ -1,25 +1,26 @@
import { MaybeNullOrUndefined, Roles } from '@speckle/shared'
import { MaybeNullOrUndefined, Roles, ServerRoles, StreamRoles } from '@speckle/shared'
import {
MutationStreamInviteUseArgs,
ProjectInviteCreateInput,
ProjectInviteUseInput,
StreamInviteCreateInput,
TokenResourceIdentifier,
TokenResourceIdentifierType
StreamInviteCreateInput
} from '@/modules/core/graph/generated/graphql'
import { InviteCreateValidationError } from '@/modules/serverinvites/errors'
import {
buildUserTarget,
ResourceTargets
} from '@/modules/serverinvites/helpers/inviteHelper'
import { createAndSendInvite } from '@/modules/serverinvites/services/inviteCreationService'
import { has } from 'lodash'
import { finalizeStreamInvite } from '@/modules/serverinvites/services/inviteProcessingService'
import {
ContextResourceAccessRules,
isResourceAllowed
} from '@/modules/core/helpers/token'
import { StreamInvalidAccessError } from '@/modules/core/errors/stream'
import {
CreateAndSendInvite,
FinalizeStreamInvite
} from '@/modules/serverinvites/services/operations'
import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types'
type FullProjectInviteCreateInput = ProjectInviteCreateInput & { projectId: string }
@@ -27,68 +28,72 @@ const isStreamInviteCreateInput = (
i: StreamInviteCreateInput | FullProjectInviteCreateInput
): i is StreamInviteCreateInput => has(i, 'streamId')
export async function createStreamInviteAndNotify(
input: StreamInviteCreateInput | FullProjectInviteCreateInput,
inviterId: string,
inviterResourceAccessRules: MaybeNullOrUndefined<TokenResourceIdentifier[]>
) {
const { email, userId, role } = input
export const createStreamInviteAndNotifyFactory =
({ createAndSendInvite }: { createAndSendInvite: CreateAndSendInvite }) =>
async (
input: StreamInviteCreateInput | FullProjectInviteCreateInput,
inviterId: string,
inviterResourceAccessRules: MaybeNullOrUndefined<TokenResourceIdentifier[]>
) => {
const { email, userId, role } = input
if (!email && !userId) {
throw new InviteCreateValidationError('Either email or userId must be specified')
if (!email && !userId) {
throw new InviteCreateValidationError('Either email or userId must be specified')
}
const target = (userId ? buildUserTarget(userId) : email)!
await createAndSendInvite(
{
target,
inviterId,
resourceTarget: ResourceTargets.Streams,
resourceId: isStreamInviteCreateInput(input) ? input.streamId : input.projectId,
role: (role as StreamRoles) || Roles.Stream.Contributor,
message: isStreamInviteCreateInput(input)
? input.message || undefined
: undefined,
serverRole: (input.serverRole as ServerRoles) || undefined
},
inviterResourceAccessRules
)
}
const target = (userId ? buildUserTarget(userId) : email)!
await createAndSendInvite(
{
target,
inviterId,
resourceTarget: ResourceTargets.Streams,
resourceId: isStreamInviteCreateInput(input) ? input.streamId : input.projectId,
role: role || Roles.Stream.Contributor,
message: isStreamInviteCreateInput(input)
? input.message || undefined
: undefined,
serverRole: input.serverRole || undefined
},
inviterResourceAccessRules
)
}
const isStreamInviteUseArgs = (
i: MutationStreamInviteUseArgs | ProjectInviteUseInput
): i is MutationStreamInviteUseArgs => has(i, 'streamId')
export async function useStreamInviteAndNotify(
input: MutationStreamInviteUseArgs | ProjectInviteUseInput,
userId: string,
userResourceAccessRules: ContextResourceAccessRules
) {
const { accept, token } = input
export const useStreamInviteAndNotify =
({ finalizeStreamInvite }: { finalizeStreamInvite: FinalizeStreamInvite }) =>
async (
input: MutationStreamInviteUseArgs | ProjectInviteUseInput,
userId: string,
userResourceAccessRules: ContextResourceAccessRules
) => {
const { accept, token } = input
if (
!isResourceAllowed({
resourceId: isStreamInviteUseArgs(input) ? input.streamId : input.projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: userResourceAccessRules
})
) {
throw new StreamInvalidAccessError(
'You are not allowed to process an invite for this stream',
{
info: {
userId,
userResourceAccessRules,
input
if (
!isResourceAllowed({
resourceId: isStreamInviteUseArgs(input) ? input.streamId : input.projectId,
resourceType: 'project',
resourceAccessRules: userResourceAccessRules
})
) {
throw new StreamInvalidAccessError(
'You are not allowed to process an invite for this stream',
{
info: {
userId,
userResourceAccessRules,
input
}
}
}
)
}
await finalizeStreamInvite(
accept,
isStreamInviteUseArgs(input) ? input.streamId : input.projectId,
token,
userId
)
}
await finalizeStreamInvite(
accept,
isStreamInviteUseArgs(input) ? input.streamId : input.projectId,
token,
userId
)
}
@@ -0,0 +1,21 @@
import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types'
import { CreateInviteParams } from '@/modules/serverinvites/domain/operations'
import { ServerInviteRecord } from '@/modules/serverinvites/domain/types'
export type InviteResult = {
inviteId: string
token: string
}
export type CreateAndSendInvite = (
params: CreateInviteParams,
inviterResourceAccessLimits?: TokenResourceIdentifier[] | null
) => Promise<InviteResult>
export type FinalizeStreamInvite = (
accept: boolean,
streamId: string,
token: string,
userId: string
) => Promise<void>
export type ResendInviteEmail = (invite: ServerInviteRecord) => Promise<void>
@@ -0,0 +1,120 @@
import { UserRecord } from '@/modules/core/helpers/types'
import { CreateInviteParams } from '@/modules/serverinvites/domain/operations'
import { InviteCreateValidationError } from '@/modules/serverinvites/errors'
import { ResourceTargets, isServerInvite, resolveTarget } from '../helpers/inviteHelper'
import { UserWithOptionalRole } from '@/modules/core/repositories/users'
import { authorizeResolver } from '@/modules/shared'
import { Roles } from '@speckle/shared'
import { getStreamCollaborator } from '@/modules/core/repositories/streams'
import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types'
/**
* Validate invite creation input data
*/
export async function validateInput(
params: CreateInviteParams,
inviter: UserRecord | null,
resource?: { name: string } | null,
targetUser?: UserWithOptionalRole | null,
inviterResourceAccessLimits?: TokenResourceIdentifier[] | null
) {
const { message } = params
// validate inviter & invitee
validateTargetUser(params, targetUser)
await validateInviter(params, inviter, inviterResourceAccessLimits)
// validate resource
await validateResource(params, resource, targetUser)
// check if message too long
if (message) {
if (message.length >= 1024) {
throw new InviteCreateValidationError('Personal message too long')
}
}
}
function validateTargetUser(
params: CreateInviteParams,
targetUser?: UserRecord | null
) {
const { target } = params
const { userId } = resolveTarget(target)
if (userId && !targetUser) {
throw new InviteCreateValidationError('Attempting to invite an invalid user')
}
if (isServerInvite(params) && targetUser) {
throw new InviteCreateValidationError(
'This email is already associated with an account on this server'
)
}
}
/**
* Validate that the inviter has access to the resources he's trying to invite people to
*/
async function validateInviter(
params: CreateInviteParams,
inviter: UserRecord | null,
inviterResourceAccessLimits?: TokenResourceIdentifier[] | null
) {
const { resourceId, resourceTarget } = params
if (!inviter) throw new InviteCreateValidationError('Invalid inviter')
if (isServerInvite(params)) return
try {
if (resourceTarget === ResourceTargets.Streams) {
await authorizeResolver(
inviter.id,
resourceId!, // TODO: check null
Roles.Stream.Owner,
inviterResourceAccessLimits
)
} else {
throw new InviteCreateValidationError('Unexpected resource target type')
}
} catch (e) {
throw new InviteCreateValidationError(
"Inviter doesn't have proper access to the resource",
{ cause: e as Error }
)
}
}
/**
* Validate the target resource
*/
async function validateResource(
params: CreateInviteParams,
resource?: { name: string } | null,
targetUser?: UserRecord | null
) {
const { resourceId, resourceTarget, role } = params
if (resourceId && !resource) {
throw new InviteCreateValidationError("Couldn't resolve invite resource")
}
if (resourceTarget === ResourceTargets.Streams) {
if (targetUser) {
// Check if user isn't already associated with the stream
const isStreamCollaborator = !!(await getStreamCollaborator(
resourceId!, // TODO: verify this null
targetUser.id
))
if (isStreamCollaborator) {
throw new InviteCreateValidationError(
'The target user is already a collaborator of the specified project'
)
}
}
// TODO: check null role
if (!Object.values(Roles.Stream).includes(role!)) {
throw new InviteCreateValidationError('Unexpected stream invite role')
}
}
}
@@ -10,12 +10,12 @@ const {
resendInvite,
batchCreateServerInvites,
batchCreateStreamInvites,
deleteInvite,
getStreamInvite,
useUpStreamInvite,
cancelStreamInvite,
getStreamPendingCollaborators,
getStreamInvites
getStreamInvites,
deleteInvite
} = require('@/test/graphql/serverInvites')
const { truncateTables } = require('@/test/hooks')
const { expect } = require('chai')
@@ -23,30 +23,35 @@ const {
createStream,
grantPermissionsStream
} = require('@/modules/core/services/streams')
const {
getInviteByToken,
getInvite: getInviteFromDB
} = require('@/modules/serverinvites/repositories')
const { getUserStreamRole } = require('@/test/speckle-helpers/streamHelper')
const { createInviteDirectly } = require('@/test/speckle-helpers/inviteHelper')
const { createInviteDirectlyFactory } = require('@/test/speckle-helpers/inviteHelper')
const { buildAuthenticatedApolloServer } = require('@/test/serverHelper')
const { EmailSendingServiceMock } = require('@/test/mocks/global')
const db = require('@/db/knex')
const {
findInviteByTokenFactory,
findInviteFactory
} = require('@/modules/serverinvites/repositories/serverInvites')
async function cleanup() {
await truncateTables([ServerInvites.name, Streams.name, Users.name])
}
const findInviteByToken = findInviteByTokenFactory({ db })
const findInvite = findInviteFactory({ db })
function getInviteTokenFromEmailParams(emailParams) {
const { text } = emailParams
const [, inviteId] = text.match(/\?token=(.*?)(\s|&)/i)
return inviteId
}
const createInviteDirectly = createInviteDirectlyFactory({ db })
async function validateInviteExistanceFromEmail(emailParams) {
// Validate that invite exists
const token = getInviteTokenFromEmailParams(emailParams)
expect(token).to.be.ok
const invite = await getInviteByToken(token)
const invite = await findInviteByToken(token)
expect(invite).to.be.ok
return invite
@@ -466,14 +471,16 @@ describe('[Stream & Server Invites]', () => {
// Delete all invites
for (const invite of deletableInvites) {
const result = await deleteInvite(apollo, { inviteId: invite.inviteId })
const result = await deleteInvite(apollo, {
inviteId: invite.inviteId
})
expect(result.data?.inviteDelete).to.be.ok
expect(result.errors).to.not.be.ok
}
// Validate that invites no longer exist
const invitesInDb = await Promise.all(
deletableInvites.map((i) => getInviteFromDB(i.inviteId))
deletableInvites.map((i) => findInvite(i.inviteId))
)
expect(invitesInDb.every((i) => !i)).to.be.true
})
@@ -623,7 +630,7 @@ describe('[Stream & Server Invites]', () => {
expect(data?.streamInviteUse).to.be.ok
expect(errors).to.not.be.ok
expect(await getInviteFromDB(inviteId)).to.be.not.ok
expect(await findInvite(inviteId)).to.be.not.ok
const userStreamRole = await getUserStreamRole(me.id, streamId)
expect(userStreamRole).to.eq(accept ? Roles.Stream.Contributor : null)
@@ -710,7 +717,7 @@ describe('[Stream & Server Invites]', () => {
expect(data?.streamInviteCancel).to.be.ok
expect(errors).to.be.not.ok
expect(await getInviteFromDB(inviteId)).to.be.not.ok
expect(await findInvite(inviteId)).to.be.not.ok
})
it('own pending collaborators can be retrieved', async () => {
+5 -8
View File
@@ -11,16 +11,13 @@ import {
} from '@/modules/shared/errors'
import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper'
import {
AvailableRoles,
MaybeNullOrUndefined,
Nullable,
AvailableRoles,
StreamRoles,
ServerRoles
ServerRoles,
StreamRoles
} from '@speckle/shared'
import {
TokenResourceIdentifier,
TokenResourceIdentifierType
} from '@/modules/core/graph/generated/graphql'
import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types'
import { isResourceAllowed } from '@/modules/core/helpers/token'
import { getAutomationProject } from '@/modules/automate/repositories/automations'
import { UserRoleData } from '@/modules/shared/domain/rolesAndScopes/types'
@@ -176,7 +173,7 @@ export const validateResourceAccess: AuthPipelineFunction = async ({
const hasAccess = isResourceAllowed({
resourceId: streamId,
resourceType: TokenResourceIdentifierType.Project,
resourceType: 'project',
resourceAccessRules
})
+1 -1
View File
@@ -40,7 +40,7 @@ async function validateScopes(scopes, scope) {
* @param {string | null | undefined} userId
* @param {string} resourceId
* @param {string} requiredRole
* @param {import('@/modules/core/graph/generated/graphql').TokenResourceIdentifier[] | undefined | null} [userResourceAccessLimits]
* @param {import('@/modules/serverinvites/services/operations').TokenResourceIdentifier[] | undefined | null} [userResourceAccessLimits]
*/
async function authorizeResolver(
userId,
@@ -5,9 +5,6 @@ import Redis from 'ioredis'
import { withFilter } from 'graphql-subscriptions'
import { GraphQLContext } from '@/modules/shared/helpers/typeHelper'
import {
AutomationRun,
AutomationsStatus,
ProjectAutomationsStatusUpdatedMessage,
ProjectCommentsUpdatedMessage,
ProjectFileImportUpdatedMessage,
ProjectModelsUpdatedMessage,
@@ -16,7 +13,6 @@ import {
ProjectUpdatedMessage,
ProjectVersionsPreviewGeneratedMessage,
ProjectVersionsUpdatedMessage,
SubscriptionProjectAutomationsStatusUpdatedArgs,
SubscriptionProjectAutomationsUpdatedArgs,
SubscriptionProjectCommentsUpdatedArgs,
SubscriptionProjectFileImportUpdatedArgs,
@@ -44,7 +40,6 @@ import {
} from '@/modules/core/helpers/graphTypes'
import { CommentGraphQLReturn } from '@/modules/comments/helpers/graphTypes'
import { FileUploadGraphQLReturn } from '@/modules/fileuploads/helpers/types'
import { AutomationFunctionRunGraphQLReturn } from '@/modules/betaAutomations/helpers/graphTypes'
import {
ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn,
ProjectAutomationsUpdatedMessageGraphQLReturn
@@ -100,7 +95,6 @@ export enum ProjectSubscriptions {
ProjectVersionsPreviewGenerated = 'PROJECT_VERSIONS_PREVIEW_GENERATED',
ProjectCommentsUpdated = 'PROJECT_COMMENTS_UPDATED',
// old beta subscription:
ProjectAutomationStatusUpdated = 'PROJECT_AUTOMATION_STATUS_UPDATED',
ProjectTriggeredAutomationsStatusUpdated = 'PROJECT_TRIGGERED_AUTOMATION_STATUS_UPDATED',
ProjectAutomationsUpdated = 'PROJECT_AUTOMATIONS_UPDATED',
ProjectVersionGendoAIRenderUpdated = 'PROJECT_VERSION_GENDO_AI_RENDER_UPDATED',
@@ -229,31 +223,6 @@ type SubscriptionTypeMap = {
}
variables: SubscriptionProjectFileImportUpdatedArgs
}
[ProjectSubscriptions.ProjectAutomationStatusUpdated]: {
payload: {
projectAutomationsStatusUpdated: Merge<
ProjectAutomationsStatusUpdatedMessage,
{
version: VersionGraphQLReturn
model: ModelGraphQLReturn
project: ProjectGraphQLReturn
status: Merge<
AutomationsStatus,
{
automationRuns: Array<
Merge<
AutomationRun,
{ functionRuns: AutomationFunctionRunGraphQLReturn[] }
>
>
}
>
}
>
projectId: string
}
variables: SubscriptionProjectAutomationsStatusUpdatedArgs
}
[ProjectSubscriptions.ProjectTriggeredAutomationsStatusUpdated]: {
payload: {
projectTriggeredAutomationsStatusUpdated: ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn
@@ -374,62 +374,6 @@ export type AutomationCollection = {
totalCount: Scalars['Int']['output'];
};
export type AutomationCreateInput = {
automationId: Scalars['String']['input'];
automationName: Scalars['String']['input'];
automationRevisionId: Scalars['String']['input'];
modelId: Scalars['String']['input'];
projectId: Scalars['String']['input'];
webhookId?: InputMaybe<Scalars['String']['input']>;
};
export type AutomationFunctionRun = {
__typename?: 'AutomationFunctionRun';
contextView?: Maybe<Scalars['String']['output']>;
elapsed: Scalars['Float']['output'];
functionId: Scalars['String']['output'];
functionLogo?: Maybe<Scalars['String']['output']>;
functionName: Scalars['String']['output'];
id: Scalars['ID']['output'];
resultVersions: Array<Version>;
/**
* NOTE: this is the schema for the results field below!
* Current schema: {
* version: "1.0.0",
* values: {
* objectResults: Record<str, {
* category: string
* level: ObjectResultLevel
* objectIds: string[]
* message: str | null
* metadata: Records<str, unknown> | null
* visualoverrides: Records<str, unknown> | null
* }[]>
* blobIds?: string[]
* }
* }
*/
results?: Maybe<Scalars['JSONObject']['output']>;
status: AutomationRunStatus;
statusMessage?: Maybe<Scalars['String']['output']>;
};
export type AutomationMutations = {
__typename?: 'AutomationMutations';
create: Scalars['Boolean']['output'];
functionRunStatusReport: Scalars['Boolean']['output'];
};
export type AutomationMutationsCreateArgs = {
input: AutomationCreateInput;
};
export type AutomationMutationsFunctionRunStatusReportArgs = {
input: AutomationRunStatusUpdateInput;
};
export type AutomationRevision = {
__typename?: 'AutomationRevision';
functions: Array<AutomationRevisionFunction>;
@@ -453,44 +397,8 @@ export type AutomationRevisionFunction = {
export type AutomationRevisionTriggerDefinition = VersionCreatedTriggerDefinition;
export type AutomationRun = {
__typename?: 'AutomationRun';
automationId: Scalars['String']['output'];
automationName: Scalars['String']['output'];
createdAt: Scalars['DateTime']['output'];
functionRuns: Array<AutomationFunctionRun>;
id: Scalars['ID']['output'];
/** Resolved from all function run statuses */
status: AutomationRunStatus;
updatedAt: Scalars['DateTime']['output'];
versionId: Scalars['String']['output'];
};
export enum AutomationRunStatus {
Failed = 'FAILED',
Initializing = 'INITIALIZING',
Running = 'RUNNING',
Succeeded = 'SUCCEEDED'
}
export type AutomationRunStatusUpdateInput = {
automationId: Scalars['String']['input'];
automationRevisionId: Scalars['String']['input'];
automationRunId: Scalars['String']['input'];
functionRuns: Array<FunctionRunStatusInput>;
versionId: Scalars['String']['input'];
};
export type AutomationRunTrigger = VersionCreatedTrigger;
export type AutomationsStatus = {
__typename?: 'AutomationsStatus';
automationRuns: Array<AutomationRun>;
id: Scalars['ID']['output'];
status: AutomationRunStatus;
statusMessage?: Maybe<Scalars['String']['output']>;
};
export type AvatarUser = {
__typename?: 'AvatarUser';
avatar?: Maybe<Scalars['String']['output']>;
@@ -940,27 +848,6 @@ export type FileUpload = {
userId: Scalars['String']['output'];
};
export type FunctionRunStatusInput = {
contextView?: InputMaybe<Scalars['String']['input']>;
elapsed: Scalars['Float']['input'];
functionId: Scalars['String']['input'];
functionLogo?: InputMaybe<Scalars['String']['input']>;
functionName: Scalars['String']['input'];
resultVersionIds: Array<Scalars['String']['input']>;
/**
* Current schema: {
* version: "1.0.0",
* values: {
* speckleObjects: Record<ObjectId, {level: string; statusMessage: string}[]>
* blobIds?: string[]
* }
* }
*/
results?: InputMaybe<Scalars['JSONObject']['input']>;
status: AutomationRunStatus;
statusMessage?: InputMaybe<Scalars['String']['input']>;
};
export type GendoAiRender = {
__typename?: 'GendoAIRender';
camera?: Maybe<Scalars['JSONObject']['output']>;
@@ -1086,7 +973,6 @@ export type LimitedUserTimelineArgs = {
export type Model = {
__typename?: 'Model';
author: LimitedUser;
automationStatus?: Maybe<AutomationsStatus>;
automationsStatus?: Maybe<TriggeredAutomationsStatus>;
/** Return a model tree of children */
childrenTree: Array<ModelsTreeItem>;
@@ -1219,7 +1105,6 @@ export type Mutation = {
appUpdate: Scalars['Boolean']['output'];
automateFunctionRunStatusReport: Scalars['Boolean']['output'];
automateMutations: AutomateMutations;
automationMutations: AutomationMutations;
branchCreate: Scalars['String']['output'];
branchDelete: Scalars['Boolean']['output'];
branchUpdate: Scalars['Boolean']['output'];
@@ -1905,14 +1790,6 @@ export type ProjectAutomationUpdateInput = {
name?: InputMaybe<Scalars['String']['input']>;
};
export type ProjectAutomationsStatusUpdatedMessage = {
__typename?: 'ProjectAutomationsStatusUpdatedMessage';
model: Model;
project: Project;
status: AutomationsStatus;
version: Version;
};
export type ProjectAutomationsUpdatedMessage = {
__typename?: 'ProjectAutomationsUpdatedMessage';
automation?: Maybe<Automation>;
@@ -2879,7 +2756,6 @@ export type Subscription = {
commitDeleted?: Maybe<Scalars['JSONObject']['output']>;
/** Subscribe to commit updated event. */
commitUpdated?: Maybe<Scalars['JSONObject']['output']>;
projectAutomationsStatusUpdated: ProjectAutomationsStatusUpdatedMessage;
/** Subscribe to updates to automations in the project */
projectAutomationsUpdated: ProjectAutomationsUpdatedMessage;
/**
@@ -2975,11 +2851,6 @@ export type SubscriptionCommitUpdatedArgs = {
};
export type SubscriptionProjectAutomationsStatusUpdatedArgs = {
projectId: Scalars['String']['input'];
};
export type SubscriptionProjectAutomationsUpdatedArgs = {
projectId: Scalars['String']['input'];
};
@@ -3321,7 +3192,6 @@ export type UserUpdateInput = {
export type Version = {
__typename?: 'Version';
authorUser?: Maybe<LimitedUser>;
automationStatus?: Maybe<AutomationsStatus>;
automationsStatus?: Maybe<TriggeredAutomationsStatus>;
/** All comment threads in this version */
commentThreads: CommentCollection;
@@ -1,44 +0,0 @@
const { Roles } = require('@/modules/core/helpers/mainConstants')
const {
buildUserTarget,
ResourceTargets
} = require('@/modules/serverinvites/helpers/inviteHelper')
const {
createAndSendInvite
} = require('@/modules/serverinvites/services/inviteCreationService')
/**
* Create a new invite. User & userId are alternatives for each other, and so
* are stream & streamId
* @param {{
* email?: string,
* user?: import("@/modules/core/helpers/userHelper").UserRecord,
* userId?: string,
* message?: string,
* stream?: Object,
* streamId?: string
* }} invite
* @param {string} creatorId
*
* @returns {Promise<{inviteId: string, token: string}>}
*/
function createInviteDirectly(invite, creatorId) {
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')
const streamId = invite.streamId || invite.stream?.id || null
return createAndSendInvite({
target: email || buildUserTarget(userId),
inviterId: creatorId,
message: invite.message,
resourceTarget: streamId ? ResourceTargets.Streams : null,
resourceId: streamId || null,
role: streamId ? Roles.Stream.Contributor : null
})
}
module.exports = {
createInviteDirectly
}
@@ -0,0 +1,58 @@
import { Roles } from '@speckle/shared'
import {
buildUserTarget,
ResourceTargets
} from '@/modules/serverinvites/helpers/inviteHelper'
import { InviteResult } from '@/modules/serverinvites/services/operations'
import { StreamRecord, UserRecord } from '@/modules/core/helpers/types'
import {
findResourceFactory,
findUserByTargetFactory,
insertInviteAndDeleteOldFactory
} from '@/modules/serverinvites/repositories/serverInvites'
import { Knex } from 'knex'
import { createAndSendInviteFactory } from '@/modules/serverinvites/services/inviteCreationService'
/**
* Create a new invite. User & userId are alternatives for each other, and so
* are stream & streamId
*/
export const createInviteDirectlyFactory =
// This is a test helper, so im ok, with leaking the internal abstractions here
({ db }: { db: Knex }) =>
async (
invite: {
email?: string
user?: UserRecord
userId?: string
message?: string
stream?: StreamRecord
streamId?: string
},
creatorId: string
): Promise<InviteResult> => {
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')
const streamId = invite.streamId || invite.stream?.id
const target = email || buildUserTarget(userId)
if (!target) throw new Error('Cannot create invite without a target')
return await createAndSendInviteFactory({
findUserByTarget: findUserByTargetFactory(),
findResource: findResourceFactory(),
insertInviteAndDeleteOld: insertInviteAndDeleteOldFactory({ db })
})({
target,
inviterId: creatorId,
message: invite.message,
resourceTarget: streamId ? ResourceTargets.Streams : undefined,
resourceId: streamId,
role: streamId ? Roles.Stream.Contributor : undefined
})
}
+17 -6
View File
@@ -4,17 +4,28 @@ import { z } from 'zod'
function parseFeatureFlags() {
//INFO
// As a convention all feature flags should be prefixed with a FF_
const featureFlagSchema = z.object({
return parseEnv(process.env, {
// Enables the automate module.
FF_AUTOMATE_MODULE_ENABLED: z.boolean().default(false),
FF_AUTOMATE_MODULE_ENABLED: {
schema: z.boolean(),
defaults: { production: false, _: true }
},
// Enables the gendo ai integration
FF_GENDOAI_MODULE_ENABLED: z.boolean().default(false),
FF_GENDOAI_MODULE_ENABLED: {
schema: z.boolean(),
defaults: { production: false, _: true }
},
// Disables writing to the closure table in the create objects batched services (re object upload routes)
FF_NO_CLOSURE_WRITES: z.boolean().default(false),
// Enables the workspaces module
FF_WORKSPACES_MODULE_ENABLED: z.boolean().default(false)
FF_WORKSPACES_MODULE_ENABLED: {
schema: z.boolean(),
defaults: { production: false, _: true }
},
FF_NO_CLOSURE_WRITES: {
schema: z.boolean(),
defaults: { production: false, _: false }
}
})
return parseEnv(process.env, featureFlagSchema.shape)
}
let parsedFlags: ReturnType<typeof parseFeatureFlags> | undefined
-17
View File
@@ -1,17 +0,0 @@
#!/bin/bash
set -euo pipefail
echo "* Getting latest version of SpeckleServer Setup files..."
mkdir -p /opt/speckle-server
cd /opt/speckle-server || exit 1
wget https://raw.githubusercontent.com/specklesystems/speckle-server/main/utils/1click_image_scripts/setup.py -O setup.py
wget https://raw.githubusercontent.com/specklesystems/speckle-server/main/utils/1click_image_scripts/template-nginx-site.conf -O template-nginx-site.conf
wget https://raw.githubusercontent.com/specklesystems/speckle-server/main/utils/1click_image_scripts/template-docker-compose.yml -O template-docker-compose.yml
docker image rm {speckle/speckle-preview-service:2,speckle/speckle-webhook-service:2,speckle/speckle-server:2,speckle/speckle-frontend:2} || true
chmod +x setup.py
echo "* Getting the docker images for the latest SpeckleServer release..."
docker compose -f template-docker-compose.yml pull
+11 -200
View File
@@ -1,14 +1,5 @@
#!/usr/bin/env python3
import os
import socket
import subprocess
import secrets
import ruamel.yaml # this module preserves yaml comments and whitespaces
from ruamel.yaml.scalarstring import DoubleQuotedScalarString
FILE_PATH = os.path.dirname(os.path.abspath(__file__))
LOGO_STR = """
_____ _ _ _____
/ ___| | | | | / ___|
@@ -21,202 +12,22 @@ LOGO_STR = """
"""
def get_local_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
if not ip:
print("Error: Can't get local IP address")
exit(1)
return ip
def read_domain(ip):
print("\nYou can set up a domain name for this Speckle server.")
print(
"Important: To use a domain name, you must first configure it to point to this VM address (so we can issue the SSL certificate)"
)
print(f"VM address: {ip}")
while True:
domain = input("Domain name (leave blank to use the IP address): ").strip()
if not domain:
return None
try:
domain_ip = socket.gethostbyname(domain.strip())
except Exception as ex:
print(f"Error: Domain '{domain}' cannot be resolved: {str(ex)}")
continue
if domain_ip != ip:
print(f"Error: Domain '{domain}' points to {domain_ip} instead of {ip}")
continue
return domain
def read_email_settings(domain):
print(
"\nYou should configure an email provider to allow the Speckle Server to send emails."
)
print(
"Supported vendors: Any email provider that can provide SMTP connection details (mailjet, mailgun, etc)."
)
print(
"Important: If you don't configure email details, some features that require sending emails will not work, nevertheless the server should be functional."
)
while True:
enable_email = False
while True:
enable_email = input("Enable emails? [Y/n]: ").strip().lower()
if enable_email in ["n", "no"]:
enable_email = False
break
elif enable_email in ["", "y", "yes"]:
enable_email = True
break
else:
print("Unrecognized option")
continue
if not enable_email:
return None
print("Enter your SMTP connection details offered by your email provider")
smtp_host = input("SMTP server / host: ").strip()
smtp_port = input("SMTP port: ").strip()
try:
int(smtp_port)
except Exception:
print("Error: SMTP port must be a number. Retrying...")
continue
smtp_user = input("SMTP Username: ").strip()
smtp_pass = input("SMTP Password: ").strip()
if domain:
default_from_email = "no-reply@" + domain
else:
default_from_email = ""
email_from = input(f"Email address to send email as [{default_from_email}]: ")
if not email_from.strip():
email_from = default_from_email
if (
not smtp_host
or not smtp_port
or not smtp_user
or not smtp_pass
or not email_from
):
print("Error: One or more fields were empty. Retrying...")
continue
return {
"host": smtp_host,
"port": smtp_port,
"user": smtp_user,
"pass": smtp_pass,
"from": email_from,
}
def main():
print(LOGO_STR)
ip = get_local_ip()
###
### Read user input
#########
domain = read_domain(ip)
if domain:
canonical_url = f"https://{domain}"
else:
canonical_url = f"http://{ip}"
email = read_email_settings(domain)
###
### Create docker-compose.yml from the template
#########
print("\nConfiguring docker containers...")
yaml = ruamel.yaml.YAML()
yaml.preserve_quotes = True
with open(os.path.join(FILE_PATH, "template-docker-compose.yml"), "r") as f:
yml_doc = yaml.load(f)
env = yml_doc["services"]["speckle-server"]["environment"]
env["CANONICAL_URL"] = DoubleQuotedScalarString(canonical_url)
env["FRONTEND_ORIGIN"] = DoubleQuotedScalarString(canonical_url)
env["SESSION_SECRET"] = DoubleQuotedScalarString(secrets.token_hex(32))
if email:
env["EMAIL"] = DoubleQuotedScalarString("true")
env["EMAIL_HOST"] = DoubleQuotedScalarString(email["host"])
env["EMAIL_PORT"] = DoubleQuotedScalarString(email["port"])
env["EMAIL_USERNAME"] = DoubleQuotedScalarString(email["user"])
env["EMAIL_PASSWORD"] = DoubleQuotedScalarString(email["pass"])
env["EMAIL_FROM"] = DoubleQuotedScalarString(email["from"])
else:
env["EMAIL"] = DoubleQuotedScalarString("false")
fe2env = yml_doc["services"]["speckle-frontend-2"]["environment"]
fe2env["NUXT_PUBLIC_SERVER_NAME"] = DoubleQuotedScalarString(canonical_url)
fe2env["NUXT_PUBLIC_API_ORIGIN"] = DoubleQuotedScalarString(canonical_url)
fe2env["NUXT_PUBLIC_BASE_URL"] = DoubleQuotedScalarString(canonical_url)
with open(os.path.join(FILE_PATH, "docker-compose.yml"), "w") as f:
f.write("# This file was generated by SpeckleServer setup.\n")
f.write("# If the setup is re-run, this file will be overwritten.\n\n")
yaml.dump(yml_doc, f)
###
### Run the new docker compose file (will update containers if already running)
#########
subprocess.run(
["bash", "-c", f'cd "{FILE_PATH}"; docker compose up -d'], check=True
)
###
### Update nginx config and restart nginx
#########
print("\nConfiguring local nginx...")
nginx_conf_str = "# This file is managed by SpeckleServer setup script.\n"
nginx_conf_str += (
"# Any modifications will be removed when the setup script is re-executed\n\n"
)
with open(os.path.join(FILE_PATH, "template-nginx-site.conf"), "r") as f:
nginx_conf_str += f.read()
if domain:
nginx_conf_str = nginx_conf_str.replace("TODO_REPLACE_WITH_SERVER_NAME", domain)
else:
nginx_conf_str = nginx_conf_str.replace("TODO_REPLACE_WITH_SERVER_NAME", "_")
with open("/etc/nginx/sites-available/speckle-server", "w") as f:
f.write(nginx_conf_str)
subprocess.run(["nginx", "-s", "reload"], check=True)
###
### Run letsencrypt on new config
#########
if domain:
print("\n***")
print(
"*** Will now run LetsEncrypt utility to generate https certificate. Please answer any questions that are presented"
)
print(
"*** We highly recommend setting a good email address so that you are notified if there is any action needed to renew certificates"
)
print("***")
subprocess.run(["certbot", "--nginx", "-d", domain])
print("\nConfiguration complete!")
print("You can access your speckle server at: " + canonical_url)
print(LOGO_STR)
print("\nOne more thing and you are ready to roll:")
print(
f" - Go to {canonical_url} in your browser and create an account. The first user to register will be granted administrator rights."
"\nAs of March 2024, Speckle server DigitalOcean 1-click setup script is no longer supported."
)
print(
" - Fill in information about your server under your profile page (in the lower left corner)."
"\nPlease use the official Speckle Server installation guide to install Speckle Server on your own infrastructure."
)
print(
"\nThis could be on a DigitalOcean Droplet that you have created yourself using an Ubuntu image."
)
print(
"\nOur documentation can be found at https://speckle.guide/dev/server-manualsetup.html"
)
print(
"\nIf you require to view previous versions of the 1-click setup script, please review the history of the speckle-server repository on GitHub."
)
print("\nHappy Speckling!")
@@ -1,129 +0,0 @@
version: '2.3'
services:
####
# Speckle Server dependencies
#######
postgres:
image: 'postgres:14.5-alpine'
restart: always
environment:
POSTGRES_DB: speckle
POSTGRES_USER: speckle
POSTGRES_PASSWORD: speckle
volumes:
- ./postgres-data:/var/lib/postgresql/data/
ports:
- '127.0.0.1:5432:5432'
redis:
image: 'redis:7.0-alpine'
restart: always
volumes:
- ./redis-data:/data
ports:
- '127.0.0.1:6379:6379'
minio:
image: 'minio/minio'
command: server /data --console-address ":9001"
restart: always
volumes:
- ./minio-data:/data
ports:
- '127.0.0.1:9000:9000'
- '127.0.0.1:9001:9001'
####
# Speckle Server
#######
speckle-frontend-2:
image: speckle/speckle-frontend-2:2
restart: always
ports:
- '127.0.0.1:8080:8080'
environment:
NUXT_PUBLIC_SERVER_NAME: 'TODO: change' # e.g. 'my-speckle-server'
NUXT_PUBLIC_API_ORIGIN: 'TODO: change' # e.g. 'http://127.0.0.1'
NUXT_PUBLIC_BASE_URL: 'TODO: change' # e.g. 'http://127.0.0.1'
NUXT_PUBLIC_BACKEND_API_ORIGIN: 'http://speckle-server:3000'
NUXT_PUBLIC_LOG_LEVEL: 'warn'
NUXT_REDIS_URL: 'redis://redis'
speckle-server:
image: speckle/speckle-server:2
restart: always
healthcheck:
test:
- CMD
- node
- -e
- "try { require('node:http').request({headers: {'Content-Type': 'application/json'}, port:3000, hostname:'127.0.0.1', path:'/liveness', method: 'GET', timeout: 2000 }, (res) => { body = ''; res.on('data', (chunk) => {body += chunk;}); res.on('end', () => {process.exit(res.statusCode != 200 || body.toLowerCase().includes('error'));}); }).end(); } catch { process.exit(1); }"
interval: 10s
timeout: 10s
retries: 3
start_period: 90s
ports:
- '127.0.0.1:3000:3000'
environment:
CANONICAL_URL: 'TODO: change'
SESSION_SECRET: 'TODO: change'
STRATEGY_LOCAL: 'true'
LOG_LEVEL: 'info'
POSTGRES_URL: 'postgres'
POSTGRES_USER: 'speckle'
POSTGRES_PASSWORD: 'speckle'
POSTGRES_DB: 'speckle'
REDIS_URL: 'redis://redis'
WAIT_HOSTS: 'postgres:5432, redis:6379, minio:9000'
EMAIL: 'false'
EMAIL_HOST: 'TODO'
EMAIL_PORT: 'TODO'
EMAIL_USERNAME: 'TODO'
EMAIL_PASSWORD: 'TODO'
EMAIL_FROM: 'TODO'
EMAIL_SECURE: 'false'
S3_ENDPOINT: 'http://minio:9000'
S3_ACCESS_KEY: 'minioadmin'
S3_SECRET_KEY: 'minioadmin'
S3_BUCKET: 'speckle-server'
S3_CREATE_BUCKET: 'true'
S3_REGION: '' # optional, defaults to 'us-east-1'
FILE_SIZE_LIMIT_MB: 100
USE_FRONTEND_2: 'true'
FRONTEND_ORIGIN: 'TODO: change'
speckle-preview-service:
image: speckle/speckle-preview-service:2
restart: always
mem_limit: '1000m'
memswap_limit: '1000m'
environment:
LOG_LEVEL: 'info'
PG_CONNECTION_STRING: 'postgres://speckle:speckle@postgres/speckle'
WAIT_HOSTS: 'postgres:5432'
speckle-webhook-service:
image: speckle/speckle-webhook-service:2
restart: always
environment:
LOG_LEVEL: 'info'
PG_CONNECTION_STRING: 'postgres://speckle:speckle@postgres/speckle'
WAIT_HOSTS: 'postgres:5432'
fileimport-service:
image: speckle/speckle-fileimport-service:2
restart: always
environment:
LOG_LEVEL: 'info'
PG_CONNECTION_STRING: 'postgres://speckle:speckle@postgres/speckle'
WAIT_HOSTS: 'postgres:5432'
SPECKLE_SERVER_URL: 'http://speckle-server:3000'

Some files were not shown because too many files have changed in this diff Show More