import { StreamRecord } from '@/modules/core/helpers/types' import { GetDefaultRegion, GetWorkspace, GetWorkspaceRoleForUser, GetWorkspaceRoles, GetWorkspaceRoleToDefaultProjectRoleMapping, QueryAllWorkspaceProjects, UpdateWorkspaceProjectRole, UpdateWorkspaceRole } from '@/modules/workspaces/domain/operations' import { WorkspaceAdminError, WorkspaceInvalidProjectError, WorkspaceInvalidRoleError, WorkspaceNotFoundError, WorkspaceQueryError } from '@/modules/workspaces/errors/workspace' import { GetProject, GetProjectCollaborators, UpdateProject, UpsertProjectRole } from '@/modules/core/domain/projects/operations' import { chunk } from 'lodash' import { Roles, StreamRoles } from '@speckle/shared' import { orderByWeight } from '@/modules/shared/domain/rolesAndScopes/logic' import coreUserRoles from '@/modules/core/roles' import { GetStream, GetUserStreamsPage, LegacyGetStreams, UpdateStreamRole } from '@/modules/core/domain/streams/operations' import { ProjectNotFoundError } from '@/modules/core/errors/projects' import { WorkspaceProjectCreateInput } from '@/test/graphql/generated/graphql' import { getDb, getValidDefaultProjectRegionKey } from '@/modules/multiregion/utils/dbSelector' import { createNewProjectFactory } from '@/modules/core/services/projects' import { deleteProjectFactory, getProjectFactory, storeProjectFactory, storeProjectRoleFactory } from '@/modules/core/repositories/projects' import { mainDb } from '@/db/knex' import { storeModelFactory } from '@/modules/core/repositories/models' import { getEventBus } from '@/modules/shared/services/eventBus' import { getWorkspaceFactory, upsertWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces' export const queryAllWorkspaceProjectsFactory = ({ getStreams }: { getStreams: LegacyGetStreams }): QueryAllWorkspaceProjects => async function* queryAllWorkspaceProjects({ workspaceId }): AsyncGenerator { let cursor: Date | null = null let iterationCount = 0 do { if (iterationCount > 500) throw new WorkspaceQueryError() const { streams, cursorDate } = await getStreams({ cursor, orderBy: null, limit: 1000, visibility: null, searchQuery: null, streamIdWhitelist: null, workspaceIdWhitelist: [workspaceId] }) yield streams cursor = cursorDate iterationCount++ } while (!!cursor) } type GetWorkspaceProjectsArgs = { workspaceId: string } type GetWorkspaceProjectsOptions = { limit: number | null cursor: string | null filter: { search?: string | null userId: string } } type GetWorkspaceProjectsReturnValue = { items: StreamRecord[] cursor: string | null } export const getWorkspaceProjectsFactory = ({ getStreams }: { getStreams: GetUserStreamsPage }) => async ( args: GetWorkspaceProjectsArgs, opts: GetWorkspaceProjectsOptions ): Promise => { const { streams, cursor } = await getStreams({ cursor: opts.cursor, limit: opts.limit || 25, searchQuery: opts.filter?.search || undefined, workspaceId: args.workspaceId, userId: opts.filter.userId }) return { items: streams, cursor } } type MoveProjectToWorkspaceArgs = { projectId: string workspaceId: string } export const moveProjectToWorkspaceFactory = ({ getProject, updateProject, upsertProjectRole, getProjectCollaborators, getWorkspaceRoles, getWorkspaceRoleToDefaultProjectRoleMapping, updateWorkspaceRole }: { getProject: GetProject updateProject: UpdateProject upsertProjectRole: UpsertProjectRole getProjectCollaborators: GetProjectCollaborators getWorkspaceRoles: GetWorkspaceRoles getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping updateWorkspaceRole: UpdateWorkspaceRole }) => async ({ projectId, workspaceId }: MoveProjectToWorkspaceArgs): Promise => { const project = await getProject({ projectId }) if (!project) throw new ProjectNotFoundError() if (project.workspaceId?.length) { // We do not currently support moving projects between workspaces throw new WorkspaceInvalidProjectError( 'Specified project already belongs to a workspace. Moving between workspaces is not yet supported.' ) } // Update roles for current project members const projectTeam = await getProjectCollaborators({ projectId }) const workspaceTeam = await getWorkspaceRoles({ workspaceId }) const defaultProjectRoleMapping = await getWorkspaceRoleToDefaultProjectRoleMapping( { workspaceId } ) for (const projectMembers of chunk(projectTeam, 5)) { await Promise.all( projectMembers.map( async ({ id: userId, role: serverRole, streamRole: currentProjectRole }) => { // Update workspace role. Prefer existing workspace role if there is one. const currentWorkspaceRole = workspaceTeam.find( (role) => role.userId === userId ) const nextWorkspaceRole = currentWorkspaceRole ?? { userId, workspaceId, role: serverRole === Roles.Server.Guest ? Roles.Workspace.Guest : Roles.Workspace.Member, createdAt: new Date() } await updateWorkspaceRole(nextWorkspaceRole) // Update project role. Prefer default workspace project role if more permissive. const defaultProjectRole = defaultProjectRoleMapping[nextWorkspaceRole.role] ?? Roles.Stream.Reviewer const nextProjectRole = orderByWeight( [currentProjectRole, defaultProjectRole], coreUserRoles )[0] await upsertProjectRole({ userId, projectId, role: nextProjectRole.name as StreamRoles }) } ) ) } // Assign project to workspace return await updateProject({ projectUpdate: { id: projectId, workspaceId } }) } export const getWorkspaceRoleToDefaultProjectRoleMappingFactory = ({ getWorkspace }: { getWorkspace: GetWorkspace }): GetWorkspaceRoleToDefaultProjectRoleMapping => async ({ workspaceId }) => { const workspace = await getWorkspace({ workspaceId }) if (!workspace) { throw new WorkspaceNotFoundError() } return { [Roles.Workspace.Guest]: null, [Roles.Workspace.Member]: workspace.defaultProjectRole, [Roles.Workspace.Admin]: Roles.Stream.Owner } } export const updateWorkspaceProjectRoleFactory = ({ getStream, getWorkspaceRoleForUser, updateStreamRoleAndNotify }: { getStream: GetStream getWorkspaceRoleForUser: GetWorkspaceRoleForUser updateStreamRoleAndNotify: UpdateStreamRole }): UpdateWorkspaceProjectRole => async ({ role, updater }) => { const { workspaceId } = (await getStream({ streamId: role.projectId })) ?? {} if (!workspaceId) throw new WorkspaceInvalidProjectError() const currentWorkspaceRole = await getWorkspaceRoleForUser({ workspaceId, userId: role.userId }) if (currentWorkspaceRole?.role === Roles.Workspace.Admin) { // User is workspace admin and cannot have their project roles changed throw new WorkspaceAdminError() } if ( currentWorkspaceRole?.role === Roles.Workspace.Guest && role.role === Roles.Stream.Owner ) { // Workspace guests cannot be project owners throw new WorkspaceInvalidRoleError('Workspace guests cannot be project owners.') } return await updateStreamRoleAndNotify( role, updater.userId!, updater.resourceAccessRules ) } export const createWorkspaceProjectFactory = (deps: { getDefaultRegion: GetDefaultRegion }) => async (params: { input: WorkspaceProjectCreateInput; ownerId: string }) => { // yes, i know, this is not aligned with our current definition of a service, but this was already this way // we need to figure out a good pattern for these situations, where we can not figure out the DB-s up front // its also hard to add a unit test for this in the current setup... const { input, ownerId } = params const workspaceDefaultRegion = await deps.getDefaultRegion({ workspaceId: input.workspaceId }) const regionKey = workspaceDefaultRegion?.key ?? (await getValidDefaultProjectRegionKey()) const projectDb = await getDb({ regionKey }) const db = mainDb const regionalWorkspace = await getWorkspaceFactory({ db: projectDb })({ workspaceId: input.workspaceId }) if (!regionalWorkspace) { const workspace = await getWorkspaceFactory({ db })({ workspaceId: input.workspaceId }) if (!workspace) throw new WorkspaceNotFoundError() await upsertWorkspaceFactory({ db: projectDb })({ workspace }) } // todo, use the command factory here, but for that, we need to migrate to the event bus // deps not injected to ensure proper DB injection const createNewProject = createNewProjectFactory({ storeProject: storeProjectFactory({ db: projectDb }), getProject: getProjectFactory({ db }), deleteProject: deleteProjectFactory({ db: projectDb }), storeModel: storeModelFactory({ db: projectDb }), // THIS MUST GO TO THE MAIN DB storeProjectRole: storeProjectRoleFactory({ db }), emitEvent: getEventBus().emit }) const project = await createNewProject({ ...input, regionKey, ownerId }) return project }