import { LimitedWorkspace, Workspace, WorkspaceAcl, WorkspaceDomain, WorkspaceJoinRequest, WorkspaceWithOptionalRole } from '@/modules/workspacesCore/domain/types' import { CountDomainsByWorkspaceId, CountWorkspaceRoleWithOptionalProjectRole, CountWorkspaces, DeleteWorkspace, DeleteWorkspaceDomain, DeleteWorkspaceRole, GetAllWorkspaces, GetPaginatedWorkspaceProjects, GetPaginatedWorkspaceProjectsArgs, GetPaginatedWorkspaceProjectsItems, GetPaginatedWorkspaceProjectsTotalCount, GetUserDiscoverableWorkspaces, GetUserIdsWithRoleInWorkspace, GetWorkspace, GetWorkspaceBySlug, GetWorkspaceBySlugOrId, GetWorkspaceCollaborators, GetWorkspaceCollaboratorsTotalCount, GetWorkspaceCreationState, GetWorkspaceDomains, GetWorkspaceRoleForUser, GetWorkspaceRoles, GetWorkspaceRolesForUser, GetWorkspaceSeatCount, GetWorkspaceWithDomains, GetWorkspaces, GetWorkspacesProjectsCounts, GetWorkspacesRolesForUsers, QueryWorkspaces, StoreWorkspaceDomain, UpsertWorkspace, UpsertWorkspaceCreationState, UpsertWorkspaceRole } from '@/modules/workspaces/domain/operations' import { Knex } from 'knex' import { isNullOrUndefined, Roles } from '@speckle/shared' import { ServerAclRecord, BranchRecord, StreamAclRecord, StreamRecord, ProjectRecordVisibility } from '@/modules/core/helpers/types' import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace' import { WorkspaceAcl as DbWorkspaceAcl, WorkspaceCreationState as DbWorkspaceCreationState, WorkspaceDomains, Workspaces, WorkspaceSeats } from '@/modules/workspaces/helpers/db' import { knex, ServerAcl, StreamAcl, Streams, Users } from '@/modules/core/dbSchema' import { removePrivateFields } from '@/modules/core/helpers/userHelper' import { clamp, has, isObjectLike } from 'lodash' import { WorkspaceCreationState, WorkspaceTeamMember } from '@/modules/workspaces/domain/types' import { decodeCompositeCursor, decodeCursor, encodeCompositeCursor, encodeCursor } from '@/modules/shared/helpers/graphqlHelper' import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper' const tables = { branches: (db: Knex) => db('branches'), streams: (db: Knex) => db('streams'), streamAcl: (db: Knex) => db('stream_acl'), serverAcl: (db: Knex) => db(ServerAcl.name), workspaces: (db: Knex) => db('workspaces'), workspaceDomains: (db: Knex) => db('workspace_domains'), workspacesAcl: (db: Knex) => db('workspace_acl'), workspaceCreationState: (db: Knex) => db('workspace_creation_state'), workspaceJoinRequests: (db: Knex) => db('workspace_join_requests') } export const getUserDiscoverableWorkspacesFactory = ({ db }: { db: Knex }): GetUserDiscoverableWorkspaces => async ({ domains, userId }) => { if (domains.length === 0) { return [] } const workspaces = (await tables .workspaces(db) .select( 'workspaces.id as id', 'name', 'slug', 'description', 'logo', 'discoverabilityAutoJoinEnabled', tables .workspacesAcl(db) .select(knex.raw('count(*)::integer')) .where(DbWorkspaceAcl.col.workspaceId, knex.ref(Workspaces.col.id)) .as('teamCount') ) .distinctOn(['teamCount', 'workspaces.id']) .join('workspace_domains', 'workspace_domains.workspaceId', 'workspaces.id') .leftJoin( tables.workspacesAcl(db).select('*').where({ userId }).as('acl'), 'acl.workspaceId', 'workspaces.id' ) .leftJoin( tables .workspaceJoinRequests(db) .select('*') .where({ userId }) .as('joinRequest'), 'joinRequest.workspaceId', 'workspaces.id' ) .whereNull('joinRequest.workspaceId') .whereIn('domain', domains) .where('discoverabilityEnabled', true) .where('verified', true) .where('role', null) .orderBy([ { column: 'teamCount', order: 'desc' }, 'workspaces.id' ])) as LimitedWorkspace[] return workspaces } const workspaceWithRoleBaseQuery = ({ db, userId }: { db: Knex userId?: string }): Knex.QueryBuilder => { let q = db('workspaces') if (userId) { q = q .select([ ...Object.values(Workspaces.col), // Getting first role from grouped results knex.raw(`(array_agg("workspace_acl"."role"))[1] as role`) ]) .leftJoin(DbWorkspaceAcl.name, function () { this.on(DbWorkspaceAcl.col.workspaceId, Workspaces.col.id).andOnVal( DbWorkspaceAcl.col.userId, userId ) }) .groupBy(Workspaces.col.id) } return q } export const getWorkspacesFactory = ({ db }: { db: Knex }): GetWorkspaces => async ({ workspaceIds, userId, search, completed }) => { const q = workspaceWithRoleBaseQuery({ db, userId }) if (workspaceIds !== undefined) q.whereIn(Workspaces.col.id, workspaceIds) if (search) { q.andWhere((builder) => { builder .where('name', 'ILIKE', `%${search}%`) .orWhere('slug', 'ILIKE', `%${search}%`) }) } if (completed !== undefined) { q.leftJoin( DbWorkspaceCreationState.name, Workspaces.col.id, DbWorkspaceCreationState.col.workspaceId ).andWhere((builder) => { builder .where({ [DbWorkspaceCreationState.col.completed]: completed }) .orWhere({ [DbWorkspaceCreationState.col.completed]: null }) }) } const results = await q return results } export const getAllWorkspacesFactory = ({ db }: { db: Knex }): GetAllWorkspaces => async (args) => { const cursor = args.cursor ? decodeCursor(args.cursor) : null const limit = isNullOrUndefined(args.limit) ? 10 : args.limit const q = tables .workspaces(db) .limit(clamp(limit, 1, 25)) .orderBy(Workspaces.col.id, 'asc') if (cursor?.length) { q.andWhere(Workspaces.col.id, '>', cursor) } const res = await q return { items: res, cursor: res.length ? encodeCursor(res[res.length - 1].id) : null } } export const getWorkspaceFactory = ({ db }: { db: Knex }): GetWorkspace => async ({ workspaceId, userId }) => { const workspace = await workspaceWithRoleBaseQuery({ db, userId }) .where(Workspaces.col.id, workspaceId) .first() return workspace || null } export const getWorkspaceBySlugOrIdFactory = (deps: { db: Knex }): GetWorkspaceBySlugOrId => async ({ workspaceSlugOrId }) => { const { db } = deps const workspace = await workspaceWithRoleBaseQuery({ db }) .where(Workspaces.col.slug, workspaceSlugOrId) .orWhere(Workspaces.col.id, workspaceSlugOrId) .first() return workspace || null } export const getWorkspaceBySlugFactory = ({ db }: { db: Knex }): GetWorkspaceBySlug => async ({ workspaceSlug, userId }) => { const workspace = await workspaceWithRoleBaseQuery({ db, userId }) .where(Workspaces.col.slug, workspaceSlug) .first() return workspace || null } const buildWorkspacesQuery = ({ db, search }: { db: Knex; search?: string }) => { const query = tables.workspaces(db) if (search) { query.andWhere((builder) => { builder .where('name', 'ILIKE', `%${search}%`) .orWhere('slug', 'ILIKE', `%${search}%`) }) } return query } export const queryWorkspacesFactory = ({ db }: { db: Knex }): QueryWorkspaces => async ({ limit, cursor, filter }) => { const query = buildWorkspacesQuery({ db, search: filter?.search }) .select() .orderBy('createdAt', 'desc') .limit(limit) if (cursor) { query.andWhere('createdAt', '<', cursor) } return await query } export const countWorkspacesFactory = ({ db }: { db: Knex }): CountWorkspaces => async ({ filter }) => { const query = buildWorkspacesQuery({ db, search: filter?.search }) const [res] = await query.count() const count = parseInt(res.count.toString()) return count } export const upsertWorkspaceFactory = ({ db }: { db: Knex }): UpsertWorkspace => async ({ workspace }) => { await tables .workspaces(db) .insert(workspace) .onConflict('id') .merge([ 'description', 'logo', 'slug', 'name', 'updatedAt', 'domainBasedMembershipProtectionEnabled', 'discoverabilityEnabled', 'discoverabilityAutoJoinEnabled', 'isEmbedSpeckleBrandingHidden' ]) } export const deleteWorkspaceFactory = ({ db }: { db: Knex }): DeleteWorkspace => async ({ workspaceId }) => { await tables.workspaces(db).where({ id: workspaceId }).delete() } export const getWorkspaceRolesFactory = ({ db }: { db: Knex }): GetWorkspaceRoles => async ({ workspaceId }) => { return await tables.workspacesAcl(db).select('*').where({ workspaceId }) } export const getWorkspaceRoleForUserFactory = ({ db }: { db: Knex }): GetWorkspaceRoleForUser => async ({ userId, workspaceId }) => { return ( (await tables .workspacesAcl(db) .select('*') .where({ userId, workspaceId }) .first()) ?? null ) } export const getWorkspacesRolesForUsersFactory = (deps: { db: Knex }): GetWorkspacesRolesForUsers => async (reqs) => { const query = tables.workspacesAcl(deps.db).whereIn( [DbWorkspaceAcl.col.userId, DbWorkspaceAcl.col.workspaceId], reqs.map(({ userId, workspaceId }) => [userId, workspaceId]) ) const results = await query return results.reduce((acc, acl) => { const { userId, workspaceId } = acl if (!acc[workspaceId]) { acc[workspaceId] = {} } acc[workspaceId][userId] = acl return acc }, {} as Awaited>) } export const getWorkspaceRolesForUserFactory = ({ db }: { db: Knex }): GetWorkspaceRolesForUser => async ({ userId }, options) => { const workspaceIdFilter = options?.workspaceIdFilter ?? [] const query = tables.workspacesAcl(db).select('*').where({ userId }) if (workspaceIdFilter.length > 0) { query.whereIn('workspaceId', workspaceIdFilter) } return await query } export const deleteWorkspaceRoleFactory = ({ db }: { db: Knex }): DeleteWorkspaceRole => async ({ userId, workspaceId }) => { const deletedRoles = await tables .workspacesAcl(db) .where({ workspaceId, userId }) .delete('*') if (deletedRoles.length === 0) { return null } // Given `workspaceId` and `userId` define a primary key for `workspace_acl` table, // query returns either 0 or 1 row in all cases return deletedRoles[0] } export const upsertWorkspaceRoleFactory = ({ db }: { db: Knex }): UpsertWorkspaceRole => async ({ userId, workspaceId, role, createdAt }) => { // Verify requested role is valid workspace role const validRoles = Object.values(Roles.Workspace) if (!validRoles.includes(role)) { throw new WorkspaceInvalidRoleError() } await tables .workspacesAcl(db) .insert({ userId, workspaceId, role, createdAt }) .onConflict(['userId', 'workspaceId']) .merge(['role']) } export const getWorkspaceCollaboratorsTotalCountFactory = ({ db }: { db: Knex }): GetWorkspaceCollaboratorsTotalCount => async ({ workspaceId }) => { const [res] = await DbWorkspaceAcl.knex(db).where({ workspaceId }).count() const count = parseInt(res.count) return count || 0 } export const getWorkspaceCollaboratorsFactory = ({ db }: { db: Knex }): GetWorkspaceCollaborators => async ({ workspaceId, filter = {}, cursor, limit = 25 }) => { const query = db .from(Users.name) .select>( ...Users.cols, ServerAcl.col.role, DbWorkspaceAcl.col.workspaceId, // this field is necessary for projectRoles field resolver DbWorkspaceAcl.colAs('role', 'workspaceRole'), DbWorkspaceAcl.colAs('createdAt', 'workspaceRoleCreatedAt') ) .join(DbWorkspaceAcl.name, DbWorkspaceAcl.col.userId, Users.col.id) .join(ServerAcl.name, ServerAcl.col.userId, Users.col.id) .where(DbWorkspaceAcl.col.workspaceId, workspaceId) .orderBy('workspaceRoleCreatedAt', 'desc') const { search, roles, seatType, excludeUserIds } = filter || {} if (seatType) { query .join('workspace_seats', 'workspace_seats.userId', DbWorkspaceAcl.col.userId) .andWhere('workspace_seats.type', seatType) .andWhere('workspace_seats.workspaceId', workspaceId) } if (search) { query.andWhere((builder) => { builder .where(Users.col.name, 'ILIKE', `%${search}%`) .orWhere(Users.col.email, 'ILIKE', `%${search}%`) }) } if (roles) { query.andWhere((builder) => { builder.whereIn(DbWorkspaceAcl.col.role, roles) }) } if (excludeUserIds?.length) { query.andWhere((w) => { w.whereNotIn(Users.col.id, excludeUserIds) }) } if (cursor) { query.andWhere(DbWorkspaceAcl.col.createdAt, '<', cursor) } if (limit) { query.limit(clamp(limit, 0, 100)) } const items = (await query).map((i) => ({ ...removePrivateFields(i), workspaceRole: i.workspaceRole, workspaceRoleCreatedAt: i.workspaceRoleCreatedAt, workspaceId: i.workspaceId, role: i.role })) return items } export const storeWorkspaceDomainFactory = ({ db }: { db: Knex }): StoreWorkspaceDomain => async ({ workspaceDomain }): Promise => { await tables.workspaceDomains(db).insert(workspaceDomain) } export const getWorkspaceDomainsFactory = ({ db }: { db: Knex }): GetWorkspaceDomains => ({ workspaceIds }) => { return tables.workspaceDomains(db).whereIn('workspaceId', workspaceIds) } export const countDomainsByWorkspaceIdFactory = ({ db }: { db: Knex }): CountDomainsByWorkspaceId => async ({ workspaceId }) => { const [res] = await tables.workspaceDomains(db).where({ workspaceId }).count() return parseInt(res.count.toString()) } export const deleteWorkspaceDomainFactory = ({ db }: { db: Knex }): DeleteWorkspaceDomain => async ({ id }) => { await tables.workspaceDomains(db).where({ id }).delete() } export const getWorkspaceWithDomainsFactory = ({ db }: { db: Knex }): GetWorkspaceWithDomains => async ({ id }) => { const workspace = await tables .workspaces(db) .select([...Workspaces.cols, WorkspaceDomains.groupArray('domains')]) .where({ [Workspaces.col.id]: id }) .leftJoin( WorkspaceDomains.name, WorkspaceDomains.col.workspaceId, Workspaces.col.id ) .groupBy(Workspaces.col.id) .first() if (!workspace) return null return { ...workspace, domains: workspace.domains.filter( (domain: WorkspaceDomain | null) => domain !== null ) } as Workspace & { domains: WorkspaceDomain[] } } export const getUserIdsWithRoleInWorkspaceFactory = ({ db }: { db: Knex }): GetUserIdsWithRoleInWorkspace => async ({ workspaceId, workspaceRole }, options) => { const query = tables .workspacesAcl(db) .where(DbWorkspaceAcl.col.workspaceId, workspaceId) .where(DbWorkspaceAcl.col.role, workspaceRole) .orderBy(DbWorkspaceAcl.col.createdAt) if (options?.limit) { query.limit(options.limit) } return ( (await query.select([DbWorkspaceAcl.col.userId])) as Pick< WorkspaceAcl, 'userId' >[] ).map((wsAcl) => wsAcl.userId) } export const countWorkspaceRoleWithOptionalProjectRoleFactory = ({ db }: { db: Knex }): CountWorkspaceRoleWithOptionalProjectRole => async ({ workspaceId, workspaceRole, projectRole, skipUserIds }) => { let query if (projectRole) { query = tables .streams(db) .join(StreamAcl.name, StreamAcl.col.resourceId, Streams.col.id) .join(DbWorkspaceAcl.name, DbWorkspaceAcl.col.userId, StreamAcl.col.userId) .where(Streams.col.workspaceId, workspaceId) .andWhere(DbWorkspaceAcl.col.role, workspaceRole) // make sure to also filter on the workspace_acl workspaceId, to not leak roles across .andWhere(DbWorkspaceAcl.col.workspaceId, workspaceId) .andWhere(StreamAcl.col.role, projectRole) .countDistinct(DbWorkspaceAcl.col.userId) } else { query = tables .workspacesAcl(db) .where(DbWorkspaceAcl.col.workspaceId, workspaceId) .where(DbWorkspaceAcl.col.role, workspaceRole) .count() } if (skipUserIds) { query.whereNotIn(DbWorkspaceAcl.col.userId, skipUserIds) } const [res] = await query return parseInt(res.count.toString()) } export const getWorkspaceSeatCountFactory = ({ db }: { db: Knex }): GetWorkspaceSeatCount => async ({ workspaceId, type }) => { const query = db(WorkspaceSeats.name).where( WorkspaceSeats.col.workspaceId, workspaceId ) if (type) query.andWhere(WorkspaceSeats.col.type, type) const [{ count }] = await query.count() return parseInt(String(count)) } export const getWorkspaceCreationStateFactory = ({ db }: { db: Knex }): GetWorkspaceCreationState => async ({ workspaceId }) => { const creationState = await tables .workspaceCreationState(db) .select() .where({ workspaceId }) .first() return creationState || null } export const upsertWorkspaceCreationStateFactory = ({ db }: { db: Knex }): UpsertWorkspaceCreationState => async ({ workspaceCreationState }) => { await tables .workspaceCreationState(db) .insert(workspaceCreationState) .onConflict('workspaceId') .merge() } export const getWorkspacesProjectsCountsFactory = (deps: { db: Knex }): GetWorkspacesProjectsCounts => async ({ workspaceIds }) => { const ret = workspaceIds.reduce((acc, workspaceId) => { acc[workspaceId] = 0 return acc }, {} as Record) const q = tables .streams(deps.db) .select< { workspaceId: string count: string }[] >([Streams.col.workspaceId, knex.raw('count(*) as count')]) .whereIn(Streams.col.workspaceId, workspaceIds) .groupBy(Streams.col.workspaceId) const res = await q for (const { workspaceId, count } of res) { ret[workspaceId] = parseInt(count) } return ret } const getPaginatedWorkspaceProjectsBaseQueryFactory = (deps: { db: Knex }) => (params: Omit) => { const { workspaceId, userId, filter } = params const { search, withProjectRoleOnly } = filter || {} const query = tables .streams(deps.db) .where(Streams.col.workspaceId, workspaceId) .select(Streams.cols) /** * If userId is set: * - If no workspace role, user should be server admin w/ admin override enabled * - If workspace role is admin: user can get all workspace streams * - If workspace role is guest: user should have explicit stream roles * - If workspace role is member: * - Public/Workspace visibility: get stream * - Private visibility: user should have explicit stream roles * * If withProjectRoleOnly is set: Require project role always */ if (userId) { query .leftJoin(DbWorkspaceAcl.name, (j) => { j.on(DbWorkspaceAcl.col.workspaceId, Streams.col.workspaceId).andOnVal( DbWorkspaceAcl.col.userId, userId ) }) .andWhere((w) => { // Check server_acl exist first, so subsequent checks can be optimized away if (adminOverrideEnabled() && !withProjectRoleOnly) { w.whereExists( tables .serverAcl(deps.db) .select('*') .where(ServerAcl.col.userId, userId) .andWhere(ServerAcl.col.role, Roles.Server.Admin) ) } w.orWhere((w2) => { // Ensure workspace role exists and: // user has explicit stream role or is admin or is a non-guest in a non-private project w2.whereNotNull(DbWorkspaceAcl.col.role).andWhere((w3) => { if (!withProjectRoleOnly) { w3.where(DbWorkspaceAcl.col.role, Roles.Workspace.Admin).orWhere( (w4) => { w4.whereNot( DbWorkspaceAcl.col.role, Roles.Workspace.Guest ).andWhereNot( Streams.col.visibility, ProjectRecordVisibility.Private ) } ) } w3.orWhereExists( tables .streamAcl(deps.db) .select('*') .where(StreamAcl.col.userId, userId) .andWhere(StreamAcl.col.resourceId, knex.ref(Streams.col.id)) ) }) }) }) } if (search?.length) { query.andWhere((w) => { w.where(Streams.col.name, 'ILIKE', `%${search}%`).orWhere( Streams.col.description, 'ILIKE', `%${search}%` ) }) } return query } export const getPaginatedWorkspaceProjectsItemsFactory = (deps: { db: Knex }): GetPaginatedWorkspaceProjectsItems => async (params) => { type CursorType = { updatedAt: string; id: string } const query = getPaginatedWorkspaceProjectsBaseQueryFactory(deps)(params) const limit = clamp(params.limit || 25, 1, 50) const cursor = decodeCompositeCursor( params.cursor, (c) => isObjectLike(c) && has(c, 'id') && has(c, 'updatedAt') ) if (cursor) { // filter by date, and if there's duplicate dates, filter by id too query.andWhereRaw('(??, ??) < (?, ?)', [ Streams.col.updatedAt, Streams.col.id, cursor.updatedAt, cursor.id ]) } query .orderBy([ { column: Streams.col.updatedAt, order: 'desc' }, { column: Streams.col.id, order: 'desc' } ]) .limit(limit) const rows = await query const newCursorRow = rows.at(-1) const newCursor = newCursorRow ? encodeCompositeCursor({ updatedAt: newCursorRow.updatedAt.toISOString(), id: newCursorRow.id }) : null return { items: rows, cursor: newCursor } } export const getPaginatedWorkspaceProjectsTotalCountFactory = (deps: { db: Knex }): GetPaginatedWorkspaceProjectsTotalCount => async (params) => { const query = getPaginatedWorkspaceProjectsBaseQueryFactory(deps)(params) const [res] = await query.clearSelect().count() const count = parseInt(res.count.toString()) return count } export const getPaginatedWorkspaceProjectsFactory = (deps: { db: Knex }): GetPaginatedWorkspaceProjects => async (params) => { const getItems = getPaginatedWorkspaceProjectsItemsFactory(deps) const getTotalCount = getPaginatedWorkspaceProjectsTotalCountFactory(deps) const [items, totalCount] = await Promise.all([ params.limit !== 0 ? getItems(params) : undefined, getTotalCount(params) ]) if (!items) { return { items: [], cursor: null, totalCount } } return { ...items, totalCount } }