Merge branch 'main' of github.com:specklesystems/speckle-server into alessandro/web-1767-guest-table-should-show-what-they-have-access-to

This commit is contained in:
Alessandro Magionami
2024-09-13 12:53:06 +02:00
28 changed files with 781 additions and 486 deletions
@@ -0,0 +1,97 @@
<template>
<ClientOnly>
<div class="position left-2 bottom-2 fixed z-[45]">
<div
v-if="showBanner"
class="rounded-lg flex flex-col gap-y-2 max-w-64 p-4 border border-outline-2 shadow-md bg-foundation-3 dark:bg-foundation"
>
<FormButton
color="subtle"
size="sm"
class="absolute top-2 right-2 !w-5 !h-5 !p-0"
@click="dismissedCookie = true"
>
<XMarkIcon class="h-5 w-5 text-foreground" />
</FormButton>
<h5 class="text-body-xs md:text-heading-sm text-foreground font-medium">
Still not using workspaces?
</h5>
<p class="text-body-2xs leading-5 md:text-body-xs text-foreground-2">
Be the first to reach better project management with your team
</p>
<FormButton
class="mt-2"
color="primary"
size="sm"
@click="openWorkspaceCreateDialog"
>
Start for free
</FormButton>
<WorkspaceCreateDialog
v-model:open="showWorkspaceCreateDialog"
navigate-on-success
event-source="promo-banner"
@created="dismissedCookie = true"
/>
</div>
</div>
</ClientOnly>
</template>
<script setup lang="ts">
// This is a temporary component, to meassure if in app-notifications can be succesful
// It will be remove after a certain period, if we continue with in-app notification we should further develop this
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useSynchronizedCookie } from '~~/lib/common/composables/reactiveCookie'
import { CookieKeys } from '~/lib/common/helpers/constants'
import { useIsWorkspacesEnabled } from '~/composables/globals'
import { settingsSidebarQuery } from '~/lib/settings/graphql/queries'
import { useQuery } from '@vue/apollo-composable'
import { XMarkIcon } from '@heroicons/vue/24/outline'
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const mixpanel = useMixpanel()
const dismissedCookie = useSynchronizedCookie<boolean>(
CookieKeys.DismissedWorkspaceBanner,
{
default: () => false
}
)
const { result } = useQuery(settingsSidebarQuery, null, {
enabled: isWorkspacesEnabled.value
})
const showWorkspaceCreateDialog = ref(false)
const hasWorkspaces = computed(() =>
result.value?.activeUser?.workspaces.items
? result.value.activeUser.workspaces.items.length > 0
: false
)
const showBanner = computed(
() =>
isWorkspacesEnabled.value &&
!hasWorkspaces.value &&
(import.meta.client ? !dismissedCookie.value : false)
)
const openWorkspaceCreateDialog = () => {
showWorkspaceCreateDialog.value = true
mixpanel.track('Create Workspace Button Clicked', {
source: 'promo-banner'
})
}
watch(
showBanner,
(newVal) => {
if (newVal) {
mixpanel.track('Workspace Promo Banner Viewed', {
source: 'promo-banner'
})
}
},
{ immediate: true }
)
</script>
@@ -148,6 +148,7 @@ graphql(`
graphql(`
fragment SettingsDialog_User on User {
id
workspaces {
items {
...SettingsDialog_Workspace
@@ -46,6 +46,8 @@ import { useCreateWorkspace } from '~/lib/workspaces/composables/management'
import { useWorkspacesAvatar } from '~/lib/workspaces/composables/avatar'
import { isRequired, isStringOfLength } from '~~/lib/common/helpers/validation'
const emit = defineEmits<(e: 'created') => void>()
type FormValues = { name: string; description: string }
const props = defineProps<{
@@ -101,6 +103,7 @@ const handleCreateWorkspace = handleSubmit(async () => {
)
if (newWorkspace) {
emit('created')
isOpen.value = false
}
})
+1
View File
@@ -1,6 +1,7 @@
<template>
<div>
<HeaderNavBar />
<PromoBannersWorkspace />
<div class="h-dvh w-dvh overflow-hidden flex flex-col">
<!-- Static Spacer to allow for absolutely positioned HeaderNavBar -->
<div class="h-12 w-full shrink-0"></div>
@@ -94,7 +94,7 @@ const documents = {
"\n fragment ProjectsInviteBanner on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n": types.ProjectsInviteBannerFragmentDoc,
"\n fragment ProjectsInviteBanners on User {\n projectInvites {\n ...ProjectsInviteBanner\n }\n }\n": types.ProjectsInviteBannersFragmentDoc,
"\n fragment SettingsDialog_Workspace on Workspace {\n ...WorkspaceAvatar_Workspace\n id\n role\n name\n }\n": types.SettingsDialog_WorkspaceFragmentDoc,
"\n fragment SettingsDialog_User on User {\n workspaces {\n items {\n ...SettingsDialog_Workspace\n }\n }\n }\n": types.SettingsDialog_UserFragmentDoc,
"\n fragment SettingsDialog_User on User {\n id\n workspaces {\n items {\n ...SettingsDialog_Workspace\n }\n }\n }\n": types.SettingsDialog_UserFragmentDoc,
"\n fragment SettingsServerProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": types.SettingsServerProjects_ProjectCollectionFragmentDoc,
"\n fragment SettingsSharedProjects_Project on Project {\n id\n name\n visibility\n createdAt\n updatedAt\n models {\n totalCount\n }\n versions {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n }\n": types.SettingsSharedProjects_ProjectFragmentDoc,
"\n fragment SettingsUserEmails_User on User {\n id\n emails {\n ...SettingsUserEmailCards_UserEmail\n }\n }\n": types.SettingsUserEmails_UserFragmentDoc,
@@ -667,7 +667,7 @@ export function graphql(source: "\n fragment SettingsDialog_Workspace on Worksp
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsDialog_User on User {\n workspaces {\n items {\n ...SettingsDialog_Workspace\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsDialog_User on User {\n workspaces {\n items {\n ...SettingsDialog_Workspace\n }\n }\n }\n"];
export function graphql(source: "\n fragment SettingsDialog_User on User {\n id\n workspaces {\n items {\n ...SettingsDialog_Workspace\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsDialog_User on User {\n id\n workspaces {\n items {\n ...SettingsDialog_Workspace\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -5,7 +5,8 @@ export enum CookieKeys {
AuthToken = 'authn',
Theme = 'theme',
PostAuthRedirect = 'postAuthRedirect',
DismissedDiscoverableWorkspaces = 'dismissedDiscoverableWorkspaces'
DismissedDiscoverableWorkspaces = 'dismissedDiscoverableWorkspaces',
DismissedWorkspaceBanner = 'dismissedWorkspaceBanner'
}
/**
@@ -766,6 +766,12 @@ function setupResponseResourceData(
})
onViewerLoadedResourcesError((err) => {
// Show full page error only if serious error (core data couldn't be loaded)
const isWorkingLoad = !!viewerLoadedResourcesResult.value?.project.models.items
if (isWorkingLoad) {
return
}
globalError.value = createError({
statusCode: 500,
message: `Viewer loaded resource resolution failed: ${err}`
@@ -841,6 +847,13 @@ function setupResponseResourceData(
const commentThreads = computed(() => commentThreadsMetadata.value?.items || [])
onViewerLoadedThreadsError((err) => {
// Show full page error only if serious error (core data couldn't be loaded)
const isWorkingLoad =
!!viewerLoadedThreadsResult.value?.project.commentThreads.items
if (isWorkingLoad) {
return
}
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Comment loading failed',
@@ -0,0 +1,14 @@
import { NotificationPreferences } from '@/modules/notifications/helpers/types'
export type GetSavedUserNotificationPreferences = (
userId: string
) => Promise<NotificationPreferences>
export type SaveUserNotificationPreferences = (
userId: string,
preferences: NotificationPreferences
) => Promise<void>
export type GetUserNotificationPreferences = (
userId: string
) => Promise<NotificationPreferences>
@@ -1,10 +1,25 @@
import { db } from '@/db/knex'
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import {
updateNotificationPreferences,
getUserNotificationPreferences
getSavedUserNotificationPreferencesFactory,
saveUserNotificationPreferencesFactory
} from '@/modules/notifications/repositories'
import {
getUserNotificationPreferencesFactory,
updateNotificationPreferencesFactory
} from '@/modules/notifications/services/notificationPreferences'
module.exports = {
const getUserNotificationPreferences = getUserNotificationPreferencesFactory({
getSavedUserNotificationPreferences: getSavedUserNotificationPreferencesFactory({
db
})
})
const updateNotificationPreferences = updateNotificationPreferencesFactory({
saveUserNotificationPreferences: saveUserNotificationPreferencesFactory({ db })
})
export = {
User: {
async notificationPreferences(parent) {
const preferences = await getUserNotificationPreferences(parent.id)
@@ -12,12 +27,8 @@ module.exports = {
}
},
Mutation: {
async userNotificationPreferencesUpdate(
_parent,
args,
context: { userId: string }
) {
await updateNotificationPreferences(context.userId, args.preferences)
async userNotificationPreferencesUpdate(_parent, args, context) {
await updateNotificationPreferences(context.userId!, args.preferences)
return true
}
}
@@ -1,26 +1,36 @@
import { UserNotificationPreferences } from '@/modules/core/dbSchema'
import {
GetSavedUserNotificationPreferences,
SaveUserNotificationPreferences
} from '@/modules/notifications/domain/operations'
import {
NotificationPreferences,
UserNotificationPreferencesRecord
} from '@/modules/notifications/helpers/types'
import { Knex } from 'knex'
export async function getUserNotificationPreferences(
userId: string
): Promise<NotificationPreferences> {
const userPreferences =
await UserNotificationPreferences.knex<UserNotificationPreferencesRecord>()
const tables = {
userNotificationPreferences: (db: Knex) =>
db<UserNotificationPreferencesRecord>(UserNotificationPreferences.name)
}
export const getSavedUserNotificationPreferencesFactory =
(deps: { db: Knex }): GetSavedUserNotificationPreferences =>
async (userId: string): Promise<NotificationPreferences> => {
const userPreferences = await tables
.userNotificationPreferences(deps.db)
.where({ userId })
.first()
return userPreferences?.preferences ?? {}
}
return userPreferences?.preferences ?? {}
}
export async function saveUserNotificationPreferences(
userId: string,
preferences: NotificationPreferences
): Promise<void> {
await UserNotificationPreferences.knex()
.insert({ userId, preferences })
.onConflict('userId')
.merge()
}
export const saveUserNotificationPreferencesFactory =
(deps: { db: Knex }): SaveUserNotificationPreferences =>
async (userId: string, preferences: NotificationPreferences): Promise<void> => {
await tables
.userNotificationPreferences(deps.db)
.insert({ userId, preferences })
.onConflict('userId')
.merge()
}
@@ -10,7 +10,6 @@ import {
} from '@/modules/activitystream/helpers/types'
import { getServerInfo } from '@/modules/core/services/generic'
import { ServerInfo, UserRecord } from '@/modules/core/helpers/types'
import { getUserNotificationPreferences } from '@/modules/notifications/services/notificationPreferences'
import { sendEmail, SendEmailParams } from '@/modules/emails/services/sending'
import { groupBy } from 'lodash'
import { packageRoot } from '@/bootstrap'
@@ -26,37 +25,44 @@ import {
EmailInput,
renderEmail
} from '@/modules/emails/services/emailRendering'
import { getUserNotificationPreferencesFactory } from '@/modules/notifications/services/notificationPreferences'
import { getSavedUserNotificationPreferencesFactory } from '@/modules/notifications/repositories'
import { db } from '@/db/knex'
import { GetUserNotificationPreferences } from '@/modules/notifications/domain/operations'
const handler: NotificationHandler<ActivityDigestMessage> = async (msg) => {
const {
targetUserId,
data: { streamIds, start, end }
} = msg
await digestNotificationEmailHandler(targetUserId, streamIds, start, end, sendEmail)
}
export default handler
const digestNotificationEmailHandler = async (
userId: string,
streamIds: string[],
start: Date,
end: Date,
emailSender: (params: SendEmailParams) => Promise<boolean>
): Promise<boolean | null> => {
const wantDigests =
(await (await getUserNotificationPreferences(userId)).activityDigest?.email) !==
false
const activitySummary = await createActivitySummary(userId, streamIds, start, end)
// if there are no activities stop early
if (!wantDigests || !activitySummary || !activitySummary.streamActivities.length)
return null
const serverInfo = await getServerInfo()
const digest = digestSummaryData(activitySummary, serverInfo)
if (!digest) return null
const emailInput = await prepareSummaryEmail(digest, serverInfo)
return await emailSender(emailInput)
}
const digestNotificationEmailHandlerFactory =
(
deps: {
getUserNotificationPreferences: GetUserNotificationPreferences
createActivitySummary: typeof createActivitySummary
getServerInfo: typeof getServerInfo
} & PrepareSummaryEmailDeps
) =>
async (
userId: string,
streamIds: string[],
start: Date,
end: Date,
emailSender: (params: SendEmailParams) => Promise<boolean>
): Promise<boolean | null> => {
const wantDigests =
(await deps.getUserNotificationPreferences(userId)).activityDigest?.email !==
false
const activitySummary = await deps.createActivitySummary(
userId,
streamIds,
start,
end
)
// if there are no activities stop early
if (!wantDigests || !activitySummary || !activitySummary.streamActivities.length)
return null
const serverInfo = await deps.getServerInfo()
const digest = digestSummaryData(activitySummary, serverInfo)
if (!digest) return null
const emailInput = await prepareSummaryEmailFactory(deps)(digest, serverInfo)
return await emailSender(emailInput)
}
/**
* Organize the activity summary into topics.
@@ -361,24 +367,27 @@ const flattenActivities = (
return allActivity
}
export const prepareSummaryEmail = async (
digest: Digest,
serverInfo: ServerInfo
): Promise<EmailInput> => {
const body = await renderEmailBody(digest, serverInfo)
const cta = {
title: 'Check activities',
url: serverInfo.canonicalUrl
}
const subject = 'Speckle weekly digest'
const { text, html } = await renderEmail(
{ mjml: { bodyStart: body.mjml }, text: { bodyStart: body.text }, cta },
serverInfo,
digest.user
)
return { to: digest.user.email, subject, text, html }
type PrepareSummaryEmailDeps = {
renderEmail: typeof renderEmail
}
export const prepareSummaryEmailFactory =
(deps: PrepareSummaryEmailDeps) =>
async (digest: Digest, serverInfo: ServerInfo): Promise<EmailInput> => {
const body = await renderEmailBody(digest, serverInfo)
const cta = {
title: 'Check activities',
url: serverInfo.canonicalUrl
}
const subject = 'Speckle weekly digest'
const { text, html } = await deps.renderEmail(
{ mjml: { bodyStart: body.mjml }, text: { bodyStart: body.text }, cta },
serverInfo,
digest.user
)
return { to: digest.user.email, subject, text, html }
}
export const renderEmailBody = async (
digest: Digest,
serverInfo: ServerInfo
@@ -416,3 +425,25 @@ Here's a summary of what happened in the past week
mjml += mjmlTopics.join('\n')
return { text, mjml }
}
const digestNotificationEmailHandler = digestNotificationEmailHandlerFactory({
getUserNotificationPreferences: getUserNotificationPreferencesFactory({
getSavedUserNotificationPreferences: getSavedUserNotificationPreferencesFactory({
db
})
}),
createActivitySummary,
getServerInfo,
renderEmail
})
const handler: NotificationHandler<ActivityDigestMessage> = async (msg) => {
const {
targetUserId,
data: { streamIds, start, end }
} = msg
await digestNotificationEmailHandler(targetUserId, streamIds, start, end, sendEmail)
}
export default handler
@@ -157,45 +157,73 @@ function buildEmailTemplateParams(
/**
* Notification that is triggered when a user is mentioned in a comment
*/
const handler: NotificationHandler<MentionedInCommentMessage> = async (msg) => {
const {
targetUserId,
data: { threadId, authorId, streamId, commentId }
} = msg
const mentionedInCommentHandlerFactory =
(deps: {
getUser: typeof getUser
getStream: typeof getStream
getComment: typeof getComment
getServerInfo: typeof getServerInfo
renderEmail: typeof renderEmail
sendEmail: typeof sendEmail
}): NotificationHandler<MentionedInCommentMessage> =>
async (msg) => {
const {
targetUserId,
data: { threadId, authorId, streamId, commentId }
} = msg
const isCommentAndThreadTheSame = threadId === commentId
const isCommentAndThreadTheSame = threadId === commentId
const [targetUser, author, stream, threadComment, comment, serverInfo] =
await Promise.all([
getUser(targetUserId),
getUser(authorId),
getStream({ streamId }),
getComment({ id: threadId }),
isCommentAndThreadTheSame ? null : getComment({ id: commentId }),
getServerInfo()
])
const [targetUser, author, stream, threadComment, comment, serverInfo] =
await Promise.all([
deps.getUser(targetUserId),
deps.getUser(authorId),
deps.getStream({ streamId }),
deps.getComment({ id: threadId }),
isCommentAndThreadTheSame ? null : deps.getComment({ id: commentId }),
deps.getServerInfo()
])
const mentionComment = isCommentAndThreadTheSame ? threadComment : comment
const mentionComment = isCommentAndThreadTheSame ? threadComment : comment
// Validate message
const state = validate({
targetUser,
author,
stream,
threadComment,
mentionComment,
msg,
serverInfo
})
const templateParams = buildEmailTemplateParams(state)
const { text, html } = await renderEmail(templateParams, serverInfo, targetUser)
await sendEmail({
to: state.targetUser.email,
text,
html,
subject: "You've just been mentioned in a Speckle comment"
// Validate message
const state = validate({
targetUser,
author,
stream,
threadComment,
mentionComment,
msg,
serverInfo
})
const templateParams = buildEmailTemplateParams(state)
const { text, html } = await deps.renderEmail(
templateParams,
serverInfo,
targetUser
)
await deps.sendEmail({
to: state.targetUser.email,
text,
html,
subject: "You've just been mentioned in a Speckle comment"
})
}
/**
* Notification that is triggered when a user is mentioned in a comment
*/
const handler: NotificationHandler<MentionedInCommentMessage> = async (...args) => {
const mentionedInCommentHandler = mentionedInCommentHandlerFactory({
getUser,
getStream,
getComment,
getServerInfo,
renderEmail,
sendEmail
})
return mentionedInCommentHandler(...args)
}
export default handler
@@ -21,47 +21,59 @@ import {
} from '@/modules/emails/services/emailRendering'
import { getServerInfo } from '@/modules/core/services/generic'
import { db } from '@/db/knex'
import { GetPendingAccessRequest } from '@/modules/accessrequests/domain/operations'
async function validateMessage(msg: NewStreamAccessRequestMessage) {
const {
targetUserId,
data: { requestId }
} = msg
const [request, user] = await Promise.all([
getPendingAccessRequestFactory({ db })(requestId, AccessRequestType.Stream),
getUser(targetUserId)
])
if (!request)
throw new NotificationValidationError('Nonexistant stream access request')
if (!user) throw new NotificationValidationError('Nonexistant user')
const [streamWithRole, requester] = await Promise.all([
getStream({
streamId: request.resourceId,
userId: targetUserId
}),
getUser(request.requesterId)
])
if (!streamWithRole) throw new NotificationValidationError('Nonexistant stream')
if (streamWithRole.role !== Roles.Stream.Owner)
throw new NotificationValidationError(
'Only stream owners can receive notifications about stream access requests'
)
if (!requester)
throw new NotificationValidationError('User who made the request no longer exists')
return {
request,
stream: streamWithRole,
targetUser: user,
requester
}
type ValidateMessageDeps = {
getPendingAccessRequest: GetPendingAccessRequest
getUser: typeof getUser
getStream: typeof getStream
}
type ValidatedMessageState = Awaited<ReturnType<typeof validateMessage>>
const validateMessageFactory =
(deps: ValidateMessageDeps) => async (msg: NewStreamAccessRequestMessage) => {
const {
targetUserId,
data: { requestId }
} = msg
const [request, user] = await Promise.all([
deps.getPendingAccessRequest(requestId, AccessRequestType.Stream),
deps.getUser(targetUserId)
])
if (!request)
throw new NotificationValidationError('Nonexistant stream access request')
if (!user) throw new NotificationValidationError('Nonexistant user')
const [streamWithRole, requester] = await Promise.all([
deps.getStream({
streamId: request.resourceId,
userId: targetUserId
}),
deps.getUser(request.requesterId)
])
if (!streamWithRole) throw new NotificationValidationError('Nonexistant stream')
if (streamWithRole.role !== Roles.Stream.Owner)
throw new NotificationValidationError(
'Only stream owners can receive notifications about stream access requests'
)
if (!requester)
throw new NotificationValidationError(
'User who made the request no longer exists'
)
return {
request,
stream: streamWithRole,
targetUser: user,
requester
}
}
type ValidatedMessageState = Awaited<
ReturnType<ReturnType<typeof validateMessageFactory>>
>
function buildEmailTemplateHtml(
state: ValidatedMessageState
@@ -106,22 +118,42 @@ function buildEmailTemplateParams(state: ValidatedMessageState): EmailTemplatePa
}
}
const handler: NotificationHandler<NewStreamAccessRequestMessage> = async (msg) => {
const state = await validateMessage(msg)
const htmlTemplateParams = buildEmailTemplateParams(state)
const serverInfo = await getServerInfo()
const { html, text } = await renderEmail(
htmlTemplateParams,
serverInfo,
state.targetUser
)
const newStreamAccessRequestHandlerFactory =
(
deps: {
getServerInfo: typeof getServerInfo
renderEmail: typeof renderEmail
sendEmail: typeof sendEmail
} & ValidateMessageDeps
): NotificationHandler<NewStreamAccessRequestMessage> =>
async (msg) => {
const state = await validateMessageFactory(deps)(msg)
const htmlTemplateParams = buildEmailTemplateParams(state)
const serverInfo = await deps.getServerInfo()
const { html, text } = await deps.renderEmail(
htmlTemplateParams,
serverInfo,
state.targetUser
)
await sendEmail({
to: state.targetUser.email,
text,
html,
subject: 'A user requested access to your project'
await deps.sendEmail({
to: state.targetUser.email,
text,
html,
subject: 'A user requested access to your project'
})
}
const handler: NotificationHandler<NewStreamAccessRequestMessage> = (...args) => {
const newStreamAccessRequestHandler = newStreamAccessRequestHandlerFactory({
getServerInfo,
renderEmail,
sendEmail,
getUser,
getStream,
getPendingAccessRequest: getPendingAccessRequestFactory({ db })
})
return newStreamAccessRequestHandler(...args)
}
export default handler
@@ -16,35 +16,43 @@ import {
StreamAccessRequestApprovedMessage
} from '@/modules/notifications/helpers/types'
async function validateMessage(msg: StreamAccessRequestApprovedMessage) {
const {
targetUserId,
data: {
request: { resourceId },
finalizedBy
}
} = msg
const [targetUser, finalizer, stream] = await Promise.all([
getUser(targetUserId),
getUser(finalizedBy),
getStream({ streamId: resourceId, userId: targetUserId })
])
if (!targetUser)
throw new NotificationValidationError('Invalid notification target user')
if (!finalizer)
throw new NotificationValidationError('Invalid notification finalizer')
if (!stream) throw new NotificationValidationError('Invalid stream')
if (!stream.role)
throw new NotificationValidationError(
'User doesnt appear to have a role on the stream'
)
return { targetUser, finalizer, stream }
type ValidateMessageDeps = {
getUser: typeof getUser
getStream: typeof getStream
}
type ValidatedMessageState = Awaited<ReturnType<typeof validateMessage>>
const validateMessageFactory =
(deps: ValidateMessageDeps) => async (msg: StreamAccessRequestApprovedMessage) => {
const {
targetUserId,
data: {
request: { resourceId },
finalizedBy
}
} = msg
const [targetUser, finalizer, stream] = await Promise.all([
deps.getUser(targetUserId),
deps.getUser(finalizedBy),
deps.getStream({ streamId: resourceId, userId: targetUserId })
])
if (!targetUser)
throw new NotificationValidationError('Invalid notification target user')
if (!finalizer)
throw new NotificationValidationError('Invalid notification finalizer')
if (!stream) throw new NotificationValidationError('Invalid stream')
if (!stream.role)
throw new NotificationValidationError(
'User doesnt appear to have a role on the stream'
)
return { targetUser, finalizer, stream }
}
type ValidatedMessageState = Awaited<
ReturnType<ReturnType<typeof validateMessageFactory>>
>
function buildEmailTemplateMjml(
state: ValidatedMessageState
@@ -87,24 +95,43 @@ function buildEmailTemplateParams(state: ValidatedMessageState): EmailTemplatePa
}
}
const handler: NotificationHandler<StreamAccessRequestApprovedMessage> = async (
msg
) => {
const state = await validateMessage(msg)
const htmlTemplateParams = buildEmailTemplateParams(state)
const serverInfo = await getServerInfo()
const { html, text } = await renderEmail(
htmlTemplateParams,
serverInfo,
state.targetUser
)
const streamAccessRequestApprovedHandlerFactory =
(
deps: {
getServerInfo: typeof getServerInfo
renderEmail: typeof renderEmail
sendEmail: typeof sendEmail
} & ValidateMessageDeps
): NotificationHandler<StreamAccessRequestApprovedMessage> =>
async (msg) => {
const state = await validateMessageFactory(deps)(msg)
const htmlTemplateParams = buildEmailTemplateParams(state)
const serverInfo = await deps.getServerInfo()
const { html, text } = await deps.renderEmail(
htmlTemplateParams,
serverInfo,
state.targetUser
)
await sendEmail({
to: state.targetUser.email,
text,
html,
subject: 'Your project access request has been approved'
await deps.sendEmail({
to: state.targetUser.email,
text,
html,
subject: 'Your project access request has been approved'
})
}
const handler: NotificationHandler<StreamAccessRequestApprovedMessage> = async (
...args
) => {
const streamAccessRequestApprovedHandler = streamAccessRequestApprovedHandlerFactory({
getServerInfo,
renderEmail,
sendEmail,
getUser,
getStream
})
return streamAccessRequestApprovedHandler(...args)
}
export default handler
@@ -1,19 +1,25 @@
import * as repo from '@/modules/notifications/repositories'
import {
NotificationChannel,
NotificationType,
NotificationPreferences
} from '@/modules/notifications/helpers/types'
import { InvalidArgumentError } from '@/modules/shared/errors'
import {
GetSavedUserNotificationPreferences,
GetUserNotificationPreferences,
SaveUserNotificationPreferences
} from '@/modules/notifications/domain/operations'
export async function getUserNotificationPreferences(
userId: string
): Promise<NotificationPreferences> {
const savedPreferences = await repo.getUserNotificationPreferences(userId)
return addDefaultPreferenceValues(savedPreferences)
}
export const getUserNotificationPreferencesFactory =
(deps: {
getSavedUserNotificationPreferences: GetSavedUserNotificationPreferences
}): GetUserNotificationPreferences =>
async (userId: string): Promise<NotificationPreferences> => {
const savedPreferences = await deps.getSavedUserNotificationPreferences(userId)
return addDefaultPreferenceValues(savedPreferences)
}
export function addDefaultPreferenceValues(
function addDefaultPreferenceValues(
preferences: NotificationPreferences
): NotificationPreferences {
const savedPreferences = { ...preferences }
@@ -29,35 +35,34 @@ export function addDefaultPreferenceValues(
return savedPreferences
}
export async function updateNotificationPreferences(
userId: string,
rawPreferences: Record<string, unknown>
): Promise<void> {
const parsedPreferences: NotificationPreferences = {}
// lets do some nested attribute copying, to sanitize the input
for (const key in rawPreferences) {
if (!Object.values(NotificationType).includes(key as NotificationType))
throw new InvalidArgumentError(
`Notification preferences input contains an unknown setting: ${key}`
)
const nt = key as NotificationType
const notificationTypePreferences: Partial<Record<NotificationChannel, boolean>> =
{}
const notificationTypeSettings = rawPreferences[nt] as Record<string, unknown>
for (const ncKey in notificationTypeSettings) {
if (!Object.values(NotificationChannel).includes(ncKey as NotificationChannel))
export const updateNotificationPreferencesFactory =
(deps: { saveUserNotificationPreferences: SaveUserNotificationPreferences }) =>
async (userId: string, rawPreferences: Record<string, unknown>): Promise<void> => {
const parsedPreferences: NotificationPreferences = {}
// lets do some nested attribute copying, to sanitize the input
for (const key in rawPreferences) {
if (!Object.values(NotificationType).includes(key as NotificationType))
throw new InvalidArgumentError(
`Notification preferences input contains an unknown setting: ${ncKey}`
`Notification preferences input contains an unknown setting: ${key}`
)
const nc = ncKey as NotificationChannel
const preferenceValue = notificationTypeSettings[nc]
if (typeof preferenceValue !== 'boolean')
throw new InvalidArgumentError(
`Notification preferences input contains and invalid value: ${preferenceValue}`
)
notificationTypePreferences[nc] = preferenceValue
const nt = key as NotificationType
const notificationTypePreferences: Partial<Record<NotificationChannel, boolean>> =
{}
const notificationTypeSettings = rawPreferences[nt] as Record<string, unknown>
for (const ncKey in notificationTypeSettings) {
if (!Object.values(NotificationChannel).includes(ncKey as NotificationChannel))
throw new InvalidArgumentError(
`Notification preferences input contains an unknown setting: ${ncKey}`
)
const nc = ncKey as NotificationChannel
const preferenceValue = notificationTypeSettings[nc]
if (typeof preferenceValue !== 'boolean')
throw new InvalidArgumentError(
`Notification preferences input contains and invalid value: ${preferenceValue}`
)
notificationTypePreferences[nc] = preferenceValue
}
parsedPreferences[nt] = notificationTypePreferences
}
parsedPreferences[nt] = notificationTypePreferences
return await deps.saveUserNotificationPreferences(userId, parsedPreferences)
}
return await repo.saveUserNotificationPreferences(userId, parsedPreferences)
}
@@ -9,6 +9,7 @@ import {
StreamActivitySummary
} from '@/modules/activitystream/services/summary'
import { ServerInfo, UserRecord } from '@/modules/core/helpers/types'
import { renderEmail } from '@/modules/emails/services/emailRendering'
import {
digestMostActiveStream,
mostActiveComment,
@@ -19,11 +20,15 @@ import {
digestActiveStreams,
closingOverview,
Digest,
prepareSummaryEmail
prepareSummaryEmailFactory
} from '@/modules/notifications/services/handlers/activityDigest'
import { expect } from 'chai'
import { range } from 'lodash'
const prepareSummaryEmail = prepareSummaryEmailFactory({
renderEmail
})
describe('Activity digest notifications @notifications', () => {
const user: UserRecord = {
id: 'foobar',
@@ -1,14 +1,31 @@
import { truncateTables } from '@/test/hooks'
import { UserNotificationPreferences, Users } from '@/modules/core/dbSchema'
import { BasicTestUser, createTestUsers } from '@/test/authHelper'
import * as repo from '@/modules/notifications/repositories'
import * as services from '@/modules/notifications/services/notificationPreferences'
import { expect } from 'chai'
import {
NotificationType,
NotificationChannel
} from '@/modules/notifications/helpers/types'
import { BaseError } from '@/modules/shared/errors'
import {
getUserNotificationPreferencesFactory,
updateNotificationPreferencesFactory
} from '@/modules/notifications/services/notificationPreferences'
import {
getSavedUserNotificationPreferencesFactory,
saveUserNotificationPreferencesFactory
} from '@/modules/notifications/repositories'
import { db } from '@/db/knex'
const getSavedUserNotificationPreferences = getSavedUserNotificationPreferencesFactory({
db
})
const getUserNotificationPreferences = getUserNotificationPreferencesFactory({
getSavedUserNotificationPreferences
})
const updateNotificationPreferences = updateNotificationPreferencesFactory({
saveUserNotificationPreferences: saveUserNotificationPreferencesFactory({ db })
})
const cleanup = async () => {
await truncateTables([Users.name, UserNotificationPreferences.name])
@@ -28,10 +45,10 @@ describe('User notification preferences @notifications', () => {
describe('services', () => {
it('gets default preferences if none saved', async () => {
const savedPreferences = await repo.getUserNotificationPreferences(userA.id)
const savedPreferences = await getSavedUserNotificationPreferences(userA.id)
expect(savedPreferences).to.deep.equal({})
expect(savedPreferences).to.be.empty
const preferences = await services.getUserNotificationPreferences(userA.id)
const preferences = await getUserNotificationPreferences(userA.id)
expect(preferences).to.not.be.empty
for (const val of Object.values(preferences)) {
for (const setting of Object.values(val)) {
@@ -40,16 +57,16 @@ describe('User notification preferences @notifications', () => {
}
})
it('store notification settings', async () => {
await services.updateNotificationPreferences(userA.id, {
await updateNotificationPreferences(userA.id, {
activityDigest: { email: false }
})
let preferences = await services.getUserNotificationPreferences(userA.id)
let preferences = await getUserNotificationPreferences(userA.id)
expect(preferences).to.not.be.empty
expect(preferences.activityDigest?.email).to.be.false
await services.updateNotificationPreferences(userA.id, {
await updateNotificationPreferences(userA.id, {
activityDigest: { email: true }
})
preferences = await services.getUserNotificationPreferences(userA.id)
preferences = await getUserNotificationPreferences(userA.id)
expect(preferences.activityDigest?.email).to.be.true
})
it("doesn't store invalid preference keys", async () => {
@@ -72,7 +89,7 @@ describe('User notification preferences @notifications', () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
preferences[nt][nc] = value
await services.updateNotificationPreferences(userA.id, preferences)
await updateNotificationPreferences(userA.id, preferences)
} catch (err) {
expect(err instanceof BaseError)
const error = err as BaseError
@@ -3,7 +3,11 @@ import {
ProjectEvents,
ProjectEventsPayloads
} from '@/modules/core/events/projectsEmitter'
import { getStream } from '@/modules/core/repositories/streams'
import {
deleteProjectRoleFactory,
getStream,
upsertProjectRoleFactory
} from '@/modules/core/repositories/streams'
import {
GetWorkspaceRoles,
GetWorkspaceRoleToDefaultProjectRoleMapping,
@@ -17,13 +21,27 @@ import {
isProjectResourceTarget,
resolveTarget
} from '@/modules/serverinvites/helpers/core'
import { logger } from '@/logging/logging'
import { logger, moduleLogger } from '@/logging/logging'
import { updateWorkspaceRoleFactory } from '@/modules/workspaces/services/management'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { WorkspaceInviteResourceType } from '@/modules/workspaces/domain/constants'
import { Roles, WorkspaceRoles } from '@speckle/shared'
import { UpsertProjectRole } from '@/modules/core/domain/projects/operations'
import {
DeleteProjectRole,
UpsertProjectRole
} from '@/modules/core/domain/projects/operations'
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
import { Knex } from 'knex'
import { mapWorkspaceRoleToInitialProjectRole } from '@/modules/workspaces/domain/logic'
import {
getWorkspaceRolesFactory,
getWorkspaceWithDomainsFactory,
upsertWorkspaceRoleFactory
} from '@/modules/workspaces/repositories/workspaces'
import { queryAllWorkspaceProjectsFactory } from '@/modules/workspaces/services/projects'
import { getStreams } from '@/modules/core/services/streams'
import { withTransaction } from '@/modules/shared/helpers/dbHelper'
import { findVerifiedEmailsByUserIdFactory } from '@/modules/core/repositories/userEmails'
export const onProjectCreatedFactory =
({
@@ -89,7 +107,7 @@ export const onInviteFinalizedFactory =
})
if (!project || !project.role) {
deps.logger.warn(
`When handling accepted invite - project not found or useris not a collaborator`,
`When handling accepted invite - project not found or user is not a collaborator`,
{ invite, project: { id: project?.id, role: project?.role } }
)
return
@@ -109,39 +127,77 @@ export const onInviteFinalizedFactory =
})
}
export const onWorkspaceJoinedFactory =
export const onWorkspaceRoleDeletedFactory =
({
queryAllWorkspaceProjects,
deleteProjectRole
}: {
queryAllWorkspaceProjects: QueryAllWorkspaceProjects
deleteProjectRole: DeleteProjectRole
}) =>
async ({ userId, workspaceId }: { userId: string; workspaceId: string }) => {
// Delete roles for all workspace projects
for await (const projectsPage of queryAllWorkspaceProjects({
workspaceId
})) {
await Promise.all(
projectsPage.map(({ id: projectId }) =>
deleteProjectRole({ projectId, userId })
)
)
}
}
export const onWorkspaceRoleUpdatedFactory =
({
getDefaultWorkspaceProjectRoleMapping,
queryAllWorkspaceProjects,
deleteProjectRole,
upsertProjectRole
}: {
getDefaultWorkspaceProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
queryAllWorkspaceProjects: QueryAllWorkspaceProjects
deleteProjectRole: DeleteProjectRole
upsertProjectRole: UpsertProjectRole
}) =>
async ({
userId,
role,
workspaceId
workspaceId,
flags
}: {
userId: string
role: WorkspaceRoles
workspaceId: string
flags?: {
skipProjectRoleUpdatesFor: string[]
}
}) => {
const defaultRoleMapping = await getDefaultWorkspaceProjectRoleMapping({
const defaultProjectRoleMapping = await getDefaultWorkspaceProjectRoleMapping({
workspaceId
})
const maybeProjectRole = defaultRoleMapping[role]
if (!maybeProjectRole) return
const nextProjectRole = defaultProjectRoleMapping[role]
for await (const projects of queryAllWorkspaceProjects({ workspaceId })) {
for await (const projectsPage of queryAllWorkspaceProjects({ workspaceId })) {
await Promise.all(
projects.map(async (project) => {
projectsPage.map(async ({ id: projectId }) => {
if (flags?.skipProjectRoleUpdatesFor.includes(projectId)) {
// Skip assignment (used during invite flow)
// TODO: Can we refactor this special case away?
return
}
if (!nextProjectRole) {
// User is being demoted to a workspace role without project access
await deleteProjectRole({ projectId, userId })
return
}
await upsertProjectRole({
projectId: project.id,
projectId,
userId,
role: maybeProjectRole
role: nextProjectRole
})
})
)
@@ -149,25 +205,50 @@ export const onWorkspaceJoinedFactory =
}
export const initializeEventListenersFactory =
({
onProjectCreated,
onInviteFinalized,
onWorkspaceJoined
}: {
onProjectCreated: ReturnType<typeof onProjectCreatedFactory>
onInviteFinalized: ReturnType<typeof onInviteFinalizedFactory>
onWorkspaceJoined: ReturnType<typeof onWorkspaceJoinedFactory>
}) =>
({ db }: { db: Knex }) =>
() => {
const eventBus = getEventBus()
const quitCbs = [
ProjectsEmitter.listen(ProjectEvents.Created, onProjectCreated),
eventBus.listen(ServerInvitesEvents.Finalized, ({ payload }) =>
onInviteFinalized(payload)
),
eventBus.listen(WorkspaceEvents.JoinedFromDiscovery, ({ payload }) =>
onWorkspaceJoined(payload)
)
ProjectsEmitter.listen(ProjectEvents.Created, async (payload) => {
const onProjectCreated = onProjectCreatedFactory({
getDefaultWorkspaceProjectRoleMapping: mapWorkspaceRoleToInitialProjectRole,
upsertProjectRole: upsertProjectRoleFactory({ db }),
getWorkspaceRoles: getWorkspaceRolesFactory({ db })
})
await onProjectCreated(payload)
}),
eventBus.listen(ServerInvitesEvents.Finalized, async ({ payload }) => {
const onInviteFinalized = onInviteFinalizedFactory({
getStream,
logger: moduleLogger,
updateWorkspaceRole: updateWorkspaceRoleFactory({
getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }),
findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }),
getWorkspaceRoles: getWorkspaceRolesFactory({ db }),
upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }),
emitWorkspaceEvent: (...args) => getEventBus().emit(...args)
})
})
await onInviteFinalized(payload)
}),
eventBus.listen(WorkspaceEvents.RoleDeleted, async ({ payload }) => {
const trx = await db.transaction()
const onWorkspaceRoleDeleted = onWorkspaceRoleDeletedFactory({
queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ getStreams }),
deleteProjectRole: deleteProjectRoleFactory({ db: trx })
})
await withTransaction(onWorkspaceRoleDeleted(payload), trx)
}),
eventBus.listen(WorkspaceEvents.RoleUpdated, async ({ payload }) => {
const trx = await db.transaction()
const onWorkspaceRoleUpdated = onWorkspaceRoleUpdatedFactory({
getDefaultWorkspaceProjectRoleMapping: mapWorkspaceRoleToInitialProjectRole,
queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ getStreams }),
deleteProjectRole: deleteProjectRoleFactory({ db: trx }),
upsertProjectRole: upsertProjectRoleFactory({ db: trx })
})
await withTransaction(onWorkspaceRoleUpdated(payload), trx)
})
]
return () => quitCbs.forEach((quit) => quit())
@@ -5,8 +5,6 @@ import {
getStream,
getUserStreams,
getUserStreamsCount,
upsertProjectRoleFactory,
deleteProjectRoleFactory,
getRolesByUserIdFactory
} from '@/modules/core/repositories/streams'
import { getUser, getUsers } from '@/modules/core/repositories/users'
@@ -123,7 +121,6 @@ import {
isUserWorkspaceDomainPolicyCompliantFactory
} from '@/modules/workspaces/services/domains'
import { getServerInfo } from '@/modules/core/services/generic'
import { mapWorkspaceRoleToInitialProjectRole } from '@/modules/workspaces/domain/logic'
import { updateStreamRoleAndNotify } from '@/modules/core/services/streams/management'
import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories'
import { renderEmail } from '@/modules/emails/services/emailRendering'
@@ -355,10 +352,6 @@ export = FF_WORKSPACES_MODULE_ENABLED
const deleteWorkspaceRole = deleteWorkspaceRoleFactory({
deleteWorkspaceRole: repoDeleteWorkspaceRoleFactory({ db: trx }),
getWorkspaceRoles: getWorkspaceRolesFactory({ db: trx }),
deleteProjectRole: deleteProjectRoleFactory({ db: trx }),
queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({
getStreams
}),
emitWorkspaceEvent: getEventBus().emit
})
@@ -377,13 +370,6 @@ export = FF_WORKSPACES_MODULE_ENABLED
db: trx
}),
getWorkspaceRoles: getWorkspaceRolesFactory({ db: trx }),
getDefaultWorkspaceProjectRoleMapping:
mapWorkspaceRoleToInitialProjectRole,
upsertProjectRole: upsertProjectRoleFactory({ db: trx }),
deleteProjectRole: deleteProjectRoleFactory({ db: trx }),
queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({
getStreams
}),
emitWorkspaceEvent: getEventBus().emit
})
@@ -466,8 +452,6 @@ export = FF_WORKSPACES_MODULE_ENABLED
const deleteWorkspaceRole = deleteWorkspaceRoleFactory({
deleteWorkspaceRole: repoDeleteWorkspaceRoleFactory({ db: trx }),
getWorkspaceRoles: getWorkspaceRolesFactory({ db: trx }),
deleteProjectRole: deleteProjectRoleFactory({ db: trx }),
queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ getStreams }),
emitWorkspaceEvent: getEventBus().emit
})
@@ -581,13 +565,6 @@ export = FF_WORKSPACES_MODULE_ENABLED
findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }),
getWorkspaceRoles: getWorkspaceRolesFactory({ db }),
upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }),
upsertProjectRole: upsertProjectRoleFactory({ db }),
getDefaultWorkspaceProjectRoleMapping:
mapWorkspaceRoleToInitialProjectRole,
deleteProjectRole: deleteProjectRoleFactory({ db }),
queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({
getStreams
}),
emitWorkspaceEvent: getEventBus().emit
})
}),
+2 -49
View File
@@ -6,29 +6,8 @@ import { Optional, SpeckleModule } from '@/modules/shared/helpers/typeHelper'
import { workspaceRoles } from '@/modules/workspaces/roles'
import { workspaceScopes } from '@/modules/workspaces/scopes'
import { registerOrUpdateRole } from '@/modules/shared/repositories/roles'
import {
initializeEventListenersFactory,
onInviteFinalizedFactory,
onProjectCreatedFactory,
onWorkspaceJoinedFactory
} from '@/modules/workspaces/events/eventListener'
import {
getWorkspaceRolesFactory,
getWorkspaceWithDomainsFactory,
upsertWorkspaceRoleFactory
} from '@/modules/workspaces/repositories/workspaces'
import {
deleteProjectRoleFactory,
getStream,
upsertProjectRoleFactory
} from '@/modules/core/repositories/streams'
import { updateWorkspaceRoleFactory } from '@/modules/workspaces/services/management'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { getStreams } from '@/modules/core/services/streams'
import { findVerifiedEmailsByUserIdFactory } from '@/modules/core/repositories/userEmails'
import { initializeEventListenersFactory } from '@/modules/workspaces/events/eventListener'
import { validateModuleLicense } from '@/modules/gatekeeper/services/validateLicense'
import { queryAllWorkspaceProjectsFactory } from '@/modules/workspaces/services/projects'
import { mapWorkspaceRoleToInitialProjectRole } from '@/modules/workspaces/domain/logic'
const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
@@ -58,33 +37,7 @@ const workspacesModule: SpeckleModule = {
moduleLogger.info('⚒️ Init workspaces module')
if (isInitial) {
quitListeners = initializeEventListenersFactory({
onProjectCreated: onProjectCreatedFactory({
getDefaultWorkspaceProjectRoleMapping: mapWorkspaceRoleToInitialProjectRole,
upsertProjectRole: upsertProjectRoleFactory({ db }),
getWorkspaceRoles: getWorkspaceRolesFactory({ db })
}),
onWorkspaceJoined: onWorkspaceJoinedFactory({
getDefaultWorkspaceProjectRoleMapping: mapWorkspaceRoleToInitialProjectRole,
queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ getStreams }),
upsertProjectRole: upsertProjectRoleFactory({ db })
}),
onInviteFinalized: onInviteFinalizedFactory({
getStream,
logger: moduleLogger,
updateWorkspaceRole: updateWorkspaceRoleFactory({
getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }),
findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }),
getWorkspaceRoles: getWorkspaceRolesFactory({ db }),
upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }),
getDefaultWorkspaceProjectRoleMapping: mapWorkspaceRoleToInitialProjectRole,
upsertProjectRole: upsertProjectRoleFactory({ db }),
deleteProjectRole: deleteProjectRoleFactory({ db }),
queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ getStreams }),
emitWorkspaceEvent: (...args) => getEventBus().emit(...args)
})
})
})()
quitListeners = initializeEventListenersFactory({ db })()
}
await Promise.all([initScopes(), initRoles()])
},
@@ -9,7 +9,6 @@ import {
UpsertWorkspaceRole,
GetWorkspaceWithDomains,
GetWorkspaceDomains,
GetWorkspaceRoleToDefaultProjectRoleMapping,
UpdateWorkspace
} from '@/modules/workspaces/domain/operations'
import {
@@ -52,10 +51,6 @@ import { DeleteAllResourceInvites } from '@/modules/serverinvites/domain/operati
import { WorkspaceInviteResourceType } from '@/modules/workspaces/domain/constants'
import { ProjectInviteResourceType } from '@/modules/serverinvites/domain/constants'
import { chunk, isEmpty, omit } from 'lodash'
import {
DeleteProjectRole,
UpsertProjectRole
} from '@/modules/core/domain/projects/operations'
import { userEmailsCompliantWithWorkspaceDomains } from '@/modules/workspaces/domain/logic'
import { workspaceRoles as workspaceRoleDefinitions } from '@/modules/workspaces/roles'
import { blockedDomains } from '@speckle/shared'
@@ -229,15 +224,11 @@ export const deleteWorkspaceRoleFactory =
({
getWorkspaceRoles,
deleteWorkspaceRole,
emitWorkspaceEvent,
queryAllWorkspaceProjects,
deleteProjectRole
emitWorkspaceEvent
}: {
getWorkspaceRoles: GetWorkspaceRoles
deleteWorkspaceRole: DeleteWorkspaceRole
emitWorkspaceEvent: EmitWorkspaceEvent
queryAllWorkspaceProjects: QueryAllWorkspaceProjects
deleteProjectRole: DeleteProjectRole
}) =>
async ({
workspaceId,
@@ -255,17 +246,6 @@ export const deleteWorkspaceRoleFactory =
return null
}
// Delete workspace project roles
for await (const projectsPage of queryAllWorkspaceProjects({
workspaceId
})) {
await Promise.all(
projectsPage.map(({ id: projectId }) =>
deleteProjectRole({ projectId, userId })
)
)
}
// Emit deleted role
await emitWorkspaceEvent({
eventName: WorkspaceEvents.RoleDeleted,
@@ -295,20 +275,12 @@ export const updateWorkspaceRoleFactory =
getWorkspaceWithDomains,
findVerifiedEmailsByUserId,
upsertWorkspaceRole,
upsertProjectRole,
deleteProjectRole,
getDefaultWorkspaceProjectRoleMapping,
queryAllWorkspaceProjects,
emitWorkspaceEvent
}: {
getWorkspaceRoles: GetWorkspaceRoles
getWorkspaceWithDomains: GetWorkspaceWithDomains
findVerifiedEmailsByUserId: FindVerifiedEmailsByUserId
upsertWorkspaceRole: UpsertWorkspaceRole
upsertProjectRole: UpsertProjectRole
deleteProjectRole: DeleteProjectRole
getDefaultWorkspaceProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
queryAllWorkspaceProjects: QueryAllWorkspaceProjects
emitWorkspaceEvent: EmitWorkspaceEvent
}) =>
async ({
@@ -327,8 +299,16 @@ export const updateWorkspaceRoleFactory =
*/
preventRoleDowngrade?: boolean
}): Promise<void> => {
// Protect against removing last admin
const workspaceRoles = await getWorkspaceRoles({ workspaceId })
// Return early if no work required
const previousWorkspaceRole = workspaceRoles.find((acl) => acl.userId === userId)
if (previousWorkspaceRole?.role === nextWorkspaceRole) {
return
}
// Protect against removing last admin
if (
isUserLastWorkspaceAdmin(workspaceRoles, userId) &&
nextWorkspaceRole !== Roles.Workspace.Admin
@@ -336,8 +316,6 @@ export const updateWorkspaceRoleFactory =
throw new WorkspaceAdminRequiredError()
}
const previousWorkspaceRole = workspaceRoles.find((acl) => acl.userId === userId)
// prevent role downgrades (used during invite flow)
if (preventRoleDowngrade) {
if (previousWorkspaceRole) {
@@ -369,7 +347,7 @@ export const updateWorkspaceRoleFactory =
}
}
// Perform upsert
// Perform and emit change
await upsertWorkspaceRole({
userId,
workspaceId,
@@ -377,45 +355,17 @@ export const updateWorkspaceRoleFactory =
createdAt: previousWorkspaceRole?.createdAt ?? new Date()
})
// Emit new role
await emitWorkspaceEvent({
eventName: WorkspaceEvents.RoleUpdated,
payload: { userId, workspaceId, role: nextWorkspaceRole }
payload: {
userId,
workspaceId,
role: nextWorkspaceRole,
flags: {
skipProjectRoleUpdatesFor: skipProjectRoleUpdatesFor ?? []
}
}
})
// Update roles for all workspace projects
const defaultProjectRoleMapping = await getDefaultWorkspaceProjectRoleMapping({
workspaceId
})
for await (const projectsPage of queryAllWorkspaceProjects({
workspaceId
})) {
await Promise.all(
projectsPage.map(({ id: projectId }) => {
// skip assigning project role implied by workspace role (used during invite flow)
if (skipProjectRoleUpdatesFor?.includes(projectId)) {
return
}
// no change required
if (previousWorkspaceRole?.role === nextWorkspaceRole) return
const nextProjectRole = defaultProjectRoleMapping[nextWorkspaceRole]
// user is being removed from workspace or demoted to workspace guest
if (!nextWorkspaceRole || !nextProjectRole)
return deleteProjectRole({ projectId, userId })
// user is being granted a workspace role with new role for given project
return upsertProjectRole({
projectId,
userId,
role: nextProjectRole
})
})
)
}
}
export const addDomainToWorkspaceFactory =
@@ -1,21 +1,15 @@
import { db } from '@/db/knex'
import {
deleteProjectRoleFactory,
getStream,
upsertProjectRoleFactory
} from '@/modules/core/repositories/streams'
import { getStream } from '@/modules/core/repositories/streams'
import {
findEmailsByUserIdFactory,
findVerifiedEmailsByUserIdFactory
} from '@/modules/core/repositories/userEmails'
import { getStreams } from '@/modules/core/services/streams'
import {
findUserByTargetFactory,
insertInviteAndDeleteOldFactory
} from '@/modules/serverinvites/repositories/serverInvites'
import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { mapWorkspaceRoleToInitialProjectRole } from '@/modules/workspaces/domain/logic'
import {
getWorkspaceRolesFactory,
upsertWorkspaceFactory,
@@ -38,7 +32,6 @@ import {
updateWorkspaceFactory,
addDomainToWorkspaceFactory
} from '@/modules/workspaces/services/management'
import { queryAllWorkspaceProjectsFactory } from '@/modules/workspaces/services/projects'
import { BasicTestUser } from '@/test/authHelper'
import { CreateWorkspaceInviteMutationVariables } from '@/test/graphql/generated/graphql'
import { MaybeNullOrUndefined, Roles, WorkspaceRoles } from '@speckle/shared'
@@ -138,10 +131,6 @@ export const assignToWorkspace = async (
findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }),
getWorkspaceRoles: getWorkspaceRolesFactory({ db }),
upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }),
getDefaultWorkspaceProjectRoleMapping: mapWorkspaceRoleToInitialProjectRole,
upsertProjectRole: upsertProjectRoleFactory({ db }),
deleteProjectRole: deleteProjectRoleFactory({ db }),
queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ getStreams }),
emitWorkspaceEvent: (...args) => getEventBus().emit(...args)
})
@@ -159,8 +148,6 @@ export const unassignFromWorkspace = async (
const deleteWorkspaceRole = deleteWorkspaceRoleFactory({
getWorkspaceRoles: getWorkspaceRolesFactory({ db }),
deleteWorkspaceRole: dbDeleteWorkspaceRoleFactory({ db }),
deleteProjectRole: deleteProjectRoleFactory({ db }),
queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ getStreams }),
emitWorkspaceEvent: (...args) => getEventBus().emit(...args)
})
@@ -565,11 +565,9 @@ describe('Workspaces GQL CRUD', () => {
// first 10 users
await createTestUsers(freeGuests)
await Promise.all(
freeGuests.map((guest) =>
assignToWorkspace(workspace, guest, Roles.Workspace.Guest)
)
)
for (const guest of freeGuests) {
await assignToWorkspace(workspace, guest, Roles.Workspace.Guest)
}
await Promise.all([
createTestUser(member),
@@ -4,7 +4,7 @@ import { Roles, StreamRoles } from '@speckle/shared'
import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types'
import {
onProjectCreatedFactory,
onWorkspaceJoinedFactory
onWorkspaceRoleUpdatedFactory
} from '@/modules/workspaces/events/eventListener'
import { expect } from 'chai'
import { mapWorkspaceRoleToInitialProjectRole } from '@/modules/workspaces/domain/logic'
@@ -61,16 +61,22 @@ describe('Event handlers', () => {
expect(projectRoles.length).to.equal(2)
})
})
describe('onWorkspaceJoinedFactory creates a function, that', () => {
describe('onWorkspaceRoleUpdatedFactory creates a function, that', () => {
it('assigns no project roles if the role mapping returns null', async () => {
await onWorkspaceJoinedFactory({
let isDeleteCalled = false
await onWorkspaceRoleUpdatedFactory({
getDefaultWorkspaceProjectRoleMapping: async () => ({
[Roles.Workspace.Admin]: Roles.Stream.Owner,
[Roles.Workspace.Member]: Roles.Stream.Contributor,
[Roles.Workspace.Guest]: null
}),
async *queryAllWorkspaceProjects() {
expect.fail()
yield [{ id: 'test' } as StreamRecord]
},
deleteProjectRole: async () => {
isDeleteCalled = true
return undefined
},
upsertProjectRole: async () => {
expect.fail()
@@ -80,6 +86,8 @@ describe('Event handlers', () => {
userId: cryptoRandomString({ length: 10 }),
workspaceId: cryptoRandomString({ length: 10 })
})
expect(isDeleteCalled).to.be.true
})
it('assigns the mapped projects roles to all queried project', async () => {
const projectIds = [
@@ -92,7 +100,7 @@ describe('Event handlers', () => {
const projectRole = Roles.Stream.Reviewer
const storedRoles: { userId: string; role: StreamRoles; projectId: string }[] = []
await onWorkspaceJoinedFactory({
await onWorkspaceRoleUpdatedFactory({
getDefaultWorkspaceProjectRoleMapping: async () => ({
[Roles.Workspace.Admin]: Roles.Stream.Owner,
[Roles.Workspace.Member]: projectRole,
@@ -103,6 +111,9 @@ describe('Event handlers', () => {
yield projIds.map((projId) => ({ id: projId } as unknown as StreamRecord))
}
},
deleteProjectRole: async () => {
expect.fail()
},
upsertProjectRole: async (args) => {
storedRoles.push(args)
return {} as StreamRecord
@@ -14,7 +14,10 @@ import {
import { Roles } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
import {
WorkspaceEvents,
WorkspaceEventsPayloads
} from '@/modules/workspacesCore/domain/events'
import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types'
import { expectToThrow } from '@/test/assertionHelper'
import { createRandomPassword } from '@/modules/core/helpers/testHelpers'
@@ -386,17 +389,21 @@ const buildDeleteWorkspaceRoleAndTestContext = (
context.eventData.eventName = eventName
context.eventData.payload = payload
switch (eventName) {
case 'workspace.role-deleted': {
const { userId } =
payload as WorkspaceEventsPayloads['workspace.role-deleted']
for (const project of context.workspaceProjects) {
context.workspaceProjectRoles = context.workspaceProjectRoles.filter(
(role) => role.resourceId !== project.id && role.userId !== userId
)
}
break
}
}
return []
},
async *queryAllWorkspaceProjects() {
yield context.workspaceProjects
},
deleteProjectRole: async ({ projectId, userId }) => {
context.workspaceProjectRoles = context.workspaceProjectRoles.filter(
(role) => role.resourceId !== projectId && role.userId !== userId
)
return {} as StreamRecord
},
...dependencyOverrides
}
@@ -430,32 +437,47 @@ const buildUpdateWorkspaceRoleAndTestContext = (
context.eventData.eventName = eventName
context.eventData.payload = payload
return []
},
async *queryAllWorkspaceProjects() {
yield context.workspaceProjects
},
getDefaultWorkspaceProjectRoleMapping: mapWorkspaceRoleToInitialProjectRole,
upsertProjectRole: async (role) => {
const streamAcl: StreamAclRecord = {
userId: role.userId,
role: role.role,
resourceId: role.projectId
switch (eventName) {
case 'workspace.role-deleted': {
const { userId } =
payload as WorkspaceEventsPayloads['workspace.role-deleted']
for (const project of context.workspaceProjects) {
context.workspaceProjectRoles = context.workspaceProjectRoles.filter(
(role) => role.resourceId !== project.id && role.userId !== userId
)
}
break
}
case 'workspace.role-updated': {
const workspaceRole =
payload as WorkspaceEventsPayloads['workspace.role-updated']
const mapping = await mapWorkspaceRoleToInitialProjectRole({
workspaceId: workspaceRole.workspaceId
})
for (const project of context.workspaceProjects) {
const projectRole = mapping[workspaceRole.role]
if (!projectRole) {
continue
}
const streamAcl: StreamAclRecord = {
userId: workspaceRole.userId,
role: projectRole,
resourceId: project.id
}
context.workspaceProjectRoles = context.workspaceProjectRoles.filter(
(acl) => acl.userId !== workspaceRole.userId
)
context.workspaceProjectRoles.push(streamAcl)
}
break
}
}
context.workspaceProjectRoles = context.workspaceProjectRoles.filter(
(acl) => acl.userId !== role.userId
)
context.workspaceProjectRoles.push(streamAcl)
return {} as StreamRecord
},
deleteProjectRole: async ({ userId }) => {
context.workspaceProjectRoles = context.workspaceProjectRoles.filter(
(acl) => acl.userId !== userId
)
return {} as StreamRecord
return []
},
...dependencyOverrides
}
@@ -589,9 +611,15 @@ describe('Workspace role services', () => {
await updateWorkspaceRole(role)
const payload = {
...(context.eventData
.payload as WorkspaceEventsPayloads['workspace.role-updated'])
}
delete payload.flags
expect(context.eventData.isCalled).to.be.true
expect(context.eventData.eventName).to.equal(WorkspaceEvents.RoleUpdated)
expect(context.eventData.payload).to.deep.equal(role)
expect(payload).to.deep.equal(role)
})
it('throws if attempting to remove the last admin in a workspace', async () => {
const userId = cryptoRandomString({ length: 10 })
@@ -20,7 +20,10 @@ type WorkspaceCreatedPayload = Workspace & {
}
type WorkspaceUpdatedPayload = Workspace
type WorkspaceRoleDeletedPayload = Pick<WorkspaceAcl, 'userId' | 'workspaceId' | 'role'>
type WorkspaceRoleUpdatedPayload = Pick<WorkspaceAcl, 'userId' | 'workspaceId' | 'role'>
type WorkspaceRoleUpdatedPayload = Pick<
WorkspaceAcl,
'userId' | 'workspaceId' | 'role'
> & { flags?: { skipProjectRoleUpdatesFor: string[] } }
type WorkspaceJoinedFromDiscoveryPayload = {
userId: string
workspaceId: string
@@ -1,7 +1,8 @@
<template>
<div class="relative w-full bg-outline-3 rounded h-1.5">
<div class="relative w-full bg-outline-3 rounded h-1.5 overflow-hidden">
<div
class="aboslute left-0 top-0 bg-success rounded h-1.5"
class="aboslute left-0 top-0 rounded h-1.5"
:class="colorClass"
:style="{ width: `${percentage <= 100 ? percentage : 100}%` }"
/>
</div>
@@ -16,4 +17,14 @@ const props = defineProps<{
}>()
const percentage = computed(() => (props.currentValue / props.maxValue) * 100)
const colorClass = computed(() => {
if (percentage.value >= 100) {
return 'bg-danger'
}
if (percentage.value >= 80) {
return 'bg-warning'
}
return 'bg-success'
})
</script>