Files
speckle-server/packages/server/modules/viewer/graph/resolvers/savedViews.ts
T
Kristaps Fabians Geikins 0c837715a3 feat: support group delete (#5215)
* backend implemented

* added create to group, but search seems busted

* group search fixed

* moar group retrieval fixes

* more recalculations

* support group delete

* delete confirm dialogs
2025-08-13 10:14:44 +03:00

447 lines
15 KiB
TypeScript

import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
import type { Resolvers } from '@/modules/core/graph/generated/graphql'
import { mapGqlToDbSortDirection } from '@/modules/core/helpers/project'
import { throwIfResourceAccessNotAllowed } from '@/modules/core/helpers/token'
import {
getBranchesByIdsFactory,
getBranchLatestCommitsFactory,
getStreamBranchesByNameFactory
} from '@/modules/core/repositories/branches'
import {
getAllBranchCommitsFactory,
getSpecificBranchCommitsFactory
} from '@/modules/core/repositories/commits'
import { getStreamObjectsFactory } from '@/modules/core/repositories/objects'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { LogicError, NotFoundError, NotImplementedError } from '@/modules/shared/errors'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { buildDefaultGroupId } from '@/modules/viewer/helpers/savedViews'
import {
deleteSavedViewGroupRecordFactory,
deleteSavedViewRecordFactory,
getGroupSavedViewsPageItemsFactory,
getGroupSavedViewsTotalCountFactory,
getProjectSavedViewGroupsPageItemsFactory,
getProjectSavedViewGroupsTotalCountFactory,
getStoredViewCountFactory,
getStoredViewGroupCountFactory,
getUngroupedSavedViewsGroupFactory,
recalculateGroupResourceIdsFactory,
storeSavedViewFactory,
storeSavedViewGroupFactory,
updateSavedViewRecordFactory
} from '@/modules/viewer/repositories/savedViews'
import {
createSavedViewFactory,
createSavedViewGroupFactory,
deleteSavedViewFactory,
deleteSavedViewGroupFactory,
getGroupSavedViewsFactory,
getProjectSavedViewGroupsFactory,
updateSavedViewFactory
} from '@/modules/viewer/services/savedViewsManagement'
import { getViewerResourceGroupsFactory } from '@/modules/viewer/services/viewerResources'
import { Authz } from '@speckle/shared'
import { parseResourceFromString, resourceBuilder } from '@speckle/shared/viewer/route'
import { formatSerializedViewerState } from '@speckle/shared/viewer/state'
import type { Knex } from 'knex'
import { ungroupedScenesGroupTitle } from '@speckle/shared/saved-views'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import {
getSavedViewFactory,
getSavedViewGroupFactory
} from '@/modules/viewer/repositories/dataLoaders/savedViews'
import type { RequestDataLoaders } from '@/modules/core/loaders'
const buildGetViewerResourceGroups = (params: {
projectDb: Knex
loaders: RequestDataLoaders
}) => {
const { projectDb } = params
return getViewerResourceGroupsFactory({
getStreamObjects: getStreamObjectsFactory({ db: projectDb }),
getBranchLatestCommits: getBranchLatestCommitsFactory({ db: projectDb }),
getStreamBranchesByName: getStreamBranchesByNameFactory({ db: projectDb }),
getSpecificBranchCommits: getSpecificBranchCommitsFactory({ db: projectDb }),
getAllBranchCommits: getAllBranchCommitsFactory({ db: projectDb }),
getBranchesByIds: getBranchesByIdsFactory({ db: projectDb }),
getSavedView: getSavedViewFactory({ loaders: params.loaders })
})
}
const resolvers: Resolvers = {
Project: {
async savedViewGroups(parent, args, ctx) {
const { input } = args
const projectDb = await getProjectDbClient({ projectId: parent.id })
const getProjectSavedViewGroups = getProjectSavedViewGroupsFactory({
getProjectSavedViewGroupsPageItems: getProjectSavedViewGroupsPageItemsFactory({
db: projectDb
}),
getProjectSavedViewGroupsTotalCount: getProjectSavedViewGroupsTotalCountFactory(
{ db: projectDb }
)
})
return await getProjectSavedViewGroups({
projectId: parent.id,
resourceIdString: input.resourceIdString,
userId: ctx.userId,
onlyAuthored: input.onlyAuthored,
search: input.search,
limit: input.limit,
cursor: input.cursor
})
},
async savedViewGroup(parent, args, ctx) {
const projectDb = await getProjectDbClient({ projectId: parent.id })
const group = await ctx.loaders
.forRegion({ db: projectDb })
.savedViews.getSavedViewGroup.load({
groupId: args.id,
projectId: parent.id
})
if (!group) {
throw new NotFoundError(
`Saved view group with ID ${args.id} not found in project ${parent.id}`
)
}
return group
},
ungroupedViewGroup: async (parent, args) => {
const getDefaultGroup = getUngroupedSavedViewsGroupFactory()
const group = getDefaultGroup({
projectId: parent.id,
resourceIdString: args.input.resourceIdString
})
return group
},
savedView: async (parent, args, ctx) => {
const projectDb = await getProjectDbClient({ projectId: parent.id })
const view = await ctx.loaders
.forRegion({ db: projectDb })
.savedViews.getSavedView.load({
viewId: args.id,
projectId: parent.id
})
if (!view) {
throw new NotFoundError(
`Saved view with ID ${args.id} not found in project ${parent.id}`
)
}
return view
},
savedViewIfExists: async (parent, args, ctx) => {
if (!args.id?.length) return null
const projectDb = await getProjectDbClient({ projectId: parent.id })
const view = await ctx.loaders
.forRegion({ db: projectDb })
.savedViews.getSavedView.load({
viewId: args.id,
projectId: parent.id
})
return view
}
},
SavedView: {
async author(parent, _args, ctx) {
return parent.authorId
? await ctx.loaders.users.getUser.load(parent.authorId)
: null
},
resourceIdString(parent) {
const resourceIds = parent.resourceIds
return resourceBuilder().addFromString(resourceIds.join(',')).toString()
},
viewerState(parent) {
return formatSerializedViewerState(parent.viewerState.state)
},
group: async (parent, _args, ctx) => {
const groupId =
parent.groupId ||
buildDefaultGroupId({
resourceIds: parent.resourceIds,
projectId: parent.projectId
})
const projectDb = await getProjectDbClient({ projectId: parent.projectId })
const group = await ctx.loaders
.forRegion({ db: projectDb })
.savedViews.getSavedViewGroup.load({
groupId,
projectId: parent.projectId
})
if (!group) {
throw new LogicError('Unexpectedly could not resolve a view group')
}
return group
}
},
SavedViewGroup: {
title: (parent) => parent.name || ungroupedScenesGroupTitle,
isUngroupedViewsGroup: (parent) => parent.name === null,
groupId: (parent) => (parent.name ? parent.id : null),
async views(parent, args, ctx) {
const { input } = args
const projectDb = await getProjectDbClient({ projectId: parent.projectId })
const getGroupSavedViews = getGroupSavedViewsFactory({
getGroupSavedViewsPageItems: getGroupSavedViewsPageItemsFactory({
db: projectDb
}),
getGroupSavedViewsTotalCount: getGroupSavedViewsTotalCountFactory({
db: projectDb
})
})
const allowedSortBy = <const>['createdAt', 'name', 'updatedAt']
const sortBy = input.sortBy
? allowedSortBy.find((s) => s === input.sortBy)
: undefined
return await getGroupSavedViews({
projectId: parent.projectId,
groupResourceIdString: resourceBuilder()
.addResources(parent.resourceIds.map(parseResourceFromString))
.toString(),
userId: ctx.userId,
groupId: parent.name ? parent.id : null,
onlyAuthored: input.onlyAuthored,
search: input.search,
limit: input.limit,
cursor: input.cursor,
sortDirection: input.sortDirection
? mapGqlToDbSortDirection(input.sortDirection)
: undefined,
sortBy
})
}
},
ProjectMutations: {
savedViewMutations: () => ({})
},
SavedViewMutations: {
createView: async (_parent, args, ctx) => {
const projectId = args.input.projectId
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
})
const canCreate = await ctx.authPolicies.project.savedViews.canCreate({
userId: ctx.userId,
projectId
})
throwIfAuthNotOk(canCreate)
const projectDb = await getProjectDbClient({ projectId })
const createSavedView = createSavedViewFactory({
getViewerResourceGroups: buildGetViewerResourceGroups({
projectDb,
loaders: ctx.loaders
}),
getStoredViewCount: getStoredViewCountFactory({ db: projectDb }),
storeSavedView: storeSavedViewFactory({ db: projectDb }),
getSavedViewGroup: getSavedViewGroupFactory({ loaders: ctx.loaders }),
recalculateGroupResourceIds: recalculateGroupResourceIdsFactory({
db: projectDb
})
})
return await createSavedView({ input: args.input, authorId: ctx.userId! })
},
deleteView: async (_parent, args, ctx) => {
const projectId = args.input.projectId
const projectDb = await getProjectDbClient({ projectId })
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
})
const canUpdate = await ctx.authPolicies.project.savedViews.canUpdate({
userId: ctx.userId,
projectId,
savedViewId: args.input.id
})
throwIfAuthNotOk(canUpdate)
await deleteSavedViewFactory({
getSavedView: getSavedViewFactory({ loaders: ctx.loaders }),
deleteSavedViewRecord: deleteSavedViewRecordFactory({
db: projectDb
}),
recalculateGroupResourceIds: recalculateGroupResourceIdsFactory({
db: projectDb
})
})({
id: args.input.id,
projectId,
userId: ctx.userId!
})
return true
},
updateView: async (_parent, args, ctx) => {
const projectId = args.input.projectId
const projectDb = await getProjectDbClient({ projectId })
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
})
const canUpdate = await ctx.authPolicies.project.savedViews.canUpdate({
userId: ctx.userId,
projectId,
savedViewId: args.input.id
})
throwIfAuthNotOk(canUpdate)
const updateSavedView = updateSavedViewFactory({
getViewerResourceGroups: buildGetViewerResourceGroups({
projectDb,
loaders: ctx.loaders
}),
getSavedView: getSavedViewFactory({ loaders: ctx.loaders }),
getSavedViewGroup: getSavedViewGroupFactory({ loaders: ctx.loaders }),
updateSavedViewRecord: updateSavedViewRecordFactory({
db: projectDb
}),
recalculateGroupResourceIds: recalculateGroupResourceIdsFactory({
db: projectDb
})
})
const updatedView = await updateSavedView({
input: args.input,
userId: ctx.userId!
})
// update loader cache
ctx.loaders.forEachCachedRegion(({ loaders }) => {
loaders.savedViews.getSavedView.clear({
viewId: updatedView.id,
projectId: updatedView.projectId
})
loaders.savedViews.getSavedView.prime(
{
viewId: updatedView.id,
projectId: updatedView.projectId
},
updatedView
)
})
return updatedView
},
createGroup: async (_parent, args, ctx) => {
const projectId = args.input.projectId
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
})
const canCreate = await ctx.authPolicies.project.savedViews.canCreate({
userId: ctx.userId,
projectId
})
throwIfAuthNotOk(canCreate)
const projectDb = await getProjectDbClient({ projectId })
const createSavedViewGroup = createSavedViewGroupFactory({
storeSavedViewGroup: storeSavedViewGroupFactory({ db: projectDb }),
getViewerResourceGroups: buildGetViewerResourceGroups({
projectDb,
loaders: ctx.loaders
}),
getStoredViewGroupCount: getStoredViewGroupCountFactory({
db: projectDb
})
})
return await createSavedViewGroup({
input: args.input,
authorId: ctx.userId!
})
},
deleteGroup: async (_parent, args, ctx) => {
const projectId = args.input.projectId
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
})
const canDelete = await ctx.authPolicies.project.savedViews.canUpdateGroup({
userId: ctx.userId,
projectId,
groupId: args.input.groupId
})
throwIfAuthNotOk(canDelete)
const projectDb = await getProjectDbClient({ projectId })
const deleteSavedViewGroup = deleteSavedViewGroupFactory({
deleteSavedViewGroupRecord: deleteSavedViewGroupRecordFactory({
db: projectDb
})
})
await deleteSavedViewGroup({
input: {
groupId: args.input.groupId,
projectId
},
userId: ctx.userId!
})
return true
}
},
ProjectPermissionChecks: {
canCreateSavedView: async (parent, _args, ctx) => {
const projectId = parent.projectId
const canCreate = await ctx.authPolicies.project.savedViews.canCreate({
userId: ctx.userId,
projectId
})
return Authz.toGraphqlResult(canCreate)
}
}
}
const disabledMessage = 'Saved views are disabled on this server'
const disabledResolvers: Resolvers = {
Project: {
savedViewGroups: () => {
throw new NotImplementedError(disabledMessage)
},
savedViewGroup: () => {
throw new NotImplementedError(disabledMessage)
},
ungroupedViewGroup: () => {
throw new NotImplementedError(disabledMessage)
},
savedView: () => {
throw new NotImplementedError(disabledMessage)
},
savedViewIfExists: () => {
return null // intentional - so we dont have to FF guard the query
}
},
ProjectMutations: {
savedViewMutations: () => {
throw new NotImplementedError(disabledMessage)
}
}
}
export default getFeatureFlags().FF_SAVED_VIEWS_ENABLED ? resolvers : disabledResolvers