Files
speckle-server/packages/server/modules/workspaces/repositories/workspaces.ts
T
2024-10-10 14:25:45 +02:00

434 lines
13 KiB
TypeScript

import {
Workspace,
WorkspaceAcl,
WorkspaceDomain,
WorkspaceWithOptionalRole
} from '@/modules/workspacesCore/domain/types'
import {
CountDomainsByWorkspaceId,
CountProjectsVersionsByWorkspaceId,
CountWorkspaceRoleWithOptionalProjectRole,
DeleteWorkspace,
DeleteWorkspaceDomain,
DeleteWorkspaceRole,
GetUserDiscoverableWorkspaces,
GetUserIdsWithRoleInWorkspace,
GetWorkspace,
GetWorkspaceBySlug,
GetWorkspaceCollaborators,
GetWorkspaceCollaboratorsTotalCount,
GetWorkspaceDomains,
GetWorkspaceRoleForUser,
GetWorkspaceRoles,
GetWorkspaceRolesForUser,
GetWorkspaceWithDomains,
GetWorkspaces,
StoreWorkspaceDomain,
UpsertWorkspace,
UpsertWorkspaceRole
} from '@/modules/workspaces/domain/operations'
import { Knex } from 'knex'
import { Roles } from '@speckle/shared'
import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types'
import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace'
import {
WorkspaceAcl as DbWorkspaceAcl,
WorkspaceDomains,
Workspaces
} from '@/modules/workspaces/helpers/db'
import {
knex,
ServerAcl,
ServerInvites,
StreamAcl,
StreamCommits,
Streams,
Users
} from '@/modules/core/dbSchema'
import { removePrivateFields } from '@/modules/core/helpers/userHelper'
import {
filterByResource,
InvitesRetrievalValidityFilter
} from '@/modules/serverinvites/repositories/serverInvites'
import { WorkspaceInviteResourceType } from '@/modules/workspaces/domain/constants'
import { clamp } from 'lodash'
import { WorkspaceTeamMember } from '@/modules/workspaces/domain/types'
const tables = {
streams: (db: Knex) => db<StreamRecord>('streams'),
streamAcl: (db: Knex) => db<StreamAclRecord>('stream_acl'),
workspaces: (db: Knex) => db<Workspace>('workspaces'),
workspaceDomains: (db: Knex) => db<WorkspaceDomain>('workspace_domains'),
workspacesAcl: (db: Knex) => db<WorkspaceAcl>('workspace_acl')
}
export const getUserDiscoverableWorkspacesFactory =
({ db }: { db: Knex }): GetUserDiscoverableWorkspaces =>
async ({ domains, userId }) => {
if (domains.length === 0) {
return []
}
return (await tables
.workspaces(db)
.select('workspaces.id as id', 'name', 'description', 'logo', 'defaultLogoIndex')
.distinctOn('workspaces.id')
.join('workspace_domains', 'workspace_domains.workspaceId', 'workspaces.id')
.leftJoin(
tables.workspacesAcl(db).select('*').where({ userId }).as('acl'),
'acl.workspaceId',
'workspaces.id'
)
.whereIn('domain', domains)
.where('discoverabilityEnabled', true)
.where('verified', true)
.where('role', null)) as Pick<
Workspace,
'id' | 'name' | 'description' | 'logo' | 'defaultLogoIndex'
>[]
}
const workspaceWithRoleBaseQuery = ({
db,
userId
}: {
db: Knex
userId?: string
}): Knex.QueryBuilder<WorkspaceWithOptionalRole, WorkspaceWithOptionalRole[]> => {
let q = db<WorkspaceWithOptionalRole, WorkspaceWithOptionalRole[]>('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 (params: {
workspaceIds: string[]
/**
* Optionally - for each workspace, return the user's role in that workspace
*/
userId?: string
}) => {
const { workspaceIds, userId } = params
const q = workspaceWithRoleBaseQuery({ db, userId })
const results = await q.whereIn(Workspaces.col.id, workspaceIds)
return results
}
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 getWorkspaceBySlugFactory =
({ db }: { db: Knex }): GetWorkspaceBySlug =>
async ({ workspaceSlug, userId }) => {
const workspace = await workspaceWithRoleBaseQuery({ db, userId })
.where(Workspaces.col.slug, workspaceSlug)
.first()
return workspace || null
}
export const upsertWorkspaceFactory =
({ db }: { db: Knex }): UpsertWorkspace =>
async ({ workspace }) => {
await tables
.workspaces(db)
.insert(workspace)
.onConflict('id')
.merge([
'description',
'logo',
'slug',
'defaultLogoIndex',
'defaultProjectRole',
'name',
'updatedAt',
'domainBasedMembershipProtectionEnabled',
'discoverabilityEnabled'
])
}
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 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<Array<WorkspaceTeamMember & { workspaceRoleCreatedAt: Date }>>(
...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 } = filter || {}
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 (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,
workspaceId: i.workspaceId,
role: i.role,
createdAt: i.workspaceRoleCreatedAt
}))
return items
}
export const workspaceInviteValidityFilter: InvitesRetrievalValidityFilter = (q) => {
return q
.leftJoin(
knex.raw(
":workspaces: ON :resourceCol: ->> 'resourceType' = :resourceType AND :resourceCol: ->> 'resourceId' = :workspaceIdCol:",
{
workspaces: Workspaces.name,
resourceCol: ServerInvites.col.resource,
resourceType: WorkspaceInviteResourceType,
workspaceIdCol: Workspaces.col.id
}
)
)
.where((w1) => {
w1.whereNot((w2) =>
filterByResource(w2, { resourceType: WorkspaceInviteResourceType })
).orWhereNotNull(Workspaces.col.id)
})
}
export const storeWorkspaceDomainFactory =
({ db }: { db: Knex }): StoreWorkspaceDomain =>
async ({ workspaceDomain }): Promise<void> => {
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 countProjectsVersionsByWorkspaceIdFactory =
({ db }: { db: Knex }): CountProjectsVersionsByWorkspaceId =>
async ({ workspaceId }) => {
const [res] = await tables
.streams(db)
.join(StreamCommits.name, StreamCommits.col.streamId, Streams.col.id)
.where({ workspaceId })
.count(StreamCommits.col.commitId)
return parseInt(res.count.toString())
}
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())
}