Files
speckle-server/packages/server/modules/serverinvites/repositories/serverInvites.ts
T
Kristaps Fabians Geikins bde148f286 chore(server): migrating fully to ESM (#5042)
* wip

* some extra fixes

* stuff kinda works?

* need to figure out mocks

* need to figure out mocks

* fix db listener

* gqlgen fix

* minor gqlgen watch adjustment

* lint fixes

* delete old codegen file

* converting migrations to ESM

* getModuleDIrectory

* vitest sort of works

* added back ts-vitest

* resolve gql double load

* fixing test timeout configs

* TSC lint fix

* fix automate tests

* moar debugging

* debugging

* more debugging

* codegen update

* server works

* yargs migrated

* chore(server): getting rid of global mocks for Server ESM (#5046)

* got rid of email mock

* got rid of comment mocks

* got rid of multi region mocks

* got rid of stripe mock

* admin override mock updated

* removed final mock

* fixing import.meta.resolve calls

* another import.meta.resolve fix

* added requested test

* nyc ESM fix

* removed unneeded deps + linting

* yarn lock forgot to commit

* tryna fix flakyness

* email capture util fix

* sendEmail fix

* fix TSX check

* sender transporter fix + CR comments

* merge main fix

* test fixx

* circleci fix

* gqlgen bigint fix

* error formatter fix

* more error formatting improvements

* esmloader added to Dockerfile

* more dockerfile fixes

* bg jobs fix
2025-07-14 10:26:19 +03:00

538 lines
17 KiB
TypeScript

import { knex, ServerInvites, Streams, Users } from '@/modules/core/dbSchema'
import {
getUserByEmailFactory,
getUserFactory,
UserWithOptionalRole
} from '@/modules/core/repositories/users'
import { resolveTarget, buildUserTarget } from '@/modules/serverinvites/helpers/core'
import { isObjectLike, uniq } from 'lodash-es'
import {
ExtendedInvite,
InviteResourceTarget,
ServerInviteRecord
} from '@/modules/serverinvites/domain/types'
import { Knex } from 'knex'
import {
CountServerInvites,
DeleteAllResourceInvites,
DeleteAllUserInvites,
DeleteInvite,
DeleteInvitesByTarget,
DeleteServerOnlyInvites,
FindInvite,
FindInviteByToken,
FindServerInvite,
FindServerInvites,
InsertInviteAndDeleteOld,
MarkInviteUpdated,
QueryAllResourceInvites,
QueryAllUserResourceInvites,
QueryInvites,
QueryServerInvites,
UpdateAllInviteTargets
} from '@/modules/serverinvites/domain/operations'
import {
ProjectInviteResourceType,
ServerInviteResourceType
} from '@/modules/serverinvites/domain/constants'
import { isNonNullable, Optional } from '@speckle/shared'
import { LogicError } from '@/modules/shared/errors'
import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants'
import { WorkspaceAcl, Workspaces } from '@/modules/workspacesCore/helpers/db'
import { Project } from '@/modules/core/domain/streams/types'
import { Workspace } from '@/modules/workspacesCore/domain/types'
import { formatJsonArrayRecords } from '@/modules/shared/helpers/dbHelper'
export type ServerInviteResourceFilter<
Target extends InviteResourceTarget = InviteResourceTarget
> = {
resourceId?: string
resourceType: Target['resourceType']
}
type PreformattedExtendedInvite<
Resource extends InviteResourceTarget = InviteResourceTarget
> = ServerInviteRecord<Resource> & {
workspaces: Workspace[]
projects: Project[]
}
export const filterByPrimaryResource = <Q extends Knex.QueryBuilder>(
query: Q,
filter: ServerInviteResourceFilter
) => {
query.whereRaw(`?? ->> 'resourceType' = ?`, [
ServerInvites.col.resource,
filter.resourceType
])
if (filter.resourceId) {
query.whereRaw(`?? ->> 'resourceId' = ?`, [
ServerInvites.col.resource,
filter.resourceId
])
}
}
/**
* Use this wherever you're retrieving invites, not necessarily where you're writing to them
*/
const buildInvitesBaseQuery =
({ db }: { db: Knex }) =>
<Result extends PreformattedExtendedInvite[] = PreformattedExtendedInvite[]>(
options?: Partial<{
/**
* Sort order. Defaults to 'asc'.
*/
sort: 'asc' | 'desc'
}>
) => {
const { sort = 'asc' } = options || {}
const query = db(ServerInvites.name)
.select<Result>([
...ServerInvites.cols,
Workspaces.groupArray('workspaces'),
Streams.groupArray('projects')
])
.orderBy(ServerInvites.col.updatedAt, sort)
const RawServerInvites = ServerInvites.with({ quoted: true })
const RawStreams = Streams.with({ quoted: true })
const RawWorkspaces = Workspaces.with({ quoted: true })
const RawWorkspaceAcl = WorkspaceAcl.with({ quoted: true })
// Join streams for project invites
query.leftJoin(
knex.raw(
`${RawStreams.name} ON
(${RawServerInvites.col.resource} ->> 'resourceType' = '${ProjectInviteResourceType}' AND ${RawServerInvites.col.resource} ->> 'resourceId' = ${RawStreams.col.id})
`
)
)
// Join workspaces for (even implicit) workspace invites
query.leftJoin(
knex.raw(
`${RawWorkspaces.name} ON
(${RawServerInvites.col.resource} ->> 'resourceType' = '${WorkspaceInviteResourceType}' AND ${RawServerInvites.col.resource} ->> 'resourceId' = ${RawWorkspaces.col.id})
OR (
${RawServerInvites.col.resource} ->> 'resourceType' = '${ProjectInviteResourceType}' AND ${RawServerInvites.col.resource} ->> 'resourceId' = ${RawStreams.col.id}
AND ${RawStreams.col.workspaceId} = ${RawWorkspaces.col.id}
)
`
)
)
// Join workspace acl so we can filter out implicit workspace invites for already existing workspace members
query.leftJoin(
knex.raw(
`${RawWorkspaceAcl.name} ON
(${RawWorkspaceAcl.col.workspaceId} = ${RawWorkspaces.col.id} AND ${RawWorkspaceAcl.col.userId} = SUBSTRING(${RawServerInvites.col.target} FROM 2))
`
)
)
// Do validity checks so that we don't return invites for deleted projects/workspaces
query.andWhere((w) => {
w.andWhere((w2) => {
w2.orWhereRaw(
knex.raw(
`(${RawServerInvites.col.resource} ->> 'resourceType' = '${ProjectInviteResourceType}' AND ${RawStreams.col.id} IS NOT NULL)`
)
)
.orWhereRaw(
knex.raw(
`(${RawServerInvites.col.resource} ->> 'resourceType' = '${WorkspaceInviteResourceType}' AND ${RawWorkspaces.col.id} IS NOT NULL)`
)
)
.orWhereRaw(
knex.raw(
`(${RawServerInvites.col.resource} ->> 'resourceType' = '${ServerInviteResourceType}')`
)
)
})
})
query.groupBy(ServerInvites.col.id)
return {
query,
/**
* Allows filtering by resource and supports implicit invites and joins
*/
filterByResource: <Q extends Knex.QueryBuilder>(
query: Q,
filter: ServerInviteResourceFilter
) => {
const RawServerInvites = ServerInvites.with({ quoted: true })
const RawWorkspaces = Workspaces.with({ quoted: true })
const isWorkspaceInvite = filter.resourceType === WorkspaceInviteResourceType
if (!isWorkspaceInvite) {
// Just look for this explicit type and (optionally) id
query.whereRaw(`?? ->> 'resourceType' = ?`, [
ServerInvites.col.resource,
filter.resourceType
])
if (filter.resourceId) {
query.whereRaw(`?? ->> 'resourceId' = ?`, [
ServerInvites.col.resource,
filter.resourceId
])
}
return
} else {
// If workspace invite, we also look for workspace project invites as implicit workspace invites
query.where((w) => {
w.orWhereRaw(
knex.raw(
`${RawServerInvites.col.resource} ->> 'resourceType' = '${ProjectInviteResourceType}' AND ${RawWorkspaces.col.id} IS NOT NULL AND ${RawWorkspaceAcl.col.userId} IS NULL`
)
).orWhereRaw(
knex.raw(
`${RawServerInvites.col.resource} ->> 'resourceType' = '${WorkspaceInviteResourceType}'`
)
)
})
if (filter.resourceId) {
query.andWhere(Workspaces.col.id, filter.resourceId)
}
}
}
}
}
const formatIntoExtendedInvite = <
Resource extends InviteResourceTarget = InviteResourceTarget
>(
invite: PreformattedExtendedInvite<Resource>
): ExtendedInvite<Resource> => {
const { workspaces, projects, ...rest } = invite
return {
...rest,
workspace: formatJsonArrayRecords(workspaces)[0] as Optional<Workspace>,
project: formatJsonArrayRecords(projects)[0] as Optional<Project>
}
}
/**
* Try to find a user using the target value
*/
export const findUserByTargetFactory =
(deps: { db: Knex }) =>
(target: string): Promise<UserWithOptionalRole | null> => {
const { userEmail, userId } = resolveTarget(target)
return userEmail
? getUserByEmailFactory(deps)(userEmail, { withRole: true })
: getUserFactory(deps)(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
const deleteQ = db<ServerInviteRecord>(ServerInvites.name)
.where((q) => filterByPrimaryResource(q, invite.resource))
.whereIn(ServerInvites.col.target, allTargets)
.delete()
const deleted = (await deleteQ) || 0
// Insert new
invite.target = invite.target.toLowerCase() // Extra safety cause our schema is case sensitive
const [newInvite] = await db<ServerInviteRecord>(ServerInvites.name).insert(
invite,
'*'
)
return { deleted, invite: newInvite }
}
export const queryAllUserResourceInvitesFactory =
({ db }: { db: Knex }): QueryAllUserResourceInvites =>
async <Target extends InviteResourceTarget = InviteResourceTarget>(params: {
userId: string
resourceType: Target['resourceType']
}) => {
const { userId, resourceType } = params
if (!userId) return []
const target = buildUserTarget(userId)
const { query, filterByResource } = buildInvitesBaseQuery({ db })<
PreformattedExtendedInvite<Target>[]
>()
query
.where({
[ServerInvites.col.target]: target
})
.where((q) =>
filterByResource(q, {
resourceType
})
)
const res = await query
return res.map(formatIntoExtendedInvite)
}
export const findServerInviteFactory =
({ db }: { db: Knex }): FindServerInvite =>
async (email, token) => {
if (!email && !token) return null
const { query } = buildInvitesBaseQuery({ db })()
if (email) {
query.andWhere({
[ServerInvites.col.target]: email.toLowerCase()
})
}
if (token) {
query.andWhere(ServerInvites.col.token, token)
}
const res = (await query.first()) || null
return res ? formatIntoExtendedInvite(res) : null
}
export const queryAllResourceInvitesFactory =
({ db }: { db: Knex }): QueryAllResourceInvites =>
async <Target extends InviteResourceTarget = InviteResourceTarget>(
filter: Pick<Target, 'resourceId' | 'resourceType'> & { search?: string }
) => {
if (!filter.resourceId) return []
const { query, filterByResource } = buildInvitesBaseQuery({ db })<
PreformattedExtendedInvite<Target>[]
>()
query.where((q) => filterByResource(q, filter))
if (filter.search) {
query
.leftJoin(
Users.name,
Users.col.id,
knex.raw('SUBSTRING(?? FROM 2)', [ServerInvites.col.target])
)
.where((w1) => {
w1.where(ServerInvites.col.target, 'ILIKE', `%${filter.search}%`).orWhere(
Users.col.name,
'ILIKE',
`%${filter.search}%`
)
})
}
const res = await query
return res.map(formatIntoExtendedInvite)
}
export const deleteAllResourceInvitesFactory =
({ db }: { db: Knex }): DeleteAllResourceInvites =>
async <Target extends InviteResourceTarget = InviteResourceTarget>(
filter: Pick<Target, 'resourceId' | 'resourceType'>
) => {
if (!filter.resourceId) return false
await db(ServerInvites.name)
.where((q) => filterByPrimaryResource(q, filter))
.delete()
return true
}
export const deleteServerOnlyInvitesFactory =
({ db }: { db: Knex }): DeleteServerOnlyInvites =>
async (email) => {
if (!email) return
return db<ServerInviteRecord>(ServerInvites.name)
.where((q) =>
filterByPrimaryResource(q, { resourceType: ServerInviteResourceType })
)
.where({
[ServerInvites.col.target]: email.toLowerCase()
})
.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
const ServerInvitesCols = ServerInvites.with({ withoutTablePrefix: true }).col
return db(ServerInvites.name)
.whereIn(ServerInvitesCols.target, oldTargets)
.update(ServerInvitesCols.target, newTarget.toLowerCase())
}
const findServerInvitesBaseQueryFactory =
({ db }: { db: Knex }) =>
(searchQuery: string | null, sort: 'asc' | 'desc' = 'asc'): Knex.QueryBuilder => {
const { query } = buildInvitesBaseQuery({ db })({ sort })
if (searchQuery) {
// TODO: Is this safe from SQL injection?
query.andWhere(ServerInvites.col.target, 'ILIKE', `%${searchQuery}%`)
}
// Not an invite for an already registered user
query.andWhere(ServerInvites.col.target, 'NOT ILIKE', '@%')
return query
}
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 (await q) as ServerInviteRecord[]
}
export const findInviteFactory =
({ db }: { db: Knex }): FindInvite =>
async <Target extends InviteResourceTarget = InviteResourceTarget>(params: {
inviteId?: string
token?: string
target?: string
resourceFilter?: ServerInviteResourceFilter<Target>
}) => {
if (!isObjectLike(params)) {
throw new LogicError('Invalid params - expected a params object')
}
if (!Object.values(params).filter(isNonNullable).length) return null
const { inviteId, target, token, resourceFilter } = params
const { query, filterByResource } = buildInvitesBaseQuery({ db })<
PreformattedExtendedInvite<Target>[]
>()
if (inviteId) {
query.where(ServerInvites.col.id, inviteId)
}
if (target) {
query.where(ServerInvites.col.target, target)
}
if (token) {
query.where(ServerInvites.col.token, token)
}
if (resourceFilter) {
query.where((w1) => filterByResource(w1, resourceFilter))
}
const res = (await query.first()) || null
return res ? formatIntoExtendedInvite(res) : null
}
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, resourceType, resourceId) => {
if (!targets) return false
targets = Array.isArray(targets) ? targets : [targets]
if (!targets.length) return false
await db(ServerInvites.name)
.where((q) => filterByPrimaryResource(q, { resourceType, resourceId }))
.whereIn(ServerInvites.col.target, targets)
.delete()
return true
}
export const queryInvitesFactory =
({ db }: { db: Knex }): QueryInvites =>
async (inviteIds) => {
if (!inviteIds?.length) return []
const { query: query } = buildInvitesBaseQuery({ db })()
query.whereIn(ServerInvites.col.id, inviteIds)
const ret = await query
return ret.map(formatIntoExtendedInvite)
}
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 ({ token }) => {
if (!token?.length) return null
const { query: query } = buildInvitesBaseQuery({ db })()
query.where(ServerInvites.col.token, token)
const ret = (await query.first()) || null
return ret ? formatIntoExtendedInvite(ret) : null
}
export const markInviteUpdatedFactory =
({ db }: { db: Knex }): MarkInviteUpdated =>
async ({ inviteId }) => {
const cols = ServerInvites.with({ withoutTablePrefix: true }).col
const ret = await db(ServerInvites.name)
.where(ServerInvites.col.id, inviteId)
.update(cols.updatedAt, new Date())
return !!ret
}