Merge branch 'main' of github.com:specklesystems/speckle-server into gergo/web-1119-define-workspaces-dataschema
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -39,8 +39,9 @@ What is Speckle? Check our [](https://app.speckle.systems) ⇒ creating an account
|
||||
- [](https://marketplace.digitalocean.com/apps/speckle-server?refcode=947a2b5d7dc1) ⇒ deploying an instance in 1 click
|
||||
- [](https://app.speckle.systems) ⇒ Create an account at app.speckle.systems
|
||||
- [](<[https://](https://speckle.guide/dev/server-manualsetup.html)>) ⇒ Deploy on your own infrastructure with Docker Compose
|
||||
- [](<[https://](https://speckle.guide/dev/server-setup-k8s.html)>) ⇒ Deploy on your own infrastructure with Kubernetes
|
||||
|
||||
## Resources
|
||||
|
||||
|
||||
+12
-15
@@ -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 }
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user